From 1277e9743bc8d408a79e5f7d0af290f9158242fb Mon Sep 17 00:00:00 2001 From: miraserver <20286838+miraserver@users.noreply.github.com> Date: Sun, 8 Mar 2026 18:18:40 +0300 Subject: [PATCH 01/42] feat(providers): add sub-navigation, Options tab, and proxy indicator (#876) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(providers): add sub-navigation and Options tab to provider form sidebar Add Scheduling, Circuit Breaker, Timeout sub-items under their parent tabs in the desktop sidebar for quick scroll access. Promote Options to a top-level tab. Includes scroll tracking, i18n (5 langs), and 13 tests. Co-Authored-By: Claude Opus 4.6 * feat(providers): add Active Time sub-item under Options tab Add activeTime sub-navigation for quick scroll access to Scheduled Active Time section. Also add variant="highlight" to Options SectionCard for consistent visual styling with other main sections. Co-Authored-By: Claude Opus 4.6 * feat(providers): show green ShieldCheck icon when proxy is configured Separate icon with tooltip next to endpoint count on provider list. Visible only when proxyUrl is set. i18n for 5 languages. Co-Authored-By: Claude Opus 4.6 * refactor(providers): fix 7 review issues in sub-nav and form architecture - Derive TAB_CONFIG, TAB_ORDER, NAV_ORDER, PARENT_MAP from NAV_CONFIG (DRY) - Add sub-nav to tablet and mobile breakpoints - Move activeSubTab from useState into form reducer - Compute Options tab status from routing state instead of hardcoding - Lift TooltipProvider from per-item to list container level - Fix RU i18n: singular form for timeout label - Add 8 new tests covering derived constants and responsive sub-nav Co-Authored-By: Claude Opus 4.6 * refactor(providers): extract OptionsSection, perf fixes, batch dialog fix - Extract OptionsSection from RoutingSection (963 -> 432 lines) - Throttle scroll handler via requestAnimationFrame - Merge double dispatch into single SET_ACTIVE_NAV action - Derive sectionRefs from NAV_ORDER instead of manual record - Add NAV_BY_ID lookup map for O(1) tablet/mobile nav access - Add excludeTabs prop to FormTabNav, hide Options in batch dialog - Clean up setTimeout/rAF refs on unmount Co-Authored-By: Claude Opus 4.6 * feat(providers): add Options tab to batch edit dialog Co-Authored-By: Claude Opus 4.6 * fix(providers): show proxy badge for providers without resolved vendor Move ShieldCheck proxy indicator out of the vendor-specific branch so it renders for all providers with a configured proxyUrl. Co-Authored-By: Claude Opus 4.6 * refactor(providers): remove dead SET_ACTIVE_SUB_TAB, add status grouping comments Address Gemini Code Assist review findings: - Remove unused SET_ACTIVE_SUB_TAB action type and reducer case (superseded by SET_ACTIVE_NAV) - Add grouping comments to options tab status conditional for readability Co-Authored-By: Claude Opus 4.6 * fix(providers): address CodeRabbit review findings - Fix zh-CN/zh-TW i18n terminology consistency (供应商/供應商 instead of 提供者) - Add activeTimeEnd check to options tab status indicator - Add focus-visible ring to tablet/mobile sub-nav buttons for accessibility Co-Authored-By: Claude Opus 4.6 * fix(providers): use i18n fallback for unknown tag errors and scrollend event for scroll lock - Replace raw reason string fallback with tUI("unknownError") i18n key in tag validation callbacks (routing-section.tsx) - Add "unknownError" key to tagInput namespace in all 5 locales - Use scrollend event with { once: true } + 1000ms fallback timer instead of fixed 500ms setTimeout for scroll lock release (index.tsx) Co-Authored-By: Claude Opus 4.6 * test(providers): add unit tests for OptionsSection component (25 tests) Covers rendering, conditional display by provider type (claude/codex/gemini), batch mode, dispatch actions, active time UI, disabled state, edit mode IDs, and batch-only badges. Co-Authored-By: Claude Opus 4.6 * fix(providers): remove stale scrollend listener on rapid tab clicks Store previous scrollend listener in a ref and remove it at the start of each scrollToSection call, preventing premature unlock when multiple smooth scrolls overlap during fast sequential tab clicks. Co-Authored-By: Claude Opus 4.6 * refactor(providers): fix 3 style-level review findings - Remove dead subSectionRefs.options property from OptionsSection (parent div in index.tsx already tracks this ref) - Use filteredNav.find() instead of NAV_BY_ID for tablet/mobile sub-row lookup so excludeTabs is respected; remove unused NAV_BY_ID - Replace non-null assertion with guarded clearTimeout in scrollend handler Co-Authored-By: Claude Opus 4.6 * fix(ci): resolve 3 pre-existing test failures from PR #873 All caused by commit 2e663cd5 which changed billing model and session API without updating tests/translations: - i18n: add missing `prices.badges.multi` key to ja/ru/zh-TW locales - tests: update cost-calculation expectations to match full-request pricing (all tokens at premium rate when context > 200K threshold) - tests: fix lease-decrement session mock to use getResolvedPricingByBillingSource instead of removed method Co-Authored-By: Claude Opus 4.6 * fix(types): narrow Recharts v3 dataKey type for React Key/ReactNode compat Recharts v3 widened `dataKey` to `string | number | ((obj: any) => any) | undefined`, which is incompatible with React `Key` and `ReactNode`. Wrap with `String()` in 2 files to satisfy tsgo in CI. Pre-existing issue from main, not introduced by this branch. Co-Authored-By: Claude Opus 4.6 * chore: format code (feat-providers-list-3-0732ba6) * ci: retrigger CI after auto-format fix Co-Authored-By: Claude Opus 4.6 * fix(types): narrow Recharts ValueType for formatter call in chart.tsx Removing the explicit .map() parameter type exposed ValueType (string | number | readonly (string|number)[]) which is too wide for the formatter's (string | number) parameter. Cast item.value. Co-Authored-By: Claude Opus 4.6 * fix(types): restore narrowing annotation for Recharts v3 tooltip map Bring back the explicit type annotation on .map() callback but extend dataKey to include ((obj: any) => any) to match Recharts v3 DataKey. This keeps value/name narrowed for formatter compatibility while making the annotation assignable from TooltipPayloadEntry. Replaces the previous approach of removing the annotation entirely, which exposed ValueType and NameType width issues one by one. Co-Authored-By: Claude Opus 4.6 * fix(types): use inline casts instead of annotation for Recharts v3 compat Remove the .map() parameter annotation entirely (it causes TS2345 due to contravariance — our narrower type is not assignable from TooltipPayloadEntry). Instead, let TS infer the full type and cast only at the two call sites where formatter needs narrower types: item.value as string|number, item.name as string. All other usages of item.dataKey, item.name, item.value are compatible with the wider Recharts v3 types (String() wraps, template literals, ReactNode). Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: John Doe Co-authored-by: Claude Opus 4.6 Co-authored-by: github-actions[bot] --- .../en/settings/providers/form/common.json | 5 + .../en/settings/providers/form/sections.json | 4 + messages/en/settings/providers/list.json | 3 +- messages/en/ui.json | 3 +- messages/ja/settings/prices.json | 3 +- .../ja/settings/providers/form/common.json | 5 + .../ja/settings/providers/form/sections.json | 4 + messages/ja/settings/providers/list.json | 3 +- messages/ja/ui.json | 3 +- messages/ru/settings/prices.json | 3 +- .../ru/settings/providers/form/common.json | 5 + .../ru/settings/providers/form/sections.json | 4 + messages/ru/settings/providers/list.json | 3 +- messages/ru/ui.json | 3 +- .../zh-CN/settings/providers/form/common.json | 5 + .../settings/providers/form/sections.json | 4 + messages/zh-CN/settings/providers/list.json | 3 +- messages/zh-CN/ui.json | 3 +- messages/zh-TW/settings/prices.json | 3 +- .../zh-TW/settings/providers/form/common.json | 5 + .../settings/providers/form/sections.json | 4 + messages/zh-TW/settings/providers/list.json | 3 +- messages/zh-TW/ui.json | 3 +- .../_components/provider/latency-chart.tsx | 6 +- .../batch-edit/provider-batch-dialog.tsx | 2 + .../provider-form/components/form-tab-nav.tsx | 273 ++++- .../_components/forms/provider-form/index.tsx | 184 +++- .../provider-form/provider-form-context.tsx | 9 +- .../provider-form/provider-form-types.ts | 10 +- .../provider-form/sections/limits-section.tsx | 256 ++--- .../sections/network-section.tsx | 164 +-- .../sections/options-section.tsx | 571 +++++++++++ .../sections/routing-section.tsx | 948 ++++-------------- .../providers/_components/provider-list.tsx | 55 +- .../_components/provider-rich-list-item.tsx | 14 + src/components/ui/chart.tsx | 145 ++- .../lib/cost-calculation-breakdown.test.ts | 12 +- .../response-handler-lease-decrement.test.ts | 26 +- .../settings/providers/form-tab-nav.test.tsx | 211 +++- .../providers/options-section.test.tsx | 534 ++++++++++ 40 files changed, 2336 insertions(+), 1168 deletions(-) create mode 100644 src/app/[locale]/settings/providers/_components/forms/provider-form/sections/options-section.tsx create mode 100644 tests/unit/settings/providers/options-section.test.tsx diff --git a/messages/en/settings/providers/form/common.json b/messages/en/settings/providers/form/common.json index ab4c7b067..942370f83 100644 --- a/messages/en/settings/providers/form/common.json +++ b/messages/en/settings/providers/form/common.json @@ -6,6 +6,11 @@ "limits": "Limits", "network": "Network", "testing": "Testing", + "scheduling": "Scheduling", + "options": "Options", + "activeTime": "Active Time", + "circuitBreaker": "Circuit Breaker", + "timeout": "Timeout", "stepProgress": "Step progress" } } diff --git a/messages/en/settings/providers/form/sections.json b/messages/en/settings/providers/form/sections.json index cba4db294..3dab715b9 100644 --- a/messages/en/settings/providers/form/sections.json +++ b/messages/en/settings/providers/form/sections.json @@ -343,6 +343,10 @@ "help": "Keep off by default for privacy. Enable only when upstream must see the end-user IP.", "label": "Forward client IP" }, + "options": { + "title": "Options", + "desc": "Additional provider options and overrides" + }, "providerType": { "desc": "(determines scheduling policy)", "label": "Provider Type", diff --git a/messages/en/settings/providers/list.json b/messages/en/settings/providers/list.json index 0b3c13284..8e39dab8c 100644 --- a/messages/en/settings/providers/list.json +++ b/messages/en/settings/providers/list.json @@ -42,5 +42,6 @@ "actionResetUsage": "Reset Usage", "actionDelete": "Delete", "selectProvider": "Select {name}", - "schedule": "Schedule" + "schedule": "Schedule", + "proxyEnabled": "Proxy enabled" } diff --git a/messages/en/ui.json b/messages/en/ui.json index 8e07905e5..e5c6d585c 100644 --- a/messages/en/ui.json +++ b/messages/en/ui.json @@ -52,7 +52,8 @@ "maxTags": "Maximum number of tags reached", "tooLong": "Tag length cannot exceed {max} characters", "invalidFormat": "Tags can only contain letters, numbers, underscores and hyphens", - "removeTag": "Remove tag {tag}" + "removeTag": "Remove tag {tag}", + "unknownError": "Invalid input" }, "providerGroupSelect": { "placeholder": "Select provider groups", diff --git a/messages/ja/settings/prices.json b/messages/ja/settings/prices.json index 80afbb19d..7c9ef477a 100644 --- a/messages/ja/settings/prices.json +++ b/messages/ja/settings/prices.json @@ -31,7 +31,8 @@ "openrouter": "OpenRouter" }, "badges": { - "local": "ローカル" + "local": "ローカル", + "multi": "マルチ" }, "capabilities": { "assistantPrefill": "アシスタント事前入力", diff --git a/messages/ja/settings/providers/form/common.json b/messages/ja/settings/providers/form/common.json index fe766439c..663afad0d 100644 --- a/messages/ja/settings/providers/form/common.json +++ b/messages/ja/settings/providers/form/common.json @@ -6,6 +6,11 @@ "limits": "制限", "network": "ネットワーク", "testing": "テスト", + "scheduling": "スケジューリング", + "options": "オプション", + "activeTime": "アクティブ時間", + "circuitBreaker": "サーキットブレーカー", + "timeout": "タイムアウト", "stepProgress": "ステップ進捗" } } diff --git a/messages/ja/settings/providers/form/sections.json b/messages/ja/settings/providers/form/sections.json index 7b5782156..4555ca8e2 100644 --- a/messages/ja/settings/providers/form/sections.json +++ b/messages/ja/settings/providers/form/sections.json @@ -344,6 +344,10 @@ "help": "プライバシー保護のためデフォルトはオフ。上流側で端末 IP が必要な場合のみ有効化してください。", "label": "クライアント IP を転送" }, + "options": { + "title": "オプション", + "desc": "追加のプロバイダーオプションとオーバーライド" + }, "providerType": { "desc": "(スケジューリングに影響)", "label": "プロバイダー種別", diff --git a/messages/ja/settings/providers/list.json b/messages/ja/settings/providers/list.json index aeb44a4d2..3a80c9c6e 100644 --- a/messages/ja/settings/providers/list.json +++ b/messages/ja/settings/providers/list.json @@ -42,5 +42,6 @@ "actionResetUsage": "使用量リセット", "actionDelete": "削除", "selectProvider": "{name} を選択", - "schedule": "スケジュール" + "schedule": "スケジュール", + "proxyEnabled": "プロキシ有効" } diff --git a/messages/ja/ui.json b/messages/ja/ui.json index 20b712f6e..1e205086d 100644 --- a/messages/ja/ui.json +++ b/messages/ja/ui.json @@ -52,7 +52,8 @@ "maxTags": "最大タグ数に達しました", "tooLong": "タグの長さは{max}文字を超えることはできません", "invalidFormat": "タグには英数字、アンダースコア、ハイフンのみ使用できます", - "removeTag": "タグ {tag} を削除" + "removeTag": "タグ {tag} を削除", + "unknownError": "無効な入力" }, "providerGroupSelect": { "placeholder": "プロバイダーグループを選択", diff --git a/messages/ru/settings/prices.json b/messages/ru/settings/prices.json index 70a8d8cd2..261a598c4 100644 --- a/messages/ru/settings/prices.json +++ b/messages/ru/settings/prices.json @@ -31,7 +31,8 @@ "openrouter": "OpenRouter" }, "badges": { - "local": "Локальная" + "local": "Локальная", + "multi": "Мульти" }, "capabilities": { "assistantPrefill": "Предзаполнение ассистента", diff --git a/messages/ru/settings/providers/form/common.json b/messages/ru/settings/providers/form/common.json index a294a3792..0a61209ed 100644 --- a/messages/ru/settings/providers/form/common.json +++ b/messages/ru/settings/providers/form/common.json @@ -6,6 +6,11 @@ "limits": "Лимиты", "network": "Сеть", "testing": "Тестирование", + "scheduling": "Планирование", + "options": "Параметры", + "activeTime": "Активное время", + "circuitBreaker": "Автовыключатель", + "timeout": "Таймаут", "stepProgress": "Прогресс шагов" } } diff --git a/messages/ru/settings/providers/form/sections.json b/messages/ru/settings/providers/form/sections.json index da7e25dc5..b9a8e2509 100644 --- a/messages/ru/settings/providers/form/sections.json +++ b/messages/ru/settings/providers/form/sections.json @@ -344,6 +344,10 @@ "help": "По умолчанию выключено для приватности. Включайте только если апстриму нужен IP пользователя.", "label": "Пробрасывать IP клиента" }, + "options": { + "title": "Параметры", + "desc": "Дополнительные параметры и переопределения провайдера" + }, "providerType": { "desc": "(определяет политику выбора)", "label": "Тип провайдера", diff --git a/messages/ru/settings/providers/list.json b/messages/ru/settings/providers/list.json index 8de44f407..1265c8bd5 100644 --- a/messages/ru/settings/providers/list.json +++ b/messages/ru/settings/providers/list.json @@ -42,5 +42,6 @@ "actionResetUsage": "Сбросить использование", "actionDelete": "Удалить", "selectProvider": "Выбрать {name}", - "schedule": "Расписание" + "schedule": "Расписание", + "proxyEnabled": "Прокси включен" } diff --git a/messages/ru/ui.json b/messages/ru/ui.json index 1558a800a..a622829b8 100644 --- a/messages/ru/ui.json +++ b/messages/ru/ui.json @@ -52,7 +52,8 @@ "maxTags": "Достигнуто максимальное количество тегов", "tooLong": "Длина тега не может превышать {max} символов", "invalidFormat": "Теги могут содержать только буквы, цифры, подчеркивания и дефисы", - "removeTag": "Удалить тег {tag}" + "removeTag": "Удалить тег {tag}", + "unknownError": "Некорректный ввод" }, "providerGroupSelect": { "placeholder": "Выберите группы провайдеров", diff --git a/messages/zh-CN/settings/providers/form/common.json b/messages/zh-CN/settings/providers/form/common.json index ea5277908..fd5e10d2f 100644 --- a/messages/zh-CN/settings/providers/form/common.json +++ b/messages/zh-CN/settings/providers/form/common.json @@ -6,6 +6,11 @@ "limits": "限制", "network": "网络", "testing": "测试", + "scheduling": "调度", + "options": "选项", + "activeTime": "活跃时间", + "circuitBreaker": "断路器", + "timeout": "超时", "stepProgress": "步骤进度" } } diff --git a/messages/zh-CN/settings/providers/form/sections.json b/messages/zh-CN/settings/providers/form/sections.json index adc1d8060..abee3fd54 100644 --- a/messages/zh-CN/settings/providers/form/sections.json +++ b/messages/zh-CN/settings/providers/form/sections.json @@ -40,6 +40,10 @@ "desc": "向上游转发 x-forwarded-for / x-real-ip,可能暴露真实来源 IP", "help": "默认关闭以保护隐私;仅在需要上游感知终端 IP 时开启。" }, + "options": { + "title": "选项", + "desc": "附加供应商选项和覆写设置" + }, "modelWhitelist": { "title": "模型白名单", "desc": "限制此供应商可以处理的模型。默认情况下,供应商可以处理该类型下的所有模型。", diff --git a/messages/zh-CN/settings/providers/list.json b/messages/zh-CN/settings/providers/list.json index 04331a7d9..dcb54db1f 100644 --- a/messages/zh-CN/settings/providers/list.json +++ b/messages/zh-CN/settings/providers/list.json @@ -42,5 +42,6 @@ "actionResetUsage": "重置用量", "actionDelete": "删除", "selectProvider": "选择 {name}", - "schedule": "调度" + "schedule": "调度", + "proxyEnabled": "已启用代理" } diff --git a/messages/zh-CN/ui.json b/messages/zh-CN/ui.json index 2a092714a..2377fb69d 100644 --- a/messages/zh-CN/ui.json +++ b/messages/zh-CN/ui.json @@ -52,7 +52,8 @@ "maxTags": "已达到最大标签数量", "tooLong": "标签长度不能超过 {max} 个字符", "invalidFormat": "标签只能包含字母、数字、下划线和连字符", - "removeTag": "移除标签 {tag}" + "removeTag": "移除标签 {tag}", + "unknownError": "输入无效" }, "providerGroupSelect": { "placeholder": "选择供应商分组", diff --git a/messages/zh-TW/settings/prices.json b/messages/zh-TW/settings/prices.json index 6618a0659..bb972bfd2 100644 --- a/messages/zh-TW/settings/prices.json +++ b/messages/zh-TW/settings/prices.json @@ -31,7 +31,8 @@ "openrouter": "OpenRouter" }, "badges": { - "local": "本機" + "local": "本機", + "multi": "多供應商" }, "capabilities": { "assistantPrefill": "助手預填充", diff --git a/messages/zh-TW/settings/providers/form/common.json b/messages/zh-TW/settings/providers/form/common.json index 246f543fd..91057fb02 100644 --- a/messages/zh-TW/settings/providers/form/common.json +++ b/messages/zh-TW/settings/providers/form/common.json @@ -6,6 +6,11 @@ "limits": "限制", "network": "網路", "testing": "測試", + "scheduling": "排程", + "options": "選項", + "activeTime": "活躍時間", + "circuitBreaker": "斷路器", + "timeout": "逾時", "stepProgress": "步驟進度" } } diff --git a/messages/zh-TW/settings/providers/form/sections.json b/messages/zh-TW/settings/providers/form/sections.json index 0b323c03c..7900fe323 100644 --- a/messages/zh-TW/settings/providers/form/sections.json +++ b/messages/zh-TW/settings/providers/form/sections.json @@ -344,6 +344,10 @@ "help": "預設關閉以保護隱私;僅在需要上游感知終端 IP 時開啟。", "label": "透傳客戶端 IP" }, + "options": { + "title": "選項", + "desc": "附加供應商選項和覆寫設定" + }, "providerType": { "desc": "(決定調度策略)", "label": "供應商類型", diff --git a/messages/zh-TW/settings/providers/list.json b/messages/zh-TW/settings/providers/list.json index 00ed50510..00dbcff38 100644 --- a/messages/zh-TW/settings/providers/list.json +++ b/messages/zh-TW/settings/providers/list.json @@ -42,5 +42,6 @@ "actionResetUsage": "重設用量", "actionDelete": "刪除", "selectProvider": "選擇 {name}", - "schedule": "排程" + "schedule": "排程", + "proxyEnabled": "已啟用代理" } diff --git a/messages/zh-TW/ui.json b/messages/zh-TW/ui.json index 008c4edb2..4781dc343 100644 --- a/messages/zh-TW/ui.json +++ b/messages/zh-TW/ui.json @@ -52,7 +52,8 @@ "maxTags": "已達到最大標籤數量", "tooLong": "標籤長度不能超過 {max} 個字元", "invalidFormat": "標籤只能包含字母、數字、底線和連字符", - "removeTag": "移除標籤 {tag}" + "removeTag": "移除標籤 {tag}", + "unknownError": "輸入無效" }, "providerGroupSelect": { "placeholder": "選擇供應商分組", diff --git a/src/app/[locale]/dashboard/availability/_components/provider/latency-chart.tsx b/src/app/[locale]/dashboard/availability/_components/provider/latency-chart.tsx index 0beb7958b..53eb8e083 100644 --- a/src/app/[locale]/dashboard/availability/_components/provider/latency-chart.tsx +++ b/src/app/[locale]/dashboard/availability/_components/provider/latency-chart.tsx @@ -151,14 +151,14 @@ export function LatencyChart({ providers, className }: LatencyChartProps) {
{formatTime(label as string)}
{payload.map((item) => ( -
+
- {chartConfig[item.dataKey as keyof typeof chartConfig]?.label || - item.dataKey} + {chartConfig[String(item.dataKey) as keyof typeof chartConfig]?.label || + String(item.dataKey)} : {formatLatency(item.value as number)} diff --git a/src/app/[locale]/settings/providers/_components/batch-edit/provider-batch-dialog.tsx b/src/app/[locale]/settings/providers/_components/batch-edit/provider-batch-dialog.tsx index 6920c1888..ef4946acf 100644 --- a/src/app/[locale]/settings/providers/_components/batch-edit/provider-batch-dialog.tsx +++ b/src/app/[locale]/settings/providers/_components/batch-edit/provider-batch-dialog.tsx @@ -43,6 +43,7 @@ import { import { BasicInfoSection } from "../forms/provider-form/sections/basic-info-section"; import { LimitsSection } from "../forms/provider-form/sections/limits-section"; import { NetworkSection } from "../forms/provider-form/sections/network-section"; +import { OptionsSection } from "../forms/provider-form/sections/options-section"; import { RoutingSection } from "../forms/provider-form/sections/routing-section"; import { TestingSection } from "../forms/provider-form/sections/testing-section"; import { buildPatchDraftFromFormState } from "./build-patch-draft"; @@ -293,6 +294,7 @@ function BatchEditDialogContent({
{state.ui.activeTab === "basic" && } {state.ui.activeTab === "routing" && } + {state.ui.activeTab === "options" && } {state.ui.activeTab === "limits" && } {state.ui.activeTab === "network" && } {state.ui.activeTab === "testing" && } diff --git a/src/app/[locale]/settings/providers/_components/forms/provider-form/components/form-tab-nav.tsx b/src/app/[locale]/settings/providers/_components/forms/provider-form/components/form-tab-nav.tsx index 6f4a51f23..1d3206328 100644 --- a/src/app/[locale]/settings/providers/_components/forms/provider-form/components/form-tab-nav.tsx +++ b/src/app/[locale]/settings/providers/_components/forms/provider-form/components/form-tab-nav.tsx @@ -1,22 +1,83 @@ "use client"; import { motion } from "framer-motion"; -import { FileText, FlaskConical, Gauge, Network, Route } from "lucide-react"; +import { + Clock, + FileText, + FlaskConical, + Gauge, + Network, + Route, + Scale, + Settings, + Shield, + Timer, +} from "lucide-react"; import { useTranslations } from "next-intl"; import { cn } from "@/lib/utils"; -import type { TabId } from "../provider-form-types"; +import type { NavTargetId, SubTabId, TabId } from "../provider-form-types"; -const TAB_CONFIG: { id: TabId; icon: typeof FileText; labelKey: string }[] = [ +type NavItemConfig = { + id: TabId; + icon: typeof FileText; + labelKey: string; + children?: { + id: SubTabId; + icon: typeof FileText; + labelKey: string; + }[]; +}; + +const NAV_CONFIG: NavItemConfig[] = [ { id: "basic", icon: FileText, labelKey: "tabs.basic" }, - { id: "routing", icon: Route, labelKey: "tabs.routing" }, - { id: "limits", icon: Gauge, labelKey: "tabs.limits" }, - { id: "network", icon: Network, labelKey: "tabs.network" }, + { + id: "routing", + icon: Route, + labelKey: "tabs.routing", + children: [{ id: "scheduling", icon: Scale, labelKey: "tabs.scheduling" }], + }, + { + id: "options", + icon: Settings, + labelKey: "tabs.options", + children: [{ id: "activeTime", icon: Clock, labelKey: "tabs.activeTime" }], + }, + { + id: "limits", + icon: Gauge, + labelKey: "tabs.limits", + children: [{ id: "circuitBreaker", icon: Shield, labelKey: "tabs.circuitBreaker" }], + }, + { + id: "network", + icon: Network, + labelKey: "tabs.network", + children: [{ id: "timeout", icon: Timer, labelKey: "tabs.timeout" }], + }, { id: "testing", icon: FlaskConical, labelKey: "tabs.testing" }, ]; +const TAB_CONFIG: { id: TabId; icon: typeof FileText; labelKey: string }[] = NAV_CONFIG.map( + ({ id, icon, labelKey }) => ({ id, icon, labelKey }) +); + +export const TAB_ORDER: TabId[] = NAV_CONFIG.map((item) => item.id); + +export const NAV_ORDER: NavTargetId[] = NAV_CONFIG.flatMap((item) => [ + item.id, + ...(item.children?.map((c) => c.id) ?? []), +]); + +export const PARENT_MAP = Object.fromEntries( + NAV_CONFIG.flatMap((item) => (item.children ?? []).map((child) => [child.id, item.id])) +) as Record; + interface FormTabNavProps { activeTab: TabId; + activeSubTab?: SubTabId | null; + excludeTabs?: TabId[]; onTabChange: (tab: TabId) => void; + onSubTabChange?: (subTab: SubTabId) => void; disabled?: boolean; tabStatus?: Partial>; layout?: "vertical" | "horizontal"; @@ -24,13 +85,23 @@ interface FormTabNavProps { export function FormTabNav({ activeTab, + activeSubTab = null, onTabChange, + onSubTabChange, disabled, tabStatus = {}, layout = "vertical", + excludeTabs, }: FormTabNavProps) { const t = useTranslations("settings.providers.form"); + const filteredTabs = excludeTabs?.length + ? TAB_CONFIG.filter((tab) => !excludeTabs.includes(tab.id)) + : TAB_CONFIG; + const filteredNav = excludeTabs?.length + ? NAV_CONFIG.filter((item) => !excludeTabs.includes(item.id)) + : NAV_CONFIG; + const getStatusColor = (status?: "default" | "warning" | "configured") => { switch (status) { case "warning": @@ -42,15 +113,15 @@ export function FormTabNav({ } }; - const activeTabIndex = TAB_CONFIG.findIndex((tab) => tab.id === activeTab); + const activeTabIndex = filteredTabs.findIndex((tab) => tab.id === activeTab); const stepNumber = activeTabIndex >= 0 ? activeTabIndex + 1 : 0; - const stepProgressWidth = `${(stepNumber / TAB_CONFIG.length) * 100}%`; + const stepProgressWidth = `${(stepNumber / filteredTabs.length) * 100}%`; if (layout === "horizontal") { return ( {/* Tablet: Horizontal Tabs */} -
{/* Network Section */} @@ -620,7 +708,13 @@ function ProviderFormContent({ sectionRefs.current.network = el; }} > - + { + sectionRefs.current.timeout = el; + }, + }} + />
{/* Testing Section */} diff --git a/src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-context.tsx b/src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-context.tsx index 9ea0146b7..04f537f13 100644 --- a/src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-context.tsx +++ b/src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-context.tsx @@ -147,6 +147,7 @@ export function createInitialState( batch: { isEnabled: "no_change" }, ui: { activeTab: "basic", + activeSubTab: null, isPending: false, showFailureThresholdConfirm: false, }, @@ -241,6 +242,7 @@ export function createInitialState( batch: { isEnabled: "no_change" }, ui: { activeTab: "basic", + activeSubTab: null, isPending: false, showFailureThresholdConfirm: false, }, @@ -476,7 +478,12 @@ export function providerFormReducer( // UI case "SET_ACTIVE_TAB": - return { ...state, ui: { ...state.ui, activeTab: action.payload } }; + return { ...state, ui: { ...state.ui, activeTab: action.payload, activeSubTab: null } }; + case "SET_ACTIVE_NAV": + return { + ...state, + ui: { ...state.ui, activeTab: action.payload.tab, activeSubTab: action.payload.subTab }, + }; case "SET_IS_PENDING": return { ...state, ui: { ...state.ui, isPending: action.payload } }; case "SET_SHOW_FAILURE_THRESHOLD_CONFIRM": diff --git a/src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-types.ts b/src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-types.ts index 3c89b6bec..0f5e5f6b7 100644 --- a/src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-types.ts +++ b/src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-types.ts @@ -20,7 +20,13 @@ import type { export type FormMode = "create" | "edit" | "batch"; // Tab identifiers -export type TabId = "basic" | "routing" | "limits" | "network" | "testing"; +export type TabId = "basic" | "routing" | "options" | "limits" | "network" | "testing"; + +// Sub-tab identifiers for sub-navigation within parent sections +export type SubTabId = "scheduling" | "activeTime" | "circuitBreaker" | "timeout"; + +// Combined navigation target (parent tab or sub-tab) +export type NavTargetId = TabId | SubTabId; // Tab configuration export interface TabConfig { @@ -106,6 +112,7 @@ export interface BatchState { export interface UIState { activeTab: TabId; + activeSubTab: SubTabId | null; isPending: boolean; showFailureThresholdConfirm: boolean; } @@ -186,6 +193,7 @@ export type ProviderFormAction = | { type: "SET_MCP_PASSTHROUGH_URL"; payload: string } // UI actions | { type: "SET_ACTIVE_TAB"; payload: TabId } + | { type: "SET_ACTIVE_NAV"; payload: { tab: TabId; subTab: SubTabId | null } } | { type: "SET_IS_PENDING"; payload: boolean } | { type: "SET_SHOW_FAILURE_THRESHOLD_CONFIRM"; payload: boolean } // Bulk actions diff --git a/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/limits-section.tsx b/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/limits-section.tsx index 3b9f8a470..be75c3b5d 100644 --- a/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/limits-section.tsx +++ b/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/limits-section.tsx @@ -123,7 +123,13 @@ function LimitCard({ ); } -export function LimitsSection() { +interface LimitsSectionProps { + subSectionRefs?: { + circuitBreaker?: (el: HTMLDivElement | null) => void; + }; +} + +export function LimitsSection({ subSectionRefs }: LimitsSectionProps) { const t = useTranslations("settings.providers.form"); const { state, dispatch, mode } = useProviderForm(); const isEdit = mode === "edit"; @@ -279,146 +285,150 @@ export function LimitsSection() { {/* Circuit Breaker Settings */} - -
- {/* Circuit Breaker Parameters */} -
- -
- { - const val = e.target.value; - dispatch({ - type: "SET_FAILURE_THRESHOLD", - payload: val === "" ? undefined : parseInt(val, 10), - }); - }} - placeholder={t("sections.circuitBreaker.failureThreshold.placeholder")} - disabled={state.ui.isPending} - min="0" - step="1" - className={cn(state.circuitBreaker.failureThreshold === 0 && "border-yellow-500")} - /> - -
- {state.circuitBreaker.failureThreshold === 0 && ( -

- {t("sections.circuitBreaker.failureThreshold.warning")} -

- )} -
+
+ +
+ {/* Circuit Breaker Parameters */} +
+ +
+ { + const val = e.target.value; + dispatch({ + type: "SET_FAILURE_THRESHOLD", + payload: val === "" ? undefined : parseInt(val, 10), + }); + }} + placeholder={t("sections.circuitBreaker.failureThreshold.placeholder")} + disabled={state.ui.isPending} + min="0" + step="1" + className={cn( + state.circuitBreaker.failureThreshold === 0 && "border-yellow-500" + )} + /> + +
+ {state.circuitBreaker.failureThreshold === 0 && ( +

+ {t("sections.circuitBreaker.failureThreshold.warning")} +

+ )} +
+ + +
+ { + const val = e.target.value; + dispatch({ + type: "SET_OPEN_DURATION_MINUTES", + payload: val === "" ? undefined : parseInt(val, 10), + }); + }} + placeholder={t("sections.circuitBreaker.openDuration.placeholder")} + disabled={state.ui.isPending} + min="1" + max="1440" + step="1" + className="pr-12" + /> + + min + +
+
- -
+ { const val = e.target.value; dispatch({ - type: "SET_OPEN_DURATION_MINUTES", + type: "SET_HALF_OPEN_SUCCESS_THRESHOLD", payload: val === "" ? undefined : parseInt(val, 10), }); }} - placeholder={t("sections.circuitBreaker.openDuration.placeholder")} + placeholder={t("sections.circuitBreaker.successThreshold.placeholder")} disabled={state.ui.isPending} min="1" - max="1440" + max="10" step="1" - className="pr-12" /> - - min - -
-
+ - - { - const val = e.target.value; - dispatch({ - type: "SET_HALF_OPEN_SUCCESS_THRESHOLD", - payload: val === "" ? undefined : parseInt(val, 10), - }); - }} - placeholder={t("sections.circuitBreaker.successThreshold.placeholder")} - disabled={state.ui.isPending} - min="1" - max="10" - step="1" - /> - + +
+ { + const val = e.target.value; + dispatch({ + type: "SET_MAX_RETRY_ATTEMPTS", + payload: val === "" ? null : parseInt(val, 10), + }); + }} + placeholder={t("sections.circuitBreaker.maxRetryAttempts.placeholder")} + disabled={state.ui.isPending} + min="1" + max="10" + step="1" + /> + +
+
+
- -
- { - const val = e.target.value; - dispatch({ - type: "SET_MAX_RETRY_ATTEMPTS", - payload: val === "" ? null : parseInt(val, 10), - }); - }} - placeholder={t("sections.circuitBreaker.maxRetryAttempts.placeholder")} - disabled={state.ui.isPending} - min="1" - max="10" - step="1" - /> - + {/* Circuit Breaker Status Indicator */} +
+ +
+ {t("sections.circuitBreaker.summary", { + failureThreshold: state.circuitBreaker.failureThreshold ?? 5, + openDuration: state.circuitBreaker.openDurationMinutes ?? 30, + successThreshold: state.circuitBreaker.halfOpenSuccessThreshold ?? 2, + maxRetryAttempts: + state.circuitBreaker.maxRetryAttempts ?? PROVIDER_DEFAULTS.MAX_RETRY_ATTEMPTS, + })}
- -
- - {/* Circuit Breaker Status Indicator */} -
- -
- {t("sections.circuitBreaker.summary", { - failureThreshold: state.circuitBreaker.failureThreshold ?? 5, - openDuration: state.circuitBreaker.openDurationMinutes ?? 30, - successThreshold: state.circuitBreaker.halfOpenSuccessThreshold ?? 2, - maxRetryAttempts: - state.circuitBreaker.maxRetryAttempts ?? PROVIDER_DEFAULTS.MAX_RETRY_ATTEMPTS, - })}
-
- + +
); } diff --git a/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/network-section.tsx b/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/network-section.tsx index 021733be9..cd420d6fb 100644 --- a/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/network-section.tsx +++ b/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/network-section.tsx @@ -110,7 +110,13 @@ function TimeoutInput({ ); } -export function NetworkSection() { +interface NetworkSectionProps { + subSectionRefs?: { + timeout?: (el: HTMLDivElement | null) => void; + }; +} + +export function NetworkSection({ subSectionRefs }: NetworkSectionProps) { const t = useTranslations("settings.providers.form"); const { state, dispatch, mode } = useProviderForm(); const isEdit = mode === "edit"; @@ -198,88 +204,90 @@ export function NetworkSection() {
{/* Timeout Configuration */} - -
- -
- - dispatch({ type: "SET_FIRST_BYTE_TIMEOUT_STREAMING", payload: value }) - } - disabled={state.ui.isPending} - min="0" - max="180" - icon={Clock} - isCore={true} - /> +
+ +
+ +
+ + dispatch({ type: "SET_FIRST_BYTE_TIMEOUT_STREAMING", payload: value }) + } + disabled={state.ui.isPending} + min="0" + max="180" + icon={Clock} + isCore={true} + /> - - dispatch({ type: "SET_STREAMING_IDLE_TIMEOUT", payload: value }) - } - disabled={state.ui.isPending} - min="0" - max="600" - icon={Timer} - isCore={true} - /> + + dispatch({ type: "SET_STREAMING_IDLE_TIMEOUT", payload: value }) + } + disabled={state.ui.isPending} + min="0" + max="600" + icon={Timer} + isCore={true} + /> - - dispatch({ type: "SET_REQUEST_TIMEOUT_NON_STREAMING", payload: value }) - } - disabled={state.ui.isPending} - min="0" - max="1200" - icon={Clock} - isCore={true} - /> -
-
+ + dispatch({ type: "SET_REQUEST_TIMEOUT_NON_STREAMING", payload: value }) + } + disabled={state.ui.isPending} + min="0" + max="1200" + icon={Clock} + isCore={true} + /> +
+ - {/* Timeout Summary */} -
- -
- {t("sections.timeout.summary", { - streaming: - state.network.firstByteTimeoutStreamingSeconds ?? - PROVIDER_TIMEOUT_DEFAULTS.FIRST_BYTE_TIMEOUT_STREAMING_MS / 1000, - idle: - state.network.streamingIdleTimeoutSeconds ?? - PROVIDER_TIMEOUT_DEFAULTS.STREAMING_IDLE_TIMEOUT_MS / 1000, - nonStreaming: - state.network.requestTimeoutNonStreamingSeconds ?? - PROVIDER_TIMEOUT_DEFAULTS.REQUEST_TIMEOUT_NON_STREAMING_MS / 1000, - })} + {/* Timeout Summary */} +
+ +
+ {t("sections.timeout.summary", { + streaming: + state.network.firstByteTimeoutStreamingSeconds ?? + PROVIDER_TIMEOUT_DEFAULTS.FIRST_BYTE_TIMEOUT_STREAMING_MS / 1000, + idle: + state.network.streamingIdleTimeoutSeconds ?? + PROVIDER_TIMEOUT_DEFAULTS.STREAMING_IDLE_TIMEOUT_MS / 1000, + nonStreaming: + state.network.requestTimeoutNonStreamingSeconds ?? + PROVIDER_TIMEOUT_DEFAULTS.REQUEST_TIMEOUT_NON_STREAMING_MS / 1000, + })} +
-
-

{t("sections.timeout.disableHint")}

-
-
+

{t("sections.timeout.disableHint")}

+
+ +
); } diff --git a/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/options-section.tsx b/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/options-section.tsx new file mode 100644 index 000000000..7f1678072 --- /dev/null +++ b/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/options-section.tsx @@ -0,0 +1,571 @@ +"use client"; + +import { motion } from "framer-motion"; +import { Clock, Info, Settings, Timer } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { Badge } from "@/components/ui/badge"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import type { + CodexParallelToolCallsPreference, + CodexReasoningEffortPreference, + CodexReasoningSummaryPreference, + CodexServiceTierPreference, + CodexTextVerbosityPreference, + GeminiGoogleSearchPreference, +} from "@/types/provider"; +import { AdaptiveThinkingEditor } from "../../../adaptive-thinking-editor"; +import { ThinkingBudgetEditor } from "../../../thinking-budget-editor"; +import { SectionCard, SmartInputWrapper, ToggleRow } from "../components/section-card"; +import { useProviderForm } from "../provider-form-context"; + +interface OptionsSectionProps { + subSectionRefs?: { + activeTime?: (el: HTMLDivElement | null) => void; + }; +} + +export function OptionsSection({ subSectionRefs }: OptionsSectionProps) { + const t = useTranslations("settings.providers.form"); + const tBatch = useTranslations("settings.providers.batchEdit"); + const { state, dispatch, mode } = useProviderForm(); + const isEdit = mode === "edit"; + const isBatch = mode === "batch"; + const providerType = state.routing.providerType; + + return ( + + +
+ {/* Advanced Settings */} + +
+ + + dispatch({ type: "SET_PRESERVE_CLIENT_IP", payload: checked }) + } + disabled={state.ui.isPending} + /> + + + {/* Swap Cache TTL Billing */} + + + dispatch({ type: "SET_SWAP_CACHE_TTL_BILLING", payload: checked }) + } + disabled={state.ui.isPending} + /> + + + {/* Cache TTL */} + + + + + {/* 1M Context Window - Claude type only (or batch mode) */} + {(providerType === "claude" || providerType === "claude-auth" || isBatch) && ( + + + + )} +
+
+ + {/* Codex Overrides - Codex type only (or batch mode) */} + {(providerType === "codex" || isBatch) && ( + {tBatch("batchNotes.codexOnly")} + ) : undefined + } + > +
+ + + +
+ +
+
+ +

+ {t("sections.routing.codexOverrides.reasoningEffort.help")} +

+
+
+
+ + + + + + + + + + + + + + + + +
+ +
+
+ +

+ {t("sections.routing.codexOverrides.serviceTier.help")} +

+
+
+
+
+
+ )} + + {/* Anthropic Overrides - Claude type only (or batch mode) */} + {(providerType === "claude" || providerType === "claude-auth" || isBatch) && ( + {tBatch("batchNotes.claudeOnly")} + ) : undefined + } + > +
+ +
+ + {state.routing.anthropicMaxTokensPreference !== "inherit" && ( + { + const val = e.target.value; + if (val === "") { + dispatch({ type: "SET_ANTHROPIC_MAX_TOKENS", payload: "inherit" }); + } else { + dispatch({ type: "SET_ANTHROPIC_MAX_TOKENS", payload: val }); + } + }} + placeholder={t("sections.routing.anthropicOverrides.maxTokens.placeholder")} + disabled={state.ui.isPending} + min="1" + max="64000" + className="flex-1" + /> + )} +
+
+ + + + dispatch({ + type: "SET_ANTHROPIC_THINKING_BUDGET", + payload: val, + }) + } + disabled={state.ui.isPending} + /> + + + + dispatch({ type: "SET_ADAPTIVE_THINKING_ENABLED", payload: enabled }) + } + onConfigChange={(newConfig) => { + dispatch({ + type: "SET_ADAPTIVE_THINKING_EFFORT", + payload: newConfig.effort, + }); + dispatch({ + type: "SET_ADAPTIVE_THINKING_MODEL_MATCH_MODE", + payload: newConfig.modelMatchMode, + }); + dispatch({ + type: "SET_ADAPTIVE_THINKING_MODELS", + payload: newConfig.models, + }); + }} + disabled={state.ui.isPending} + /> +
+
+ )} + + {/* Gemini Overrides - Gemini type only (or batch mode) */} + {(providerType === "gemini" || providerType === "gemini-cli" || isBatch) && ( + {tBatch("batchNotes.geminiOnly")} + ) : undefined + } + > + + + + + )} + + {/* Scheduled Active Time */} +
+ +
+ + { + if (checked) { + dispatch({ type: "SET_ACTIVE_TIME_START", payload: "09:00" }); + dispatch({ type: "SET_ACTIVE_TIME_END", payload: "22:00" }); + } else { + dispatch({ type: "SET_ACTIVE_TIME_START", payload: null }); + dispatch({ type: "SET_ACTIVE_TIME_END", payload: null }); + } + }} + disabled={state.ui.isPending} + /> + + + {state.routing.activeTimeStart !== null && state.routing.activeTimeEnd !== null && ( +
+
+ + + dispatch({ type: "SET_ACTIVE_TIME_START", payload: e.target.value }) + } + disabled={state.ui.isPending} + /> + + + + dispatch({ type: "SET_ACTIVE_TIME_END", payload: e.target.value }) + } + disabled={state.ui.isPending} + /> + +
+

+ {t("sections.routing.activeTime.timezoneNote")} +

+ {state.routing.activeTimeStart > state.routing.activeTimeEnd && ( +

+ {t("sections.routing.activeTime.crossDayHint", { + start: state.routing.activeTimeStart, + end: state.routing.activeTimeEnd, + })} +

+ )} +
+ )} +
+
+
+
+
+
+ ); +} diff --git a/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/routing-section.tsx b/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/routing-section.tsx index e222e2cd0..cb1f6ddd4 100644 --- a/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/routing-section.tsx +++ b/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/routing-section.tsx @@ -1,7 +1,7 @@ "use client"; import { motion } from "framer-motion"; -import { Clock, Info, Layers, Route, Scale, Settings, Timer } from "lucide-react"; +import { Info, Layers, Route, Scale } from "lucide-react"; import { useTranslations } from "next-intl"; import { useEffect, useState } from "react"; import { toast } from "sonner"; @@ -17,35 +17,28 @@ import { } from "@/components/ui/select"; import { Switch } from "@/components/ui/switch"; import { TagInput } from "@/components/ui/tag-input"; -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { getProviderTypeConfig } from "@/lib/provider-type-utils"; -import type { - CodexParallelToolCallsPreference, - CodexReasoningEffortPreference, - CodexReasoningSummaryPreference, - CodexServiceTierPreference, - CodexTextVerbosityPreference, - GeminiGoogleSearchPreference, - ProviderType, -} from "@/types/provider"; -import { AdaptiveThinkingEditor } from "../../../adaptive-thinking-editor"; +import type { ProviderType } from "@/types/provider"; import { ModelMultiSelect } from "../../../model-multi-select"; import { ModelRedirectEditor } from "../../../model-redirect-editor"; -import { ThinkingBudgetEditor } from "../../../thinking-budget-editor"; import { FieldGroup, SectionCard, SmartInputWrapper, ToggleRow } from "../components/section-card"; import { useProviderForm } from "../provider-form-context"; const GROUP_TAG_MAX_TOTAL_LENGTH = 50; -export function RoutingSection() { +interface RoutingSectionProps { + subSectionRefs?: { + scheduling?: (el: HTMLDivElement | null) => void; + }; +} + +export function RoutingSection({ subSectionRefs }: RoutingSectionProps) { const t = useTranslations("settings.providers.form"); - const tBatch = useTranslations("settings.providers.batchEdit"); const tUI = useTranslations("ui.tagInput"); const { state, dispatch, mode, provider, enableMultiProviderTypes, groupSuggestions } = useProviderForm(); const isEdit = mode === "edit"; const isBatch = mode === "batch"; - const { providerType } = state.routing; const renderProviderTypeLabel = (type: ProviderType) => { switch (type) { @@ -93,231 +86,224 @@ export function RoutingSection() { }; return ( - - - {/* Provider Type & Group - hidden in batch mode */} - {!isBatch && ( - -
- - - {!enableMultiProviderTypes && - state.routing.providerType === "openai-compatible" && ( -

- {t("sections.routing.providerTypeDisabledNote")} -

- )} -
- - - { - const messages: Record = { - empty: tUI("emptyTag"), - duplicate: tUI("duplicateTag"), - too_long: tUI("tooLong", { max: GROUP_TAG_MAX_TOTAL_LENGTH }), - invalid_format: tUI("invalidFormat"), - max_tags: tUI("maxTags"), - }; - toast.error(messages[reason] || reason); - }} - /> - -
-
- )} - - {/* Model Configuration */} + + {/* Provider Type & Group - hidden in batch mode */} + {!isBatch && (
- {/* Model Redirects */} - - ) => - dispatch({ type: "SET_MODEL_REDIRECTS", payload: value }) + + + {!enableMultiProviderTypes && state.routing.providerType === "openai-compatible" && ( +

+ {t("sections.routing.providerTypeDisabledNote")}

-
-
+ )} + - - { + const messages: Record = { + empty: tUI("emptyTag"), + duplicate: tUI("duplicateTag"), + too_long: tUI("tooLong", { max: GROUP_TAG_MAX_TOTAL_LENGTH }), + invalid_format: tUI("invalidFormat"), + max_tags: tUI("maxTags"), + }; + toast.error(messages[reason] || tUI("unknownError")); + }} /> - + +
+
+ )} - {clientRestrictionsEnabled && ( -
-
-

- {t("sections.routing.clientRestrictions.priorityNote")} -

-

- {t("sections.routing.clientRestrictions.customHelp")} -

+ {/* Model Configuration */} + +
+ {/* Model Redirects */} + + ) => + dispatch({ type: "SET_MODEL_REDIRECTS", payload: value }) + } + disabled={state.ui.isPending} + /> + + + {/* Allowed Models */} + +
+ + dispatch({ type: "SET_ALLOWED_MODELS", payload: value }) + } + disabled={state.ui.isPending} + providerUrl={state.basic.url} + apiKey={state.basic.key} + proxyUrl={state.network.proxyUrl} + proxyFallbackToDirect={state.network.proxyFallbackToDirect} + providerId={isEdit ? provider?.id : undefined} + /> + {state.routing.allowedModels.length > 0 && ( +
+ {state.routing.allowedModels.slice(0, 5).map((model) => ( + + {model} + + ))} + {state.routing.allowedModels.length > 5 && ( + + {t("sections.routing.modelWhitelist.moreModels", { + count: state.routing.allowedModels.length - 5, + })} + + )}
+ )} +

+ {state.routing.allowedModels.length === 0 ? ( + + {t("sections.routing.modelWhitelist.allowAll")} + + ) : ( + + {t("sections.routing.modelWhitelist.selectedOnly", { + count: state.routing.allowedModels.length, + })} + + )} +

+
+
- - dispatch({ type: "SET_ALLOWED_CLIENTS", payload: next }) - } - onBlockedChange={(next) => - dispatch({ type: "SET_BLOCKED_CLIENTS", payload: next }) - } - disabled={state.ui.isPending} - translations={{ - allowAction: t("sections.routing.clientRestrictions.allowAction"), - blockAction: t("sections.routing.clientRestrictions.blockAction"), - customAllowedLabel: t("sections.routing.clientRestrictions.customAllowedLabel"), - customAllowedPlaceholder: t( - "sections.routing.clientRestrictions.customAllowedPlaceholder" + + + + + {clientRestrictionsEnabled && ( +
+
+

+ {t("sections.routing.clientRestrictions.priorityNote")} +

+

+ {t("sections.routing.clientRestrictions.customHelp")} +

+
+ + dispatch({ type: "SET_ALLOWED_CLIENTS", payload: next })} + onBlockedChange={(next) => dispatch({ type: "SET_BLOCKED_CLIENTS", payload: next })} + disabled={state.ui.isPending} + translations={{ + allowAction: t("sections.routing.clientRestrictions.allowAction"), + blockAction: t("sections.routing.clientRestrictions.blockAction"), + customAllowedLabel: t("sections.routing.clientRestrictions.customAllowedLabel"), + customAllowedPlaceholder: t( + "sections.routing.clientRestrictions.customAllowedPlaceholder" + ), + customBlockedLabel: t("sections.routing.clientRestrictions.customBlockedLabel"), + customBlockedPlaceholder: t( + "sections.routing.clientRestrictions.customBlockedPlaceholder" + ), + customHelp: t("sections.routing.clientRestrictions.customHelp"), + presetClients: { + "claude-code": t( + "sections.routing.clientRestrictions.presetClients.claude-code" ), - customBlockedLabel: t("sections.routing.clientRestrictions.customBlockedLabel"), - customBlockedPlaceholder: t( - "sections.routing.clientRestrictions.customBlockedPlaceholder" + "gemini-cli": t("sections.routing.clientRestrictions.presetClients.gemini-cli"), + "factory-cli": t( + "sections.routing.clientRestrictions.presetClients.factory-cli" ), - customHelp: t("sections.routing.clientRestrictions.customHelp"), - presetClients: { - "claude-code": t( - "sections.routing.clientRestrictions.presetClients.claude-code" - ), - "gemini-cli": t( - "sections.routing.clientRestrictions.presetClients.gemini-cli" - ), - "factory-cli": t( - "sections.routing.clientRestrictions.presetClients.factory-cli" - ), - "codex-cli": t("sections.routing.clientRestrictions.presetClients.codex-cli"), - }, - }} - onInvalidTag={(_tag, reason) => { - const messages: Record = { - empty: tUI("emptyTag"), - duplicate: tUI("duplicateTag"), - too_long: tUI("tooLong", { max: 64 }), - invalid_format: tUI("invalidFormat"), - max_tags: tUI("maxTags"), - }; - toast.error(messages[reason] || reason); - }} - /> -
- )} -
-
+ "codex-cli": t("sections.routing.clientRestrictions.presetClients.codex-cli"), + }, + }} + onInvalidTag={(_tag, reason) => { + const messages: Record = { + empty: tUI("emptyTag"), + duplicate: tUI("duplicateTag"), + too_long: tUI("tooLong", { max: 64 }), + invalid_format: tUI("invalidFormat"), + max_tags: tUI("maxTags"), + }; + toast.error(messages[reason] || tUI("unknownError")); + }} + /> +
+ )} +
+ - {/* Scheduling Parameters */} + {/* Scheduling Parameters */} +
e.target.select()} placeholder={t("sections.routing.scheduleParams.costMultiplier.placeholder")} @@ -426,510 +415,7 @@ export function RoutingSection() {
)} - - {/* Advanced Settings */} - -
- - - dispatch({ type: "SET_PRESERVE_CLIENT_IP", payload: checked }) - } - disabled={state.ui.isPending} - /> - - - {/* Swap Cache TTL Billing */} - - - dispatch({ type: "SET_SWAP_CACHE_TTL_BILLING", payload: checked }) - } - disabled={state.ui.isPending} - /> - - - {/* Cache TTL */} - - - - - {/* 1M Context Window - Claude type only (or batch mode) */} - {(providerType === "claude" || providerType === "claude-auth" || isBatch) && ( - - - - )} -
-
- - {/* Codex Overrides - Codex type only (or batch mode) */} - {(providerType === "codex" || isBatch) && ( - {tBatch("batchNotes.codexOnly")} - ) : undefined - } - > -
- - - -
- -
-
- -

- {t("sections.routing.codexOverrides.reasoningEffort.help")} -

-
-
-
- - - - - - - - - - - - - - - - -
- -
-
- -

- {t("sections.routing.codexOverrides.serviceTier.help")} -

-
-
-
-
-
- )} - - {/* Anthropic Overrides - Claude type only (or batch mode) */} - {(providerType === "claude" || providerType === "claude-auth" || isBatch) && ( - {tBatch("batchNotes.claudeOnly")} - ) : undefined - } - > -
- -
- - {state.routing.anthropicMaxTokensPreference !== "inherit" && ( - { - const val = e.target.value; - if (val === "") { - dispatch({ type: "SET_ANTHROPIC_MAX_TOKENS", payload: "inherit" }); - } else { - dispatch({ type: "SET_ANTHROPIC_MAX_TOKENS", payload: val }); - } - }} - placeholder={t("sections.routing.anthropicOverrides.maxTokens.placeholder")} - disabled={state.ui.isPending} - min="1" - max="64000" - className="flex-1" - /> - )} -
-
- - - - dispatch({ - type: "SET_ANTHROPIC_THINKING_BUDGET", - payload: val, - }) - } - disabled={state.ui.isPending} - /> - - - - dispatch({ type: "SET_ADAPTIVE_THINKING_ENABLED", payload: enabled }) - } - onConfigChange={(newConfig) => { - dispatch({ - type: "SET_ADAPTIVE_THINKING_EFFORT", - payload: newConfig.effort, - }); - dispatch({ - type: "SET_ADAPTIVE_THINKING_MODEL_MATCH_MODE", - payload: newConfig.modelMatchMode, - }); - dispatch({ - type: "SET_ADAPTIVE_THINKING_MODELS", - payload: newConfig.models, - }); - }} - disabled={state.ui.isPending} - /> -
-
- )} - - {/* Gemini Overrides - Gemini type only (or batch mode) */} - {(providerType === "gemini" || providerType === "gemini-cli" || isBatch) && ( - {tBatch("batchNotes.geminiOnly")} - ) : undefined - } - > - - - - - )} - - {/* Scheduled Active Time */} - -
- - { - if (checked) { - dispatch({ type: "SET_ACTIVE_TIME_START", payload: "09:00" }); - dispatch({ type: "SET_ACTIVE_TIME_END", payload: "22:00" }); - } else { - dispatch({ type: "SET_ACTIVE_TIME_START", payload: null }); - dispatch({ type: "SET_ACTIVE_TIME_END", payload: null }); - } - }} - disabled={state.ui.isPending} - /> - - - {state.routing.activeTimeStart !== null && state.routing.activeTimeEnd !== null && ( -
-
- - - dispatch({ type: "SET_ACTIVE_TIME_START", payload: e.target.value }) - } - disabled={state.ui.isPending} - /> - - - - dispatch({ type: "SET_ACTIVE_TIME_END", payload: e.target.value }) - } - disabled={state.ui.isPending} - /> - -
-

- {t("sections.routing.activeTime.timezoneNote")} -

- {state.routing.activeTimeStart > state.routing.activeTimeEnd && ( -

- {t("sections.routing.activeTime.crossDayHint", { - start: state.routing.activeTimeStart, - end: state.routing.activeTimeEnd, - })} -

- )} -
- )} -
-
- - +
+ ); } diff --git a/src/app/[locale]/settings/providers/_components/provider-list.tsx b/src/app/[locale]/settings/providers/_components/provider-list.tsx index 2b209b39a..d3ebb5c44 100644 --- a/src/app/[locale]/settings/providers/_components/provider-list.tsx +++ b/src/app/[locale]/settings/providers/_components/provider-list.tsx @@ -4,6 +4,7 @@ import { Globe } from "lucide-react"; import { useTranslations } from "next-intl"; import { useMemo } from "react"; import { getProviderVendors } from "@/actions/provider-endpoints"; +import { TooltipProvider } from "@/components/ui/tooltip"; import type { CurrencyCode } from "@/lib/utils/currency"; import type { ProviderDisplay, ProviderStatisticsMap } from "@/types/provider"; import type { User } from "@/types/user"; @@ -81,30 +82,34 @@ export function ProviderList({ } return ( -
- {providers.map((provider) => ( - onSelectProvider(provider.id, checked) : undefined - } - allGroups={allGroups} - userGroups={userGroups} - isAdmin={isAdmin} - /> - ))} -
+ +
+ {providers.map((provider) => ( + onSelectProvider(provider.id, checked) : undefined + } + allGroups={allGroups} + userGroups={userGroups} + isAdmin={isAdmin} + /> + ))} +
+
); } diff --git a/src/app/[locale]/settings/providers/_components/provider-rich-list-item.tsx b/src/app/[locale]/settings/providers/_components/provider-rich-list-item.tsx index 5681eb8de..af485b7c6 100644 --- a/src/app/[locale]/settings/providers/_components/provider-rich-list-item.tsx +++ b/src/app/[locale]/settings/providers/_components/provider-rich-list-item.tsx @@ -11,6 +11,7 @@ import { Key, MoreHorizontal, RotateCcw, + ShieldCheck, Trash, XCircle, } from "lucide-react"; @@ -55,6 +56,7 @@ import { } from "@/components/ui/dropdown-menu"; import { Skeleton } from "@/components/ui/skeleton"; import { Switch } from "@/components/ui/switch"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { PROVIDER_GROUP, PROVIDER_LIMITS } from "@/lib/constants/provider.constants"; import { PROVIDER_BATCH_PATCH_ERROR_CODES } from "@/lib/provider-batch-patch-error-codes"; import { getProviderTypeConfig, getProviderTypeTranslationKey } from "@/lib/provider-type-utils"; @@ -766,6 +768,18 @@ export function ProviderRichListItem({ ) : ( {provider.url} )} + {provider.proxyUrl && ( + + + + + + + + {tList("proxyEnabled")} + + + )} {/* 官网链接 */} {provider.websiteUrl && ( diff --git a/src/components/ui/chart.tsx b/src/components/ui/chart.tsx index 6e9cbc01f..2b92ee2ff 100644 --- a/src/components/ui/chart.tsx +++ b/src/components/ui/chart.tsx @@ -170,87 +170,76 @@ function ChartTooltipContent({
{payload .filter((item: { type?: string }) => item.type !== "none") - .map( - ( - item: { - dataKey?: string | number; - name?: string; - payload?: { fill?: string }; - color?: string; - value?: number | string; - }, - index: number - ) => { - const key = `${nameKey || item.name || item.dataKey || "value"}`; - const itemConfig = getPayloadConfigFromPayload(config, item, key); - const indicatorColor = color || item.payload?.fill || item.color; - - return ( -
svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5", - indicator === "dot" && "items-center" - )} - > - {formatter && item?.value !== undefined && item.name ? ( - formatter( - item.value, - item.name, - item as Parameters[2], - index, - payload - ) - ) : ( - <> - {itemConfig?.icon ? ( - - ) : ( - !hideIndicator && ( -
{ + const key = `${nameKey || item.name || String(item.dataKey ?? "") || "value"}`; + const itemConfig = getPayloadConfigFromPayload(config, item, key); + const indicatorColor = color || item.payload?.fill || item.color; + + return ( +
svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5", + indicator === "dot" && "items-center" + )} + > + {formatter && item?.value !== undefined && item.name ? ( + formatter( + item.value as string | number, + item.name as string, + item as Parameters[2], + index, + payload + ) + ) : ( + <> + {itemConfig?.icon ? ( + + ) : ( + !hideIndicator && ( +
- ) + )} + style={ + { + "--color-bg": indicatorColor, + "--color-border": indicatorColor, + } as React.CSSProperties + } + /> + ) + )} +
-
- {nestLabel ? tooltipLabel : null} - - {itemConfig?.label || item.name} - -
- {item.value && ( - - {item.value.toLocaleString()} - - )} + > +
+ {nestLabel ? tooltipLabel : null} + + {itemConfig?.label || item.name} +
- - )} -
- ); - } - )} + {item.value && ( + + {item.value.toLocaleString()} + + )} +
+ + )} +
+ ); + })}
); diff --git a/tests/unit/lib/cost-calculation-breakdown.test.ts b/tests/unit/lib/cost-calculation-breakdown.test.ts index b8589ffb9..af49fdd3f 100644 --- a/tests/unit/lib/cost-calculation-breakdown.test.ts +++ b/tests/unit/lib/cost-calculation-breakdown.test.ts @@ -80,10 +80,10 @@ describe("calculateRequestCostBreakdown", () => { true // context1mApplied ); - // input: 200000 * 0.000003 + 100000 * 0.000003 * 2.0 = 0.6 + 0.6 = 1.2 - expect(result.input).toBeCloseTo(1.2, 4); - // output: 100 tokens, below 200k threshold - expect(result.output).toBeCloseTo(0.0015, 6); + // input: 300000 * 0.000003 * 2.0 = 1.8 (all tokens at premium when context > 200K) + expect(result.input).toBeCloseTo(1.8, 4); + // output: 100 * 0.000015 * 1.5 = 0.00225 (output also at premium when context > 200K) + expect(result.output).toBeCloseTo(0.00225, 6); }); test("200k tier pricing (Gemini style)", () => { @@ -97,8 +97,8 @@ describe("calculateRequestCostBreakdown", () => { }) ); - // input: 200000 * 0.000003 + 100000 * 0.000006 = 0.6 + 0.6 = 1.2 - expect(result.input).toBeCloseTo(1.2, 4); + // input: 300000 * 0.000006 = 1.8 (all tokens at above-200k rate when context > 200K) + expect(result.input).toBeCloseTo(1.8, 4); }); test("categories sum to total", () => { diff --git a/tests/unit/proxy/response-handler-lease-decrement.test.ts b/tests/unit/proxy/response-handler-lease-decrement.test.ts index 1100256ef..a3a6a3f98 100644 --- a/tests/unit/proxy/response-handler-lease-decrement.test.ts +++ b/tests/unit/proxy/response-handler-lease-decrement.test.ts @@ -158,13 +158,19 @@ function createSession(opts: { specialSettings: [], cachedPriceData: undefined, cachedBillingModelSource: undefined, + resolvedPricingCache: new Map(), endpointPolicy: resolveEndpointPolicy("/v1/messages"), isHeaderModified: () => false, getContext1mApplied: () => false, getOriginalModel: () => originalModel, getCurrentModel: () => redirectedModel, getProviderChain: () => [], - getCachedPriceDataByBillingSource: async () => testPriceData, + getResolvedPricingByBillingSource: async () => ({ + resolvedModelName: redirectedModel, + resolvedPricingProviderKey: "test-provider", + source: "cloud_exact" as const, + priceData: testPriceData, + }), recordTtfb: () => 100, ttfbMs: null, getRequestSequence: () => 1, @@ -374,10 +380,22 @@ describe("Lease Budget Decrement after trackCostToRedis", () => { messageId: 5003, }); - // Override getCachedPriceDataByBillingSource to return zero prices + // Override getResolvedPricingByBillingSource to return zero prices ( - session as { getCachedPriceDataByBillingSource: () => Promise } - ).getCachedPriceDataByBillingSource = async () => zeroPriceData; + session as { + getResolvedPricingByBillingSource: () => Promise<{ + resolvedModelName: string; + resolvedPricingProviderKey: string; + source: string; + priceData: ModelPriceData; + }>; + } + ).getResolvedPricingByBillingSource = async () => ({ + resolvedModelName: originalModel, + resolvedPricingProviderKey: "test-provider", + source: "cloud_exact" as const, + priceData: zeroPriceData, + }); const response = createNonStreamResponse(usage); await ProxyResponseHandler.dispatch(session, response); diff --git a/tests/unit/settings/providers/form-tab-nav.test.tsx b/tests/unit/settings/providers/form-tab-nav.test.tsx index 8dfeec4af..387bcaa30 100644 --- a/tests/unit/settings/providers/form-tab-nav.test.tsx +++ b/tests/unit/settings/providers/form-tab-nav.test.tsx @@ -26,15 +26,25 @@ vi.mock("framer-motion", () => ({ vi.mock("lucide-react", () => { const stub = ({ className }: any) => ; return { + Clock: stub, FileText: stub, Route: stub, Gauge: stub, Network: stub, FlaskConical: stub, + Scale: stub, + Settings: stub, + Shield: stub, + Timer: stub, }; }); -import { FormTabNav } from "@/app/[locale]/settings/providers/_components/forms/provider-form/components/form-tab-nav"; +import { + FormTabNav, + NAV_ORDER, + PARENT_MAP, + TAB_ORDER, +} from "@/app/[locale]/settings/providers/_components/forms/provider-form/components/form-tab-nav"; // --------------------------------------------------------------------------- // Render helper (matches project convention) @@ -71,12 +81,12 @@ describe("FormTabNav", () => { // -- Default (vertical) layout ------------------------------------------- describe("default vertical layout", () => { - it("renders all 5 tabs across 3 responsive breakpoints (15 total)", () => { + it("renders all tabs across 3 responsive breakpoints (22 total when no children on active tab)", () => { const { container, unmount } = render(); - // Desktop (5) + Tablet (5) + Mobile (5) = 15 + // Desktop (10) + Tablet (6) + Mobile (6) = 22 const buttons = container.querySelectorAll("button"); - expect(buttons.length).toBe(15); + expect(buttons.length).toBe(22); unmount(); }); @@ -91,6 +101,129 @@ describe("FormTabNav", () => { unmount(); }); + + it("renders sub-items only in the desktop sidebar", () => { + const { container, unmount } = render(); + const desktopNav = container.querySelector("nav"); + const desktopButtons = desktopNav!.querySelectorAll("button"); + expect(desktopButtons.length).toBe(10); + unmount(); + }); + + it("calls onSubTabChange when a sub-item is clicked", () => { + const onSubTabChange = vi.fn(); + const { container, unmount } = render( + + ); + const desktopNav = container.querySelector("nav"); + const desktopButtons = desktopNav!.querySelectorAll("button"); + act(() => { + desktopButtons[2].click(); + }); + expect(onSubTabChange).toHaveBeenCalledWith("scheduling"); + unmount(); + }); + + it("calls onSubTabChange when the activeTime sub-item is clicked", () => { + const onSubTabChange = vi.fn(); + const { container, unmount } = render( + + ); + const desktopNav = container.querySelector("nav"); + const activeTimeButton = Array.from(desktopNav!.querySelectorAll("button")).find((button) => + button.textContent?.includes("tabs.activeTime") + ); + + expect(activeTimeButton).toBeTruthy(); + + act(() => { + activeTimeButton!.click(); + }); + + expect(onSubTabChange).toHaveBeenCalledWith("activeTime"); + unmount(); + }); + + it("highlights active sub-item with text-primary", () => { + const { container, unmount } = render( + + ); + const desktopNav = container.querySelector("nav"); + const desktopButtons = desktopNav!.querySelectorAll("button"); + expect(desktopButtons[2].className).toContain("text-primary"); + unmount(); + }); + + it("renders sub-items in tablet nav when active tab has children", () => { + const { container, unmount } = render(); + const navs = container.querySelectorAll("nav"); + // Second nav is tablet (hidden md:flex md:flex-col lg:hidden) + const tabletNav = navs[1]; + const schedulingBtn = Array.from(tabletNav!.querySelectorAll("button")).find((btn) => + btn.textContent?.includes("tabs.scheduling") + ); + expect(schedulingBtn).toBeTruthy(); + unmount(); + }); + + it("renders sub-items in mobile nav when active tab has children", () => { + const { container, unmount } = render(); + const navs = container.querySelectorAll("nav"); + // Third nav is mobile (flex md:hidden) + const mobileNav = navs[2]; + const schedulingBtn = Array.from(mobileNav!.querySelectorAll("button")).find((btn) => + btn.textContent?.includes("tabs.scheduling") + ); + expect(schedulingBtn).toBeTruthy(); + unmount(); + }); + + it("does not render sub-items in tablet/mobile when active tab has no children", () => { + const { container, unmount } = render(); + const navs = container.querySelectorAll("nav"); + const tabletNav = navs[1]; + const mobileNav = navs[2]; + // basic has no children, so no sub-item buttons beyond the main 6 + const tabletButtons = tabletNav!.querySelectorAll("button"); + expect(tabletButtons.length).toBe(6); + const mobileButtons = mobileNav!.querySelectorAll("button"); + expect(mobileButtons.length).toBe(6); + unmount(); + }); + + it("calls onSubTabChange from tablet sub-item click", () => { + const onSubTabChange = vi.fn(); + const { container, unmount } = render( + + ); + const navs = container.querySelectorAll("nav"); + const tabletNav = navs[1]; + const schedulingBtn = Array.from(tabletNav!.querySelectorAll("button")).find((btn) => + btn.textContent?.includes("tabs.scheduling") + ); + act(() => { + schedulingBtn!.click(); + }); + expect(onSubTabChange).toHaveBeenCalledWith("scheduling"); + unmount(); + }); + + it("calls onSubTabChange from mobile sub-item click", () => { + const onSubTabChange = vi.fn(); + const { container, unmount } = render( + + ); + const navs = container.querySelectorAll("nav"); + const mobileNav = navs[2]; + const schedulingBtn = Array.from(mobileNav!.querySelectorAll("button")).find((btn) => + btn.textContent?.includes("tabs.scheduling") + ); + act(() => { + schedulingBtn!.click(); + }); + expect(onSubTabChange).toHaveBeenCalledWith("scheduling"); + unmount(); + }); }); // -- Horizontal layout --------------------------------------------------- @@ -118,6 +251,13 @@ describe("FormTabNav", () => { unmount(); }); + it("does not render sub-items in horizontal layout", () => { + const { container, unmount } = render(); + const buttons = container.querySelectorAll("button"); + expect(buttons.length).toBe(6); + unmount(); + }); + it("highlights the active tab with text-primary", () => { const { container, unmount } = render( @@ -153,9 +293,9 @@ describe("FormTabNav", () => { ); const buttons = container.querySelectorAll("button"); - // Click the "network" tab (index 3) + // Click the "network" tab (index 4) act(() => { - buttons[3].click(); + buttons[4].click(); }); expect(onTabChange).toHaveBeenCalledWith("network"); @@ -199,8 +339,8 @@ describe("FormTabNav", () => { const routingDot = buttons[1].querySelector(".bg-yellow-500"); expect(routingDot).toBeTruthy(); - // limits (index 2) should have a primary dot - const limitsDot = buttons[2].querySelector(".bg-primary"); + // limits (index 3) should have a primary dot + const limitsDot = buttons[3].querySelector(".bg-primary"); expect(limitsDot).toBeTruthy(); // basic (index 0) should have no status dot @@ -210,4 +350,59 @@ describe("FormTabNav", () => { unmount(); }); }); + + describe("derived constants", () => { + it("TAB_ORDER has correct length matching NAV_CONFIG", () => { + expect(TAB_ORDER.length).toBe(6); + expect(TAB_ORDER).toEqual(["basic", "routing", "options", "limits", "network", "testing"]); + }); + + it("NAV_ORDER includes all tabs and sub-tabs", () => { + expect(NAV_ORDER).toEqual([ + "basic", + "routing", + "scheduling", + "options", + "activeTime", + "limits", + "circuitBreaker", + "network", + "timeout", + "testing", + ]); + }); + + it("PARENT_MAP maps each sub-tab to its parent", () => { + expect(PARENT_MAP).toEqual({ + scheduling: "routing", + activeTime: "options", + circuitBreaker: "limits", + timeout: "network", + }); + }); + }); + + describe("excludeTabs", () => { + it("hides excluded tabs in horizontal layout", () => { + const { container, unmount } = render( + + ); + const buttons = container.querySelectorAll("button"); + expect(buttons.length).toBe(5); + const labels = Array.from(buttons).map((btn) => btn.textContent); + expect(labels).not.toContain("tabs.options"); + unmount(); + }); + + it("hides excluded tabs in desktop sidebar", () => { + const { container, unmount } = render( + + ); + const desktopNav = container.querySelector("nav"); + const desktopButtons = desktopNav!.querySelectorAll("button"); + const labels = Array.from(desktopButtons).map((btn) => btn.textContent); + expect(labels).not.toContain("tabs.options"); + unmount(); + }); + }); }); diff --git a/tests/unit/settings/providers/options-section.test.tsx b/tests/unit/settings/providers/options-section.test.tsx new file mode 100644 index 000000000..f34141a7a --- /dev/null +++ b/tests/unit/settings/providers/options-section.test.tsx @@ -0,0 +1,534 @@ +/** @vitest-environment happy-dom */ + +const mockDispatch = vi.fn(); +const mockUseProviderForm = vi.fn(); + +vi.mock("next-intl", () => ({ useTranslations: () => (key: string) => key })); +vi.mock("framer-motion", () => ({ + motion: { div: ({ children, ...rest }: any) =>
{children}
}, +})); +vi.mock("lucide-react", () => { + const stub = ({ className }: any) => ; + return { Clock: stub, Info: stub, Settings: stub, Timer: stub }; +}); +vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } })); +vi.mock( + "@/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-context", + () => ({ + useProviderForm: (...args: any[]) => mockUseProviderForm(...args), + }) +); +vi.mock("@/app/[locale]/settings/providers/_components/adaptive-thinking-editor", () => ({ + AdaptiveThinkingEditor: (props: any) =>
, +})); +vi.mock("@/app/[locale]/settings/providers/_components/thinking-budget-editor", () => ({ + ThinkingBudgetEditor: (props: any) =>
, +})); +vi.mock("@/components/ui/badge", () => ({ + Badge: ({ children, className }: any) => {children}, +})); +vi.mock("@/components/ui/input", () => ({ + Input: (props: any) => , +})); +vi.mock("@/components/ui/select", () => ({ + Select: ({ children }: any) =>
{children}
, + SelectContent: ({ children }: any) =>
{children}
, + SelectItem: ({ children, value }: any) =>
{children}
, + SelectTrigger: ({ children, className }: any) =>
{children}
, + SelectValue: ({ placeholder }: any) => {placeholder}, +})); +vi.mock("@/components/ui/switch", () => ({ + Switch: ({ id, checked, onCheckedChange, disabled }: any) => ( +
+ {/* Enable Response Input Rectifier */} +
+
+
+ +
+
+

+ {t("enableResponseInputRectifier")} +

+

+ {t("enableResponseInputRectifierDesc")} +

+
+
+ setEnableResponseInputRectifier(checked)} + disabled={isPending} + /> +
+ {/* Enable Codex Session ID Completion */}
diff --git a/src/app/[locale]/settings/config/page.tsx b/src/app/[locale]/settings/config/page.tsx index 75bef5427..4a5373b37 100644 --- a/src/app/[locale]/settings/config/page.tsx +++ b/src/app/[locale]/settings/config/page.tsx @@ -51,6 +51,7 @@ async function SettingsConfigContent() { enableThinkingSignatureRectifier: settings.enableThinkingSignatureRectifier, enableThinkingBudgetRectifier: settings.enableThinkingBudgetRectifier, enableBillingHeaderRectifier: settings.enableBillingHeaderRectifier, + enableResponseInputRectifier: settings.enableResponseInputRectifier, enableCodexSessionIdCompletion: settings.enableCodexSessionIdCompletion, enableClaudeMetadataUserIdInjection: settings.enableClaudeMetadataUserIdInjection, enableResponseFixer: settings.enableResponseFixer, diff --git a/src/app/v1/_lib/proxy-handler.ts b/src/app/v1/_lib/proxy-handler.ts index 744791aa9..e9e07324e 100644 --- a/src/app/v1/_lib/proxy-handler.ts +++ b/src/app/v1/_lib/proxy-handler.ts @@ -9,6 +9,7 @@ import { detectClientFormat, detectFormatByEndpoint } from "./proxy/format-mappe import { ProxyForwarder } from "./proxy/forwarder"; import { GuardPipelineBuilder } from "./proxy/guard-pipeline"; import { ProxyResponseHandler } from "./proxy/response-handler"; +import { normalizeResponseInput } from "./proxy/response-input-rectifier"; import { ProxyResponses } from "./proxy/responses"; import { ProxySession } from "./proxy/session"; @@ -49,6 +50,11 @@ export async function handleProxyRequest(c: Context): Promise { } } + // Response API input rectifier: normalize non-array input before guard pipeline + if (session.originalFormat === "response") { + await normalizeResponseInput(session); + } + // Build guard pipeline from session endpoint policy const pipeline = GuardPipelineBuilder.fromSession(session); diff --git a/src/app/v1/_lib/proxy/format-mapper.ts b/src/app/v1/_lib/proxy/format-mapper.ts index faf17cfeb..9d4839f5f 100644 --- a/src/app/v1/_lib/proxy/format-mapper.ts +++ b/src/app/v1/_lib/proxy/format-mapper.ts @@ -121,6 +121,7 @@ export function detectClientFormat(requestBody: Record): Client } // 3. 检测 Response API (Codex) 格式 + // 仅通过 input 数组识别;字符串/单对象简写由 response-input-rectifier 在端点确认后规范化 if (Array.isArray(requestBody.input)) { return "response"; } diff --git a/src/app/v1/_lib/proxy/response-input-rectifier.ts b/src/app/v1/_lib/proxy/response-input-rectifier.ts new file mode 100644 index 000000000..a301fe442 --- /dev/null +++ b/src/app/v1/_lib/proxy/response-input-rectifier.ts @@ -0,0 +1,106 @@ +/** + * Response Input Rectifier + * + * OpenAI Responses API (/v1/responses) 的 input 字段支持多种格式: + * - 字符串简写: "hello" + * - 单对象: { role: "user", content: [...] } + * - 数组 (标准): [{ role: "user", content: [...] }] + * + * 下游代码 (format detection, converters) 要求 input 为数组。 + * 此整流器在 guard pipeline 之前将非数组 input 规范化为数组格式。 + */ + +import { getCachedSystemSettings } from "@/lib/config/system-settings-cache"; +import { logger } from "@/lib/logger"; +import type { ProxySession } from "./session"; + +export type ResponseInputRectifierAction = + | "string_to_array" + | "object_to_array" + | "empty_string_to_empty_array" + | "passthrough"; + +export type ResponseInputRectifierResult = { + applied: boolean; + action: ResponseInputRectifierAction; + originalType: "string" | "object" | "array" | "other"; +}; + +/** + * 规范化 Response API 请求体的 input 字段。 + * 原地修改 message 对象(与现有整流器约定一致)。 + */ +export function rectifyResponseInput( + message: Record +): ResponseInputRectifierResult { + const input = message.input; + + // Case 1: 数组 -- passthrough + if (Array.isArray(input)) { + return { applied: false, action: "passthrough", originalType: "array" }; + } + + // Case 2: 字符串 + if (typeof input === "string") { + if (input === "") { + message.input = []; + return { applied: true, action: "empty_string_to_empty_array", originalType: "string" }; + } + + message.input = [ + { + role: "user", + content: [{ type: "input_text", text: input }], + }, + ]; + return { applied: true, action: "string_to_array", originalType: "string" }; + } + + // Case 3: 单对象 (MessageInput 有 role, ToolOutputsInput 有 type) + if (typeof input === "object" && input !== null) { + const obj = input as Record; + if ("role" in obj || "type" in obj) { + message.input = [input]; + return { applied: true, action: "object_to_array", originalType: "object" }; + } + } + + // Case 4: undefined/null/其他 -- passthrough,让下游处理错误 + return { + applied: false, + action: "passthrough", + originalType: "other", + }; +} + +/** + * 入口:检查系统设置,执行整流,记录审计。 + * 在 proxy-handler.ts 中格式检测确认 "response" 后调用。 + */ +export async function normalizeResponseInput(session: ProxySession): Promise { + const settings = await getCachedSystemSettings(); + const enabled = settings.enableResponseInputRectifier ?? true; + + if (!enabled) { + return; + } + + const message = session.request.message as Record; + const result = rectifyResponseInput(message); + + if (result.applied) { + session.addSpecialSetting({ + type: "response_input_rectifier", + scope: "request", + hit: true, + action: result.action, + originalType: result.originalType, + }); + + logger.info("[ResponseInputRectifier] Input normalized", { + action: result.action, + originalType: result.originalType, + sessionId: session.sessionId, + }); + } +} diff --git a/src/drizzle/schema.ts b/src/drizzle/schema.ts index ba354ea2a..a54697aea 100644 --- a/src/drizzle/schema.ts +++ b/src/drizzle/schema.ts @@ -715,6 +715,12 @@ export const systemSettings = pgTable('system_settings', { .notNull() .default(true), + // Response API input 整流器(默认开启) + // 开启后:当 /v1/responses 端点收到非数组 input 时,自动规范化为数组格式 + enableResponseInputRectifier: boolean('enable_response_input_rectifier') + .notNull() + .default(true), + // Codex Session ID 补全(默认开启) // 开启后:当 Codex 请求缺少 session_id / prompt_cache_key 时,自动补全或生成稳定的会话标识 enableCodexSessionIdCompletion: boolean('enable_codex_session_id_completion') diff --git a/src/lib/config/system-settings-cache.ts b/src/lib/config/system-settings-cache.ts index 9382f7ee9..4205d4f73 100644 --- a/src/lib/config/system-settings-cache.ts +++ b/src/lib/config/system-settings-cache.ts @@ -31,6 +31,7 @@ const DEFAULT_SETTINGS: Pick< | "enableThinkingSignatureRectifier" | "enableThinkingBudgetRectifier" | "enableBillingHeaderRectifier" + | "enableResponseInputRectifier" | "enableCodexSessionIdCompletion" | "enableClaudeMetadataUserIdInjection" | "enableResponseFixer" @@ -41,6 +42,7 @@ const DEFAULT_SETTINGS: Pick< enableThinkingSignatureRectifier: true, enableThinkingBudgetRectifier: true, enableBillingHeaderRectifier: true, + enableResponseInputRectifier: true, enableCodexSessionIdCompletion: true, enableClaudeMetadataUserIdInjection: true, enableResponseFixer: true, @@ -114,6 +116,7 @@ export async function getCachedSystemSettings(): Promise { enableThinkingSignatureRectifier: DEFAULT_SETTINGS.enableThinkingSignatureRectifier, enableThinkingBudgetRectifier: DEFAULT_SETTINGS.enableThinkingBudgetRectifier, enableBillingHeaderRectifier: DEFAULT_SETTINGS.enableBillingHeaderRectifier, + enableResponseInputRectifier: DEFAULT_SETTINGS.enableResponseInputRectifier, enableCodexSessionIdCompletion: DEFAULT_SETTINGS.enableCodexSessionIdCompletion, enableClaudeMetadataUserIdInjection: DEFAULT_SETTINGS.enableClaudeMetadataUserIdInjection, enableResponseFixer: DEFAULT_SETTINGS.enableResponseFixer, diff --git a/src/lib/utils/special-settings.ts b/src/lib/utils/special-settings.ts index ffd33ada6..0b9e2e2e4 100644 --- a/src/lib/utils/special-settings.ts +++ b/src/lib/utils/special-settings.ts @@ -124,6 +124,8 @@ function buildSettingKey(setting: SpecialSetting): string { setting.actualServiceTier, setting.effectivePriority, ]); + case "response_input_rectifier": + return JSON.stringify([setting.type, setting.hit, setting.action, setting.originalType]); default: { // 兜底:保证即使未来扩展类型也不会导致运行时崩溃 const _exhaustive: never = setting; diff --git a/src/lib/validation/schemas.ts b/src/lib/validation/schemas.ts index f00b6d777..657634ca7 100644 --- a/src/lib/validation/schemas.ts +++ b/src/lib/validation/schemas.ts @@ -942,6 +942,8 @@ export const UpdateSystemSettingsSchema = z.object({ enableThinkingBudgetRectifier: z.boolean().optional(), // billing header 整流器(可选) enableBillingHeaderRectifier: z.boolean().optional(), + // Response API input 整流器(可选) + enableResponseInputRectifier: z.boolean().optional(), // Codex Session ID 补全(可选) enableCodexSessionIdCompletion: z.boolean().optional(), // Claude metadata.user_id 注入(可选) diff --git a/src/repository/_shared/transformers.ts b/src/repository/_shared/transformers.ts index 0bc58987f..583dad123 100644 --- a/src/repository/_shared/transformers.ts +++ b/src/repository/_shared/transformers.ts @@ -201,6 +201,7 @@ export function toSystemSettings(dbSettings: any): SystemSettings { enableThinkingSignatureRectifier: dbSettings?.enableThinkingSignatureRectifier ?? true, enableThinkingBudgetRectifier: dbSettings?.enableThinkingBudgetRectifier ?? true, enableBillingHeaderRectifier: dbSettings?.enableBillingHeaderRectifier ?? true, + enableResponseInputRectifier: dbSettings?.enableResponseInputRectifier ?? true, enableCodexSessionIdCompletion: dbSettings?.enableCodexSessionIdCompletion ?? true, enableClaudeMetadataUserIdInjection: dbSettings?.enableClaudeMetadataUserIdInjection ?? true, enableResponseFixer: dbSettings?.enableResponseFixer ?? true, diff --git a/src/repository/system-config.ts b/src/repository/system-config.ts index 67e063492..e68459381 100644 --- a/src/repository/system-config.ts +++ b/src/repository/system-config.ts @@ -152,6 +152,7 @@ function createFallbackSettings(): SystemSettings { enableThinkingSignatureRectifier: true, enableThinkingBudgetRectifier: true, enableBillingHeaderRectifier: true, + enableResponseInputRectifier: true, enableCodexSessionIdCompletion: true, enableClaudeMetadataUserIdInjection: true, enableResponseFixer: true, @@ -196,6 +197,7 @@ export async function getSystemSettings(): Promise { enableThinkingSignatureRectifier: systemSettings.enableThinkingSignatureRectifier, enableThinkingBudgetRectifier: systemSettings.enableThinkingBudgetRectifier, enableBillingHeaderRectifier: systemSettings.enableBillingHeaderRectifier, + enableResponseInputRectifier: systemSettings.enableResponseInputRectifier, enableCodexSessionIdCompletion: systemSettings.enableCodexSessionIdCompletion, enableClaudeMetadataUserIdInjection: systemSettings.enableClaudeMetadataUserIdInjection, enableResponseFixer: systemSettings.enableResponseFixer, @@ -356,6 +358,11 @@ export async function updateSystemSettings( updates.enableBillingHeaderRectifier = payload.enableBillingHeaderRectifier; } + // Response API input 整流器开关(如果提供) + if (payload.enableResponseInputRectifier !== undefined) { + updates.enableResponseInputRectifier = payload.enableResponseInputRectifier; + } + // Codex Session ID 补全开关(如果提供) if (payload.enableCodexSessionIdCompletion !== undefined) { updates.enableCodexSessionIdCompletion = payload.enableCodexSessionIdCompletion; @@ -420,6 +427,8 @@ export async function updateSystemSettings( interceptAnthropicWarmupRequests: systemSettings.interceptAnthropicWarmupRequests, enableThinkingSignatureRectifier: systemSettings.enableThinkingSignatureRectifier, enableThinkingBudgetRectifier: systemSettings.enableThinkingBudgetRectifier, + enableBillingHeaderRectifier: systemSettings.enableBillingHeaderRectifier, + enableResponseInputRectifier: systemSettings.enableResponseInputRectifier, enableCodexSessionIdCompletion: systemSettings.enableCodexSessionIdCompletion, enableClaudeMetadataUserIdInjection: systemSettings.enableClaudeMetadataUserIdInjection, enableResponseFixer: systemSettings.enableResponseFixer, diff --git a/src/types/special-settings.ts b/src/types/special-settings.ts index aae2ce19c..e8ad72cfa 100644 --- a/src/types/special-settings.ts +++ b/src/types/special-settings.ts @@ -18,7 +18,8 @@ export type SpecialSetting = | AnthropicContext1mHeaderOverrideSpecialSetting | GeminiGoogleSearchOverrideSpecialSetting | PricingResolutionSpecialSetting - | CodexServiceTierResultSpecialSetting; + | CodexServiceTierResultSpecialSetting + | ResponseInputRectifierSpecialSetting; export type SpecialSettingChangeValue = string | number | boolean | null; @@ -225,3 +226,17 @@ export type CodexServiceTierResultSpecialSetting = { actualServiceTier: string | null; effectivePriority: boolean; }; + +/** + * Response Input 整流器审计 + * + * 用于记录:当 /v1/responses 端点收到非数组格式的 input 时, + * 系统自动将其规范化为数组格式的行为,便于在请求日志中审计。 + */ +export type ResponseInputRectifierSpecialSetting = { + type: "response_input_rectifier"; + scope: "request"; + hit: boolean; + action: "string_to_array" | "object_to_array" | "empty_string_to_empty_array" | "passthrough"; + originalType: "string" | "object" | "array" | "other"; +}; diff --git a/src/types/system-config.ts b/src/types/system-config.ts index d45e4cf20..e6eda9888 100644 --- a/src/types/system-config.ts +++ b/src/types/system-config.ts @@ -58,6 +58,11 @@ export interface SystemSettings { // 防止 Amazon Bedrock 等非原生 Anthropic 上游返回 400 错误 enableBillingHeaderRectifier: boolean; + // Response API input 整流器(默认开启) + // 目标:当 /v1/responses 端点收到非数组 input(字符串或单对象)时, + // 自动规范化为数组格式,确保下游处理兼容 OpenAI 完整规范 + enableResponseInputRectifier: boolean; + // Codex Session ID 补全(默认开启) // 目标:当 Codex 请求缺少 session_id / prompt_cache_key 时,自动补全或生成稳定的会话标识 enableCodexSessionIdCompletion: boolean; @@ -123,6 +128,9 @@ export interface UpdateSystemSettingsInput { // billing header 整流器(可选) enableBillingHeaderRectifier?: boolean; + // Response API input 整流器(可选) + enableResponseInputRectifier?: boolean; + // Codex Session ID 补全(可选) enableCodexSessionIdCompletion?: boolean; diff --git a/tests/unit/proxy/response-input-rectifier.test.ts b/tests/unit/proxy/response-input-rectifier.test.ts new file mode 100644 index 000000000..a77922d96 --- /dev/null +++ b/tests/unit/proxy/response-input-rectifier.test.ts @@ -0,0 +1,242 @@ +import { describe, expect, it, vi } from "vitest"; +import { + normalizeResponseInput, + rectifyResponseInput, +} from "@/app/v1/_lib/proxy/response-input-rectifier"; +import type { ProxySession } from "@/app/v1/_lib/proxy/session"; +import type { SpecialSetting } from "@/types/special-settings"; + +vi.mock("@/lib/config/system-settings-cache", () => ({ + getCachedSystemSettings: vi.fn(), +})); +vi.mock("@/lib/logger", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, +})); + +const { getCachedSystemSettings } = await import("@/lib/config/system-settings-cache"); +const getCachedMock = vi.mocked(getCachedSystemSettings); + +function createMockSession(input: unknown): { + session: ProxySession; + specialSettings: SpecialSetting[]; +} { + const specialSettings: SpecialSetting[] = []; + const session = { + request: { message: { model: "gpt-4o", input } }, + sessionId: "sess_test", + addSpecialSetting: (s: SpecialSetting) => specialSettings.push(s), + } as unknown as ProxySession; + return { session, specialSettings }; +} + +describe("rectifyResponseInput", () => { + // --- Passthrough cases --- + + it("passes through array input unchanged", () => { + const message: Record = { + model: "gpt-4o", + input: [{ role: "user", content: [{ type: "input_text", text: "hi" }] }], + }; + const original = message.input; + + const result = rectifyResponseInput(message); + + expect(result).toEqual({ applied: false, action: "passthrough", originalType: "array" }); + expect(message.input).toBe(original); + }); + + it("passes through empty array input unchanged", () => { + const message: Record = { input: [] }; + const result = rectifyResponseInput(message); + + expect(result).toEqual({ applied: false, action: "passthrough", originalType: "array" }); + expect(message.input).toEqual([]); + }); + + it("passes through undefined input", () => { + const message: Record = { model: "gpt-4o" }; + const result = rectifyResponseInput(message); + + expect(result).toEqual({ applied: false, action: "passthrough", originalType: "other" }); + expect(message.input).toBeUndefined(); + }); + + it("passes through null input", () => { + const message: Record = { input: null }; + const result = rectifyResponseInput(message); + + expect(result).toEqual({ applied: false, action: "passthrough", originalType: "other" }); + expect(message.input).toBeNull(); + }); + + // --- String normalization --- + + it("normalizes non-empty string to user message array", () => { + const message: Record = { model: "gpt-4o", input: "hello world" }; + const result = rectifyResponseInput(message); + + expect(result).toEqual({ applied: true, action: "string_to_array", originalType: "string" }); + expect(message.input).toEqual([ + { + role: "user", + content: [{ type: "input_text", text: "hello world" }], + }, + ]); + }); + + it("normalizes empty string to empty array", () => { + const message: Record = { input: "" }; + const result = rectifyResponseInput(message); + + expect(result).toEqual({ + applied: true, + action: "empty_string_to_empty_array", + originalType: "string", + }); + expect(message.input).toEqual([]); + }); + + it("normalizes whitespace-only string to user message (not empty)", () => { + const message: Record = { input: " " }; + const result = rectifyResponseInput(message); + + expect(result).toEqual({ applied: true, action: "string_to_array", originalType: "string" }); + expect(message.input).toEqual([ + { + role: "user", + content: [{ type: "input_text", text: " " }], + }, + ]); + }); + + it("normalizes multiline string", () => { + const message: Record = { input: "line1\nline2\nline3" }; + const result = rectifyResponseInput(message); + + expect(result).toEqual({ applied: true, action: "string_to_array", originalType: "string" }); + expect(message.input).toEqual([ + { + role: "user", + content: [{ type: "input_text", text: "line1\nline2\nline3" }], + }, + ]); + }); + + // --- Object normalization --- + + it("wraps single MessageInput (has role) into array", () => { + const inputObj = { role: "user", content: [{ type: "input_text", text: "hi" }] }; + const message: Record = { input: inputObj }; + const result = rectifyResponseInput(message); + + expect(result).toEqual({ applied: true, action: "object_to_array", originalType: "object" }); + expect(message.input).toEqual([inputObj]); + }); + + it("wraps single ToolOutputsInput (has type) into array", () => { + const inputObj = { type: "function_call_output", call_id: "call_123", output: "result" }; + const message: Record = { input: inputObj }; + const result = rectifyResponseInput(message); + + expect(result).toEqual({ applied: true, action: "object_to_array", originalType: "object" }); + expect(message.input).toEqual([inputObj]); + }); + + it("passes through object without role or type", () => { + const message: Record = { input: { foo: "bar", baz: 42 } }; + const result = rectifyResponseInput(message); + + expect(result).toEqual({ applied: false, action: "passthrough", originalType: "other" }); + }); + + // --- Edge cases --- + + it("does not modify other message fields", () => { + const message: Record = { + model: "gpt-4o", + input: "hello", + temperature: 0.7, + stream: true, + }; + rectifyResponseInput(message); + + expect(message.model).toBe("gpt-4o"); + expect(message.temperature).toBe(0.7); + expect(message.stream).toBe(true); + }); + + it("passes through numeric input as other", () => { + const message: Record = { input: 42 }; + const result = rectifyResponseInput(message); + + expect(result).toEqual({ applied: false, action: "passthrough", originalType: "other" }); + }); + + it("passes through boolean input as other", () => { + const message: Record = { input: true }; + const result = rectifyResponseInput(message); + + expect(result).toEqual({ applied: false, action: "passthrough", originalType: "other" }); + }); +}); + +describe("normalizeResponseInput", () => { + it("normalizes string input and records audit when enabled", async () => { + getCachedMock.mockResolvedValue({ enableResponseInputRectifier: true } as any); + + const { session, specialSettings } = createMockSession("hello"); + await normalizeResponseInput(session); + + const message = session.request.message as Record; + expect(message.input).toEqual([ + { role: "user", content: [{ type: "input_text", text: "hello" }] }, + ]); + expect(specialSettings).toHaveLength(1); + expect(specialSettings[0]).toMatchObject({ + type: "response_input_rectifier", + hit: true, + action: "string_to_array", + originalType: "string", + }); + }); + + it("skips normalization when feature is disabled", async () => { + getCachedMock.mockResolvedValue({ enableResponseInputRectifier: false } as any); + + const { session, specialSettings } = createMockSession("hello"); + await normalizeResponseInput(session); + + const message = session.request.message as Record; + expect(message.input).toBe("hello"); + expect(specialSettings).toHaveLength(0); + }); + + it("does not record audit for passthrough (array input)", async () => { + getCachedMock.mockResolvedValue({ enableResponseInputRectifier: true } as any); + + const arrayInput = [{ role: "user", content: [{ type: "input_text", text: "hi" }] }]; + const { session, specialSettings } = createMockSession(arrayInput); + await normalizeResponseInput(session); + + const message = session.request.message as Record; + expect(message.input).toBe(arrayInput); + expect(specialSettings).toHaveLength(0); + }); + + it("wraps single object input and records audit when enabled", async () => { + getCachedMock.mockResolvedValue({ enableResponseInputRectifier: true } as any); + + const inputObj = { role: "user", content: [{ type: "input_text", text: "hi" }] }; + const { session, specialSettings } = createMockSession(inputObj); + await normalizeResponseInput(session); + + const message = session.request.message as Record; + expect(message.input).toEqual([inputObj]); + expect(specialSettings).toHaveLength(1); + expect(specialSettings[0]).toMatchObject({ + type: "response_input_rectifier", + action: "object_to_array", + originalType: "object", + }); + }); +}); From a528427a53924760b3ffa363cb94242ad86a5449 Mon Sep 17 00:00:00 2001 From: Ding <44717411+ding113@users.noreply.github.com> Date: Mon, 9 Mar 2026 09:57:51 +0800 Subject: [PATCH 07/42] feat(leaderboard): add user model drill-down and user insights page (#861) (#886) * feat(leaderboard): add user model drill-down and user insights page (#861) - Add user-level modelStats data layer with null model preservation - Cache key isolation for user scope includeModelStats - Admin-only includeUserModelStats API gate (403 for non-admin) - Extract shared ModelBreakdownColumn/Row UI primitive - Admin-only expandable user rows with per-model breakdown - User name links to /dashboard/leaderboard/user/[userId] - Admin-only user insights page with overview cards, key trend chart, and model breakdown - Server actions for getUserInsightsOverview, KeyTrend, ModelBreakdown - Full i18n support (zh-CN, zh-TW, en, ja, ru) - 54 new tests across 6 test files * chore: format code (feat-861-user-leaderboard-drill-down-81697a4) * fix(leaderboard): resolve bugbot review findings for user insights (#886) - Fix critical bug: overview card labeled "Cache Hit Rate" was showing todayErrorRate; renamed to "Error Rate" with correct i18n (5 locales) - Fix security: Cache-Control now private when allowGlobalUsageView=false - Add date validation (YYYY-MM-DD regex + range check) in getUserInsightsModelBreakdown server action - Normalize key trend date field to ISO string to prevent client crash when cache returns Date objects - Replace hardcoded "OK" button with tCommon("ok") i18n key - Replace hardcoded "Calls" chart label with tStats("requests") - Tighten userId validation to positive integers only - Add NULLIF(TRIM()) to model field for consistency with leaderboard.ts - Use machine-readable error code for 403 response - Strengthen return type from unknown to DatabaseKeyStatRow[] - Update tests: assert errorRate metric, date normalization, date validation, and error code assertions * test: use realistic DatabaseKeyStatRow fields in key trend test mock Mock data now matches the actual DatabaseKeyStatRow interface with key_id, key_name, api_calls, and total_cost fields instead of generic cost/requests. Assertions verify the full data contract. --------- Co-authored-by: github-actions[bot] --- messages/en/dashboard.json | 24 +- messages/ja/dashboard.json | 24 +- messages/ru/dashboard.json | 24 +- messages/zh-CN/dashboard.json | 24 +- messages/zh-TW/dashboard.json | 24 +- src/actions/admin-user-insights.ts | 130 ++++++ .../_components/leaderboard-view.tsx | 43 +- .../_components/user-insights-view.tsx | 43 ++ .../_components/user-key-trend-chart.tsx | 199 +++++++++ .../_components/user-model-breakdown.tsx | 150 +++++++ .../_components/user-overview-cards.tsx | 94 ++++ .../leaderboard/user/[userId]/page.tsx | 31 ++ .../_components/statistics-summary-card.tsx | 272 +----------- src/app/api/leaderboard/route.ts | 41 +- .../analytics/model-breakdown-column.tsx | 337 ++++++++++++++ src/lib/redis/leaderboard-cache.ts | 18 +- src/repository/admin-user-insights.ts | 63 +++ src/repository/leaderboard.ts | 99 ++++- .../unit/actions/admin-user-insights.test.ts | 389 ++++++++++++++++ tests/unit/api/leaderboard-route.test.ts | 65 +++ .../model-breakdown-column.test.tsx | 313 +++++++++++++ .../dashboard/user-insights-page.test.tsx | 418 ++++++++++++++++++ .../leaderboard-user-model-stats.test.ts | 326 ++++++++++++++ 23 files changed, 2850 insertions(+), 301 deletions(-) create mode 100644 src/actions/admin-user-insights.ts create mode 100644 src/app/[locale]/dashboard/leaderboard/user/[userId]/_components/user-insights-view.tsx create mode 100644 src/app/[locale]/dashboard/leaderboard/user/[userId]/_components/user-key-trend-chart.tsx create mode 100644 src/app/[locale]/dashboard/leaderboard/user/[userId]/_components/user-model-breakdown.tsx create mode 100644 src/app/[locale]/dashboard/leaderboard/user/[userId]/_components/user-overview-cards.tsx create mode 100644 src/app/[locale]/dashboard/leaderboard/user/[userId]/page.tsx create mode 100644 src/components/analytics/model-breakdown-column.tsx create mode 100644 src/repository/admin-user-insights.ts create mode 100644 tests/unit/actions/admin-user-insights.test.ts create mode 100644 tests/unit/components/model-breakdown-column.test.tsx create mode 100644 tests/unit/dashboard/user-insights-page.test.tsx create mode 100644 tests/unit/repository/leaderboard-user-model-stats.test.ts diff --git a/messages/en/dashboard.json b/messages/en/dashboard.json index 030082046..16102648c 100644 --- a/messages/en/dashboard.json +++ b/messages/en/dashboard.json @@ -456,7 +456,8 @@ "avgTtfbMs": "Avg TTFB", "avgTokensPerSecond": "Avg tok/s", "avgCostPerRequest": "Avg Cost/Req", - "avgCostPerMillionTokens": "Avg Cost/1M Tokens" + "avgCostPerMillionTokens": "Avg Cost/1M Tokens", + "unknownModel": "Unknown" }, "expandModelStats": "Expand model details", "collapseModelStats": "Collapse model details", @@ -479,6 +480,27 @@ "filters": { "userTagsPlaceholder": "Filter by user tags...", "userGroupsPlaceholder": "Filter by user groups..." + }, + "userInsights": { + "title": "User Insights", + "backToLeaderboard": "Back to Leaderboard", + "overview": "Overview", + "keyTrend": "Key Usage Trend", + "modelBreakdown": "Model Breakdown", + "todayRequests": "Today Requests", + "todayCost": "Today Cost", + "avgResponseTime": "Avg Response Time", + "errorRate": "Error Rate", + "timeRange": { + "today": "Today", + "7days": "Last 7 Days", + "30days": "Last 30 Days", + "thisMonth": "This Month" + }, + "unknownModel": "Unknown Model", + "noData": "No data available", + "dateRange": "Date Range", + "allTime": "All Time" } }, "sessions": { diff --git a/messages/ja/dashboard.json b/messages/ja/dashboard.json index ec85f4650..628f96141 100644 --- a/messages/ja/dashboard.json +++ b/messages/ja/dashboard.json @@ -456,7 +456,8 @@ "avgTtfbMs": "平均TTFB", "avgTokensPerSecond": "平均トークン/秒", "avgCostPerRequest": "平均リクエスト単価", - "avgCostPerMillionTokens": "100万トークンあたりコスト" + "avgCostPerMillionTokens": "100万トークンあたりコスト", + "unknownModel": "不明" }, "expandModelStats": "モデル詳細を展開", "collapseModelStats": "モデル詳細を折りたたむ", @@ -479,6 +480,27 @@ "filters": { "userTagsPlaceholder": "ユーザータグでフィルタ...", "userGroupsPlaceholder": "ユーザーグループでフィルタ..." + }, + "userInsights": { + "title": "ユーザーインサイト", + "backToLeaderboard": "ランキングに戻る", + "overview": "概要", + "keyTrend": "Key 使用トレンド", + "modelBreakdown": "モデル内訳", + "todayRequests": "本日リクエスト", + "todayCost": "本日コスト", + "avgResponseTime": "平均応答時間", + "errorRate": "エラー率", + "timeRange": { + "today": "今日", + "7days": "過去7日間", + "30days": "過去30日間", + "thisMonth": "今月" + }, + "unknownModel": "不明なモデル", + "noData": "データがありません", + "dateRange": "期間", + "allTime": "全期間" } }, "sessions": { diff --git a/messages/ru/dashboard.json b/messages/ru/dashboard.json index a8c5688bb..8ff31bbe3 100644 --- a/messages/ru/dashboard.json +++ b/messages/ru/dashboard.json @@ -456,7 +456,8 @@ "avgTtfbMs": "Средний TTFB", "avgTokensPerSecond": "Средн. ток/с", "avgCostPerRequest": "Ср. стоимость/запрос", - "avgCostPerMillionTokens": "Ср. стоимость/1М токенов" + "avgCostPerMillionTokens": "Ср. стоимость/1М токенов", + "unknownModel": "Неизвестно" }, "expandModelStats": "Развернуть модели", "collapseModelStats": "Свернуть модели", @@ -479,6 +480,27 @@ "filters": { "userTagsPlaceholder": "Фильтр по тегам пользователей...", "userGroupsPlaceholder": "Фильтр по группам пользователей..." + }, + "userInsights": { + "title": "Аналитика пользователя", + "backToLeaderboard": "Вернуться к рейтингу", + "overview": "Обзор", + "keyTrend": "Тренд использования ключей", + "modelBreakdown": "Разбивка по моделям", + "todayRequests": "Запросы за сегодня", + "todayCost": "Стоимость за сегодня", + "avgResponseTime": "Среднее время ответа", + "errorRate": "Частота ошибок", + "timeRange": { + "today": "Сегодня", + "7days": "Последние 7 дней", + "30days": "Последние 30 дней", + "thisMonth": "Этот месяц" + }, + "unknownModel": "Неизвестная модель", + "noData": "Нет данных", + "dateRange": "Диапазон дат", + "allTime": "Всё время" } }, "sessions": { diff --git a/messages/zh-CN/dashboard.json b/messages/zh-CN/dashboard.json index 5b172f782..c2d4fc350 100644 --- a/messages/zh-CN/dashboard.json +++ b/messages/zh-CN/dashboard.json @@ -456,7 +456,8 @@ "avgTtfbMs": "平均 TTFB", "avgTokensPerSecond": "平均输出速率", "avgCostPerRequest": "平均单次请求成本", - "avgCostPerMillionTokens": "平均百万 Token 成本" + "avgCostPerMillionTokens": "平均百万 Token 成本", + "unknownModel": "未知" }, "expandModelStats": "展开模型详情", "collapseModelStats": "收起模型详情", @@ -479,6 +480,27 @@ "filters": { "userTagsPlaceholder": "按用户标签筛选...", "userGroupsPlaceholder": "按用户分组筛选..." + }, + "userInsights": { + "title": "用户洞察", + "backToLeaderboard": "返回排行榜", + "overview": "概览", + "keyTrend": "Key 使用趋势", + "modelBreakdown": "模型明细", + "todayRequests": "今日请求", + "todayCost": "今日费用", + "avgResponseTime": "平均响应时间", + "errorRate": "错误率", + "timeRange": { + "today": "今日", + "7days": "近 7 天", + "30days": "近 30 天", + "thisMonth": "本月" + }, + "unknownModel": "未知模型", + "noData": "暂无数据", + "dateRange": "日期范围", + "allTime": "全部时间" } }, "sessions": { diff --git a/messages/zh-TW/dashboard.json b/messages/zh-TW/dashboard.json index ab0075317..2c58e4509 100644 --- a/messages/zh-TW/dashboard.json +++ b/messages/zh-TW/dashboard.json @@ -456,7 +456,8 @@ "avgTtfbMs": "平均 TTFB(ms)", "avgTokensPerSecond": "平均輸出速率", "avgCostPerRequest": "平均每次請求成本", - "avgCostPerMillionTokens": "平均每百萬 Token 成本" + "avgCostPerMillionTokens": "平均每百萬 Token 成本", + "unknownModel": "未知" }, "expandModelStats": "展開模型詳情", "collapseModelStats": "收起模型詳情", @@ -479,6 +480,27 @@ "filters": { "userTagsPlaceholder": "按使用者標籤篩選...", "userGroupsPlaceholder": "按使用者群組篩選..." + }, + "userInsights": { + "title": "使用者洞察", + "backToLeaderboard": "返回排行榜", + "overview": "概覽", + "keyTrend": "Key 使用趨勢", + "modelBreakdown": "模型明細", + "todayRequests": "今日請求", + "todayCost": "今日費用", + "avgResponseTime": "平均回應時間", + "errorRate": "錯誤率", + "timeRange": { + "today": "今日", + "7days": "近 7 天", + "30days": "近 30 天", + "thisMonth": "本月" + }, + "unknownModel": "未知模型", + "noData": "暫無資料", + "dateRange": "日期範圍", + "allTime": "全部時間" } }, "sessions": { diff --git a/src/actions/admin-user-insights.ts b/src/actions/admin-user-insights.ts new file mode 100644 index 000000000..915f6e625 --- /dev/null +++ b/src/actions/admin-user-insights.ts @@ -0,0 +1,130 @@ +"use server"; + +import { getSession } from "@/lib/auth"; +import { getOverviewWithCache } from "@/lib/redis/overview-cache"; +import { getStatisticsWithCache } from "@/lib/redis/statistics-cache"; +import { + type AdminUserModelBreakdownItem, + getUserModelBreakdown, +} from "@/repository/admin-user-insights"; +import type { OverviewMetricsWithComparison } from "@/repository/overview"; +import { getSystemSettings } from "@/repository/system-config"; +import { findUserById } from "@/repository/user"; +import type { DatabaseKeyStatRow } from "@/types/statistics"; +import type { User } from "@/types/user"; +import type { ActionResult } from "./types"; + +const VALID_TIME_RANGES = ["today", "7days", "30days", "thisMonth"] as const; +type ValidTimeRange = (typeof VALID_TIME_RANGES)[number]; + +const DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/; + +function isValidTimeRange(value: string): value is ValidTimeRange { + return (VALID_TIME_RANGES as readonly string[]).includes(value); +} + +/** + * Get overview metrics for a specific user (admin only). + */ +export async function getUserInsightsOverview(targetUserId: number): Promise< + ActionResult<{ + user: User; + overview: OverviewMetricsWithComparison; + currencyCode: string; + }> +> { + const session = await getSession(); + if (!session || session.user.role !== "admin") { + return { ok: false, error: "Unauthorized" }; + } + + const user = await findUserById(targetUserId); + if (!user) { + return { ok: false, error: "User not found" }; + } + + const [overview, settings] = await Promise.all([ + getOverviewWithCache(targetUserId), + getSystemSettings(), + ]); + + return { + ok: true, + data: { + user, + overview, + currencyCode: settings.currencyDisplay, + }, + }; +} + +/** + * Get key-level trend statistics for a specific user (admin only). + */ +export async function getUserInsightsKeyTrend( + targetUserId: number, + timeRange: string +): Promise> { + const session = await getSession(); + if (!session || session.user.role !== "admin") { + return { ok: false, error: "Unauthorized" }; + } + + if (!isValidTimeRange(timeRange)) { + return { + ok: false, + error: `Invalid timeRange: must be one of ${VALID_TIME_RANGES.join(", ")}`, + }; + } + + const statistics = await getStatisticsWithCache(timeRange, "keys", targetUserId); + + const normalized = (statistics as DatabaseKeyStatRow[]).map((row) => ({ + ...row, + date: typeof row.date === "string" ? row.date : new Date(row.date).toISOString(), + })); + + return { ok: true, data: normalized }; +} + +/** + * Get model-level usage breakdown for a specific user (admin only). + */ +export async function getUserInsightsModelBreakdown( + targetUserId: number, + startDate?: string, + endDate?: string +): Promise< + ActionResult<{ + breakdown: AdminUserModelBreakdownItem[]; + currencyCode: string; + }> +> { + const session = await getSession(); + if (!session || session.user.role !== "admin") { + return { ok: false, error: "Unauthorized" }; + } + + if (startDate && !DATE_REGEX.test(startDate)) { + return { ok: false, error: "Invalid startDate format: use YYYY-MM-DD" }; + } + if (endDate && !DATE_REGEX.test(endDate)) { + return { ok: false, error: "Invalid endDate format: use YYYY-MM-DD" }; + } + if (startDate && endDate && new Date(startDate) > new Date(endDate)) { + return { ok: false, error: "startDate must not be after endDate" }; + } + + const [breakdown, settings] = await Promise.all([ + getUserModelBreakdown(targetUserId, startDate, endDate), + getSystemSettings(), + ]); + + return { + ok: true, + data: { + breakdown, + currencyCode: settings.currencyDisplay, + }, + }; +} diff --git a/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-view.tsx b/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-view.tsx index 66baac510..2ae52f137 100644 --- a/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-view.tsx +++ b/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-view.tsx @@ -9,6 +9,7 @@ import { Card, CardContent } from "@/components/ui/card"; import { Skeleton } from "@/components/ui/skeleton"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { TagInput } from "@/components/ui/tag-input"; +import { Link } from "@/i18n/routing"; import { formatTokenAmount } from "@/lib/utils"; import type { DateRangeParams, @@ -19,6 +20,7 @@ import type { ModelProviderStat, ProviderCacheHitRateLeaderboardEntry, ProviderLeaderboardEntry, + UserModelStat, } from "@/repository/leaderboard"; import type { ProviderType } from "@/types/provider"; import { DateRangePicker } from "./date-range-picker"; @@ -36,7 +38,12 @@ type ProviderCostFormattedFields = { avgCostPerRequestFormatted?: string | null; avgCostPerMillionTokensFormatted?: string | null; }; -type UserEntry = LeaderboardEntry & TotalCostFormattedFields; +type UserEntry = LeaderboardEntry & + TotalCostFormattedFields & { + modelStats?: UserModelStatClient[]; + }; +type UserModelStatClient = UserModelStat & TotalCostFormattedFields; +type UserTableRow = UserEntry | UserModelStatClient; type ModelEntry = ModelLeaderboardEntry & TotalCostFormattedFields; type ModelProviderStatClient = ModelProviderStat & ProviderCostFormattedFields; type ProviderEntry = Omit & @@ -135,6 +142,9 @@ export function LeaderboardView({ isAdmin }: LeaderboardViewProps) { if (scope === "provider") { url += "&includeModelStats=1"; } + if (scope === "user" && isAdmin) { + url += "&includeUserModelStats=1"; + } if (scope === "user") { if (userTagFilters.length > 0) { url += `&userTags=${encodeURIComponent(userTagFilters.join(","))}`; @@ -167,7 +177,7 @@ export function LeaderboardView({ isAdmin }: LeaderboardViewProps) { return () => { cancelled = true; }; - }, [scope, period, dateRange, providerTypeFilter, userTagFilters, userGroupFilters, t]); + }, [scope, period, dateRange, providerTypeFilter, userTagFilters, userGroupFilters, isAdmin, t]); const handlePeriodChange = useCallback( (newPeriod: LeaderboardPeriod, newDateRange?: DateRangeParams) => { @@ -196,12 +206,27 @@ export function LeaderboardView({ isAdmin }: LeaderboardViewProps) {
); - const userColumns: ColumnDef[] = [ + const userColumns: ColumnDef[] = [ { header: t("columns.user"), - cell: (row) => row.userName, + cell: (row) => { + if ("userName" in row) { + return isAdmin ? ( + + {row.userName} + + ) : ( + row.userName + ); + } + return renderSubModelLabel(row.model ?? t("columns.unknownModel")); + }, sortKey: "userName", - getValue: (row) => row.userName, + getValue: (row) => ("userName" in row ? row.userName : (row.model ?? "")), }, { header: t("columns.requests"), @@ -396,11 +421,17 @@ export function LeaderboardView({ isAdmin }: LeaderboardViewProps) { ]; const renderUserTable = () => ( - + data={data as UserEntry[]} period={period} columns={userColumns} getRowKey={(row) => row.userId} + {...(isAdmin + ? { + getSubRows: (row) => row.modelStats, + getSubRowKey: (subRow) => subRow.model ?? "__null__", + } + : {})} /> ); diff --git a/src/app/[locale]/dashboard/leaderboard/user/[userId]/_components/user-insights-view.tsx b/src/app/[locale]/dashboard/leaderboard/user/[userId]/_components/user-insights-view.tsx new file mode 100644 index 000000000..d03d5c84f --- /dev/null +++ b/src/app/[locale]/dashboard/leaderboard/user/[userId]/_components/user-insights-view.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { ArrowLeft } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { Button } from "@/components/ui/button"; +import { useRouter } from "@/i18n/routing"; +import { UserKeyTrendChart } from "./user-key-trend-chart"; +import { UserModelBreakdown } from "./user-model-breakdown"; +import { UserOverviewCards } from "./user-overview-cards"; + +interface UserInsightsViewProps { + userId: number; + userName: string; +} + +export function UserInsightsView({ userId, userName }: UserInsightsViewProps) { + const t = useTranslations("dashboard.leaderboard.userInsights"); + const router = useRouter(); + + return ( +
+
+ +
+

+ {t("title")} - {userName} +

+
+
+ + + + +
+ ); +} diff --git a/src/app/[locale]/dashboard/leaderboard/user/[userId]/_components/user-key-trend-chart.tsx b/src/app/[locale]/dashboard/leaderboard/user/[userId]/_components/user-key-trend-chart.tsx new file mode 100644 index 000000000..88a213a3d --- /dev/null +++ b/src/app/[locale]/dashboard/leaderboard/user/[userId]/_components/user-key-trend-chart.tsx @@ -0,0 +1,199 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { useTranslations } from "next-intl"; +import { useMemo, useState } from "react"; +import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts"; +import { getUserInsightsKeyTrend } from "@/actions/admin-user-insights"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { type ChartConfig, ChartContainer, ChartTooltip } from "@/components/ui/chart"; +import { Skeleton } from "@/components/ui/skeleton"; +import type { DatabaseKeyStatRow } from "@/types/statistics"; + +interface UserKeyTrendChartProps { + userId: number; +} + +type TimeRangeKey = "today" | "7days" | "30days" | "thisMonth"; + +const CHART_COLORS = [ + "hsl(var(--chart-1))", + "hsl(var(--chart-2))", + "hsl(var(--chart-3))", + "hsl(var(--chart-4))", + "hsl(var(--chart-5))", + "#8b5cf6", + "#ec4899", + "#f97316", +]; + +interface ChartKey { + id: number; + name: string; + dataKey: string; +} + +export function UserKeyTrendChart({ userId }: UserKeyTrendChartProps) { + const t = useTranslations("dashboard.leaderboard.userInsights"); + const tStats = useTranslations("dashboard.stats"); + const [timeRange, setTimeRange] = useState("7days"); + + const { data: rawData, isLoading } = useQuery({ + queryKey: ["user-insights-key-trend", userId, timeRange], + queryFn: async () => { + const result = await getUserInsightsKeyTrend(userId, timeRange); + if (!result.ok) throw new Error(result.error); + return result.data as DatabaseKeyStatRow[]; + }, + }); + + const { chartData, keys, chartConfig } = useMemo(() => { + if (!rawData || rawData.length === 0) { + return { chartData: [], keys: [] as ChartKey[], chartConfig: {} as ChartConfig }; + } + + // Extract unique keys + const keyMap = new Map(); + for (const row of rawData) { + if (!keyMap.has(row.key_id)) { + keyMap.set(row.key_id, row.key_name); + } + } + + const uniqueKeys: ChartKey[] = Array.from(keyMap.entries()).map(([id, name]) => ({ + id, + name, + dataKey: `key-${id}`, + })); + + // Build chart data grouped by date + const dataByDate = new Map>(); + for (const row of rawData) { + const dateStr = + timeRange === "today" ? new Date(row.date).toISOString() : row.date.split("T")[0]; + + if (!dataByDate.has(dateStr)) { + dataByDate.set(dateStr, { date: dateStr }); + } + const entry = dataByDate.get(dateStr)!; + const dk = `key-${row.key_id}`; + entry[`${dk}_calls`] = row.api_calls || 0; + const cost = row.total_cost; + entry[`${dk}_cost`] = + typeof cost === "number" ? cost : cost != null ? Number.parseFloat(cost) || 0 : 0; + } + + // Build chart config + const config: ChartConfig = { + calls: { label: tStats("requests") }, + }; + for (let i = 0; i < uniqueKeys.length; i++) { + const key = uniqueKeys[i]; + config[key.dataKey] = { + label: key.name, + color: CHART_COLORS[i % CHART_COLORS.length], + }; + } + + return { + chartData: Array.from(dataByDate.values()).sort((a, b) => + (a.date as string).localeCompare(b.date as string) + ), + keys: uniqueKeys, + chartConfig: config, + }; + }, [rawData, timeRange, tStats]); + + const timeRangeOptions: { key: TimeRangeKey; labelKey: string }[] = [ + { key: "today", labelKey: "timeRange.today" }, + { key: "7days", labelKey: "timeRange.7days" }, + { key: "30days", labelKey: "timeRange.30days" }, + { key: "thisMonth", labelKey: "timeRange.thisMonth" }, + ]; + + return ( + + + {t("keyTrend")} +
+ {timeRangeOptions.map((opt) => ( + + ))} +
+
+ + {isLoading ? ( + + ) : chartData.length === 0 ? ( +
+ {t("noData")} +
+ ) : ( + + + + {keys.map((key, index) => { + const color = CHART_COLORS[index % CHART_COLORS.length]; + return ( + + + + + ); + })} + + + { + if (timeRange === "today") { + const d = new Date(value); + return `${d.getHours().toString().padStart(2, "0")}:${d.getMinutes().toString().padStart(2, "0")}`; + } + const parts = value.split("-"); + return `${parts[1]}/${parts[2]}`; + }} + /> + + + {keys.map((key, index) => { + const color = CHART_COLORS[index % CHART_COLORS.length]; + return ( + + ); + })} + + + )} +
+
+ ); +} diff --git a/src/app/[locale]/dashboard/leaderboard/user/[userId]/_components/user-model-breakdown.tsx b/src/app/[locale]/dashboard/leaderboard/user/[userId]/_components/user-model-breakdown.tsx new file mode 100644 index 000000000..5e49c3ceb --- /dev/null +++ b/src/app/[locale]/dashboard/leaderboard/user/[userId]/_components/user-model-breakdown.tsx @@ -0,0 +1,150 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { BarChart3 } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useState } from "react"; +import { getUserInsightsModelBreakdown } from "@/actions/admin-user-insights"; +import { + ModelBreakdownColumn, + type ModelBreakdownItem, + type ModelBreakdownLabels, +} from "@/components/analytics/model-breakdown-column"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Skeleton } from "@/components/ui/skeleton"; +import type { CurrencyCode } from "@/lib/utils/currency"; + +interface UserModelBreakdownProps { + userId: number; +} + +export function UserModelBreakdown({ userId }: UserModelBreakdownProps) { + const t = useTranslations("dashboard.leaderboard.userInsights"); + const tCommon = useTranslations("common"); + const tStats = useTranslations("myUsage.stats"); + + const [startDate, setStartDate] = useState(""); + const [endDate, setEndDate] = useState(""); + const [appliedRange, setAppliedRange] = useState<{ + start?: string; + end?: string; + }>({}); + + const { data, isLoading } = useQuery({ + queryKey: ["user-insights-model-breakdown", userId, appliedRange.start, appliedRange.end], + queryFn: async () => { + const result = await getUserInsightsModelBreakdown( + userId, + appliedRange.start || undefined, + appliedRange.end || undefined + ); + if (!result.ok) throw new Error(result.error); + return result.data; + }, + }); + + const handleApplyRange = () => { + setAppliedRange({ start: startDate || undefined, end: endDate || undefined }); + }; + + const handleClearRange = () => { + setStartDate(""); + setEndDate(""); + setAppliedRange({}); + }; + + const labels: ModelBreakdownLabels = { + unknownModel: t("unknownModel"), + modal: { + requests: tStats("modal.requests"), + cost: tStats("modal.cost"), + inputTokens: tStats("modal.inputTokens"), + outputTokens: tStats("modal.outputTokens"), + cacheCreationTokens: tStats("modal.cacheWrite"), + cacheReadTokens: tStats("modal.cacheRead"), + totalTokens: tStats("modal.totalTokens"), + costPercentage: tStats("modal.cost"), + cacheHitRate: tStats("modal.cacheHitRate"), + cacheTokens: tStats("modal.cacheTokens"), + performanceHigh: tStats("modal.performanceHigh"), + performanceMedium: tStats("modal.performanceMedium"), + performanceLow: tStats("modal.performanceLow"), + }, + }; + + const items: ModelBreakdownItem[] = data + ? data.breakdown.map((item) => ({ + model: item.model, + requests: item.requests, + cost: item.cost, + inputTokens: item.inputTokens, + outputTokens: item.outputTokens, + cacheCreationTokens: item.cacheCreationTokens, + cacheReadTokens: item.cacheReadTokens, + })) + : []; + + const totalCost = items.reduce((sum, item) => sum + item.cost, 0); + const currencyCode = (data?.currencyCode ?? "USD") as CurrencyCode; + + return ( + + + + + {t("modelBreakdown")} + +
+ {t("dateRange")}: + setStartDate(e.target.value)} + className="h-7 w-[130px] text-xs" + /> + - + setEndDate(e.target.value)} + className="h-7 w-[130px] text-xs" + /> + + {(appliedRange.start || appliedRange.end) && ( + + )} +
+
+ + {isLoading ? ( +
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+ ) : items.length === 0 ? ( +
+ {t("noData")} +
+ ) : ( +
+ +
+ )} +
+
+ ); +} diff --git a/src/app/[locale]/dashboard/leaderboard/user/[userId]/_components/user-overview-cards.tsx b/src/app/[locale]/dashboard/leaderboard/user/[userId]/_components/user-overview-cards.tsx new file mode 100644 index 000000000..c0183f04c --- /dev/null +++ b/src/app/[locale]/dashboard/leaderboard/user/[userId]/_components/user-overview-cards.tsx @@ -0,0 +1,94 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { Activity, Clock, DollarSign, TrendingUp } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { getUserInsightsOverview } from "@/actions/admin-user-insights"; +import { Card, CardContent } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; +import { type CurrencyCode, formatCurrency } from "@/lib/utils"; + +interface UserOverviewCardsProps { + userId: number; +} + +function formatResponseTime(ms: number): string { + if (ms < 1000) return `${ms}ms`; + return `${(ms / 1000).toFixed(1)}s`; +} + +export function UserOverviewCards({ userId }: UserOverviewCardsProps) { + const t = useTranslations("dashboard.leaderboard.userInsights"); + + const { data, isLoading } = useQuery({ + queryKey: ["user-insights-overview", userId], + queryFn: async () => { + const result = await getUserInsightsOverview(userId); + if (!result.ok) throw new Error(result.error); + return result.data; + }, + }); + + if (isLoading) { + return ( +
+ {Array.from({ length: 4 }).map((_, i) => ( + + + + + + + ))} +
+ ); + } + + if (!data) return null; + + const { overview, currencyCode } = data; + const cc = currencyCode as CurrencyCode; + + const metrics = [ + { + key: "todayRequests", + label: t("todayRequests"), + value: overview.todayRequests.toLocaleString(), + icon: TrendingUp, + }, + { + key: "todayCost", + label: t("todayCost"), + value: formatCurrency(overview.todayCost, cc), + icon: DollarSign, + }, + { + key: "avgResponseTime", + label: t("avgResponseTime"), + value: formatResponseTime(overview.avgResponseTime), + icon: Clock, + }, + { + key: "errorRate", + label: t("errorRate"), + value: `${overview.todayErrorRate.toFixed(1)}%`, + icon: Activity, + }, + ]; + + return ( +
+ {metrics.map((metric) => ( + + +
+ + {metric.label} +
+
{metric.value}
+
+
+ ))} +
+ ); +} diff --git a/src/app/[locale]/dashboard/leaderboard/user/[userId]/page.tsx b/src/app/[locale]/dashboard/leaderboard/user/[userId]/page.tsx new file mode 100644 index 000000000..9f71cc066 --- /dev/null +++ b/src/app/[locale]/dashboard/leaderboard/user/[userId]/page.tsx @@ -0,0 +1,31 @@ +import { redirect } from "@/i18n/routing"; +import { getSession } from "@/lib/auth"; +import { findUserById } from "@/repository/user"; +import { UserInsightsView } from "./_components/user-insights-view"; + +export const dynamic = "force-dynamic"; + +export default async function UserInsightsPage({ + params, +}: { + params: Promise<{ locale: string; userId: string }>; +}) { + const { locale, userId: userIdStr } = await params; + const session = await getSession(); + + if (!session || session.user.role !== "admin") { + return redirect({ href: "/dashboard/leaderboard", locale }); + } + + const userId = Number(userIdStr); + if (!Number.isInteger(userId) || userId <= 0) { + return redirect({ href: "/dashboard/leaderboard", locale }); + } + + const user = await findUserById(userId); + if (!user) { + return redirect({ href: "/dashboard/leaderboard", locale }); + } + + return ; +} diff --git a/src/app/[locale]/my-usage/_components/statistics-summary-card.tsx b/src/app/[locale]/my-usage/_components/statistics-summary-card.tsx index a73947cea..16d3f1a64 100644 --- a/src/app/[locale]/my-usage/_components/statistics-summary-card.tsx +++ b/src/app/[locale]/my-usage/_components/statistics-summary-card.tsx @@ -1,34 +1,17 @@ "use client"; import { format } from "date-fns"; -import { - Activity, - ArrowDownRight, - ArrowUpRight, - BarChart3, - ChevronLeft, - ChevronRight, - Coins, - Database, - Hash, - Percent, - RefreshCw, - Target, -} from "lucide-react"; +import { BarChart3, ChevronLeft, ChevronRight, RefreshCw } from "lucide-react"; import { useTranslations } from "next-intl"; import { useCallback, useEffect, useRef, useState } from "react"; -import { - getMyStatsSummary, - type ModelBreakdownItem, - type MyStatsSummary, -} from "@/actions/my-usage"; +import { getMyStatsSummary, type MyStatsSummary } from "@/actions/my-usage"; +import { ModelBreakdownColumn } from "@/components/analytics/model-breakdown-column"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Separator } from "@/components/ui/separator"; import { Skeleton } from "@/components/ui/skeleton"; import { formatTokenAmount } from "@/lib/utils"; -import { type CurrencyCode, formatCurrency } from "@/lib/utils/currency"; +import { formatCurrency } from "@/lib/utils/currency"; import { LogsDateRangePicker } from "../../dashboard/logs/_components/logs-date-range-picker"; interface StatisticsSummaryCardProps { @@ -320,250 +303,3 @@ export function StatisticsSummaryCard({ } const MODEL_BREAKDOWN_PAGE_SIZE = 5; - -interface ModelBreakdownColumnProps { - pageItems: ModelBreakdownItem[]; - currencyCode: CurrencyCode; - totalCost: number; - keyPrefix: string; - pageOffset: number; -} - -function ModelBreakdownColumn({ - pageItems, - currencyCode, - totalCost, - keyPrefix, - pageOffset, -}: ModelBreakdownColumnProps) { - return ( -
- {pageItems.map((item, index) => ( - - ))} -
- ); -} - -interface ModelBreakdownRowProps { - model: string | null; - requests: number; - cost: number; - inputTokens: number; - outputTokens: number; - cacheCreationTokens: number; - cacheReadTokens: number; - currencyCode: CurrencyCode; - totalCost: number; -} - -function ModelBreakdownRow({ - model, - requests, - cost, - inputTokens, - outputTokens, - cacheCreationTokens, - cacheReadTokens, - currencyCode, - totalCost, -}: ModelBreakdownRowProps) { - const [open, setOpen] = useState(false); - const t = useTranslations("myUsage.stats"); - - const totalAllTokens = inputTokens + outputTokens + cacheCreationTokens + cacheReadTokens; - const totalInputTokens = inputTokens + cacheCreationTokens + cacheReadTokens; - const cacheHitRate = - totalInputTokens > 0 ? ((cacheReadTokens / totalInputTokens) * 100).toFixed(1) : "0.0"; - const costPercentage = totalCost > 0 ? ((cost / totalCost) * 100).toFixed(1) : "0.0"; - - const cacheHitRateNum = Number.parseFloat(cacheHitRate); - const cacheHitColor = - cacheHitRateNum >= 85 - ? "text-green-600 dark:text-green-400" - : cacheHitRateNum >= 60 - ? "text-yellow-600 dark:text-yellow-400" - : "text-orange-600 dark:text-orange-400"; - - return ( - <> -
setOpen(true)} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - setOpen(true); - } - }} - > -
- {model || t("unknownModel")} -
- - - {requests.toLocaleString()} - - - - {formatTokenAmount(totalAllTokens)} - - - - {cacheHitRate}% - -
-
-
-
{formatCurrency(cost, currencyCode)}
-
({costPercentage}%)
-
-
- - - - - - - {model || t("unknownModel")} - - -
-
-
-
- - {t("modal.requests")} -
-
{requests.toLocaleString()}
-
- -
-
- - {t("modal.totalTokens")} -
-
- {formatTokenAmount(totalAllTokens)} -
-
- -
-
- - {t("modal.cost")} -
-
- {formatCurrency(cost, currencyCode)} -
-
-
- - - -
-

- - {t("modal.totalTokens")} -

-
-
-
- - {t("modal.inputTokens")} -
-
- {formatTokenAmount(inputTokens)} -
-
- -
-
- - {t("modal.outputTokens")} -
-
- {formatTokenAmount(outputTokens)} -
-
-
-
- - - -
-

- - {t("modal.cacheTokens")} -

-
-
-
- - {t("modal.cacheWrite")} -
-
- {formatTokenAmount(cacheCreationTokens)} -
-
- -
-
- - {t("modal.cacheRead")} -
-
- {formatTokenAmount(cacheReadTokens)} -
-
-
- -
-
-
- - {t("modal.cacheHitRate")} -
-
- - {cacheHitRate}% - - = 85 - ? "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400" - : cacheHitRateNum >= 60 - ? "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400" - : "bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400" - }`} - > - - {cacheHitRateNum >= 85 - ? t("modal.performanceHigh") - : cacheHitRateNum >= 60 - ? t("modal.performanceMedium") - : t("modal.performanceLow")} - -
-
-
-
-
-
-
- - ); -} diff --git a/src/app/api/leaderboard/route.ts b/src/app/api/leaderboard/route.ts index 8fdef012a..083601ab6 100644 --- a/src/app/api/leaderboard/route.ts +++ b/src/app/api/leaderboard/route.ts @@ -79,6 +79,7 @@ export async function GET(request: NextRequest) { const includeModelStatsParam = searchParams.get("includeModelStats"); const userTagsParam = searchParams.get("userTags"); const userGroupsParam = searchParams.get("userGroups"); + const includeUserModelStatsParam = searchParams.get("includeUserModelStats"); if (!VALID_PERIODS.includes(period)) { return NextResponse.json( @@ -135,6 +136,19 @@ export async function GET(request: NextRequest) { includeModelStatsParam === "true" || includeModelStatsParam === "yes"); + const includeUserModelStats = + scope === "user" && + (includeUserModelStatsParam === "1" || + includeUserModelStatsParam === "true" || + includeUserModelStatsParam === "yes"); + + if (includeUserModelStats && !isAdmin) { + return NextResponse.json( + { error: "INCLUDE_USER_MODEL_STATS_ADMIN_REQUIRED" }, + { status: 403 } + ); + } + const parseListParam = (param: string | null): string[] | undefined => { if (!param) return undefined; const items = param @@ -158,7 +172,12 @@ export async function GET(request: NextRequest) { systemSettings.currencyDisplay, scope, dateRange, - { providerType, userTags, userGroups, includeModelStats } + { + providerType, + userTags, + userGroups, + includeModelStats: includeModelStats || includeUserModelStats, + } ); // 格式化金额字段 @@ -227,11 +246,26 @@ export async function GET(request: NextRequest) { }) : undefined; + const userModelStatsFormatted = + scope === "user" && Array.isArray(typedEntry.modelStats) + ? typedEntry.modelStats.map((ms) => { + const stat = ms as { + totalCost: number; + model: string | null; + } & Record; + return { + ...stat, + totalCostFormatted: formatCurrency(stat.totalCost, systemSettings.currencyDisplay), + }; + }) + : undefined; + return { ...base, ...providerFields, ...cacheFields, ...(modelStatsFormatted !== undefined ? { modelStats: modelStatsFormatted } : {}), + ...(userModelStatsFormatted !== undefined ? { modelStats: userModelStatsFormatted } : {}), }; }); @@ -251,7 +285,10 @@ export async function GET(request: NextRequest) { return NextResponse.json(data, { headers: { - "Cache-Control": "public, s-maxage=60, stale-while-revalidate=120", + "Cache-Control": + includeUserModelStats || !systemSettings.allowGlobalUsageView + ? "private, no-store" + : "public, s-maxage=60, stale-while-revalidate=120", }, }); } catch (error) { diff --git a/src/components/analytics/model-breakdown-column.tsx b/src/components/analytics/model-breakdown-column.tsx new file mode 100644 index 000000000..93e8311af --- /dev/null +++ b/src/components/analytics/model-breakdown-column.tsx @@ -0,0 +1,337 @@ +"use client"; + +import { + Activity, + ArrowDownRight, + ArrowUpRight, + Coins, + Database, + Hash, + Percent, + Target, +} from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useState } from "react"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Separator } from "@/components/ui/separator"; +import { formatTokenAmount } from "@/lib/utils"; +import { type CurrencyCode, formatCurrency } from "@/lib/utils/currency"; + +export interface ModelBreakdownItem { + model: string | null; + requests: number; + cost: number; + inputTokens: number; + outputTokens: number; + cacheCreationTokens: number; + cacheReadTokens: number; +} + +export interface ModelBreakdownLabels { + unknownModel: string; + modal: { + requests: string; + cost: string; + inputTokens: string; + outputTokens: string; + cacheCreationTokens: string; + cacheReadTokens: string; + totalTokens: string; + costPercentage: string; + cacheHitRate: string; + cacheTokens: string; + performanceHigh: string; + performanceMedium: string; + performanceLow: string; + }; +} + +interface ModelBreakdownColumnProps { + pageItems: ModelBreakdownItem[]; + currencyCode: CurrencyCode; + totalCost: number; + keyPrefix: string; + pageOffset: number; + labels?: ModelBreakdownLabels; +} + +export function ModelBreakdownColumn({ + pageItems, + currencyCode, + totalCost, + keyPrefix, + pageOffset, + labels, +}: ModelBreakdownColumnProps) { + return ( +
+ {pageItems.map((item, index) => ( + + ))} +
+ ); +} + +interface ModelBreakdownRowProps { + model: string | null; + requests: number; + cost: number; + inputTokens: number; + outputTokens: number; + cacheCreationTokens: number; + cacheReadTokens: number; + currencyCode: CurrencyCode; + totalCost: number; + labels?: ModelBreakdownLabels; +} + +function useLabels(labels?: ModelBreakdownLabels) { + const t = useTranslations("myUsage.stats"); + + if (labels) { + return { + unknownModel: labels.unknownModel, + modalRequests: labels.modal.requests, + modalCost: labels.modal.cost, + modalInputTokens: labels.modal.inputTokens, + modalOutputTokens: labels.modal.outputTokens, + modalCacheCreationTokens: labels.modal.cacheCreationTokens, + modalCacheReadTokens: labels.modal.cacheReadTokens, + modalTotalTokens: labels.modal.totalTokens, + modalCacheHitRate: labels.modal.cacheHitRate, + modalCacheTokens: labels.modal.cacheTokens, + modalPerformanceHigh: labels.modal.performanceHigh, + modalPerformanceMedium: labels.modal.performanceMedium, + modalPerformanceLow: labels.modal.performanceLow, + }; + } + + return { + unknownModel: t("unknownModel"), + modalRequests: t("modal.requests"), + modalCost: t("modal.cost"), + modalInputTokens: t("modal.inputTokens"), + modalOutputTokens: t("modal.outputTokens"), + modalCacheCreationTokens: t("modal.cacheWrite"), + modalCacheReadTokens: t("modal.cacheRead"), + modalTotalTokens: t("modal.totalTokens"), + modalCacheHitRate: t("modal.cacheHitRate"), + modalCacheTokens: t("modal.cacheTokens"), + modalPerformanceHigh: t("modal.performanceHigh"), + modalPerformanceMedium: t("modal.performanceMedium"), + modalPerformanceLow: t("modal.performanceLow"), + }; +} + +export function ModelBreakdownRow({ + model, + requests, + cost, + inputTokens, + outputTokens, + cacheCreationTokens, + cacheReadTokens, + currencyCode, + totalCost, + labels, +}: ModelBreakdownRowProps) { + const [open, setOpen] = useState(false); + const l = useLabels(labels); + + const totalAllTokens = inputTokens + outputTokens + cacheCreationTokens + cacheReadTokens; + const totalInputTokens = inputTokens + cacheCreationTokens + cacheReadTokens; + const cacheHitRate = + totalInputTokens > 0 ? ((cacheReadTokens / totalInputTokens) * 100).toFixed(1) : "0.0"; + const costPercentage = totalCost > 0 ? ((cost / totalCost) * 100).toFixed(1) : "0.0"; + + const cacheHitRateNum = Number.parseFloat(cacheHitRate); + const cacheHitColor = + cacheHitRateNum >= 85 + ? "text-green-600 dark:text-green-400" + : cacheHitRateNum >= 60 + ? "text-yellow-600 dark:text-yellow-400" + : "text-orange-600 dark:text-orange-400"; + + return ( + <> +
setOpen(true)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + setOpen(true); + } + }} + > +
+ {model || l.unknownModel} +
+ + + {requests.toLocaleString()} + + + + {formatTokenAmount(totalAllTokens)} + + + + {cacheHitRate}% + +
+
+
+
{formatCurrency(cost, currencyCode)}
+
({costPercentage}%)
+
+
+ + + + + + + {model || l.unknownModel} + + +
+
+
+
+ + {l.modalRequests} +
+
{requests.toLocaleString()}
+
+ +
+
+ + {l.modalTotalTokens} +
+
+ {formatTokenAmount(totalAllTokens)} +
+
+ +
+
+ + {l.modalCost} +
+
+ {formatCurrency(cost, currencyCode)} +
+
+
+ + + +
+

+ + {l.modalTotalTokens} +

+
+
+
+ + {l.modalInputTokens} +
+
+ {formatTokenAmount(inputTokens)} +
+
+ +
+
+ + {l.modalOutputTokens} +
+
+ {formatTokenAmount(outputTokens)} +
+
+
+
+ + + +
+

+ + {l.modalCacheTokens} +

+
+
+
+ + {l.modalCacheCreationTokens} +
+
+ {formatTokenAmount(cacheCreationTokens)} +
+
+ +
+
+ + {l.modalCacheReadTokens} +
+
+ {formatTokenAmount(cacheReadTokens)} +
+
+
+ +
+
+
+ + {l.modalCacheHitRate} +
+
+ + {cacheHitRate}% + + = 85 + ? "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400" + : cacheHitRateNum >= 60 + ? "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400" + : "bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400" + }`} + > + + {cacheHitRateNum >= 85 + ? l.modalPerformanceHigh + : cacheHitRateNum >= 60 + ? l.modalPerformanceMedium + : l.modalPerformanceLow} + +
+
+
+
+
+
+
+ + ); +} diff --git a/src/lib/redis/leaderboard-cache.ts b/src/lib/redis/leaderboard-cache.ts index 26abb74ac..536b39f16 100644 --- a/src/lib/redis/leaderboard-cache.ts +++ b/src/lib/redis/leaderboard-cache.ts @@ -46,7 +46,7 @@ export interface LeaderboardFilters { providerType?: ProviderType; userTags?: string[]; userGroups?: string[]; - /** 仅 scope=provider 生效:是否包含按模型拆分的数据(ProviderLeaderboardEntry.modelStats) */ + /** scope=provider 或 scope=user 时生效:是否包含按模型拆分的数据 */ includeModelStats?: boolean; } @@ -65,7 +65,9 @@ function buildCacheKey( const now = new Date(); const providerTypeSuffix = filters?.providerType ? `:providerType:${filters.providerType}` : ""; const includeModelStatsSuffix = - scope === "provider" && filters?.includeModelStats ? ":includeModelStats" : ""; + (scope === "provider" || scope === "user") && filters?.includeModelStats + ? ":includeModelStats" + : ""; let userFilterSuffix = ""; if (scope === "user") { @@ -116,7 +118,7 @@ async function queryDatabase( // 处理自定义日期范围 if (period === "custom" && dateRange) { if (scope === "user") { - return await findCustomRangeLeaderboard(dateRange, userFilters); + return await findCustomRangeLeaderboard(dateRange, userFilters, filters?.includeModelStats); } if (scope === "provider") { return await findCustomRangeProviderLeaderboard( @@ -134,15 +136,15 @@ async function queryDatabase( if (scope === "user") { switch (period) { case "daily": - return await findDailyLeaderboard(userFilters); + return await findDailyLeaderboard(userFilters, filters?.includeModelStats); case "weekly": - return await findWeeklyLeaderboard(userFilters); + return await findWeeklyLeaderboard(userFilters, filters?.includeModelStats); case "monthly": - return await findMonthlyLeaderboard(userFilters); + return await findMonthlyLeaderboard(userFilters, filters?.includeModelStats); case "allTime": - return await findAllTimeLeaderboard(userFilters); + return await findAllTimeLeaderboard(userFilters, filters?.includeModelStats); default: - return await findDailyLeaderboard(userFilters); + return await findDailyLeaderboard(userFilters, filters?.includeModelStats); } } if (scope === "provider") { diff --git a/src/repository/admin-user-insights.ts b/src/repository/admin-user-insights.ts new file mode 100644 index 000000000..0686c023a --- /dev/null +++ b/src/repository/admin-user-insights.ts @@ -0,0 +1,63 @@ +"use server"; + +import { and, desc, eq, gte, lt, sql } from "drizzle-orm"; +import { db } from "@/drizzle/db"; +import { usageLedger } from "@/drizzle/schema"; +import { LEDGER_BILLING_CONDITION } from "./_shared/ledger-conditions"; +import { getSystemSettings } from "./system-config"; + +export interface AdminUserModelBreakdownItem { + model: string | null; + requests: number; + cost: number; + inputTokens: number; + outputTokens: number; + cacheCreationTokens: number; + cacheReadTokens: number; +} + +/** + * Get model-level usage breakdown for a specific user. + * Groups by the billingModelSource-resolved model field and orders by cost DESC. + */ +export async function getUserModelBreakdown( + userId: number, + startDate?: string, + endDate?: string +): Promise { + const systemSettings = await getSystemSettings(); + const billingModelSource = systemSettings.billingModelSource; + + const rawModelField = + billingModelSource === "original" + ? sql`COALESCE(${usageLedger.originalModel}, ${usageLedger.model})` + : sql`COALESCE(${usageLedger.model}, ${usageLedger.originalModel})`; + const modelField = sql`NULLIF(TRIM(${rawModelField}), '')`; + + const conditions = [LEDGER_BILLING_CONDITION, eq(usageLedger.userId, userId)]; + + if (startDate) { + conditions.push(gte(usageLedger.createdAt, sql`${startDate}::date`)); + } + + if (endDate) { + conditions.push(lt(usageLedger.createdAt, sql`(${endDate}::date + INTERVAL '1 day')`)); + } + + const rows = await db + .select({ + model: modelField, + requests: sql`count(*)::int`, + cost: sql`COALESCE(sum(${usageLedger.costUsd})::double precision, 0)`, + inputTokens: sql`COALESCE(sum(${usageLedger.inputTokens})::double precision, 0)`, + outputTokens: sql`COALESCE(sum(${usageLedger.outputTokens})::double precision, 0)`, + cacheCreationTokens: sql`COALESCE(sum(${usageLedger.cacheCreationInputTokens})::double precision, 0)`, + cacheReadTokens: sql`COALESCE(sum(${usageLedger.cacheReadInputTokens})::double precision, 0)`, + }) + .from(usageLedger) + .where(and(...conditions)) + .groupBy(modelField) + .orderBy(desc(sql`sum(${usageLedger.costUsd})`)); + + return rows; +} diff --git a/src/repository/leaderboard.ts b/src/repository/leaderboard.ts index 38d1a7971..fe0cf81bc 100644 --- a/src/repository/leaderboard.ts +++ b/src/repository/leaderboard.ts @@ -13,12 +13,20 @@ const clampRatio01 = (value: number | null | undefined) => Math.min(Math.max(val /** * 排行榜条目类型 */ +export interface UserModelStat { + model: string | null; + totalRequests: number; + totalCost: number; + totalTokens: number; +} + export interface LeaderboardEntry { userId: number; userName: string; totalRequests: number; totalCost: number; totalTokens: number; + modelStats?: UserModelStat[]; } /** @@ -113,10 +121,11 @@ export interface ModelLeaderboardEntry { * 使用 SQL AT TIME ZONE 进行时区转换,确保"今日"基于系统时区 */ export async function findDailyLeaderboard( - userFilters?: UserLeaderboardFilters + userFilters?: UserLeaderboardFilters, + includeModelStats?: boolean ): Promise { const timezone = await resolveSystemTimezone(); - return findLeaderboardWithTimezone("daily", timezone, undefined, userFilters); + return findLeaderboardWithTimezone("daily", timezone, undefined, userFilters, includeModelStats); } /** @@ -124,10 +133,17 @@ export async function findDailyLeaderboard( * 使用 SQL AT TIME ZONE 进行时区转换,确保"本月"基于系统时区 */ export async function findMonthlyLeaderboard( - userFilters?: UserLeaderboardFilters + userFilters?: UserLeaderboardFilters, + includeModelStats?: boolean ): Promise { const timezone = await resolveSystemTimezone(); - return findLeaderboardWithTimezone("monthly", timezone, undefined, userFilters); + return findLeaderboardWithTimezone( + "monthly", + timezone, + undefined, + userFilters, + includeModelStats + ); } /** @@ -135,20 +151,28 @@ export async function findMonthlyLeaderboard( * 使用 SQL AT TIME ZONE 进行时区转换,确保"本周"基于系统时区 */ export async function findWeeklyLeaderboard( - userFilters?: UserLeaderboardFilters + userFilters?: UserLeaderboardFilters, + includeModelStats?: boolean ): Promise { const timezone = await resolveSystemTimezone(); - return findLeaderboardWithTimezone("weekly", timezone, undefined, userFilters); + return findLeaderboardWithTimezone("weekly", timezone, undefined, userFilters, includeModelStats); } /** * 查询全部时间消耗排行榜(不限制数量) */ export async function findAllTimeLeaderboard( - userFilters?: UserLeaderboardFilters + userFilters?: UserLeaderboardFilters, + includeModelStats?: boolean ): Promise { const timezone = await resolveSystemTimezone(); - return findLeaderboardWithTimezone("allTime", timezone, undefined, userFilters); + return findLeaderboardWithTimezone( + "allTime", + timezone, + undefined, + userFilters, + includeModelStats + ); } /** @@ -230,7 +254,8 @@ async function findLeaderboardWithTimezone( period: LeaderboardPeriod, timezone: string, dateRange?: DateRangeParams, - userFilters?: UserLeaderboardFilters + userFilters?: UserLeaderboardFilters, + includeModelStats?: boolean ): Promise { const whereConditions = [ LEDGER_BILLING_CONDITION, @@ -284,13 +309,62 @@ async function findLeaderboardWithTimezone( .groupBy(usageLedger.userId, users.name) .orderBy(desc(sql`sum(${usageLedger.costUsd})`)); - return rankings.map((entry) => ({ + const baseEntries: LeaderboardEntry[] = rankings.map((entry) => ({ userId: entry.userId, userName: entry.userName, totalRequests: entry.totalRequests, totalCost: parseFloat(entry.totalCost), totalTokens: entry.totalTokens, })); + + if (!includeModelStats) return baseEntries; + + const systemSettings = await getSystemSettings(); + const billingModelSource = systemSettings.billingModelSource; + const rawModelField = + billingModelSource === "original" + ? sql`COALESCE(${usageLedger.originalModel}, ${usageLedger.model})` + : sql`COALESCE(${usageLedger.model}, ${usageLedger.originalModel})`; + const modelField = sql`NULLIF(TRIM(${rawModelField}), '')`; + + const modelRows = await db + .select({ + userId: usageLedger.userId, + model: modelField, + totalRequests: sql`count(*)::double precision`, + totalCost: sql`COALESCE(sum(${usageLedger.costUsd}), 0)`, + totalTokens: sql`COALESCE( + sum( + ${usageLedger.inputTokens} + + ${usageLedger.outputTokens} + + COALESCE(${usageLedger.cacheCreationInputTokens}, 0) + + COALESCE(${usageLedger.cacheReadInputTokens}, 0) + )::double precision, + 0::double precision + )`, + }) + .from(usageLedger) + .innerJoin(users, and(sql`${usageLedger.userId} = ${users.id}`, isNull(users.deletedAt))) + .where(and(...whereConditions)) + .groupBy(usageLedger.userId, modelField) + .orderBy(desc(sql`sum(${usageLedger.costUsd})`)); + + const modelStatsByUser = new Map(); + for (const row of modelRows) { + const stats = modelStatsByUser.get(row.userId) ?? []; + stats.push({ + model: row.model, + totalRequests: row.totalRequests, + totalCost: parseFloat(row.totalCost), + totalTokens: row.totalTokens, + }); + modelStatsByUser.set(row.userId, stats); + } + + return baseEntries.map((entry) => ({ + ...entry, + modelStats: modelStatsByUser.get(entry.userId) ?? [], + })); } /** @@ -298,10 +372,11 @@ async function findLeaderboardWithTimezone( */ export async function findCustomRangeLeaderboard( dateRange: DateRangeParams, - userFilters?: UserLeaderboardFilters + userFilters?: UserLeaderboardFilters, + includeModelStats?: boolean ): Promise { const timezone = await resolveSystemTimezone(); - return findLeaderboardWithTimezone("custom", timezone, dateRange, userFilters); + return findLeaderboardWithTimezone("custom", timezone, dateRange, userFilters, includeModelStats); } /** diff --git a/tests/unit/actions/admin-user-insights.test.ts b/tests/unit/actions/admin-user-insights.test.ts new file mode 100644 index 000000000..8c3e5cdb2 --- /dev/null +++ b/tests/unit/actions/admin-user-insights.test.ts @@ -0,0 +1,389 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockGetSession = vi.hoisted(() => vi.fn()); +const mockFindUserById = vi.hoisted(() => vi.fn()); +const mockGetOverviewWithCache = vi.hoisted(() => vi.fn()); +const mockGetStatisticsWithCache = vi.hoisted(() => vi.fn()); +const mockGetUserModelBreakdown = vi.hoisted(() => vi.fn()); +const mockGetSystemSettings = vi.hoisted(() => vi.fn()); + +vi.mock("@/lib/auth", () => ({ + getSession: mockGetSession, +})); + +vi.mock("@/repository/user", () => ({ + findUserById: mockFindUserById, +})); + +vi.mock("@/lib/redis/overview-cache", () => ({ + getOverviewWithCache: mockGetOverviewWithCache, +})); + +vi.mock("@/lib/redis/statistics-cache", () => ({ + getStatisticsWithCache: mockGetStatisticsWithCache, +})); + +vi.mock("@/repository/admin-user-insights", () => ({ + getUserModelBreakdown: mockGetUserModelBreakdown, +})); + +vi.mock("@/repository/system-config", () => ({ + getSystemSettings: mockGetSystemSettings, +})); + +function createAdminSession() { + return { + user: { id: 1, name: "Admin", role: "admin" }, + key: { id: 1, key: "sk-admin" }, + }; +} + +function createUserSession() { + return { + user: { id: 2, name: "User", role: "user" }, + key: { id: 2, key: "sk-user" }, + }; +} + +function createMockUser() { + return { + id: 10, + name: "Target User", + description: "", + role: "user" as const, + rpm: null, + dailyQuota: null, + providerGroup: "default", + isEnabled: true, + expiresAt: null, + dailyResetMode: "fixed" as const, + dailyResetTime: "00:00", + createdAt: new Date(), + updatedAt: new Date(), + }; +} + +function createMockOverview() { + return { + todayRequests: 50, + todayCost: 5.5, + avgResponseTime: 200, + todayErrorRate: 2.0, + yesterdaySamePeriodRequests: 40, + yesterdaySamePeriodCost: 4.0, + yesterdaySamePeriodAvgResponseTime: 220, + recentMinuteRequests: 2, + }; +} + +function createMockSettings() { + return { + id: 1, + siteTitle: "Claude Code Hub", + allowGlobalUsageView: false, + currencyDisplay: "USD", + billingModelSource: "original", + timezone: null, + enableAutoCleanup: false, + cleanupRetentionDays: 30, + cleanupSchedule: "0 2 * * *", + cleanupBatchSize: 10000, + enableClientVersionCheck: false, + verboseProviderError: false, + enableHttp2: false, + interceptAnthropicWarmupRequests: false, + enableThinkingSignatureRectifier: true, + enableThinkingBudgetRectifier: true, + enableBillingHeaderRectifier: true, + enableCodexSessionIdCompletion: true, + enableClaudeMetadataUserIdInjection: true, + enableResponseFixer: true, + responseFixerConfig: { + fixTruncatedJson: true, + fixSseFormat: true, + fixEncoding: true, + maxJsonDepth: 50, + maxFixSize: 1048576, + }, + createdAt: new Date(), + updatedAt: new Date(), + }; +} + +function createMockBreakdown() { + return [ + { + model: "claude-sonnet-4-20250514", + requests: 30, + cost: 3.5, + inputTokens: 10000, + outputTokens: 5000, + cacheCreationTokens: 2000, + cacheReadTokens: 8000, + }, + { + model: "claude-opus-4-20250514", + requests: 20, + cost: 2.0, + inputTokens: 8000, + outputTokens: 3000, + cacheCreationTokens: 1000, + cacheReadTokens: 5000, + }, + ]; +} + +describe("getUserInsightsOverview", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns unauthorized for non-admin", async () => { + mockGetSession.mockResolvedValueOnce(createUserSession()); + + const { getUserInsightsOverview } = await import("@/actions/admin-user-insights"); + const result = await getUserInsightsOverview(10); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toBe("Unauthorized"); + } + expect(mockFindUserById).not.toHaveBeenCalled(); + }); + + it("returns unauthorized when not logged in", async () => { + mockGetSession.mockResolvedValueOnce(null); + + const { getUserInsightsOverview } = await import("@/actions/admin-user-insights"); + const result = await getUserInsightsOverview(10); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toBe("Unauthorized"); + } + }); + + it("returns error for non-existent user", async () => { + mockGetSession.mockResolvedValueOnce(createAdminSession()); + mockFindUserById.mockResolvedValueOnce(null); + + const { getUserInsightsOverview } = await import("@/actions/admin-user-insights"); + const result = await getUserInsightsOverview(999); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toBe("User not found"); + } + expect(mockFindUserById).toHaveBeenCalledWith(999); + }); + + it("returns overview data for valid admin request", async () => { + const user = createMockUser(); + const overview = createMockOverview(); + const settings = createMockSettings(); + + mockGetSession.mockResolvedValueOnce(createAdminSession()); + mockFindUserById.mockResolvedValueOnce(user); + mockGetOverviewWithCache.mockResolvedValueOnce(overview); + mockGetSystemSettings.mockResolvedValueOnce(settings); + + const { getUserInsightsOverview } = await import("@/actions/admin-user-insights"); + const result = await getUserInsightsOverview(10); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.user).toEqual(user); + expect(result.data.overview).toEqual(overview); + expect(result.data.currencyCode).toBe("USD"); + } + expect(mockFindUserById).toHaveBeenCalledWith(10); + expect(mockGetOverviewWithCache).toHaveBeenCalledWith(10); + }); +}); + +describe("getUserInsightsKeyTrend", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns unauthorized for non-admin", async () => { + mockGetSession.mockResolvedValueOnce(createUserSession()); + + const { getUserInsightsKeyTrend } = await import("@/actions/admin-user-insights"); + const result = await getUserInsightsKeyTrend(10, "today"); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toBe("Unauthorized"); + } + expect(mockGetStatisticsWithCache).not.toHaveBeenCalled(); + }); + + it("validates timeRange parameter", async () => { + mockGetSession.mockResolvedValueOnce(createAdminSession()); + + const { getUserInsightsKeyTrend } = await import("@/actions/admin-user-insights"); + const result = await getUserInsightsKeyTrend(10, "invalidRange"); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toContain("Invalid timeRange"); + } + expect(mockGetStatisticsWithCache).not.toHaveBeenCalled(); + }); + + it("returns trend data for valid request", async () => { + const mockStats = [ + { key_id: 1, key_name: "sk-key-1", date: "2026-03-09", api_calls: 10, total_cost: 1.5 }, + { key_id: 2, key_name: "sk-key-2", date: "2026-03-08", api_calls: 15, total_cost: 2.0 }, + ]; + + mockGetSession.mockResolvedValueOnce(createAdminSession()); + mockGetStatisticsWithCache.mockResolvedValueOnce(mockStats); + + const { getUserInsightsKeyTrend } = await import("@/actions/admin-user-insights"); + const result = await getUserInsightsKeyTrend(10, "7days"); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data).toHaveLength(2); + expect(result.data[0].date).toBe("2026-03-09"); + expect(result.data[0].key_id).toBe(1); + expect(result.data[0].key_name).toBe("sk-key-1"); + expect(result.data[0].api_calls).toBe(10); + expect(result.data[1].date).toBe("2026-03-08"); + } + expect(mockGetStatisticsWithCache).toHaveBeenCalledWith("7days", "keys", 10); + }); + + it("normalizes Date objects to ISO strings", async () => { + const mockStats = [ + { + key_id: 1, + key_name: "sk-key-1", + date: new Date("2026-03-09T12:00:00Z"), + api_calls: 10, + total_cost: 1.5, + }, + ]; + + mockGetSession.mockResolvedValueOnce(createAdminSession()); + mockGetStatisticsWithCache.mockResolvedValueOnce(mockStats); + + const { getUserInsightsKeyTrend } = await import("@/actions/admin-user-insights"); + const result = await getUserInsightsKeyTrend(10, "today"); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(typeof result.data[0].date).toBe("string"); + expect(result.data[0].date).toContain("2026-03-09"); + } + }); + + it("accepts all valid timeRange values", async () => { + const validRanges = ["today", "7days", "30days", "thisMonth"]; + + for (const range of validRanges) { + vi.clearAllMocks(); + mockGetSession.mockResolvedValueOnce(createAdminSession()); + mockGetStatisticsWithCache.mockResolvedValueOnce([]); + + const { getUserInsightsKeyTrend } = await import("@/actions/admin-user-insights"); + const result = await getUserInsightsKeyTrend(10, range); + + expect(result.ok).toBe(true); + } + }); +}); + +describe("getUserInsightsModelBreakdown", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns unauthorized for non-admin", async () => { + mockGetSession.mockResolvedValueOnce(createUserSession()); + + const { getUserInsightsModelBreakdown } = await import("@/actions/admin-user-insights"); + const result = await getUserInsightsModelBreakdown(10); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toBe("Unauthorized"); + } + expect(mockGetUserModelBreakdown).not.toHaveBeenCalled(); + }); + + it("returns breakdown data for valid request", async () => { + const breakdown = createMockBreakdown(); + const settings = createMockSettings(); + + mockGetSession.mockResolvedValueOnce(createAdminSession()); + mockGetUserModelBreakdown.mockResolvedValueOnce(breakdown); + mockGetSystemSettings.mockResolvedValueOnce(settings); + + const { getUserInsightsModelBreakdown } = await import("@/actions/admin-user-insights"); + const result = await getUserInsightsModelBreakdown(10); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.breakdown).toEqual(breakdown); + expect(result.data.currencyCode).toBe("USD"); + } + expect(mockGetUserModelBreakdown).toHaveBeenCalledWith(10, undefined, undefined); + }); + + it("passes date range to getUserModelBreakdown", async () => { + const breakdown = createMockBreakdown(); + const settings = createMockSettings(); + + mockGetSession.mockResolvedValueOnce(createAdminSession()); + mockGetUserModelBreakdown.mockResolvedValueOnce(breakdown); + mockGetSystemSettings.mockResolvedValueOnce(settings); + + const { getUserInsightsModelBreakdown } = await import("@/actions/admin-user-insights"); + const result = await getUserInsightsModelBreakdown(10, "2026-03-01", "2026-03-09"); + + expect(result.ok).toBe(true); + expect(mockGetUserModelBreakdown).toHaveBeenCalledWith(10, "2026-03-01", "2026-03-09"); + }); + + it("rejects invalid startDate format", async () => { + mockGetSession.mockResolvedValueOnce(createAdminSession()); + + const { getUserInsightsModelBreakdown } = await import("@/actions/admin-user-insights"); + const result = await getUserInsightsModelBreakdown(10, "not-a-date"); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toContain("startDate"); + } + expect(mockGetUserModelBreakdown).not.toHaveBeenCalled(); + }); + + it("rejects invalid endDate format", async () => { + mockGetSession.mockResolvedValueOnce(createAdminSession()); + + const { getUserInsightsModelBreakdown } = await import("@/actions/admin-user-insights"); + const result = await getUserInsightsModelBreakdown(10, "2026-03-01", "03/09/2026"); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toContain("endDate"); + } + expect(mockGetUserModelBreakdown).not.toHaveBeenCalled(); + }); + + it("rejects startDate after endDate", async () => { + mockGetSession.mockResolvedValueOnce(createAdminSession()); + + const { getUserInsightsModelBreakdown } = await import("@/actions/admin-user-insights"); + const result = await getUserInsightsModelBreakdown(10, "2026-03-09", "2026-03-01"); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toContain("startDate must not be after endDate"); + } + expect(mockGetUserModelBreakdown).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/api/leaderboard-route.test.ts b/tests/unit/api/leaderboard-route.test.ts index 255018760..c358e93bd 100644 --- a/tests/unit/api/leaderboard-route.test.ts +++ b/tests/unit/api/leaderboard-route.test.ts @@ -287,4 +287,69 @@ describe("GET /api/leaderboard", () => { expect(body[0].modelStats).toHaveLength(0); }); }); + + describe("user scope includeUserModelStats", () => { + it("admin + includeUserModelStats=1 returns 200 with correct cache call and private headers", async () => { + mocks.getSession.mockResolvedValue({ user: { id: 1, name: "admin", role: "admin" } }); + mocks.getLeaderboardWithCache.mockResolvedValue([ + { + userId: 1, + userName: "user-a", + totalRequests: 100, + totalCost: 5.0, + totalTokens: 1000, + modelStats: [ + { model: "claude-3-opus", totalRequests: 60, totalCost: 3.0, totalTokens: 600 }, + { model: null, totalRequests: 40, totalCost: 2.0, totalTokens: 400 }, + ], + }, + ]); + + const { GET } = await import("@/app/api/leaderboard/route"); + const url = + "http://localhost/api/leaderboard?scope=user&period=daily&includeUserModelStats=1"; + const response = await GET({ nextUrl: new URL(url) } as any); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(response.headers.get("Cache-Control")).toBe("private, no-store"); + + const options = mocks.getLeaderboardWithCache.mock.calls[0][4]; + expect(options.includeModelStats).toBe(true); + + expect(body[0].modelStats).toHaveLength(2); + expect(body[0].modelStats[0]).toHaveProperty("totalCostFormatted"); + expect(body[0].modelStats[1].model).toBeNull(); + }); + + it("non-admin + includeUserModelStats=1 returns 403", async () => { + mocks.getSession.mockResolvedValue({ user: { id: 2, name: "user", role: "user" } }); + + const { GET } = await import("@/app/api/leaderboard/route"); + const url = + "http://localhost/api/leaderboard?scope=user&period=daily&includeUserModelStats=1"; + const response = await GET({ nextUrl: new URL(url) } as any); + + expect(response.status).toBe(403); + const body = await response.json(); + expect(body.error).toBe("INCLUDE_USER_MODEL_STATS_ADMIN_REQUIRED"); + }); + + it("non-admin with allowGlobalUsageView + includeUserModelStats=1 returns 403", async () => { + mocks.getSession.mockResolvedValue({ user: { id: 2, name: "user", role: "user" } }); + mocks.getSystemSettings.mockResolvedValue({ + currencyDisplay: "USD", + allowGlobalUsageView: true, + }); + + const { GET } = await import("@/app/api/leaderboard/route"); + const url = + "http://localhost/api/leaderboard?scope=user&period=daily&includeUserModelStats=1"; + const response = await GET({ nextUrl: new URL(url) } as any); + + expect(response.status).toBe(403); + const body = await response.json(); + expect(body.error).toBe("INCLUDE_USER_MODEL_STATS_ADMIN_REQUIRED"); + }); + }); }); diff --git a/tests/unit/components/model-breakdown-column.test.tsx b/tests/unit/components/model-breakdown-column.test.tsx new file mode 100644 index 000000000..b9dc95ca8 --- /dev/null +++ b/tests/unit/components/model-breakdown-column.test.tsx @@ -0,0 +1,313 @@ +/** + * @vitest-environment happy-dom + */ + +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vitest"; +import type { + ModelBreakdownItem, + ModelBreakdownLabels, +} from "@/components/analytics/model-breakdown-column"; +import { + ModelBreakdownColumn, + ModelBreakdownRow, +} from "@/components/analytics/model-breakdown-column"; + +// -- mocks -- + +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string) => `t:${key}`, +})); + +vi.mock("@/components/ui/dialog", () => ({ + Dialog: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + DialogContent: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + DialogHeader: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + DialogTitle: ({ children }: { children: React.ReactNode }) => ( +

{children}

+ ), +})); + +vi.mock("@/components/ui/separator", () => ({ + Separator: () =>
, +})); + +vi.mock("@/lib/utils/currency", async () => { + const actual = + await vi.importActual("@/lib/utils/currency"); + return { + ...actual, + formatCurrency: (value: number) => `$${value.toFixed(2)}`, + }; +}); + +// -- helpers -- + +function makeItem(overrides: Partial = {}): ModelBreakdownItem { + return { + model: "claude-opus-4", + requests: 150, + cost: 3.5, + inputTokens: 10000, + outputTokens: 5000, + cacheCreationTokens: 2000, + cacheReadTokens: 8000, + ...overrides, + }; +} + +const customLabels: ModelBreakdownLabels = { + unknownModel: "Custom Unknown", + modal: { + requests: "Custom Requests", + cost: "Custom Cost", + inputTokens: "Custom Input", + outputTokens: "Custom Output", + cacheCreationTokens: "Custom Cache Write", + cacheReadTokens: "Custom Cache Read", + totalTokens: "Custom Total Tokens", + costPercentage: "Custom Cost %", + cacheHitRate: "Custom Cache Hit", + cacheTokens: "Custom Cache Tokens", + performanceHigh: "Custom High", + performanceMedium: "Custom Medium", + performanceLow: "Custom Low", + }, +}; + +function renderText(element: React.ReactElement): string { + const markup = renderToStaticMarkup(element); + const container = document.createElement("div"); + // Safe: content comes from our own renderToStaticMarkup, not user input + container.textContent = ""; + const template = document.createElement("template"); + template.innerHTML = markup; + container.appendChild(template.content.cloneNode(true)); + return container.textContent ?? ""; +} + +// -- tests -- + +describe("ModelBreakdownColumn", () => { + it("renders model name for each page item", () => { + const items = [makeItem({ model: "gpt-4.1" }), makeItem({ model: "claude-sonnet-4" })]; + + const text = renderText( + + ); + + expect(text).toContain("gpt-4.1"); + expect(text).toContain("claude-sonnet-4"); + }); + + it("renders unknownModel label for null model", () => { + const items = [makeItem({ model: null })]; + + const text = renderText( + + ); + + // Falls back to useTranslations which returns "t:unknownModel" + expect(text).toContain("t:unknownModel"); + }); + + it("renders request count and token amounts", () => { + const items = [ + makeItem({ + requests: 42, + inputTokens: 1500, + outputTokens: 500, + cacheCreationTokens: 200, + cacheReadTokens: 300, + }), + ]; + + const text = renderText( + + ); + + // Request count + expect(text).toContain("42"); + // Total tokens = 1500 + 500 + 200 + 300 = 2500 -> "2.5K" + expect(text).toContain("2.5K"); + }); + + it("passes correct props to ModelBreakdownRow", () => { + const item = makeItem({ model: "test-model", cost: 5.0, requests: 99 }); + + const text = renderText( + + ); + + // Model name + expect(text).toContain("test-model"); + // Request count + expect(text).toContain("99"); + // Cost formatted + expect(text).toContain("$5.00"); + // Cost percentage = (5/10)*100 = 50.0 + expect(text).toContain("50.0%"); + }); + + it("uses custom labels when provided", () => { + const items = [makeItem({ model: null })]; + + const text = renderText( + + ); + + // Custom unknown model label instead of "t:unknownModel" + expect(text).toContain("Custom Unknown"); + expect(text).not.toContain("t:unknownModel"); + // Custom modal labels appear in the dialog content + expect(text).toContain("Custom Requests"); + expect(text).toContain("Custom Cost"); + expect(text).toContain("Custom Total Tokens"); + expect(text).toContain("Custom Cache Tokens"); + expect(text).toContain("Custom Cache Hit"); + }); +}); + +describe("ModelBreakdownRow", () => { + it("renders model name and metrics in the row", () => { + const text = renderText( + + ); + + expect(text).toContain("claude-opus-4"); + expect(text).toContain("150"); + expect(text).toContain("$3.50"); + }); + + it("computes cache hit rate correctly", () => { + // totalInputTokens = 10000 + 2000 + 8000 = 20000 + // cacheHitRate = (8000 / 20000) * 100 = 40.0 + const text = renderText( + + ); + + expect(text).toContain("40.0%"); + }); + + it("shows zero cache hit rate when no input tokens", () => { + const text = renderText( + + ); + + expect(text).toContain("0.0%"); + }); + + it("uses translation fallback when no labels provided", () => { + const text = renderText( + + ); + + // unknownModel via translation mock + expect(text).toContain("t:unknownModel"); + // modal labels via translation mock + expect(text).toContain("t:modal.requests"); + expect(text).toContain("t:modal.cacheWrite"); + expect(text).toContain("t:modal.cacheRead"); + }); + + it("uses custom labels when provided", () => { + const text = renderText( + + ); + + expect(text).toContain("Custom Unknown"); + expect(text).toContain("Custom Requests"); + expect(text).toContain("Custom Cache Write"); + expect(text).toContain("Custom Cache Read"); + expect(text).toContain("Custom Cache Tokens"); + expect(text).not.toContain("t:unknownModel"); + }); +}); diff --git a/tests/unit/dashboard/user-insights-page.test.tsx b/tests/unit/dashboard/user-insights-page.test.tsx new file mode 100644 index 000000000..0db6b3d3e --- /dev/null +++ b/tests/unit/dashboard/user-insights-page.test.tsx @@ -0,0 +1,418 @@ +/** + * @vitest-environment happy-dom + */ + +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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import dashboardMessages from "@messages/en/dashboard.json"; +import myUsageMessages from "@messages/en/myUsage.json"; +import commonMessages from "@messages/en/common.json"; + +// --- Hoisted mocks --- + +const mockGetUserInsightsOverview = vi.hoisted(() => vi.fn()); +const mockGetUserInsightsKeyTrend = vi.hoisted(() => vi.fn()); +const mockGetUserInsightsModelBreakdown = vi.hoisted(() => vi.fn()); + +vi.mock("@/actions/admin-user-insights", () => ({ + getUserInsightsOverview: mockGetUserInsightsOverview, + getUserInsightsKeyTrend: mockGetUserInsightsKeyTrend, + getUserInsightsModelBreakdown: mockGetUserInsightsModelBreakdown, +})); + +const routerPushMock = vi.fn(); +vi.mock("@/i18n/routing", () => ({ + useRouter: () => ({ + push: routerPushMock, + replace: vi.fn(), + back: vi.fn(), + }), + usePathname: () => "/dashboard/leaderboard/user/10", +})); + +// Mock recharts to avoid rendering issues in happy-dom +vi.mock("recharts", () => ({ + Area: () => null, + AreaChart: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), + CartesianGrid: () => null, + XAxis: () => null, + YAxis: () => null, + ResponsiveContainer: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), +})); + +vi.mock("@/components/ui/chart", () => ({ + ChartContainer: ({ + children, + className, + }: { + children: ReactNode; + className?: string; + config: unknown; + }) => ( +
+ {children} +
+ ), + ChartTooltip: () => null, +})); + +// --- Test helpers --- + +const messages = { + dashboard: dashboardMessages, + myUsage: myUsageMessages, + common: commonMessages, +} as const; + +let queryClient: QueryClient; + +function renderWithProviders(node: ReactNode) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render( + + + {node} + + + ); + }); + + return { + container, + unmount: () => { + act(() => root.unmount()); + container.remove(); + }, + }; +} + +async function flushMicrotasks() { + for (let i = 0; i < 5; i++) { + await act(async () => { + await new Promise((r) => setTimeout(r, 10)); + }); + } +} + +// --- Tests --- + +describe("UserInsightsView", () => { + beforeEach(() => { + vi.clearAllMocks(); + queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false, refetchOnWindowFocus: false }, + }, + }); + + // Default mocks that resolve + mockGetUserInsightsOverview.mockResolvedValue({ + ok: true, + data: { + user: { id: 10, name: "TestUser" }, + overview: { + todayRequests: 42, + todayCost: 1.23, + avgResponseTime: 850, + todayErrorRate: 2.5, + yesterdaySamePeriodRequests: 30, + yesterdaySamePeriodCost: 1.0, + yesterdaySamePeriodAvgResponseTime: 900, + recentMinuteRequests: 3, + }, + currencyCode: "USD", + }, + }); + + mockGetUserInsightsKeyTrend.mockResolvedValue({ + ok: true, + data: [ + { key_id: 1, key_name: "key-a", date: "2026-03-08", api_calls: 10, total_cost: "0.5" }, + { key_id: 1, key_name: "key-a", date: "2026-03-09", api_calls: 15, total_cost: "0.8" }, + ], + }); + + mockGetUserInsightsModelBreakdown.mockResolvedValue({ + ok: true, + data: { + breakdown: [ + { + model: "claude-sonnet-4-5-20250514", + requests: 100, + cost: 1.5, + inputTokens: 5000, + outputTokens: 3000, + cacheCreationTokens: 1000, + cacheReadTokens: 500, + }, + { + model: "gpt-4o", + requests: 50, + cost: 0.8, + inputTokens: 2000, + outputTokens: 1500, + cacheCreationTokens: 200, + cacheReadTokens: 100, + }, + ], + currencyCode: "USD", + }, + }); + }); + + afterEach(() => { + queryClient.clear(); + }); + + it("renders page title with userName", async () => { + const { UserInsightsView } = await import( + "@/app/[locale]/dashboard/leaderboard/user/[userId]/_components/user-insights-view" + ); + + const { container, unmount } = renderWithProviders( + + ); + + await flushMicrotasks(); + + const page = container.querySelector("[data-testid='user-insights-page']"); + expect(page).not.toBeNull(); + + const heading = container.querySelector("h1"); + expect(heading).not.toBeNull(); + expect(heading!.textContent).toContain("User Insights"); + expect(heading!.textContent).toContain("TestUser"); + + unmount(); + }); + + it("renders back button", async () => { + const { UserInsightsView } = await import( + "@/app/[locale]/dashboard/leaderboard/user/[userId]/_components/user-insights-view" + ); + + const { container, unmount } = renderWithProviders( + + ); + + await flushMicrotasks(); + + const backButton = Array.from(container.querySelectorAll("button")).find((btn) => + btn.textContent?.includes("Back to Leaderboard") + ); + expect(backButton).not.toBeUndefined(); + + act(() => { + backButton!.click(); + }); + expect(routerPushMock).toHaveBeenCalledWith("/dashboard/leaderboard?scope=user"); + + unmount(); + }); +}); + +describe("UserOverviewCards", () => { + beforeEach(() => { + vi.clearAllMocks(); + queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false, refetchOnWindowFocus: false }, + }, + }); + }); + + afterEach(() => { + queryClient.clear(); + }); + + it("renders 4 metric cards", async () => { + mockGetUserInsightsOverview.mockResolvedValue({ + ok: true, + data: { + user: { id: 10, name: "TestUser" }, + overview: { + todayRequests: 42, + todayCost: 1.23, + avgResponseTime: 850, + todayErrorRate: 2.5, + yesterdaySamePeriodRequests: 30, + yesterdaySamePeriodCost: 1.0, + yesterdaySamePeriodAvgResponseTime: 900, + recentMinuteRequests: 3, + }, + currencyCode: "USD", + }, + }); + + const { UserOverviewCards } = await import( + "@/app/[locale]/dashboard/leaderboard/user/[userId]/_components/user-overview-cards" + ); + + const { container, unmount } = renderWithProviders(); + + await flushMicrotasks(); + + const cards = container.querySelectorAll("[data-testid^='user-insights-metric-']"); + expect(cards.length).toBe(4); + + const todayRequests = container.querySelector( + "[data-testid='user-insights-metric-todayRequests']" + ); + expect(todayRequests).not.toBeNull(); + expect(todayRequests!.textContent).toContain("42"); + + const avgResponseTime = container.querySelector( + "[data-testid='user-insights-metric-avgResponseTime']" + ); + expect(avgResponseTime).not.toBeNull(); + expect(avgResponseTime!.textContent).toContain("850ms"); + + const errorRate = container.querySelector("[data-testid='user-insights-metric-errorRate']"); + expect(errorRate).not.toBeNull(); + expect(errorRate!.textContent).toContain("2.5%"); + + unmount(); + }); + + it("shows loading skeletons while fetching", async () => { + // Never resolves to keep loading state + mockGetUserInsightsOverview.mockReturnValue(new Promise(() => {})); + + const { UserOverviewCards } = await import( + "@/app/[locale]/dashboard/leaderboard/user/[userId]/_components/user-overview-cards" + ); + + const { container, unmount } = renderWithProviders(); + + await flushMicrotasks(); + + const skeletons = container.querySelectorAll("[data-slot='skeleton']"); + expect(skeletons.length).toBeGreaterThan(0); + + unmount(); + }); +}); + +describe("UserKeyTrendChart", () => { + beforeEach(() => { + vi.clearAllMocks(); + queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false, refetchOnWindowFocus: false }, + }, + }); + }); + + afterEach(() => { + queryClient.clear(); + }); + + it("renders time range buttons", async () => { + mockGetUserInsightsKeyTrend.mockResolvedValue({ + ok: true, + data: [], + }); + + const { UserKeyTrendChart } = await import( + "@/app/[locale]/dashboard/leaderboard/user/[userId]/_components/user-key-trend-chart" + ); + + const { container, unmount } = renderWithProviders(); + + await flushMicrotasks(); + + const todayBtn = container.querySelector("[data-testid='user-insights-time-range-today']"); + const sevenDaysBtn = container.querySelector("[data-testid='user-insights-time-range-7days']"); + const thirtyDaysBtn = container.querySelector( + "[data-testid='user-insights-time-range-30days']" + ); + const thisMonthBtn = container.querySelector( + "[data-testid='user-insights-time-range-thisMonth']" + ); + + expect(todayBtn).not.toBeNull(); + expect(sevenDaysBtn).not.toBeNull(); + expect(thirtyDaysBtn).not.toBeNull(); + expect(thisMonthBtn).not.toBeNull(); + + expect(todayBtn!.textContent).toBe("Today"); + expect(sevenDaysBtn!.textContent).toBe("Last 7 Days"); + + unmount(); + }); +}); + +describe("UserModelBreakdown", () => { + beforeEach(() => { + vi.clearAllMocks(); + queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false, refetchOnWindowFocus: false }, + }, + }); + }); + + afterEach(() => { + queryClient.clear(); + }); + + it("renders model breakdown items", async () => { + mockGetUserInsightsModelBreakdown.mockResolvedValue({ + ok: true, + data: { + breakdown: [ + { + model: "claude-sonnet-4-5-20250514", + requests: 100, + cost: 1.5, + inputTokens: 5000, + outputTokens: 3000, + cacheCreationTokens: 1000, + cacheReadTokens: 500, + }, + { + model: "gpt-4o", + requests: 50, + cost: 0.8, + inputTokens: 2000, + outputTokens: 1500, + cacheCreationTokens: 200, + cacheReadTokens: 100, + }, + ], + currencyCode: "USD", + }, + }); + + const { UserModelBreakdown } = await import( + "@/app/[locale]/dashboard/leaderboard/user/[userId]/_components/user-model-breakdown" + ); + + const { container, unmount } = renderWithProviders(); + + await flushMicrotasks(); + + const breakdownList = container.querySelector( + "[data-testid='user-insights-model-breakdown-list']" + ); + expect(breakdownList).not.toBeNull(); + + // Check model names appear + expect(breakdownList!.textContent).toContain("claude-sonnet-4-5-20250514"); + expect(breakdownList!.textContent).toContain("gpt-4o"); + + unmount(); + }); +}); diff --git a/tests/unit/repository/leaderboard-user-model-stats.test.ts b/tests/unit/repository/leaderboard-user-model-stats.test.ts new file mode 100644 index 000000000..37da7ba37 --- /dev/null +++ b/tests/unit/repository/leaderboard-user-model-stats.test.ts @@ -0,0 +1,326 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +/** + * Tests for user leaderboard modelStats (per-user model breakdown). + * + * Key difference from provider scope: null model rows are PRESERVED + * (provider scope at line 570 has `if (!row.model) continue;`). + */ + +const createChainMock = (resolvedData: unknown[]) => ({ + from: vi.fn().mockReturnThis(), + innerJoin: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + groupBy: vi.fn().mockReturnThis(), + orderBy: vi.fn().mockResolvedValue(resolvedData), +}); + +let selectCallIndex = 0; +let chainMocks: ReturnType[] = []; + +const mockSelect = vi.fn(() => { + const chain = chainMocks[selectCallIndex] ?? createChainMock([]); + selectCallIndex++; + return chain; +}); + +const mocks = vi.hoisted(() => ({ + resolveSystemTimezone: vi.fn(), + getSystemSettings: vi.fn(), +})); + +vi.mock("@/drizzle/db", () => ({ + db: { + select: (...args: unknown[]) => mockSelect(...args), + }, +})); + +vi.mock("@/drizzle/schema", () => ({ + usageLedger: { + providerId: "providerId", + finalProviderId: "finalProviderId", + userId: "userId", + costUsd: "costUsd", + inputTokens: "inputTokens", + outputTokens: "outputTokens", + cacheCreationInputTokens: "cacheCreationInputTokens", + cacheReadInputTokens: "cacheReadInputTokens", + isSuccess: "isSuccess", + blockedBy: "blockedBy", + createdAt: "createdAt", + ttfbMs: "ttfbMs", + durationMs: "durationMs", + model: "model", + originalModel: "originalModel", + }, + messageRequest: { + deletedAt: "deletedAt", + providerId: "providerId", + userId: "userId", + costUsd: "costUsd", + inputTokens: "inputTokens", + outputTokens: "outputTokens", + cacheCreationInputTokens: "cacheCreationInputTokens", + cacheReadInputTokens: "cacheReadInputTokens", + errorMessage: "errorMessage", + blockedBy: "blockedBy", + createdAt: "createdAt", + ttfbMs: "ttfbMs", + durationMs: "durationMs", + model: "model", + originalModel: "originalModel", + }, + providers: { + id: "id", + name: "name", + deletedAt: "deletedAt", + providerType: "providerType", + }, + users: { + id: "id", + name: "name", + deletedAt: "deletedAt", + tags: "tags", + providerGroup: "providerGroup", + }, +})); + +vi.mock("@/lib/utils/timezone", () => ({ + resolveSystemTimezone: mocks.resolveSystemTimezone, +})); + +vi.mock("@/repository/system-config", () => ({ + getSystemSettings: mocks.getSystemSettings, +})); + +describe("User Leaderboard Model Stats", () => { + beforeEach(() => { + vi.resetModules(); + selectCallIndex = 0; + chainMocks = []; + mockSelect.mockClear(); + mocks.resolveSystemTimezone.mockResolvedValue("UTC"); + mocks.getSystemSettings.mockResolvedValue({ billingModelSource: "redirected" }); + }); + + it("includes modelStats when includeModelStats=true", async () => { + chainMocks = [ + createChainMock([ + { + userId: 1, + userName: "alice", + totalRequests: 100, + totalCost: "10.0", + totalTokens: 50000, + }, + ]), + createChainMock([ + { + userId: 1, + model: "claude-sonnet-4", + totalRequests: 60, + totalCost: "6.0", + totalTokens: 30000, + }, + { + userId: 1, + model: "claude-opus-4", + totalRequests: 40, + totalCost: "4.0", + totalTokens: 20000, + }, + ]), + ]; + + const { findDailyLeaderboard } = await import("@/repository/leaderboard"); + const result = await findDailyLeaderboard(undefined, true); + + expect(result).toHaveLength(1); + expect(result[0].modelStats).toBeDefined(); + expect(result[0].modelStats).toHaveLength(2); + expect(result[0].modelStats![0].model).toBe("claude-sonnet-4"); + expect(result[0].modelStats![0].totalCost).toBe(6.0); + expect(result[0].modelStats![1].model).toBe("claude-opus-4"); + expect(result[0].modelStats![1].totalCost).toBe(4.0); + }); + + it("preserves null model rows (unlike provider scope)", async () => { + chainMocks = [ + createChainMock([ + { + userId: 1, + userName: "bob", + totalRequests: 50, + totalCost: "5.0", + totalTokens: 25000, + }, + ]), + createChainMock([ + { + userId: 1, + model: "claude-sonnet-4", + totalRequests: 30, + totalCost: "3.0", + totalTokens: 15000, + }, + { + userId: 1, + model: null, + totalRequests: 20, + totalCost: "2.0", + totalTokens: 10000, + }, + ]), + ]; + + const { findDailyLeaderboard } = await import("@/repository/leaderboard"); + const result = await findDailyLeaderboard(undefined, true); + + expect(result).toHaveLength(1); + expect(result[0].modelStats).toHaveLength(2); + + const nullModelStat = result[0].modelStats!.find((s) => s.model === null); + expect(nullModelStat).toBeDefined(); + expect(nullModelStat!.totalRequests).toBe(20); + expect(nullModelStat!.totalCost).toBe(2.0); + }); + + it("does not include modelStats when includeModelStats is false/undefined", async () => { + chainMocks = [ + createChainMock([ + { + userId: 1, + userName: "carol", + totalRequests: 10, + totalCost: "1.0", + totalTokens: 5000, + }, + ]), + ]; + + const { findDailyLeaderboard } = await import("@/repository/leaderboard"); + + const resultDefault = await findDailyLeaderboard(); + expect(resultDefault[0].modelStats).toBeUndefined(); + expect(mockSelect).toHaveBeenCalledTimes(1); + + selectCallIndex = 0; + mockSelect.mockClear(); + + chainMocks = [ + createChainMock([ + { + userId: 1, + userName: "carol", + totalRequests: 10, + totalCost: "1.0", + totalTokens: 5000, + }, + ]), + ]; + + const resultFalse = await findDailyLeaderboard(undefined, false); + expect(resultFalse[0].modelStats).toBeUndefined(); + expect(mockSelect).toHaveBeenCalledTimes(1); + }); + + it("groups model stats correctly by userId", async () => { + chainMocks = [ + createChainMock([ + { + userId: 1, + userName: "alice", + totalRequests: 80, + totalCost: "8.0", + totalTokens: 40000, + }, + { + userId: 2, + userName: "bob", + totalRequests: 50, + totalCost: "5.0", + totalTokens: 25000, + }, + ]), + createChainMock([ + { + userId: 1, + model: "claude-sonnet-4", + totalRequests: 50, + totalCost: "5.0", + totalTokens: 25000, + }, + { + userId: 1, + model: "claude-opus-4", + totalRequests: 30, + totalCost: "3.0", + totalTokens: 15000, + }, + { + userId: 2, + model: "claude-haiku-3.5", + totalRequests: 50, + totalCost: "5.0", + totalTokens: 25000, + }, + ]), + ]; + + const { findDailyLeaderboard } = await import("@/repository/leaderboard"); + const result = await findDailyLeaderboard(undefined, true); + + expect(result).toHaveLength(2); + + const alice = result.find((r) => r.userId === 1); + expect(alice).toBeDefined(); + expect(alice!.modelStats).toHaveLength(2); + const aliceModels = alice!.modelStats!.map((m) => m.model).sort(); + expect(aliceModels).toEqual(["claude-opus-4", "claude-sonnet-4"]); + + const bob = result.find((r) => r.userId === 2); + expect(bob).toBeDefined(); + expect(bob!.modelStats).toHaveLength(1); + expect(bob!.modelStats![0].model).toBe("claude-haiku-3.5"); + }); + + it("orders model stats by totalCost descending", async () => { + chainMocks = [ + createChainMock([ + { + userId: 1, + userName: "alice", + totalRequests: 100, + totalCost: "15.0", + totalTokens: 75000, + }, + ]), + createChainMock([ + { + userId: 1, + model: "expensive-model", + totalRequests: 30, + totalCost: "10.0", + totalTokens: 30000, + }, + { + userId: 1, + model: "cheap-model", + totalRequests: 70, + totalCost: "5.0", + totalTokens: 45000, + }, + ]), + ]; + + const { findDailyLeaderboard } = await import("@/repository/leaderboard"); + const result = await findDailyLeaderboard(undefined, true); + + expect(result).toHaveLength(1); + const stats = result[0].modelStats!; + expect(stats).toHaveLength(2); + expect(stats[0].totalCost).toBeGreaterThanOrEqual(stats[1].totalCost); + expect(stats[0].model).toBe("expensive-model"); + expect(stats[1].model).toBe("cheap-model"); + }); +}); From 7a5dc608c97041bb4e4ada1a3f97ce672c695a24 Mon Sep 17 00:00:00 2001 From: Ding <44717411+ding113@users.noreply.github.com> Date: Mon, 9 Mar 2026 10:19:55 +0800 Subject: [PATCH 08/42] fix: log cleanup not actually deleting records (#885) - Remove `deletedAt IS NULL` filter from buildWhereConditions: cleanup should delete ALL matching records regardless of soft-delete status - Add `RETURNING 1` to DELETE SQL for driver-agnostic row counting (result.length instead of fragile count/rowCount properties) - Add `FOR UPDATE SKIP LOCKED` to prevent deadlocks with concurrent jobs - Add purgeSoftDeleted: batched hard-delete of soft-deleted records as fallback after main cleanup loop - Add VACUUM ANALYZE after deletions to reclaim disk space (failure is non-fatal) - Update CleanupResult with softDeletedPurged and vacuumPerformed - Pass new fields through API route and show in UI toast - Add i18n keys for 5 languages - 19 tests covering all new behavior --- messages/en/settings/data.json | 2 + messages/ja/settings/data.json | 2 + messages/ru/settings/data.json | 2 + messages/zh-CN/settings/data.json | 2 + messages/zh-TW/settings/data.json | 2 + .../data/_components/log-cleanup-panel.tsx | 13 +- src/app/api/admin/log-cleanup/manual/route.ts | 2 + src/lib/log-cleanup/service.ts | 168 ++++++++++----- .../lib/log-cleanup/service-count.test.ts | 200 ++++++++++++++++-- 9 files changed, 323 insertions(+), 70 deletions(-) diff --git a/messages/en/settings/data.json b/messages/en/settings/data.json index a41516b7e..b2e7de657 100644 --- a/messages/en/settings/data.json +++ b/messages/en/settings/data.json @@ -29,7 +29,9 @@ }, "rangeLabel": "Cleanup Range", "statisticsRetained": "✓ Statistics data will be retained (for trend analysis)", + "softDeletePurged": "{count} soft-deleted records also purged", "successMessage": "Successfully cleaned {count} log records ({batches} batches, took {duration}s)", + "vacuumComplete": "Database space reclaimed", "willClean": "Will clean all log records from {range}" }, "description": "Manage database backup and recovery with full data import/export and log cleanup.", diff --git a/messages/ja/settings/data.json b/messages/ja/settings/data.json index b03ddbcb7..6c5d6ef6e 100644 --- a/messages/ja/settings/data.json +++ b/messages/ja/settings/data.json @@ -28,8 +28,10 @@ "default": "{days}日前" }, "rangeLabel": "クリーンアップ範囲", + "softDeletePurged": "{count}件の論理削除レコードも物理削除しました", "statisticsRetained": "✓ 統計データは保持されます(トレンド分析用)", "successMessage": "{count}件のログレコードをクリーンアップしました({batches}バッチ、所要時間{duration}秒)", + "vacuumComplete": "データベース領域を回収しました", "willClean": "{range}のすべてのログレコードをクリーンアップします" }, "description": "データベースのバックアップと復元を管理し、完全なインポート/エクスポートとログクリーンアップをサポートします。", diff --git a/messages/ru/settings/data.json b/messages/ru/settings/data.json index b7cc324fc..1e3a8d576 100644 --- a/messages/ru/settings/data.json +++ b/messages/ru/settings/data.json @@ -28,8 +28,10 @@ "default": "{days} дней назад" }, "rangeLabel": "Диапазон очистки", + "softDeletePurged": "Также удалено {count} мягко удаленных записей", "statisticsRetained": "✓ Статистические данные будут сохранены (для анализа трендов)", "successMessage": "Успешно очищено {count} записей логов ({batches} пакетов, заняло {duration}с)", + "vacuumComplete": "Дисковое пространство БД освобождено", "willClean": "Будут очищены все записи логов с {range}" }, "description": "Управление резервной копией и восстановлением БД с полным импортом/экспортом и очисткой логов.", diff --git a/messages/zh-CN/settings/data.json b/messages/zh-CN/settings/data.json index 84ebc0a66..d7219348f 100644 --- a/messages/zh-CN/settings/data.json +++ b/messages/zh-CN/settings/data.json @@ -40,7 +40,9 @@ "cancel": "取消", "confirm": "确认清理", "cleaning": "正在清理...", + "softDeletePurged": "另外清除了 {count} 条软删除记录", "successMessage": "成功清理 {count} 条日志记录({batches} 批次,耗时 {duration}s)", + "vacuumComplete": "数据库空间已回收", "failed": "清理失败", "error": "清理日志失败", "descriptionWarning": "清理历史日志数据以释放数据库存储空间。注意:统计数据将被保留,但日志详情将被永久删除。" diff --git a/messages/zh-TW/settings/data.json b/messages/zh-TW/settings/data.json index f01011524..49d8a5adf 100644 --- a/messages/zh-TW/settings/data.json +++ b/messages/zh-TW/settings/data.json @@ -28,8 +28,10 @@ "default": "約 {days} 天前" }, "rangeLabel": "清理範圍", + "softDeletePurged": "另外清除了 {count} 筆軟刪除記錄", "statisticsRetained": "✓ 統計資料將被保留(用於趨勢分析)", "successMessage": "成功清理 {count} 筆日誌記錄({batches} 批次,耗時 {duration}秒)", + "vacuumComplete": "資料庫空間已回收", "willClean": "將清理 {range} 的所有日誌記錄" }, "description": "管理資料庫的備份與恢復,支援完整資料匯入匯出和日誌清理。", diff --git a/src/app/[locale]/settings/data/_components/log-cleanup-panel.tsx b/src/app/[locale]/settings/data/_components/log-cleanup-panel.tsx index 2e6d4d9db..bc07cd9d8 100644 --- a/src/app/[locale]/settings/data/_components/log-cleanup-panel.tsx +++ b/src/app/[locale]/settings/data/_components/log-cleanup-panel.tsx @@ -97,13 +97,20 @@ export function LogCleanupPanel() { } if (result.success) { - toast.success( + const parts: string[] = [ t("successMessage", { count: result.totalDeleted.toLocaleString(), batches: result.batchCount, duration: (result.durationMs / 1000).toFixed(2), - }) - ); + }), + ]; + if (result.softDeletedPurged > 0) { + parts.push(t("softDeletePurged", { count: result.softDeletedPurged })); + } + if (result.vacuumPerformed) { + parts.push(t("vacuumComplete")); + } + toast.success(parts.join(" | ")); setIsOpen(false); } else { toast.error(result.error || t("failed")); diff --git a/src/app/api/admin/log-cleanup/manual/route.ts b/src/app/api/admin/log-cleanup/manual/route.ts index cae7a752e..0a5d9f9fe 100644 --- a/src/app/api/admin/log-cleanup/manual/route.ts +++ b/src/app/api/admin/log-cleanup/manual/route.ts @@ -93,6 +93,8 @@ export async function POST(request: NextRequest) { totalDeleted: result.totalDeleted, batchCount: result.batchCount, durationMs: result.durationMs, + softDeletedPurged: result.softDeletedPurged, + vacuumPerformed: result.vacuumPerformed, error: result.error, }); } catch (error) { diff --git a/src/lib/log-cleanup/service.ts b/src/lib/log-cleanup/service.ts index b1d359619..a839cc9d3 100644 --- a/src/lib/log-cleanup/service.ts +++ b/src/lib/log-cleanup/service.ts @@ -4,63 +4,56 @@ import { messageRequest } from "@/drizzle/schema"; import { logger } from "@/lib/logger"; /** - * 日志清理条件 + * Log cleanup conditions */ export interface CleanupConditions { - // 时间范围 + // Time range beforeDate?: Date; afterDate?: Date; - // 用户维度 + // User dimension userIds?: number[]; - // 供应商维度 + // Provider dimension providerIds?: number[]; - // 状态维度 - statusCodes?: number[]; // 精确匹配状态码 + // Status dimension + statusCodes?: number[]; statusCodeRange?: { - // 状态码范围 (如 400-499) min: number; max: number; }; - onlyBlocked?: boolean; // 仅被拦截的请求 + onlyBlocked?: boolean; } /** - * 清理选项 + * Cleanup options */ export interface CleanupOptions { - batchSize?: number; // 批量删除大小(默认 10000) - dryRun?: boolean; // 仅预览,不实际删除 + batchSize?: number; + dryRun?: boolean; } /** - * 清理结果 + * Cleanup result */ export interface CleanupResult { totalDeleted: number; batchCount: number; durationMs: number; + softDeletedPurged: number; + vacuumPerformed: boolean; error?: string; } /** - * 触发信息 + * Trigger info */ export interface TriggerInfo { type: "manual" | "scheduled"; user?: string; } -/** - * 执行日志清理 - * - * @param conditions 清理条件 - * @param options 清理选项 - * @param triggerInfo 触发信息 - * @returns 清理结果 - */ // NOTE: usage_ledger is intentionally immune to log cleanup. // Only message_request rows are deleted here. export async function cleanupLogs( @@ -72,9 +65,10 @@ export async function cleanupLogs( const batchSize = options.batchSize || 10000; let totalDeleted = 0; let batchCount = 0; + let softDeletedPurged = 0; + let vacuumPerformed = false; try { - // 1. 构建 WHERE 条件 const whereConditions = buildWhereConditions(conditions); if (whereConditions.length === 0) { @@ -86,12 +80,13 @@ export async function cleanupLogs( totalDeleted: 0, batchCount: 0, durationMs: Date.now() - startTime, - error: "未指定任何清理条件", + softDeletedPurged: 0, + vacuumPerformed: false, + error: "No cleanup conditions specified", }; } if (options.dryRun) { - // 仅统计数量 const result = await db .select({ count: sql`count(*)::int` }) .from(messageRequest) @@ -107,10 +102,12 @@ export async function cleanupLogs( totalDeleted: result[0]?.count || 0, batchCount: 0, durationMs: Date.now() - startTime, + softDeletedPurged: 0, + vacuumPerformed: false, }; } - // 2. 分批删除 + // Main delete loop while (true) { const deleted = await deleteBatch(whereConditions, batchSize); @@ -126,24 +123,33 @@ export async function cleanupLogs( totalDeleted, }); - // 避免长时间锁表,短暂休息 if (deleted === batchSize) { await sleep(100); } } + // Purge soft-deleted records as fallback + softDeletedPurged = await purgeSoftDeleted(batchSize); + + // VACUUM ANALYZE to reclaim disk space + if (totalDeleted > 0 || softDeletedPurged > 0) { + vacuumPerformed = await runVacuum(); + } + const durationMs = Date.now() - startTime; logger.info({ action: "log_cleanup_complete", totalDeleted, batchCount, + softDeletedPurged, + vacuumPerformed, durationMs, triggerType: triggerInfo.type, user: triggerInfo.user, }); - return { totalDeleted, batchCount, durationMs }; + return { totalDeleted, batchCount, durationMs, softDeletedPurged, vacuumPerformed }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); @@ -159,21 +165,21 @@ export async function cleanupLogs( totalDeleted, batchCount, durationMs: Date.now() - startTime, + softDeletedPurged, + vacuumPerformed, error: errorMessage, }; } } /** - * 构建 WHERE 条件 + * Build WHERE conditions for cleanup query. + * No deletedAt filter: cleanup should delete ALL matching records + * regardless of soft-delete status to actually reclaim space. */ -function buildWhereConditions(conditions: CleanupConditions): SQL[] { +export function buildWhereConditions(conditions: CleanupConditions): SQL[] { const where: SQL[] = []; - // 排除软删除的记录(已经被软删除的不再处理) - where.push(sql`${messageRequest.deletedAt} IS NULL`); - - // 时间范围 if (conditions.beforeDate) { where.push(lte(messageRequest.createdAt, conditions.beforeDate)); } @@ -181,17 +187,14 @@ function buildWhereConditions(conditions: CleanupConditions): SQL[] { where.push(gte(messageRequest.createdAt, conditions.afterDate)); } - // 用户维度 if (conditions.userIds && conditions.userIds.length > 0) { where.push(inArray(messageRequest.userId, conditions.userIds)); } - // 供应商维度 if (conditions.providerIds && conditions.providerIds.length > 0) { where.push(inArray(messageRequest.providerId, conditions.providerIds)); } - // 状态维度 if (conditions.statusCodes && conditions.statusCodes.length > 0) { where.push(inArray(messageRequest.statusCode, conditions.statusCodes)); } @@ -212,40 +215,112 @@ function buildWhereConditions(conditions: CleanupConditions): SQL[] { } /** - * 批量删除 - * - * 使用 CTE (Common Table Expression) + DELETE 实现原子删除 - * 避免两步操作的竞态条件,性能更好 + * Batch delete with CTE + RETURNING 1 for driver-agnostic row counting. + * Uses FOR UPDATE SKIP LOCKED to prevent deadlocks with concurrent jobs. */ async function deleteBatch(whereConditions: SQL[], batchSize: number): Promise { - // 使用 CTE 实现原子批量删除 const result = await db.execute(sql` WITH ids_to_delete AS ( SELECT id FROM message_request WHERE ${and(...whereConditions)} ORDER BY created_at ASC LIMIT ${batchSize} - FOR UPDATE + FOR UPDATE SKIP LOCKED ) DELETE FROM message_request WHERE id IN (SELECT id FROM ids_to_delete) + RETURNING 1 `); return getAffectedRows(result); } -function getAffectedRows(result: unknown): number { +/** + * Purge all soft-deleted records (deleted_at IS NOT NULL) in batches. + * Runs as fallback after main cleanup to ensure soft-deleted rows + * are also physically removed. + */ +async function purgeSoftDeleted(batchSize: number): Promise { + let totalPurged = 0; + + while (true) { + const result = await db.execute(sql` + WITH ids_to_delete AS ( + SELECT id FROM message_request + WHERE deleted_at IS NOT NULL + ORDER BY created_at ASC + LIMIT ${batchSize} + FOR UPDATE SKIP LOCKED + ) + DELETE FROM message_request + WHERE id IN (SELECT id FROM ids_to_delete) + RETURNING 1 + `); + + const deleted = getAffectedRows(result); + if (deleted === 0) break; + + totalPurged += deleted; + + logger.info({ + action: "log_cleanup_soft_delete_purge", + deletedInBatch: deleted, + totalPurged, + }); + + if (deleted === batchSize) { + await sleep(100); + } + } + + return totalPurged; +} + +/** + * Run VACUUM ANALYZE to reclaim disk space after deletions. + * Failure is non-fatal: logged but does not fail the cleanup result. + */ +async function runVacuum(): Promise { + try { + await db.execute(sql`VACUUM ANALYZE message_request`); + logger.info({ action: "log_cleanup_vacuum_complete" }); + return true; + } catch (error) { + logger.warn({ + action: "log_cleanup_vacuum_failed", + error: error instanceof Error ? error.message : String(error), + }); + return false; + } +} + +/** + * Extract affected row count from db.execute() result. + * + * Priority: + * 1. Array with length > 0 (RETURNING rows) -> result.length + * 2. result.count (postgres.js, may be BigInt) + * 3. result.rowCount (node-postgres) + * 4. 0 + */ +export function getAffectedRows(result: unknown): number { if (!result || typeof result !== "object") { return 0; } - const r = result as { count?: unknown; rowCount?: unknown }; + // RETURNING rows: postgres.js returns array of rows + if (Array.isArray(result) && result.length > 0) { + return result.length; + } + + const r = result as { count?: unknown; rowCount?: unknown; length?: unknown }; - // postgres.js returns count as BigInt; node-postgres uses rowCount as number + // postgres.js count (may be BigInt) if (r.count !== undefined) { return Number(r.count); } + // node-postgres rowCount if (typeof r.rowCount === "number") { return r.rowCount; } @@ -253,9 +328,6 @@ function getAffectedRows(result: unknown): number { return 0; } -/** - * 休眠函数 - */ function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } diff --git a/tests/unit/lib/log-cleanup/service-count.test.ts b/tests/unit/lib/log-cleanup/service-count.test.ts index 235b75618..7625fe04c 100644 --- a/tests/unit/lib/log-cleanup/service-count.test.ts +++ b/tests/unit/lib/log-cleanup/service-count.test.ts @@ -1,3 +1,5 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; import { type MockInstance, beforeEach, describe, expect, it, vi } from "vitest"; type ExecuteCountResult = unknown[] & { @@ -34,28 +36,50 @@ function makeExecuteResult(input: { return result; } +function makeReturningResult(count: number): unknown[] { + return Array.from({ length: count }, () => ({ "?column?": 1 })); +} + describe("log cleanup delete count", () => { beforeEach(async () => { const { db } = await import("@/drizzle/db"); (db.execute as MockInstance).mockReset(); }); + it("prefers RETURNING array length for row counting", async () => { + const { db } = await import("@/drizzle/db"); + (db.execute as MockInstance) + .mockResolvedValueOnce(makeReturningResult(5)) // main delete: 5 rows + .mockResolvedValueOnce([]) // main delete: 0 (exit loop) + .mockResolvedValueOnce([]) // soft-delete purge: 0 (exit) + .mockResolvedValueOnce({}); // VACUUM + + const { cleanupLogs } = await import("@/lib/log-cleanup/service"); + const result = await cleanupLogs( + { beforeDate: new Date() }, + {}, + { type: "manual", user: "test" } + ); + + expect(result.error).toBeUndefined(); + expect(result.totalDeleted).toBe(5); + expect(result.batchCount).toBe(1); + expect(result.vacuumPerformed).toBe(true); + }); + it("reads affected rows from postgres.js count field", async () => { const { db } = await import("@/drizzle/db"); (db.execute as MockInstance) - .mockResolvedValueOnce(makeExecuteResult({ count: 3 })) - .mockResolvedValueOnce(makeExecuteResult({ count: 0 })); + .mockResolvedValueOnce(makeExecuteResult({ count: 3 })) // main delete + .mockResolvedValueOnce(makeExecuteResult({ count: 0 })) // main delete exit + .mockResolvedValueOnce([]) // soft-delete purge + .mockResolvedValueOnce({}); // VACUUM const { cleanupLogs } = await import("@/lib/log-cleanup/service"); const result = await cleanupLogs( - { - beforeDate: new Date(), - }, + { beforeDate: new Date() }, {}, - { - type: "manual", - user: "test", - } + { type: "manual", user: "test" } ); expect(result.error).toBeUndefined(); @@ -65,10 +89,11 @@ describe("log cleanup delete count", () => { it("reads affected rows from postgres.js BigInt count field", async () => { const { db } = await import("@/drizzle/db"); - // postgres.js returns count as BigInt in some versions (db.execute as MockInstance) .mockResolvedValueOnce(makeExecuteResult({ count: BigInt(7) })) - .mockResolvedValueOnce(makeExecuteResult({ count: BigInt(0) })); + .mockResolvedValueOnce(makeExecuteResult({ count: BigInt(0) })) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce({}); const { cleanupLogs } = await import("@/lib/log-cleanup/service"); const result = await cleanupLogs( @@ -86,22 +111,159 @@ describe("log cleanup delete count", () => { const { db } = await import("@/drizzle/db"); (db.execute as MockInstance) .mockResolvedValueOnce(makeExecuteResult({ rowCount: 2 })) - .mockResolvedValueOnce(makeExecuteResult({ rowCount: 0 })); + .mockResolvedValueOnce(makeExecuteResult({ rowCount: 0 })) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce({}); const { cleanupLogs } = await import("@/lib/log-cleanup/service"); const result = await cleanupLogs( - { - beforeDate: new Date(), - }, + { beforeDate: new Date() }, {}, - { - type: "manual", - user: "test", - } + { type: "manual", user: "test" } ); expect(result.error).toBeUndefined(); expect(result.totalDeleted).toBe(2); expect(result.batchCount).toBe(1); }); + + it("purgeSoftDeleted runs after main cleanup and count returned in result", async () => { + const { db } = await import("@/drizzle/db"); + (db.execute as MockInstance) + .mockResolvedValueOnce(makeReturningResult(2)) // main delete: 2 + .mockResolvedValueOnce([]) // main delete exit + .mockResolvedValueOnce(makeReturningResult(4)) // soft-delete purge: 4 + .mockResolvedValueOnce([]) // soft-delete purge exit + .mockResolvedValueOnce({}); // VACUUM + + const { cleanupLogs } = await import("@/lib/log-cleanup/service"); + const result = await cleanupLogs( + { beforeDate: new Date() }, + {}, + { type: "manual", user: "test" } + ); + + expect(result.error).toBeUndefined(); + expect(result.totalDeleted).toBe(2); + expect(result.softDeletedPurged).toBe(4); + expect(result.vacuumPerformed).toBe(true); + }); + + it("VACUUM runs after deletion, failure doesn't fail cleanup", async () => { + const { db } = await import("@/drizzle/db"); + (db.execute as MockInstance) + .mockResolvedValueOnce(makeReturningResult(1)) // main delete: 1 + .mockResolvedValueOnce([]) // main delete exit + .mockResolvedValueOnce([]) // soft-delete purge: 0 + .mockRejectedValueOnce(new Error("VACUUM failed")); // VACUUM fails + + const { cleanupLogs } = await import("@/lib/log-cleanup/service"); + const result = await cleanupLogs( + { beforeDate: new Date() }, + {}, + { type: "manual", user: "test" } + ); + + expect(result.error).toBeUndefined(); + expect(result.totalDeleted).toBe(1); + expect(result.vacuumPerformed).toBe(false); + }); + + it("VACUUM skipped when 0 records deleted", async () => { + const { db } = await import("@/drizzle/db"); + (db.execute as MockInstance) + .mockResolvedValueOnce([]) // main delete: 0 (exit immediately) + .mockResolvedValueOnce([]); // soft-delete purge: 0 + + const { cleanupLogs } = await import("@/lib/log-cleanup/service"); + const result = await cleanupLogs( + { beforeDate: new Date() }, + {}, + { type: "manual", user: "test" } + ); + + expect(result.error).toBeUndefined(); + expect(result.totalDeleted).toBe(0); + expect(result.softDeletedPurged).toBe(0); + expect(result.vacuumPerformed).toBe(false); + // VACUUM should not have been called (only 2 execute calls total) + expect(db.execute).toHaveBeenCalledTimes(2); + }); +}); + +describe("getAffectedRows", () => { + it("returns array length for RETURNING rows", async () => { + const { getAffectedRows } = await import("@/lib/log-cleanup/service"); + expect(getAffectedRows(makeReturningResult(10))).toBe(10); + }); + + it("falls through to count for empty array with count property", async () => { + const { getAffectedRows } = await import("@/lib/log-cleanup/service"); + expect(getAffectedRows(makeExecuteResult({ count: 5 }))).toBe(5); + }); + + it("handles BigInt count", async () => { + const { getAffectedRows } = await import("@/lib/log-cleanup/service"); + expect(getAffectedRows(makeExecuteResult({ count: BigInt(99) }))).toBe(99); + }); + + it("handles rowCount fallback", async () => { + const { getAffectedRows } = await import("@/lib/log-cleanup/service"); + expect(getAffectedRows(makeExecuteResult({ rowCount: 42 }))).toBe(42); + }); + + it("returns 0 for null/undefined", async () => { + const { getAffectedRows } = await import("@/lib/log-cleanup/service"); + expect(getAffectedRows(null)).toBe(0); + expect(getAffectedRows(undefined)).toBe(0); + }); + + it("returns 0 for empty result", async () => { + const { getAffectedRows } = await import("@/lib/log-cleanup/service"); + expect(getAffectedRows([])).toBe(0); + expect(getAffectedRows({})).toBe(0); + }); +}); + +describe("buildWhereConditions", () => { + it("does not filter on deletedAt", async () => { + const { buildWhereConditions } = await import("@/lib/log-cleanup/service"); + const conditions = buildWhereConditions({}); + expect(conditions).toHaveLength(0); + }); + + it("returns conditions only for provided filters", async () => { + const { buildWhereConditions } = await import("@/lib/log-cleanup/service"); + const conditions = buildWhereConditions({ + beforeDate: new Date(), + userIds: [1, 2], + }); + // beforeDate + userIds = 2 conditions (no deletedAt) + expect(conditions).toHaveLength(2); + }); +}); + +describe("log cleanup SQL patterns", () => { + const serviceSource = readFileSync( + resolve(process.cwd(), "src/lib/log-cleanup/service.ts"), + "utf-8" + ); + + it("uses SKIP LOCKED in delete SQL", () => { + expect(serviceSource).toContain("FOR UPDATE SKIP LOCKED"); + }); + + it("uses RETURNING 1 in delete SQL", () => { + expect(serviceSource).toContain("RETURNING 1"); + }); + + it("does not contain deletedAt IS NULL in buildWhereConditions", () => { + const buildFnMatch = serviceSource.match(/function buildWhereConditions[\s\S]*?^}/m); + expect(buildFnMatch).not.toBeNull(); + expect(buildFnMatch![0]).not.toContain("deletedAt"); + }); + + it("includes VACUUM ANALYZE", () => { + expect(serviceSource).toContain("VACUUM ANALYZE message_request"); + }); }); From 814717d06db8e3e80593f5129f716c76ad1bea7e Mon Sep 17 00:00:00 2001 From: ding113 Date: Mon, 9 Mar 2026 10:21:57 +0800 Subject: [PATCH 09/42] fix(log-cleanup): scope soft-delete purge to cleanup conditions --- .../data/_components/log-cleanup-panel.tsx | 7 ++-- src/lib/log-cleanup/service.ts | 38 +++++++++++-------- 2 files changed, 27 insertions(+), 18 deletions(-) diff --git a/src/app/[locale]/settings/data/_components/log-cleanup-panel.tsx b/src/app/[locale]/settings/data/_components/log-cleanup-panel.tsx index bc07cd9d8..7a7ff482e 100644 --- a/src/app/[locale]/settings/data/_components/log-cleanup-panel.tsx +++ b/src/app/[locale]/settings/data/_components/log-cleanup-panel.tsx @@ -93,7 +93,8 @@ export function LogCleanupPanel() { const result = await response.json(); if (!response.ok) { - throw new Error(result.error || t("failed")); + console.error("Cleanup API error:", result.error); + throw new Error(t("failed")); } if (result.success) { @@ -105,7 +106,7 @@ export function LogCleanupPanel() { }), ]; if (result.softDeletedPurged > 0) { - parts.push(t("softDeletePurged", { count: result.softDeletedPurged })); + parts.push(t("softDeletePurged", { count: result.softDeletedPurged.toLocaleString() })); } if (result.vacuumPerformed) { parts.push(t("vacuumComplete")); @@ -113,7 +114,7 @@ export function LogCleanupPanel() { toast.success(parts.join(" | ")); setIsOpen(false); } else { - toast.error(result.error || t("failed")); + toast.error(t("failed")); } } catch (error) { console.error("Cleanup error:", error); diff --git a/src/lib/log-cleanup/service.ts b/src/lib/log-cleanup/service.ts index a839cc9d3..f9fea232f 100644 --- a/src/lib/log-cleanup/service.ts +++ b/src/lib/log-cleanup/service.ts @@ -1,4 +1,4 @@ -import { and, between, gte, inArray, isNotNull, lte, type SQL, sql } from "drizzle-orm"; +import { and, between, gte, inArray, isNotNull, isNull, lte, type SQL, sql } from "drizzle-orm"; import { db } from "@/drizzle/db"; import { messageRequest } from "@/drizzle/schema"; import { logger } from "@/lib/logger"; @@ -54,6 +54,8 @@ export interface TriggerInfo { user?: string; } +const BATCH_SLEEP_MS = 100; + // NOTE: usage_ledger is intentionally immune to log cleanup. // Only message_request rows are deleted here. export async function cleanupLogs( @@ -107,9 +109,12 @@ export async function cleanupLogs( }; } - // Main delete loop + // Main delete loop: only active rows (deleted_at IS NULL) to leverage partial indexes. + // Soft-deleted rows are handled separately by purgeSoftDeleted with the same scope. + const activeConditions = [...whereConditions, isNull(messageRequest.deletedAt)]; + while (true) { - const deleted = await deleteBatch(whereConditions, batchSize); + const deleted = await deleteBatch(activeConditions, batchSize); if (deleted === 0) break; @@ -124,12 +129,12 @@ export async function cleanupLogs( }); if (deleted === batchSize) { - await sleep(100); + await sleep(BATCH_SLEEP_MS); } } - // Purge soft-deleted records as fallback - softDeletedPurged = await purgeSoftDeleted(batchSize); + // Purge soft-deleted records scoped to the same cleanup conditions + softDeletedPurged = await purgeSoftDeleted(whereConditions, batchSize); // VACUUM ANALYZE to reclaim disk space if (totalDeleted > 0 || softDeletedPurged > 0) { @@ -174,8 +179,8 @@ export async function cleanupLogs( /** * Build WHERE conditions for cleanup query. - * No deletedAt filter: cleanup should delete ALL matching records - * regardless of soft-delete status to actually reclaim space. + * Does NOT include deleted_at filter: the caller decides whether to target + * active rows (deleteBatch) or soft-deleted rows (purgeSoftDeleted). */ export function buildWhereConditions(conditions: CleanupConditions): SQL[] { const where: SQL[] = []; @@ -236,18 +241,18 @@ async function deleteBatch(whereConditions: SQL[], batchSize: number): Promise { +async function purgeSoftDeleted(whereConditions: SQL[], batchSize: number): Promise { let totalPurged = 0; + const purgeConditions = [...whereConditions, isNotNull(messageRequest.deletedAt)]; while (true) { const result = await db.execute(sql` WITH ids_to_delete AS ( SELECT id FROM message_request - WHERE deleted_at IS NOT NULL + WHERE ${and(...purgeConditions)} ORDER BY created_at ASC LIMIT ${batchSize} FOR UPDATE SKIP LOCKED @@ -269,7 +274,7 @@ async function purgeSoftDeleted(batchSize: number): Promise { }); if (deleted === batchSize) { - await sleep(100); + await sleep(BATCH_SLEEP_MS); } } @@ -278,6 +283,9 @@ async function purgeSoftDeleted(batchSize: number): Promise { /** * Run VACUUM ANALYZE to reclaim disk space after deletions. + * NOTE: VACUUM cannot run inside a PostgreSQL transaction block. + * Drizzle's db.execute() typically runs outside transactions, but if the + * connection pool or middleware wraps calls, VACUUM will fail silently here. * Failure is non-fatal: logged but does not fail the cleanup result. */ async function runVacuum(): Promise { @@ -313,7 +321,7 @@ export function getAffectedRows(result: unknown): number { return result.length; } - const r = result as { count?: unknown; rowCount?: unknown; length?: unknown }; + const r = result as { count?: unknown; rowCount?: unknown }; // postgres.js count (may be BigInt) if (r.count !== undefined) { From 3568cef755d17ffaa49a19a81f764ee45b352f89 Mon Sep 17 00:00:00 2001 From: ding113 Date: Mon, 9 Mar 2026 11:59:37 +0800 Subject: [PATCH 10/42] fix: enforce provider allowlist before model redirect (#832) Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- src/app/v1/_lib/proxy/provider-selector.ts | 20 +--- ...provider-selector-cross-type-model.test.ts | 106 +++++++++++++++--- 2 files changed, 97 insertions(+), 29 deletions(-) diff --git a/src/app/v1/_lib/proxy/provider-selector.ts b/src/app/v1/_lib/proxy/provider-selector.ts index 6e62f553d..7a23eb214 100644 --- a/src/app/v1/_lib/proxy/provider-selector.ts +++ b/src/app/v1/_lib/proxy/provider-selector.ts @@ -99,9 +99,9 @@ function checkProviderGroupMatch(providerGroupTag: string | null, userGroups: st * 检查供应商是否支持指定模型(用于调度器匹配) * * 核心逻辑(统一所有供应商类型): - * 1. 显式声明优先:allowedModels 包含或 modelRedirects 包含 -> 支持 - * 2. 未设置 allowedModels(null 或空数组):接受任意模型(格式兼容性由 checkFormatProviderTypeCompatibility 保证) - * 3. 设置了 allowedModels 但不包含该模型 -> 不支持 + * 1. 未设置 allowedModels(null 或空数组):接受任意模型(格式兼容性由 checkFormatProviderTypeCompatibility 保证) + * 2. 设置了 allowedModels:仅当原始请求模型命中 allowedModels 时才支持 + * 3. modelRedirects 仅在供应商已被选中后用于改写上游模型,不参与调度放行 * * 注意:allowedModels 是声明性列表(用户可填写任意字符串),用于调度器匹配,不是真实模型校验。 * 格式兼容性(如 claude 格式请求只路由到 claude 类型供应商)由 checkFormatProviderTypeCompatibility 独立保证。 @@ -111,21 +111,13 @@ function checkProviderGroupMatch(providerGroupTag: string | null, userGroups: st * @returns 是否支持该模型(用于调度器筛选) */ function providerSupportsModel(provider: Provider, requestedModel: string): boolean { - // 1. 显式声明优先(allowedModels 或 modelRedirects) - if ( - provider.allowedModels?.includes(requestedModel) || - provider.modelRedirects?.[requestedModel] - ) { - return true; - } - - // 2. 未设置 allowedModels(null 或空数组):接受任意模型 + // 1. 未设置 allowedModels(null 或空数组):接受任意模型 if (!provider.allowedModels || provider.allowedModels.length === 0) { return true; } - // 3. 设置了 allowedModels 但不包含该模型,且无 modelRedirects - return false; + // 2. 设置了 allowedModels:只按原始请求模型做白名单匹配 + return provider.allowedModels.includes(requestedModel); } /** diff --git a/tests/unit/proxy/provider-selector-cross-type-model.test.ts b/tests/unit/proxy/provider-selector-cross-type-model.test.ts index 4c03838ab..60ff7b9bf 100644 --- a/tests/unit/proxy/provider-selector-cross-type-model.test.ts +++ b/tests/unit/proxy/provider-selector-cross-type-model.test.ts @@ -154,13 +154,24 @@ describe("providerSupportsModel - direct unit tests (#832)", () => { // modelRedirects { - name: "modelRedirects contains model (allowedModels does not) -> true", + name: "modelRedirects + null allowedModels -> true (wildcard)", providerType: "openai-compatible", - allowedModels: ["gpt-4o"], + allowedModels: null, modelRedirects: { "claude-opus-4-6": "custom-opus" }, requestedModel: "claude-opus-4-6", expected: true, }, + { + name: "modelRedirects does not bypass explicit allowedModels mismatch -> false", + providerType: "claude", + allowedModels: ["claude-haiku-4-5-20251001", "glm-4.6"], + modelRedirects: { + "claude-haiku-4-5-20251001": "glm-4.6", + "claude-opus-4-5-20251001": "glm-4.6", + }, + requestedModel: "claude-opus-4-5-20251001", + expected: false, + }, { name: "neither allowedModels nor modelRedirects contains model -> false", providerType: "openai-compatible", @@ -298,37 +309,36 @@ describe("findReusable - cross-type model routing (#832)", () => { ); }); - test("modelRedirects match overrides allowedModels mismatch -> reuse succeeds", async () => { + test("modelRedirects do not bypass explicit allowedModels during reuse", async () => { const { ProxyProviderResolver } = await import("@/app/v1/_lib/proxy/provider-selector"); const provider = createProvider({ id: 15, - allowedModels: ["gpt-4o"], - modelRedirects: { "claude-opus-4-6": "custom-opus" }, + providerType: "claude", + allowedModels: ["claude-haiku-4-5-20251001", "glm-4.6"], + modelRedirects: { + "claude-haiku-4-5-20251001": "glm-4.6", + "claude-opus-4-5-20251001": "glm-4.6", + }, }); sessionManagerMocks.SessionManager.getSessionProvider.mockResolvedValueOnce(15); providerRepositoryMocks.findProviderById.mockResolvedValueOnce(provider); - rateLimitMocks.RateLimitService.checkCostLimitsWithLease.mockResolvedValueOnce({ - allowed: true, - }); - rateLimitMocks.RateLimitService.checkTotalCostLimit.mockResolvedValueOnce({ - allowed: true, - current: 0, - }); const session = { sessionId: "cross-type-6", shouldReuseProvider: () => true, - getOriginalModel: () => "claude-opus-4-6", + getOriginalModel: () => "claude-opus-4-5-20251001", authState: null, getCurrentModel: () => null, } as any; const result = await (ProxyProviderResolver as any).findReusable(session); - expect(result).not.toBeNull(); - expect(result?.id).toBe(15); + expect(result).toBeNull(); + expect(sessionManagerMocks.SessionManager.clearSessionProvider).toHaveBeenCalledWith( + "cross-type-6" + ); }); }); @@ -456,4 +466,70 @@ describe("pickRandomProvider - cross-type model routing (#832)", () => { ); expect(modelMismatch).toBeDefined(); }); + + test("claude format + explicit allowlist rejects opus request even when redirect points to allowed glm", async () => { + const Resolver = await setupResolverMocks(); + + const provider = createProvider({ + id: 33, + providerType: "claude", + allowedModels: ["claude-haiku-4-5-20251001", "glm-4.6"], + modelRedirects: { + "claude-haiku-4-5-20251001": "glm-4.6", + "claude-opus-4-5-20251001": "glm-4.6", + }, + }); + const session = createPickSession("claude", [provider], "claude-opus-4-5-20251001"); + + const { provider: picked, context } = await (Resolver as any).pickRandomProvider(session, []); + + expect(picked).toBeNull(); + const mismatch = context.filteredProviders.find( + (fp: any) => fp.id === 33 && fp.reason === "model_not_allowed" + ); + expect(mismatch).toBeDefined(); + }); + + test("claude format skips priority-0 redirect-only provider and selects lower-priority allowed provider", async () => { + const { ProxyProviderResolver } = await import("@/app/v1/_lib/proxy/provider-selector"); + + vi.spyOn(ProxyProviderResolver as any, "filterByLimits").mockImplementation( + async (...args: unknown[]) => args[0] as Provider[] + ); + + const priorityZeroProvider = createProvider({ + id: 40, + providerType: "claude", + priority: 0, + allowedModels: ["claude-haiku-4-5-20251001", "glm-4.6"], + modelRedirects: { + "claude-haiku-4-5-20251001": "glm-4.6", + "claude-opus-4-5-20251001": "glm-4.6", + }, + }); + const fallbackProvider = createProvider({ + id: 41, + providerType: "claude", + priority: 1, + allowedModels: ["claude-opus-4-5-20251001"], + }); + + const session = createPickSession( + "claude", + [priorityZeroProvider, fallbackProvider], + "claude-opus-4-5-20251001" + ); + + const { provider: picked, context } = await (ProxyProviderResolver as any).pickRandomProvider( + session, + [] + ); + + expect(picked?.id).toBe(41); + expect(context.selectedPriority).toBe(1); + const mismatch = context.filteredProviders.find( + (fp: any) => fp.id === 40 && fp.reason === "model_not_allowed" + ); + expect(mismatch).toBeDefined(); + }); }); From 1bb1d49a16ebbd98514cc4e262c063c961578228 Mon Sep 17 00:00:00 2001 From: ding113 Date: Mon, 9 Mar 2026 13:01:16 +0800 Subject: [PATCH 11/42] fix: record cached tokens from chat completions usage (#889) Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- src/app/v1/_lib/proxy/response-handler.ts | 15 +++++++-- .../unit/proxy/extract-usage-metrics.test.ts | 32 +++++++++++++++++++ 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/src/app/v1/_lib/proxy/response-handler.ts b/src/app/v1/_lib/proxy/response-handler.ts index 6ffc4aeea..f7aa8cd44 100644 --- a/src/app/v1/_lib/proxy/response-handler.ts +++ b/src/app/v1/_lib/proxy/response-handler.ts @@ -2528,9 +2528,7 @@ export function extractUsageMetrics(value: unknown): UsageMetrics | null { hasAny = true; } - // OpenAI Response API 格式:input_tokens_details.cached_tokens(嵌套结构) - // 仅在顶层字段不存在时使用(避免重复计算) - if (!result.cache_read_input_tokens) { + if (result.cache_read_input_tokens === undefined) { const inputTokensDetails = usage.input_tokens_details as Record | undefined; if (inputTokensDetails && typeof inputTokensDetails.cached_tokens === "number") { result.cache_read_input_tokens = inputTokensDetails.cached_tokens; @@ -2541,6 +2539,17 @@ export function extractUsageMetrics(value: unknown): UsageMetrics | null { } } + if (result.cache_read_input_tokens === undefined) { + const promptTokensDetails = usage.prompt_tokens_details as Record | undefined; + if (promptTokensDetails && typeof promptTokensDetails.cached_tokens === "number") { + result.cache_read_input_tokens = promptTokensDetails.cached_tokens; + hasAny = true; + logger.debug("[ResponseHandler] Parsed cached tokens from OpenAI Chat Completions format", { + cachedTokens: promptTokensDetails.cached_tokens, + }); + } + } + return hasAny ? result : null; } diff --git a/tests/unit/proxy/extract-usage-metrics.test.ts b/tests/unit/proxy/extract-usage-metrics.test.ts index 4bbc43d56..281f2d44e 100644 --- a/tests/unit/proxy/extract-usage-metrics.test.ts +++ b/tests/unit/proxy/extract-usage-metrics.test.ts @@ -540,6 +540,38 @@ describe("extractUsageMetrics", () => { // 顶层优先 expect(result.usageMetrics?.cache_read_input_tokens).toBe(300); }); + + it("应从 Chat Completions 的 prompt_tokens_details.cached_tokens 提取缓存读取", () => { + const response = JSON.stringify({ + usage: { + prompt_tokens: 1000, + completion_tokens: 500, + prompt_tokens_details: { + cached_tokens: 200, + }, + }, + }); + + const result = parseUsageFromResponseText(response, "openai"); + + expect(result.usageMetrics?.cache_read_input_tokens).toBe(200); + }); + + it("顶层 cache_read_input_tokens 应优先于 Chat Completions 嵌套格式", () => { + const response = JSON.stringify({ + usage: { + prompt_tokens: 1000, + cache_read_input_tokens: 300, + prompt_tokens_details: { + cached_tokens: 200, + }, + }, + }); + + const result = parseUsageFromResponseText(response, "openai"); + + expect(result.usageMetrics?.cache_read_input_tokens).toBe(300); + }); }); describe("SSE 流式响应解析", () => { From 66d5feec2db9937d2f5158588e8ba6e45071a598 Mon Sep 17 00:00:00 2001 From: ding113 Date: Mon, 9 Mar 2026 13:37:27 +0800 Subject: [PATCH 12/42] fix: sort model price table by updatedAt instead of model name Wrap DISTINCT ON subquery so the outer ORDER BY sorts by updatedAt DESC, showing recently updated models first in the paginated price table. --- src/repository/model-price.ts | 33 +++++++++++++++++++-------------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/src/repository/model-price.ts b/src/repository/model-price.ts index f8ad21c67..0dfbb8ed9 100644 --- a/src/repository/model-price.ts +++ b/src/repository/model-price.ts @@ -193,21 +193,26 @@ export async function findAllLatestPricesPaginated( const total = Number(countResult.total); // 获取分页数据 + // 子查询: DISTINCT ON 要求 ORDER BY 首列与其一致,用于去重选出每个模型的最优记录 + // 外层: 按 updatedAt 降序排列,最近更新的模型排在前面 const dataQuery = sql` - SELECT DISTINCT ON (model_name) - id, - model_name as "modelName", - price_data as "priceData", - source, - created_at as "createdAt", - updated_at as "updatedAt" - FROM model_prices - ${whereCondition} - ORDER BY - model_name, - (source = 'manual') DESC, - created_at DESC NULLS LAST, - id DESC + SELECT * FROM ( + SELECT DISTINCT ON (model_name) + id, + model_name as "modelName", + price_data as "priceData", + source, + created_at as "createdAt", + updated_at as "updatedAt" + FROM model_prices + ${whereCondition} + ORDER BY + model_name, + (source = 'manual') DESC, + created_at DESC NULLS LAST, + id DESC + ) sub + ORDER BY sub."updatedAt" DESC NULLS LAST LIMIT ${pageSize} OFFSET ${offset} `; From cc42ac7024e542fbae4315a50b3e4c3a1bd5936b Mon Sep 17 00:00:00 2001 From: ding113 Date: Mon, 9 Mar 2026 13:49:35 +0800 Subject: [PATCH 13/42] feat(leaderboard): add provider breakdown, unified filters, and fix chart colors - Fix chart colors: replace hsl(var(--chart-N)) with var(--chart-N) to resolve oklch incompatibility causing gray fallback - Fix leaderboard username link styling: remove hyperlink appearance while keeping clickability - Add provider breakdown component with repository, server action, and side-by-side layout alongside model breakdown - Add unified filter bar (time preset, key, provider, model) controlling key trend chart and both breakdown panels - Lift filter state to UserInsightsView, refactor child components to accept filter props instead of managing internal state - Extend repository layer with filter params (keyId, providerId, model) for both model and provider breakdown queries - Add i18n keys for all 5 languages (en, zh-CN, zh-TW, ja, ru) - Add comprehensive tests for provider breakdown action and filter utils --- messages/en/dashboard.json | 12 +- messages/ja/dashboard.json | 12 +- messages/ru/dashboard.json | 12 +- messages/zh-CN/dashboard.json | 12 +- messages/zh-TW/dashboard.json | 12 +- src/actions/admin-user-insights.ts | 71 ++++++- .../_components/leaderboard-view.tsx | 2 +- .../[userId]/_components/filters/types.ts | 49 +++++ .../filters/user-insights-filter-bar.tsx | 179 +++++++++++++++++ .../_components/user-insights-view.tsx | 30 ++- .../_components/user-key-trend-chart.tsx | 53 ++--- .../_components/user-model-breakdown.tsx | 73 ++----- .../_components/user-provider-breakdown.tsx | 111 +++++++++++ src/repository/admin-user-insights.ts | 82 +++++++- tests/integration/usage-ledger.test.ts | 82 ++++---- .../unit/actions/admin-user-insights.test.ts | 183 +++++++++++++++++- .../dashboard/user-insights-page.test.tsx | 25 +-- tests/unit/user-insights-filters.test.ts | 67 +++++++ 18 files changed, 896 insertions(+), 171 deletions(-) create mode 100644 src/app/[locale]/dashboard/leaderboard/user/[userId]/_components/filters/types.ts create mode 100644 src/app/[locale]/dashboard/leaderboard/user/[userId]/_components/filters/user-insights-filter-bar.tsx create mode 100644 src/app/[locale]/dashboard/leaderboard/user/[userId]/_components/user-provider-breakdown.tsx create mode 100644 tests/unit/user-insights-filters.test.ts diff --git a/messages/en/dashboard.json b/messages/en/dashboard.json index 16102648c..c0e821cdf 100644 --- a/messages/en/dashboard.json +++ b/messages/en/dashboard.json @@ -500,7 +500,17 @@ "unknownModel": "Unknown Model", "noData": "No data available", "dateRange": "Date Range", - "allTime": "All Time" + "allTime": "All Time", + "providerBreakdown": "Provider Breakdown", + "unknownProvider": "Unknown Provider", + "apiKey": "API Key", + "provider": "Provider", + "model": "Model", + "allKeys": "All Keys", + "allProviders": "All Providers", + "allModels": "All Models", + "dimensions": "Dimensions", + "filters": "Filters" } }, "sessions": { diff --git a/messages/ja/dashboard.json b/messages/ja/dashboard.json index 628f96141..0daa44440 100644 --- a/messages/ja/dashboard.json +++ b/messages/ja/dashboard.json @@ -500,7 +500,17 @@ "unknownModel": "不明なモデル", "noData": "データがありません", "dateRange": "期間", - "allTime": "全期間" + "allTime": "全期間", + "providerBreakdown": "プロバイダー内訳", + "unknownProvider": "不明なプロバイダー", + "apiKey": "APIキー", + "provider": "プロバイダー", + "model": "モデル", + "allKeys": "すべてのキー", + "allProviders": "すべてのプロバイダー", + "allModels": "すべてのモデル", + "dimensions": "ディメンション", + "filters": "フィルター" } }, "sessions": { diff --git a/messages/ru/dashboard.json b/messages/ru/dashboard.json index 8ff31bbe3..03c4150b5 100644 --- a/messages/ru/dashboard.json +++ b/messages/ru/dashboard.json @@ -500,7 +500,17 @@ "unknownModel": "Неизвестная модель", "noData": "Нет данных", "dateRange": "Диапазон дат", - "allTime": "Всё время" + "allTime": "Всё время", + "providerBreakdown": "Разбивка по провайдерам", + "unknownProvider": "Неизвестный провайдер", + "apiKey": "API ключ", + "provider": "Провайдер", + "model": "Модель", + "allKeys": "Все ключи", + "allProviders": "Все провайдеры", + "allModels": "Все модели", + "dimensions": "Измерения", + "filters": "Фильтры" } }, "sessions": { diff --git a/messages/zh-CN/dashboard.json b/messages/zh-CN/dashboard.json index c2d4fc350..a3676f4c9 100644 --- a/messages/zh-CN/dashboard.json +++ b/messages/zh-CN/dashboard.json @@ -500,7 +500,17 @@ "unknownModel": "未知模型", "noData": "暂无数据", "dateRange": "日期范围", - "allTime": "全部时间" + "allTime": "全部时间", + "providerBreakdown": "供应商明细", + "unknownProvider": "未知供应商", + "apiKey": "API 密钥", + "provider": "供应商", + "model": "模型", + "allKeys": "全部密钥", + "allProviders": "全部供应商", + "allModels": "全部模型", + "dimensions": "维度", + "filters": "筛选" } }, "sessions": { diff --git a/messages/zh-TW/dashboard.json b/messages/zh-TW/dashboard.json index 2c58e4509..c1f69fecf 100644 --- a/messages/zh-TW/dashboard.json +++ b/messages/zh-TW/dashboard.json @@ -500,7 +500,17 @@ "unknownModel": "未知模型", "noData": "暫無資料", "dateRange": "日期範圍", - "allTime": "全部時間" + "allTime": "全部時間", + "providerBreakdown": "供應商明細", + "unknownProvider": "未知供應商", + "apiKey": "API 金鑰", + "provider": "供應商", + "model": "模型", + "allKeys": "全部金鑰", + "allProviders": "全部供應商", + "allModels": "全部模型", + "dimensions": "維度", + "filters": "篩選" } }, "sessions": { diff --git a/src/actions/admin-user-insights.ts b/src/actions/admin-user-insights.ts index 915f6e625..10ff8f7bb 100644 --- a/src/actions/admin-user-insights.ts +++ b/src/actions/admin-user-insights.ts @@ -5,7 +5,9 @@ import { getOverviewWithCache } from "@/lib/redis/overview-cache"; import { getStatisticsWithCache } from "@/lib/redis/statistics-cache"; import { type AdminUserModelBreakdownItem, + type AdminUserProviderBreakdownItem, getUserModelBreakdown, + getUserProviderBreakdown, } from "@/repository/admin-user-insights"; import type { OverviewMetricsWithComparison } from "@/repository/overview"; import { getSystemSettings } from "@/repository/system-config"; @@ -87,13 +89,33 @@ export async function getUserInsightsKeyTrend( return { ok: true, data: normalized }; } +/** + * Get model-level usage breakdown for a specific user (admin only). + */ +function validateDateRange( + startDate?: string, + endDate?: string +): { ok: false; error: string } | null { + if (startDate && !DATE_REGEX.test(startDate)) { + return { ok: false, error: "Invalid startDate format: use YYYY-MM-DD" }; + } + if (endDate && !DATE_REGEX.test(endDate)) { + return { ok: false, error: "Invalid endDate format: use YYYY-MM-DD" }; + } + if (startDate && endDate && new Date(startDate) > new Date(endDate)) { + return { ok: false, error: "startDate must not be after endDate" }; + } + return null; +} + /** * Get model-level usage breakdown for a specific user (admin only). */ export async function getUserInsightsModelBreakdown( targetUserId: number, startDate?: string, - endDate?: string + endDate?: string, + filters?: { keyId?: number; providerId?: number } ): Promise< ActionResult<{ breakdown: AdminUserModelBreakdownItem[]; @@ -105,18 +127,47 @@ export async function getUserInsightsModelBreakdown( return { ok: false, error: "Unauthorized" }; } - if (startDate && !DATE_REGEX.test(startDate)) { - return { ok: false, error: "Invalid startDate format: use YYYY-MM-DD" }; - } - if (endDate && !DATE_REGEX.test(endDate)) { - return { ok: false, error: "Invalid endDate format: use YYYY-MM-DD" }; - } - if (startDate && endDate && new Date(startDate) > new Date(endDate)) { - return { ok: false, error: "startDate must not be after endDate" }; + const dateError = validateDateRange(startDate, endDate); + if (dateError) return dateError; + + const [breakdown, settings] = await Promise.all([ + getUserModelBreakdown(targetUserId, startDate, endDate, filters), + getSystemSettings(), + ]); + + return { + ok: true, + data: { + breakdown, + currencyCode: settings.currencyDisplay, + }, + }; +} + +/** + * Get provider-level usage breakdown for a specific user (admin only). + */ +export async function getUserInsightsProviderBreakdown( + targetUserId: number, + startDate?: string, + endDate?: string, + filters?: { keyId?: number; model?: string } +): Promise< + ActionResult<{ + breakdown: AdminUserProviderBreakdownItem[]; + currencyCode: string; + }> +> { + const session = await getSession(); + if (!session || session.user.role !== "admin") { + return { ok: false, error: "Unauthorized" }; } + const dateError = validateDateRange(startDate, endDate); + if (dateError) return dateError; + const [breakdown, settings] = await Promise.all([ - getUserModelBreakdown(targetUserId, startDate, endDate), + getUserProviderBreakdown(targetUserId, startDate, endDate, filters), getSystemSettings(), ]); diff --git a/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-view.tsx b/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-view.tsx index 2ae52f137..441db5d6b 100644 --- a/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-view.tsx +++ b/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-view.tsx @@ -214,7 +214,7 @@ export function LeaderboardView({ isAdmin }: LeaderboardViewProps) { return isAdmin ? ( {row.userName} diff --git a/src/app/[locale]/dashboard/leaderboard/user/[userId]/_components/filters/types.ts b/src/app/[locale]/dashboard/leaderboard/user/[userId]/_components/filters/types.ts new file mode 100644 index 000000000..e4e301405 --- /dev/null +++ b/src/app/[locale]/dashboard/leaderboard/user/[userId]/_components/filters/types.ts @@ -0,0 +1,49 @@ +export type TimeRangePreset = "today" | "7days" | "30days" | "thisMonth"; + +export interface UserInsightsFilters { + timeRange: TimeRangePreset; + keyId?: number; + providerId?: number; + model?: string; +} + +export const DEFAULT_FILTERS: UserInsightsFilters = { + timeRange: "7days", +}; + +/** + * Convert a time range preset to start/end dates for breakdown queries. + */ +export function resolveTimePresetDates(preset: TimeRangePreset): { + startDate?: string; + endDate?: string; +} { + const now = new Date(); + const yyyy = now.getFullYear(); + const mm = String(now.getMonth() + 1).padStart(2, "0"); + const dd = String(now.getDate()).padStart(2, "0"); + const today = `${yyyy}-${mm}-${dd}`; + + switch (preset) { + case "today": + return { startDate: today, endDate: today }; + case "7days": { + const start = new Date(now); + start.setDate(start.getDate() - 6); + const sy = start.getFullYear(); + const sm = String(start.getMonth() + 1).padStart(2, "0"); + const sd = String(start.getDate()).padStart(2, "0"); + return { startDate: `${sy}-${sm}-${sd}`, endDate: today }; + } + case "30days": { + const start = new Date(now); + start.setDate(start.getDate() - 29); + const sy = start.getFullYear(); + const sm = String(start.getMonth() + 1).padStart(2, "0"); + const sd = String(start.getDate()).padStart(2, "0"); + return { startDate: `${sy}-${sm}-${sd}`, endDate: today }; + } + case "thisMonth": + return { startDate: `${yyyy}-${mm}-01`, endDate: today }; + } +} diff --git a/src/app/[locale]/dashboard/leaderboard/user/[userId]/_components/filters/user-insights-filter-bar.tsx b/src/app/[locale]/dashboard/leaderboard/user/[userId]/_components/filters/user-insights-filter-bar.tsx new file mode 100644 index 000000000..943547026 --- /dev/null +++ b/src/app/[locale]/dashboard/leaderboard/user/[userId]/_components/filters/user-insights-filter-bar.tsx @@ -0,0 +1,179 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { Filter, Key, Server } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { getKeys } from "@/actions/keys"; +import { getProviders } from "@/actions/providers"; +import { useLazyModels } from "@/app/[locale]/dashboard/logs/_hooks/use-lazy-filter-options"; +import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import type { TimeRangePreset, UserInsightsFilters } from "./types"; + +interface UserInsightsFilterBarProps { + userId: number; + filters: UserInsightsFilters; + onFiltersChange: (filters: UserInsightsFilters) => void; +} + +const TIME_RANGE_OPTIONS: { key: TimeRangePreset; labelKey: string }[] = [ + { key: "today", labelKey: "timeRange.today" }, + { key: "7days", labelKey: "timeRange.7days" }, + { key: "30days", labelKey: "timeRange.30days" }, + { key: "thisMonth", labelKey: "timeRange.thisMonth" }, +]; + +export function UserInsightsFilterBar({ + userId, + filters, + onFiltersChange, +}: UserInsightsFilterBarProps) { + const t = useTranslations("dashboard.leaderboard.userInsights"); + + const { data: keysData } = useQuery({ + queryKey: ["user-insights-keys", userId], + queryFn: async () => { + const result = await getKeys(userId); + if (!result.ok) return []; + return result.data; + }, + staleTime: 60_000, + }); + + const { data: providersData } = useQuery({ + queryKey: ["user-insights-providers"], + queryFn: async () => { + const result = await getProviders(); + return result; + }, + staleTime: 60_000, + }); + + const { data: models, onOpenChange: onModelsOpenChange } = useLazyModels(); + + const hasActiveFilters = filters.keyId || filters.providerId || filters.model; + + return ( +
+ {/* Time range preset buttons */} +
+
+ {TIME_RANGE_OPTIONS.map((opt) => ( + + ))} +
+
+ + {/* Dimension filters */} +
+
+ + {t("filters")} +
+ + {/* Key filter */} + + + {/* Provider filter */} + + + {/* Model filter */} + + + {/* Clear filters */} + {hasActiveFilters && ( + + )} +
+
+ ); +} diff --git a/src/app/[locale]/dashboard/leaderboard/user/[userId]/_components/user-insights-view.tsx b/src/app/[locale]/dashboard/leaderboard/user/[userId]/_components/user-insights-view.tsx index d03d5c84f..a2be5293e 100644 --- a/src/app/[locale]/dashboard/leaderboard/user/[userId]/_components/user-insights-view.tsx +++ b/src/app/[locale]/dashboard/leaderboard/user/[userId]/_components/user-insights-view.tsx @@ -2,11 +2,15 @@ import { ArrowLeft } from "lucide-react"; import { useTranslations } from "next-intl"; +import { useState } from "react"; import { Button } from "@/components/ui/button"; import { useRouter } from "@/i18n/routing"; +import { DEFAULT_FILTERS, resolveTimePresetDates, type UserInsightsFilters } from "./filters/types"; +import { UserInsightsFilterBar } from "./filters/user-insights-filter-bar"; import { UserKeyTrendChart } from "./user-key-trend-chart"; import { UserModelBreakdown } from "./user-model-breakdown"; import { UserOverviewCards } from "./user-overview-cards"; +import { UserProviderBreakdown } from "./user-provider-breakdown"; interface UserInsightsViewProps { userId: number; @@ -16,6 +20,9 @@ interface UserInsightsViewProps { export function UserInsightsView({ userId, userName }: UserInsightsViewProps) { const t = useTranslations("dashboard.leaderboard.userInsights"); const router = useRouter(); + const [filters, setFilters] = useState(DEFAULT_FILTERS); + + const { startDate, endDate } = resolveTimePresetDates(filters.timeRange); return (
@@ -36,8 +43,27 @@ export function UserInsightsView({ userId, userName }: UserInsightsViewProps) {
- - + + + + + +
+ + +
); } diff --git a/src/app/[locale]/dashboard/leaderboard/user/[userId]/_components/user-key-trend-chart.tsx b/src/app/[locale]/dashboard/leaderboard/user/[userId]/_components/user-key-trend-chart.tsx index 88a213a3d..ac316b643 100644 --- a/src/app/[locale]/dashboard/leaderboard/user/[userId]/_components/user-key-trend-chart.tsx +++ b/src/app/[locale]/dashboard/leaderboard/user/[userId]/_components/user-key-trend-chart.tsx @@ -2,27 +2,27 @@ import { useQuery } from "@tanstack/react-query"; import { useTranslations } from "next-intl"; -import { useMemo, useState } from "react"; +import { useMemo } from "react"; import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts"; import { getUserInsightsKeyTrend } from "@/actions/admin-user-insights"; -import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { type ChartConfig, ChartContainer, ChartTooltip } from "@/components/ui/chart"; import { Skeleton } from "@/components/ui/skeleton"; import type { DatabaseKeyStatRow } from "@/types/statistics"; +import type { TimeRangePreset } from "./filters/types"; interface UserKeyTrendChartProps { userId: number; + timeRange: TimeRangePreset; + keyId?: number; } -type TimeRangeKey = "today" | "7days" | "30days" | "thisMonth"; - const CHART_COLORS = [ - "hsl(var(--chart-1))", - "hsl(var(--chart-2))", - "hsl(var(--chart-3))", - "hsl(var(--chart-4))", - "hsl(var(--chart-5))", + "var(--chart-1)", + "var(--chart-2)", + "var(--chart-3)", + "var(--chart-4)", + "var(--chart-5)", "#8b5cf6", "#ec4899", "#f97316", @@ -34,10 +34,9 @@ interface ChartKey { dataKey: string; } -export function UserKeyTrendChart({ userId }: UserKeyTrendChartProps) { +export function UserKeyTrendChart({ userId, timeRange, keyId }: UserKeyTrendChartProps) { const t = useTranslations("dashboard.leaderboard.userInsights"); const tStats = useTranslations("dashboard.stats"); - const [timeRange, setTimeRange] = useState("7days"); const { data: rawData, isLoading } = useQuery({ queryKey: ["user-insights-key-trend", userId, timeRange], @@ -53,9 +52,12 @@ export function UserKeyTrendChart({ userId }: UserKeyTrendChartProps) { return { chartData: [], keys: [] as ChartKey[], chartConfig: {} as ChartConfig }; } + // Client-side filter by keyId if specified + const filtered = keyId ? rawData.filter((row) => row.key_id === keyId) : rawData; + // Extract unique keys const keyMap = new Map(); - for (const row of rawData) { + for (const row of filtered) { if (!keyMap.has(row.key_id)) { keyMap.set(row.key_id, row.key_name); } @@ -69,7 +71,7 @@ export function UserKeyTrendChart({ userId }: UserKeyTrendChartProps) { // Build chart data grouped by date const dataByDate = new Map>(); - for (const row of rawData) { + for (const row of filtered) { const dateStr = timeRange === "today" ? new Date(row.date).toISOString() : row.date.split("T")[0]; @@ -103,33 +105,12 @@ export function UserKeyTrendChart({ userId }: UserKeyTrendChartProps) { keys: uniqueKeys, chartConfig: config, }; - }, [rawData, timeRange, tStats]); - - const timeRangeOptions: { key: TimeRangeKey; labelKey: string }[] = [ - { key: "today", labelKey: "timeRange.today" }, - { key: "7days", labelKey: "timeRange.7days" }, - { key: "30days", labelKey: "timeRange.30days" }, - { key: "thisMonth", labelKey: "timeRange.thisMonth" }, - ]; + }, [rawData, timeRange, keyId, tStats]); return ( - + {t("keyTrend")} -
- {timeRangeOptions.map((opt) => ( - - ))} -
{isLoading ? ( diff --git a/src/app/[locale]/dashboard/leaderboard/user/[userId]/_components/user-model-breakdown.tsx b/src/app/[locale]/dashboard/leaderboard/user/[userId]/_components/user-model-breakdown.tsx index 5e49c3ceb..177184431 100644 --- a/src/app/[locale]/dashboard/leaderboard/user/[userId]/_components/user-model-breakdown.tsx +++ b/src/app/[locale]/dashboard/leaderboard/user/[userId]/_components/user-model-breakdown.tsx @@ -3,58 +3,45 @@ import { useQuery } from "@tanstack/react-query"; import { BarChart3 } from "lucide-react"; import { useTranslations } from "next-intl"; -import { useState } from "react"; import { getUserInsightsModelBreakdown } from "@/actions/admin-user-insights"; import { ModelBreakdownColumn, type ModelBreakdownItem, type ModelBreakdownLabels, } from "@/components/analytics/model-breakdown-column"; -import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Input } from "@/components/ui/input"; import { Skeleton } from "@/components/ui/skeleton"; import type { CurrencyCode } from "@/lib/utils/currency"; interface UserModelBreakdownProps { userId: number; + startDate?: string; + endDate?: string; + keyId?: number; + providerId?: number; } -export function UserModelBreakdown({ userId }: UserModelBreakdownProps) { +export function UserModelBreakdown({ + userId, + startDate, + endDate, + keyId, + providerId, +}: UserModelBreakdownProps) { const t = useTranslations("dashboard.leaderboard.userInsights"); - const tCommon = useTranslations("common"); const tStats = useTranslations("myUsage.stats"); - const [startDate, setStartDate] = useState(""); - const [endDate, setEndDate] = useState(""); - const [appliedRange, setAppliedRange] = useState<{ - start?: string; - end?: string; - }>({}); + const filters = keyId || providerId ? { keyId, providerId } : undefined; const { data, isLoading } = useQuery({ - queryKey: ["user-insights-model-breakdown", userId, appliedRange.start, appliedRange.end], + queryKey: ["user-insights-model-breakdown", userId, startDate, endDate, keyId, providerId], queryFn: async () => { - const result = await getUserInsightsModelBreakdown( - userId, - appliedRange.start || undefined, - appliedRange.end || undefined - ); + const result = await getUserInsightsModelBreakdown(userId, startDate, endDate, filters); if (!result.ok) throw new Error(result.error); return result.data; }, }); - const handleApplyRange = () => { - setAppliedRange({ start: startDate || undefined, end: endDate || undefined }); - }; - - const handleClearRange = () => { - setStartDate(""); - setEndDate(""); - setAppliedRange({}); - }; - const labels: ModelBreakdownLabels = { unknownModel: t("unknownModel"), modal: { @@ -91,35 +78,9 @@ export function UserModelBreakdown({ userId }: UserModelBreakdownProps) { return ( - - - - {t("modelBreakdown")} - -
- {t("dateRange")}: - setStartDate(e.target.value)} - className="h-7 w-[130px] text-xs" - /> - - - setEndDate(e.target.value)} - className="h-7 w-[130px] text-xs" - /> - - {(appliedRange.start || appliedRange.end) && ( - - )} -
+ + + {t("modelBreakdown")} {isLoading ? ( diff --git a/src/app/[locale]/dashboard/leaderboard/user/[userId]/_components/user-provider-breakdown.tsx b/src/app/[locale]/dashboard/leaderboard/user/[userId]/_components/user-provider-breakdown.tsx new file mode 100644 index 000000000..99115e41e --- /dev/null +++ b/src/app/[locale]/dashboard/leaderboard/user/[userId]/_components/user-provider-breakdown.tsx @@ -0,0 +1,111 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { Server } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { getUserInsightsProviderBreakdown } from "@/actions/admin-user-insights"; +import { + ModelBreakdownColumn, + type ModelBreakdownItem, + type ModelBreakdownLabels, +} from "@/components/analytics/model-breakdown-column"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; +import type { CurrencyCode } from "@/lib/utils/currency"; + +interface UserProviderBreakdownProps { + userId: number; + startDate?: string; + endDate?: string; + keyId?: number; + model?: string; +} + +export function UserProviderBreakdown({ + userId, + startDate, + endDate, + keyId, + model, +}: UserProviderBreakdownProps) { + const t = useTranslations("dashboard.leaderboard.userInsights"); + const tStats = useTranslations("myUsage.stats"); + + const filters = keyId || model ? { keyId, model } : undefined; + + const { data, isLoading } = useQuery({ + queryKey: ["user-insights-provider-breakdown", userId, startDate, endDate, keyId, model], + queryFn: async () => { + const result = await getUserInsightsProviderBreakdown(userId, startDate, endDate, filters); + if (!result.ok) throw new Error(result.error); + return result.data; + }, + }); + + const labels: ModelBreakdownLabels = { + unknownModel: t("unknownProvider"), + modal: { + requests: tStats("modal.requests"), + cost: tStats("modal.cost"), + inputTokens: tStats("modal.inputTokens"), + outputTokens: tStats("modal.outputTokens"), + cacheCreationTokens: tStats("modal.cacheWrite"), + cacheReadTokens: tStats("modal.cacheRead"), + totalTokens: tStats("modal.totalTokens"), + costPercentage: tStats("modal.cost"), + cacheHitRate: tStats("modal.cacheHitRate"), + cacheTokens: tStats("modal.cacheTokens"), + performanceHigh: tStats("modal.performanceHigh"), + performanceMedium: tStats("modal.performanceMedium"), + performanceLow: tStats("modal.performanceLow"), + }, + }; + + const items: ModelBreakdownItem[] = data + ? data.breakdown.map((item) => ({ + model: item.providerName, + requests: item.requests, + cost: item.cost, + inputTokens: item.inputTokens, + outputTokens: item.outputTokens, + cacheCreationTokens: item.cacheCreationTokens, + cacheReadTokens: item.cacheReadTokens, + })) + : []; + + const totalCost = items.reduce((sum, item) => sum + item.cost, 0); + const currencyCode = (data?.currencyCode ?? "USD") as CurrencyCode; + + return ( + + + + {t("providerBreakdown")} + + + {isLoading ? ( +
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+ ) : items.length === 0 ? ( +
+ {t("noData")} +
+ ) : ( +
+ +
+ )} +
+
+ ); +} diff --git a/src/repository/admin-user-insights.ts b/src/repository/admin-user-insights.ts index 0686c023a..bda743c72 100644 --- a/src/repository/admin-user-insights.ts +++ b/src/repository/admin-user-insights.ts @@ -2,7 +2,7 @@ import { and, desc, eq, gte, lt, sql } from "drizzle-orm"; import { db } from "@/drizzle/db"; -import { usageLedger } from "@/drizzle/schema"; +import { providers, usageLedger } from "@/drizzle/schema"; import { LEDGER_BILLING_CONDITION } from "./_shared/ledger-conditions"; import { getSystemSettings } from "./system-config"; @@ -16,6 +16,21 @@ export interface AdminUserModelBreakdownItem { cacheReadTokens: number; } +/** + * Get model-level usage breakdown for a specific user. + * Groups by the billingModelSource-resolved model field and orders by cost DESC. + */ +export interface AdminUserProviderBreakdownItem { + providerId: number; + providerName: string | null; + requests: number; + cost: number; + inputTokens: number; + outputTokens: number; + cacheCreationTokens: number; + cacheReadTokens: number; +} + /** * Get model-level usage breakdown for a specific user. * Groups by the billingModelSource-resolved model field and orders by cost DESC. @@ -23,7 +38,8 @@ export interface AdminUserModelBreakdownItem { export async function getUserModelBreakdown( userId: number, startDate?: string, - endDate?: string + endDate?: string, + filters?: { keyId?: number; providerId?: number } ): Promise { const systemSettings = await getSystemSettings(); const billingModelSource = systemSettings.billingModelSource; @@ -44,6 +60,16 @@ export async function getUserModelBreakdown( conditions.push(lt(usageLedger.createdAt, sql`(${endDate}::date + INTERVAL '1 day')`)); } + if (filters?.keyId) { + conditions.push( + sql`${usageLedger.key} = (SELECT k."key" FROM "keys" k WHERE k."id" = ${filters.keyId})` + ); + } + + if (filters?.providerId) { + conditions.push(eq(usageLedger.finalProviderId, filters.providerId)); + } + const rows = await db .select({ model: modelField, @@ -61,3 +87,55 @@ export async function getUserModelBreakdown( return rows; } + +/** + * Get provider-level usage breakdown for a specific user. + * JOINs usageLedger with providers table and groups by provider. + */ +export async function getUserProviderBreakdown( + userId: number, + startDate?: string, + endDate?: string, + filters?: { keyId?: number; model?: string } +): Promise { + const conditions = [LEDGER_BILLING_CONDITION, eq(usageLedger.userId, userId)]; + + if (startDate) { + conditions.push(gte(usageLedger.createdAt, sql`${startDate}::date`)); + } + + if (endDate) { + conditions.push(lt(usageLedger.createdAt, sql`(${endDate}::date + INTERVAL '1 day')`)); + } + + if (filters?.keyId) { + conditions.push( + sql`${usageLedger.key} = (SELECT k."key" FROM "keys" k WHERE k."id" = ${filters.keyId})` + ); + } + + if (filters?.model) { + conditions.push( + sql`(${usageLedger.model} ILIKE ${filters.model} OR ${usageLedger.originalModel} ILIKE ${filters.model})` + ); + } + + const rows = await db + .select({ + providerId: providers.id, + providerName: providers.name, + requests: sql`count(*)::int`, + cost: sql`COALESCE(sum(${usageLedger.costUsd})::double precision, 0)`, + inputTokens: sql`COALESCE(sum(${usageLedger.inputTokens})::double precision, 0)`, + outputTokens: sql`COALESCE(sum(${usageLedger.outputTokens})::double precision, 0)`, + cacheCreationTokens: sql`COALESCE(sum(${usageLedger.cacheCreationInputTokens})::double precision, 0)`, + cacheReadTokens: sql`COALESCE(sum(${usageLedger.cacheReadInputTokens})::double precision, 0)`, + }) + .from(usageLedger) + .innerJoin(providers, eq(usageLedger.finalProviderId, providers.id)) + .where(and(...conditions)) + .groupBy(providers.id, providers.name) + .orderBy(desc(sql`sum(${usageLedger.costUsd})`)); + + return rows; +} diff --git a/tests/integration/usage-ledger.test.ts b/tests/integration/usage-ledger.test.ts index d64b36ee1..303d005cd 100644 --- a/tests/integration/usage-ledger.test.ts +++ b/tests/integration/usage-ledger.test.ts @@ -278,45 +278,49 @@ run("usage ledger integration", () => { }); describe("backfill", () => { - test("backfill copies non-warmup message_request rows when ledger rows are missing", { - timeout: 60_000, - }, async () => { - const userId = nextUserId(); - const providerId = nextProviderId(); - const keepA = await insertMessageRequestRow({ - key: nextKey("backfill-a"), - userId, - providerId, - costUsd: "1.100000000000000", - }); - const keepB = await insertMessageRequestRow({ - key: nextKey("backfill-b"), - userId, - providerId, - costUsd: "2.200000000000000", - }); - const warmup = await insertMessageRequestRow({ - key: nextKey("backfill-warmup"), - userId, - providerId, - blockedBy: "warmup", - }); - - await db.delete(usageLedger).where(inArray(usageLedger.requestId, [keepA, keepB, warmup])); - - const summary = await backfillUsageLedger(); - expect(summary.totalProcessed).toBeGreaterThanOrEqual(2); - - const rows = await db - .select({ requestId: usageLedger.requestId }) - .from(usageLedger) - .where(inArray(usageLedger.requestId, [keepA, keepB, warmup])); - const requestIds = rows.map((row) => row.requestId); - - expect(requestIds).toContain(keepA); - expect(requestIds).toContain(keepB); - expect(requestIds).not.toContain(warmup); - }); + test( + "backfill copies non-warmup message_request rows when ledger rows are missing", + { + timeout: 60_000, + }, + async () => { + const userId = nextUserId(); + const providerId = nextProviderId(); + const keepA = await insertMessageRequestRow({ + key: nextKey("backfill-a"), + userId, + providerId, + costUsd: "1.100000000000000", + }); + const keepB = await insertMessageRequestRow({ + key: nextKey("backfill-b"), + userId, + providerId, + costUsd: "2.200000000000000", + }); + const warmup = await insertMessageRequestRow({ + key: nextKey("backfill-warmup"), + userId, + providerId, + blockedBy: "warmup", + }); + + await db.delete(usageLedger).where(inArray(usageLedger.requestId, [keepA, keepB, warmup])); + + const summary = await backfillUsageLedger(); + expect(summary.totalProcessed).toBeGreaterThanOrEqual(2); + + const rows = await db + .select({ requestId: usageLedger.requestId }) + .from(usageLedger) + .where(inArray(usageLedger.requestId, [keepA, keepB, warmup])); + const requestIds = rows.map((row) => row.requestId); + + expect(requestIds).toContain(keepA); + expect(requestIds).toContain(keepB); + expect(requestIds).not.toContain(warmup); + } + ); test("backfill is idempotent when running twice", { timeout: 60_000 }, async () => { const requestId = await insertMessageRequestRow({ diff --git a/tests/unit/actions/admin-user-insights.test.ts b/tests/unit/actions/admin-user-insights.test.ts index 8c3e5cdb2..7e3cf447e 100644 --- a/tests/unit/actions/admin-user-insights.test.ts +++ b/tests/unit/actions/admin-user-insights.test.ts @@ -5,6 +5,7 @@ const mockFindUserById = vi.hoisted(() => vi.fn()); const mockGetOverviewWithCache = vi.hoisted(() => vi.fn()); const mockGetStatisticsWithCache = vi.hoisted(() => vi.fn()); const mockGetUserModelBreakdown = vi.hoisted(() => vi.fn()); +const mockGetUserProviderBreakdown = vi.hoisted(() => vi.fn()); const mockGetSystemSettings = vi.hoisted(() => vi.fn()); vi.mock("@/lib/auth", () => ({ @@ -25,6 +26,7 @@ vi.mock("@/lib/redis/statistics-cache", () => ({ vi.mock("@/repository/admin-user-insights", () => ({ getUserModelBreakdown: mockGetUserModelBreakdown, + getUserProviderBreakdown: mockGetUserProviderBreakdown, })); vi.mock("@/repository/system-config", () => ({ @@ -330,7 +332,7 @@ describe("getUserInsightsModelBreakdown", () => { expect(result.data.breakdown).toEqual(breakdown); expect(result.data.currencyCode).toBe("USD"); } - expect(mockGetUserModelBreakdown).toHaveBeenCalledWith(10, undefined, undefined); + expect(mockGetUserModelBreakdown).toHaveBeenCalledWith(10, undefined, undefined, undefined); }); it("passes date range to getUserModelBreakdown", async () => { @@ -345,7 +347,28 @@ describe("getUserInsightsModelBreakdown", () => { const result = await getUserInsightsModelBreakdown(10, "2026-03-01", "2026-03-09"); expect(result.ok).toBe(true); - expect(mockGetUserModelBreakdown).toHaveBeenCalledWith(10, "2026-03-01", "2026-03-09"); + expect(mockGetUserModelBreakdown).toHaveBeenCalledWith( + 10, + "2026-03-01", + "2026-03-09", + undefined + ); + }); + + it("passes filter params to getUserModelBreakdown", async () => { + const breakdown = createMockBreakdown(); + const settings = createMockSettings(); + + mockGetSession.mockResolvedValueOnce(createAdminSession()); + mockGetUserModelBreakdown.mockResolvedValueOnce(breakdown); + mockGetSystemSettings.mockResolvedValueOnce(settings); + + const { getUserInsightsModelBreakdown } = await import("@/actions/admin-user-insights"); + const filters = { keyId: 5, providerId: 3 }; + const result = await getUserInsightsModelBreakdown(10, "2026-03-01", "2026-03-09", filters); + + expect(result.ok).toBe(true); + expect(mockGetUserModelBreakdown).toHaveBeenCalledWith(10, "2026-03-01", "2026-03-09", filters); }); it("rejects invalid startDate format", async () => { @@ -387,3 +410,159 @@ describe("getUserInsightsModelBreakdown", () => { expect(mockGetUserModelBreakdown).not.toHaveBeenCalled(); }); }); + +function createMockProviderBreakdown() { + return [ + { + providerId: 1, + providerName: "Provider A", + requests: 40, + cost: 4.0, + inputTokens: 12000, + outputTokens: 6000, + cacheCreationTokens: 2500, + cacheReadTokens: 9000, + }, + { + providerId: 2, + providerName: "Provider B", + requests: 10, + cost: 1.5, + inputTokens: 6000, + outputTokens: 2000, + cacheCreationTokens: 500, + cacheReadTokens: 4000, + }, + ]; +} + +describe("getUserInsightsProviderBreakdown", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns unauthorized for non-admin", async () => { + mockGetSession.mockResolvedValueOnce(createUserSession()); + + const { getUserInsightsProviderBreakdown } = await import("@/actions/admin-user-insights"); + const result = await getUserInsightsProviderBreakdown(10); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toBe("Unauthorized"); + } + expect(mockGetUserProviderBreakdown).not.toHaveBeenCalled(); + }); + + it("returns unauthorized when not logged in", async () => { + mockGetSession.mockResolvedValueOnce(null); + + const { getUserInsightsProviderBreakdown } = await import("@/actions/admin-user-insights"); + const result = await getUserInsightsProviderBreakdown(10); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toBe("Unauthorized"); + } + }); + + it("returns breakdown data for valid request", async () => { + const breakdown = createMockProviderBreakdown(); + const settings = createMockSettings(); + + mockGetSession.mockResolvedValueOnce(createAdminSession()); + mockGetUserProviderBreakdown.mockResolvedValueOnce(breakdown); + mockGetSystemSettings.mockResolvedValueOnce(settings); + + const { getUserInsightsProviderBreakdown } = await import("@/actions/admin-user-insights"); + const result = await getUserInsightsProviderBreakdown(10); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.breakdown).toEqual(breakdown); + expect(result.data.breakdown[0].providerName).toBe("Provider A"); + expect(result.data.currencyCode).toBe("USD"); + } + expect(mockGetUserProviderBreakdown).toHaveBeenCalledWith(10, undefined, undefined, undefined); + }); + + it("passes date range to getUserProviderBreakdown", async () => { + const breakdown = createMockProviderBreakdown(); + const settings = createMockSettings(); + + mockGetSession.mockResolvedValueOnce(createAdminSession()); + mockGetUserProviderBreakdown.mockResolvedValueOnce(breakdown); + mockGetSystemSettings.mockResolvedValueOnce(settings); + + const { getUserInsightsProviderBreakdown } = await import("@/actions/admin-user-insights"); + const result = await getUserInsightsProviderBreakdown(10, "2026-03-01", "2026-03-09"); + + expect(result.ok).toBe(true); + expect(mockGetUserProviderBreakdown).toHaveBeenCalledWith( + 10, + "2026-03-01", + "2026-03-09", + undefined + ); + }); + + it("passes filter params to getUserProviderBreakdown", async () => { + const breakdown = createMockProviderBreakdown(); + const settings = createMockSettings(); + + mockGetSession.mockResolvedValueOnce(createAdminSession()); + mockGetUserProviderBreakdown.mockResolvedValueOnce(breakdown); + mockGetSystemSettings.mockResolvedValueOnce(settings); + + const { getUserInsightsProviderBreakdown } = await import("@/actions/admin-user-insights"); + const filters = { keyId: 5, model: "claude-sonnet-4-20250514" }; + const result = await getUserInsightsProviderBreakdown(10, "2026-03-01", "2026-03-09", filters); + + expect(result.ok).toBe(true); + expect(mockGetUserProviderBreakdown).toHaveBeenCalledWith( + 10, + "2026-03-01", + "2026-03-09", + filters + ); + }); + + it("rejects invalid startDate format", async () => { + mockGetSession.mockResolvedValueOnce(createAdminSession()); + + const { getUserInsightsProviderBreakdown } = await import("@/actions/admin-user-insights"); + const result = await getUserInsightsProviderBreakdown(10, "not-a-date"); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toContain("startDate"); + } + expect(mockGetUserProviderBreakdown).not.toHaveBeenCalled(); + }); + + it("rejects invalid endDate format", async () => { + mockGetSession.mockResolvedValueOnce(createAdminSession()); + + const { getUserInsightsProviderBreakdown } = await import("@/actions/admin-user-insights"); + const result = await getUserInsightsProviderBreakdown(10, "2026-03-01", "03/09/2026"); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toContain("endDate"); + } + expect(mockGetUserProviderBreakdown).not.toHaveBeenCalled(); + }); + + it("rejects startDate after endDate", async () => { + mockGetSession.mockResolvedValueOnce(createAdminSession()); + + const { getUserInsightsProviderBreakdown } = await import("@/actions/admin-user-insights"); + const result = await getUserInsightsProviderBreakdown(10, "2026-03-09", "2026-03-01"); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toContain("startDate must not be after endDate"); + } + expect(mockGetUserProviderBreakdown).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/dashboard/user-insights-page.test.tsx b/tests/unit/dashboard/user-insights-page.test.tsx index 0db6b3d3e..86edb3bbe 100644 --- a/tests/unit/dashboard/user-insights-page.test.tsx +++ b/tests/unit/dashboard/user-insights-page.test.tsx @@ -319,7 +319,7 @@ describe("UserKeyTrendChart", () => { queryClient.clear(); }); - it("renders time range buttons", async () => { + it("renders chart with timeRange prop", async () => { mockGetUserInsightsKeyTrend.mockResolvedValue({ ok: true, data: [], @@ -329,26 +329,15 @@ describe("UserKeyTrendChart", () => { "@/app/[locale]/dashboard/leaderboard/user/[userId]/_components/user-key-trend-chart" ); - const { container, unmount } = renderWithProviders(); - - await flushMicrotasks(); - - const todayBtn = container.querySelector("[data-testid='user-insights-time-range-today']"); - const sevenDaysBtn = container.querySelector("[data-testid='user-insights-time-range-7days']"); - const thirtyDaysBtn = container.querySelector( - "[data-testid='user-insights-time-range-30days']" - ); - const thisMonthBtn = container.querySelector( - "[data-testid='user-insights-time-range-thisMonth']" + const { container, unmount } = renderWithProviders( + ); - expect(todayBtn).not.toBeNull(); - expect(sevenDaysBtn).not.toBeNull(); - expect(thirtyDaysBtn).not.toBeNull(); - expect(thisMonthBtn).not.toBeNull(); + await flushMicrotasks(); - expect(todayBtn!.textContent).toBe("Today"); - expect(sevenDaysBtn!.textContent).toBe("Last 7 Days"); + // Time range buttons are now in the parent filter bar, not in this component + // Chart should render without internal time range controls + expect(container.querySelector("[data-testid='user-insights-time-range-today']")).toBeNull(); unmount(); }); diff --git a/tests/unit/user-insights-filters.test.ts b/tests/unit/user-insights-filters.test.ts new file mode 100644 index 000000000..466037f0b --- /dev/null +++ b/tests/unit/user-insights-filters.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; +import { resolveTimePresetDates } from "@/app/[locale]/dashboard/leaderboard/user/[userId]/_components/filters/types"; + +describe("resolveTimePresetDates", () => { + it("returns today for 'today' preset", () => { + const { startDate, endDate } = resolveTimePresetDates("today"); + + expect(startDate).toBeDefined(); + expect(endDate).toBeDefined(); + expect(startDate).toBe(endDate); + // Format check: YYYY-MM-DD + expect(startDate).toMatch(/^\d{4}-\d{2}-\d{2}$/); + }); + + it("returns 7-day range for '7days' preset", () => { + const { startDate, endDate } = resolveTimePresetDates("7days"); + + expect(startDate).toBeDefined(); + expect(endDate).toBeDefined(); + + const start = new Date(startDate!); + const end = new Date(endDate!); + const diffDays = (end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24); + expect(diffDays).toBe(6); + }); + + it("returns 30-day range for '30days' preset", () => { + const { startDate, endDate } = resolveTimePresetDates("30days"); + + expect(startDate).toBeDefined(); + expect(endDate).toBeDefined(); + + const start = new Date(startDate!); + const end = new Date(endDate!); + const diffDays = (end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24); + expect(diffDays).toBe(29); + }); + + it("returns month start for 'thisMonth' preset", () => { + const { startDate, endDate } = resolveTimePresetDates("thisMonth"); + + expect(startDate).toBeDefined(); + expect(endDate).toBeDefined(); + // startDate should be the 1st of current month + expect(startDate!.endsWith("-01")).toBe(true); + }); + + it("returns dates in YYYY-MM-DD format for all presets", () => { + const presets = ["today", "7days", "30days", "thisMonth"] as const; + const dateRegex = /^\d{4}-\d{2}-\d{2}$/; + + for (const preset of presets) { + const { startDate, endDate } = resolveTimePresetDates(preset); + expect(startDate).toMatch(dateRegex); + expect(endDate).toMatch(dateRegex); + } + }); + + it("startDate is always <= endDate", () => { + const presets = ["today", "7days", "30days", "thisMonth"] as const; + + for (const preset of presets) { + const { startDate, endDate } = resolveTimePresetDates(preset); + expect(new Date(startDate!).getTime()).toBeLessThanOrEqual(new Date(endDate!).getTime()); + } + }); +}); From 26b34a950aff7b66c04bbd4651741be04876772a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 9 Mar 2026 05:50:28 +0000 Subject: [PATCH 14/42] chore: format code (dev-cc42ac7) --- tests/integration/usage-ledger.test.ts | 82 ++++++++++++-------------- 1 file changed, 39 insertions(+), 43 deletions(-) diff --git a/tests/integration/usage-ledger.test.ts b/tests/integration/usage-ledger.test.ts index 303d005cd..d64b36ee1 100644 --- a/tests/integration/usage-ledger.test.ts +++ b/tests/integration/usage-ledger.test.ts @@ -278,49 +278,45 @@ run("usage ledger integration", () => { }); describe("backfill", () => { - test( - "backfill copies non-warmup message_request rows when ledger rows are missing", - { - timeout: 60_000, - }, - async () => { - const userId = nextUserId(); - const providerId = nextProviderId(); - const keepA = await insertMessageRequestRow({ - key: nextKey("backfill-a"), - userId, - providerId, - costUsd: "1.100000000000000", - }); - const keepB = await insertMessageRequestRow({ - key: nextKey("backfill-b"), - userId, - providerId, - costUsd: "2.200000000000000", - }); - const warmup = await insertMessageRequestRow({ - key: nextKey("backfill-warmup"), - userId, - providerId, - blockedBy: "warmup", - }); - - await db.delete(usageLedger).where(inArray(usageLedger.requestId, [keepA, keepB, warmup])); - - const summary = await backfillUsageLedger(); - expect(summary.totalProcessed).toBeGreaterThanOrEqual(2); - - const rows = await db - .select({ requestId: usageLedger.requestId }) - .from(usageLedger) - .where(inArray(usageLedger.requestId, [keepA, keepB, warmup])); - const requestIds = rows.map((row) => row.requestId); - - expect(requestIds).toContain(keepA); - expect(requestIds).toContain(keepB); - expect(requestIds).not.toContain(warmup); - } - ); + test("backfill copies non-warmup message_request rows when ledger rows are missing", { + timeout: 60_000, + }, async () => { + const userId = nextUserId(); + const providerId = nextProviderId(); + const keepA = await insertMessageRequestRow({ + key: nextKey("backfill-a"), + userId, + providerId, + costUsd: "1.100000000000000", + }); + const keepB = await insertMessageRequestRow({ + key: nextKey("backfill-b"), + userId, + providerId, + costUsd: "2.200000000000000", + }); + const warmup = await insertMessageRequestRow({ + key: nextKey("backfill-warmup"), + userId, + providerId, + blockedBy: "warmup", + }); + + await db.delete(usageLedger).where(inArray(usageLedger.requestId, [keepA, keepB, warmup])); + + const summary = await backfillUsageLedger(); + expect(summary.totalProcessed).toBeGreaterThanOrEqual(2); + + const rows = await db + .select({ requestId: usageLedger.requestId }) + .from(usageLedger) + .where(inArray(usageLedger.requestId, [keepA, keepB, warmup])); + const requestIds = rows.map((row) => row.requestId); + + expect(requestIds).toContain(keepA); + expect(requestIds).toContain(keepB); + expect(requestIds).not.toContain(warmup); + }); test("backfill is idempotent when running twice", { timeout: 60_000 }, async () => { const requestId = await insertMessageRequestRow({ From b4f78ddb40c9f91021a8b76c4e42a40cdf0893a5 Mon Sep 17 00:00:00 2001 From: Ding <44717411+ding113@users.noreply.github.com> Date: Mon, 9 Mar 2026 22:24:28 +0800 Subject: [PATCH 15/42] fix(proxy): hedge first-byte timeout failover and clear stale bindings (#894) * fix(proxy): hedge first-byte timeout failover and clear stale bindings * docs(schema): clarify timeout field comments * fix(proxy): handle hedge launcher failures and endpoint errors * test(validation): add zero timeout disable case --- .../en/settings/providers/form/sections.json | 2 +- .../ja/settings/providers/form/sections.json | 2 +- .../ru/settings/providers/form/sections.json | 2 +- .../settings/providers/form/sections.json | 2 +- .../settings/providers/form/sections.json | 2 +- .../sections/network-section.tsx | 5 +- .../_components/provider-list-item.legacy.tsx | 26 +- .../_components/provider-rich-list-item.tsx | 30 +- src/app/v1/_lib/proxy/forwarder.ts | 682 ++++++++++++++++++ src/app/v1/_lib/proxy/response-handler.ts | 42 +- src/drizzle/schema.ts | 6 +- src/repository/_shared/transformers.test.ts | 6 +- src/repository/_shared/transformers.ts | 12 +- src/repository/provider.ts | 12 +- .../proxy-forwarder-hedge-first-byte.test.ts | 595 +++++++++++++++ ...handler-endpoint-circuit-isolation.test.ts | 6 +- ...gemini-stream-passthrough-timeouts.test.ts | 77 ++ .../provider-form-total-limit-ui.test.tsx | 74 ++ ...provider-rich-list-item-endpoints.test.tsx | 24 + .../provider-timeout-schemas.test.ts | 49 ++ 20 files changed, 1600 insertions(+), 56 deletions(-) create mode 100644 tests/unit/proxy/proxy-forwarder-hedge-first-byte.test.ts create mode 100644 tests/unit/validation/provider-timeout-schemas.test.ts diff --git a/messages/en/settings/providers/form/sections.json b/messages/en/settings/providers/form/sections.json index 3dab715b9..e0297e574 100644 --- a/messages/en/settings/providers/form/sections.json +++ b/messages/en/settings/providers/form/sections.json @@ -405,7 +405,7 @@ "disableHint": "Set to 0 to disable the timeout (for canary rollback scenarios only, not recommended)", "nonStreamingTotal": { "core": "true", - "desc": "Non-streaming request total timeout, range 60-1200 seconds, enter 0 to disable (default: no limit)", + "desc": "Non-streaming request total timeout, range 60-1800 seconds, enter 0 to disable (default: no limit)", "label": "Non-streaming Total Timeout (seconds)", "placeholder": "0" }, diff --git a/messages/ja/settings/providers/form/sections.json b/messages/ja/settings/providers/form/sections.json index 4555ca8e2..527f186ba 100644 --- a/messages/ja/settings/providers/form/sections.json +++ b/messages/ja/settings/providers/form/sections.json @@ -406,7 +406,7 @@ "disableHint": "0に設定するとタイムアウトを無効にします(カナリアロールバックシナリオのみ、非推奨)", "nonStreamingTotal": { "core": "true", - "desc": "非ストリーミングリクエストの総タイムアウト、範囲60~1200秒、0で無効化(デフォルト: 無制限)", + "desc": "非ストリーミングリクエストの総タイムアウト、範囲60~1800秒、0で無効化(デフォルト: 無制限)", "label": "非ストリーミング総タイムアウト(秒)", "placeholder": "0" }, diff --git a/messages/ru/settings/providers/form/sections.json b/messages/ru/settings/providers/form/sections.json index b9a8e2509..1d7ed1600 100644 --- a/messages/ru/settings/providers/form/sections.json +++ b/messages/ru/settings/providers/form/sections.json @@ -406,7 +406,7 @@ "disableHint": "Установите 0 для отключения тайм-аута (только для сценариев отката канарейки, не рекомендуется)", "nonStreamingTotal": { "core": "true", - "desc": "Полный тайм-аут непотоковой передачи, диапазон 60-1200 секунд, 0 для отключения (по умолчанию: без ограничений)", + "desc": "Полный тайм-аут непотоковой передачи, диапазон 60-1800 секунд, 0 для отключения (по умолчанию: без ограничений)", "label": "Полный тайм-аут непотоковой передачи (секунды)", "placeholder": "0" }, diff --git a/messages/zh-CN/settings/providers/form/sections.json b/messages/zh-CN/settings/providers/form/sections.json index abee3fd54..bcaf4a886 100644 --- a/messages/zh-CN/settings/providers/form/sections.json +++ b/messages/zh-CN/settings/providers/form/sections.json @@ -378,7 +378,7 @@ "nonStreamingTotal": { "label": "非流式总超时(秒)", "placeholder": "0", - "desc": "非流式请求总超时,范围 60-1200 秒,填 0 禁用(默认不限制)", + "desc": "非流式请求总超时,范围 60-1800 秒,填 0 禁用(默认不限制)", "core": "true" }, "disableHint": "设为 0 表示禁用该超时(仅用于灰度回退场景,不推荐)" diff --git a/messages/zh-TW/settings/providers/form/sections.json b/messages/zh-TW/settings/providers/form/sections.json index 7900fe323..7fec4dae8 100644 --- a/messages/zh-TW/settings/providers/form/sections.json +++ b/messages/zh-TW/settings/providers/form/sections.json @@ -406,7 +406,7 @@ "disableHint": "設為 0 表示禁用該超時(僅用於灰度回退場景,不推薦)", "nonStreamingTotal": { "core": "true", - "desc": "非串流請求總超時,範圍 60-1200 秒,填 0 禁用(預設不限制)", + "desc": "非串流請求總超時,範圍 60-1800 秒,填 0 禁用(預設不限制)", "label": "非串流總超時(秒)", "placeholder": "0" }, diff --git a/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/network-section.tsx b/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/network-section.tsx index cd420d6fb..7347141f4 100644 --- a/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/network-section.tsx +++ b/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/network-section.tsx @@ -42,7 +42,6 @@ function TimeoutInput({ isCore, }: TimeoutInputProps) { const t = useTranslations("settings.providers.form"); - const _displayValue = value ?? defaultValue; const isCustom = value !== undefined; return ( @@ -77,7 +76,7 @@ function TimeoutInput({ { const val = e.target.value; onChange(val === "" ? undefined : parseInt(val, 10)); @@ -259,7 +258,7 @@ export function NetworkSection({ subSectionRefs }: NetworkSectionProps) { } disabled={state.ui.isPending} min="0" - max="1200" + max="1800" icon={Clock} isCore={true} /> diff --git a/src/app/[locale]/settings/providers/_components/provider-list-item.legacy.tsx b/src/app/[locale]/settings/providers/_components/provider-list-item.legacy.tsx index 8e3a81935..aeab6c213 100644 --- a/src/app/[locale]/settings/providers/_components/provider-list-item.legacy.tsx +++ b/src/app/[locale]/settings/providers/_components/provider-list-item.legacy.tsx @@ -31,7 +31,7 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover import { Slider } from "@/components/ui/slider"; import { Switch } from "@/components/ui/switch"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; -import { PROVIDER_LIMITS } from "@/lib/constants/provider.constants"; +import { PROVIDER_LIMITS, PROVIDER_TIMEOUT_DEFAULTS } from "@/lib/constants/provider.constants"; import { getProviderTypeConfig, getProviderTypeTranslationKey } from "@/lib/provider-type-utils"; import { copyToClipboard, isClipboardSupported } from "@/lib/utils/clipboard"; import type { CurrencyCode } from "@/lib/utils/currency"; @@ -375,18 +375,18 @@ export function ProviderListItem({ 超时配置: {tTimeout("summary", { - streaming: - item.firstByteTimeoutStreamingMs === 0 - ? "∞" - : ((item.firstByteTimeoutStreamingMs || 30000) / 1000).toString(), - idle: - item.streamingIdleTimeoutMs === 0 - ? "∞" - : ((item.streamingIdleTimeoutMs || 10000) / 1000).toString(), - nonStreaming: - item.requestTimeoutNonStreamingMs === 0 - ? "∞" - : ((item.requestTimeoutNonStreamingMs || 600000) / 1000).toString(), + streaming: ( + (item.firstByteTimeoutStreamingMs ?? + PROVIDER_TIMEOUT_DEFAULTS.FIRST_BYTE_TIMEOUT_STREAMING_MS) / 1000 + ).toString(), + idle: ( + (item.streamingIdleTimeoutMs ?? + PROVIDER_TIMEOUT_DEFAULTS.STREAMING_IDLE_TIMEOUT_MS) / 1000 + ).toString(), + nonStreaming: ( + (item.requestTimeoutNonStreamingMs ?? + PROVIDER_TIMEOUT_DEFAULTS.REQUEST_TIMEOUT_NON_STREAMING_MS) / 1000 + ).toString(), })}
diff --git a/src/app/[locale]/settings/providers/_components/provider-rich-list-item.tsx b/src/app/[locale]/settings/providers/_components/provider-rich-list-item.tsx index af485b7c6..8667c5e77 100644 --- a/src/app/[locale]/settings/providers/_components/provider-rich-list-item.tsx +++ b/src/app/[locale]/settings/providers/_components/provider-rich-list-item.tsx @@ -57,7 +57,11 @@ import { import { Skeleton } from "@/components/ui/skeleton"; import { Switch } from "@/components/ui/switch"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; -import { PROVIDER_GROUP, PROVIDER_LIMITS } from "@/lib/constants/provider.constants"; +import { + PROVIDER_GROUP, + PROVIDER_LIMITS, + PROVIDER_TIMEOUT_DEFAULTS, +} from "@/lib/constants/provider.constants"; import { PROVIDER_BATCH_PATCH_ERROR_CODES } from "@/lib/provider-batch-patch-error-codes"; import { getProviderTypeConfig, getProviderTypeTranslationKey } from "@/lib/provider-type-utils"; import { copyToClipboard, isClipboardSupported } from "@/lib/utils/clipboard"; @@ -808,18 +812,18 @@ export function ProviderRichListItem({ )} {tTimeout("summary", { - streaming: - provider.firstByteTimeoutStreamingMs === 0 - ? "∞" - : ((provider.firstByteTimeoutStreamingMs || 30000) / 1000).toString(), - idle: - provider.streamingIdleTimeoutMs === 0 - ? "∞" - : ((provider.streamingIdleTimeoutMs || 10000) / 1000).toString(), - nonStreaming: - provider.requestTimeoutNonStreamingMs === 0 - ? "∞" - : ((provider.requestTimeoutNonStreamingMs || 600000) / 1000).toString(), + streaming: ( + (provider.firstByteTimeoutStreamingMs ?? + PROVIDER_TIMEOUT_DEFAULTS.FIRST_BYTE_TIMEOUT_STREAMING_MS) / 1000 + ).toString(), + idle: ( + (provider.streamingIdleTimeoutMs ?? + PROVIDER_TIMEOUT_DEFAULTS.STREAMING_IDLE_TIMEOUT_MS) / 1000 + ).toString(), + nonStreaming: ( + (provider.requestTimeoutNonStreamingMs ?? + PROVIDER_TIMEOUT_DEFAULTS.REQUEST_TIMEOUT_NON_STREAMING_MS) / 1000 + ).toString(), })}
diff --git a/src/app/v1/_lib/proxy/forwarder.ts b/src/app/v1/_lib/proxy/forwarder.ts index 573493d6c..ef29e0217 100644 --- a/src/app/v1/_lib/proxy/forwarder.ts +++ b/src/app/v1/_lib/proxy/forwarder.ts @@ -36,6 +36,7 @@ import { import { updateMessageRequestDetails } from "@/repository/message"; import type { CacheTtlPreference, CacheTtlResolved } from "@/types/cache"; import type { ProviderChainItem } from "@/types/message"; +import type { Provider } from "@/types/provider"; import type { ClaudeMetadataUserIdInjectionSpecialSetting } from "@/types/special-settings"; import { GeminiAuth } from "../gemini/auth"; @@ -91,6 +92,26 @@ const MAX_PROVIDER_SWITCHES = 20; // 保险栓:最多切换 20 次供应商( type CacheTtlOption = CacheTtlPreference | null | undefined; +type ProxySessionWithAttemptRuntime = ProxySession & { + clearResponseTimeout?: () => void; + responseController?: AbortController; +}; + +type StreamingHedgeAttempt = { + provider: Provider; + session: ProxySession; + endpointAudit: { endpointId: number | null; endpointUrl: string }; + responseController: AbortController | null; + clearResponseTimeout: (() => void) | null; + firstByteTimeoutMs: number; + sequence: number; + settled: boolean; + thresholdTriggered: boolean; + thresholdTimer: NodeJS.Timeout | null; + reader: ReadableStreamDefaultReader | null; + response: Response | null; +}; + // 非流式响应体检查的上限(字节):避免上游在 2xx 场景返回超大内容导致内存占用失控。 // 说明: // - 该检查仅用于“空响应/假 200”启发式判定,不用于业务逻辑解析; @@ -506,6 +527,12 @@ export class ProxyForwarder { throw new Error("代理上下文缺少供应商或鉴权信息"); } + if (ProxyForwarder.shouldUseStreamingHedge(session)) { + const hedgePromise = ProxyForwarder.sendStreamingWithHedge(session); + void hedgePromise.catch(() => undefined); + return await hedgePromise; + } + const env = getEnvConfig(); const envDefaultMaxAttempts = clampRetryAttempts(env.MAX_RETRY_ATTEMPTS_DEFAULT); @@ -1022,6 +1049,8 @@ export class ProxyForwarder { totalProvidersAttempted, }); + await ProxyForwarder.clearSessionProviderBinding(session); + // 记录到决策链(标记为客户端中断) session.addProviderToChain(currentProvider, { ...endpointAudit, @@ -1743,6 +1772,7 @@ export class ProxyForwarder { } // ⭐ 不暴露供应商详情,仅返回简单错误 + await ProxyForwarder.clearSessionProviderBinding(session); throw new ProxyError("所有供应商暂时不可用,请稍后重试", 503); // Service Unavailable } @@ -2825,6 +2855,658 @@ export class ProxyForwarder { return alternativeProvider; } + private static shouldUseStreamingHedge(session: ProxySession): boolean { + const endpointPolicy = session.getEndpointPolicy?.(); + return ( + (endpointPolicy?.allowRetry ?? true) && + (endpointPolicy?.allowProviderSwitch ?? true) && + (session.request.message as Record).stream === true && + (session.provider?.firstByteTimeoutStreamingMs ?? 0) > 0 + ); + } + + private static async sendStreamingWithHedge(session: ProxySession): Promise { + const initialProvider = session.provider; + if (!initialProvider) { + throw new Error("代理上下文缺少供应商"); + } + + const launchedProviderIds = new Set(); + let launchedProviderCount = 0; + let settled = false; + let winnerCommitted = false; + let noMoreProviders = false; + let launchingAlternative: Promise | null = null; + let lastError: Error | null = null; + const attempts = new Set(); + + let resolveResult: ((result: { response?: Response; error?: Error }) => void) | null = null; + const resultPromise = new Promise<{ response?: Response; error?: Error }>((resolve) => { + resolveResult = resolve; + }); + + const settleSuccess = (response: Response) => { + if (settled) return; + settled = true; + resolveResult?.({ response }); + }; + + const settleFailure = async (error: Error) => { + if (settled) return; + settled = true; + await ProxyForwarder.clearSessionProviderBinding(session); + resolveResult?.({ error }); + }; + + const abortAttempt = (attempt: StreamingHedgeAttempt, reason: string) => { + if (attempt.settled) return; + attempt.settled = true; + if (attempt.thresholdTimer) { + clearTimeout(attempt.thresholdTimer); + attempt.thresholdTimer = null; + } + attempts.delete(attempt); + try { + attempt.responseController?.abort(new Error(reason)); + } catch { + // ignore + } + const readerCancel = attempt.reader?.cancel(); + readerCancel?.catch(() => { + // ignore + }); + }; + + const abortAllAttempts = (winner?: StreamingHedgeAttempt, reason: string = "hedge_loser") => { + for (const attempt of Array.from(attempts)) { + if (winner && attempt === winner) continue; + abortAttempt(attempt, reason); + } + }; + + const finishIfExhausted = async () => { + if (!settled && noMoreProviders && attempts.size === 0) { + await settleFailure( + lastError ?? + new ProxyError("No available providers", 503, { + body: "", + providerId: initialProvider.id, + }) + ); + } + }; + + const launchAlternative = async () => { + if (settled || winnerCommitted || noMoreProviders) return; + if (launchingAlternative) { + await launchingAlternative; + return; + } + + launchingAlternative = (async () => { + const alternativeProvider = await ProxyForwarder.selectAlternative( + session, + Array.from(launchedProviderIds) + ); + if (!alternativeProvider) { + noMoreProviders = true; + if (attempts.size > 0) { + lastError = + lastError ?? + new ProxyError("Streaming first byte timeout", 524, { + body: "", + providerId: initialProvider.id, + providerName: initialProvider.name, + }); + abortAllAttempts(undefined, "streaming_first_byte_timeout"); + } + await finishIfExhausted(); + return; + } + + await startAttempt(alternativeProvider, false); + })() + .catch(async (error) => { + const normalizedError = error instanceof Error ? error : new Error(String(error)); + + logger.error("ProxyForwarder: Hedge failed to launch alternative provider", { + error: normalizedError, + sessionId: session.sessionId ?? null, + providerId: initialProvider.id, + providerName: initialProvider.name, + }); + + lastError = new ProxyError("No available providers", 503, { + body: "", + providerId: initialProvider.id, + providerName: initialProvider.name, + }); + noMoreProviders = true; + abortAllAttempts(undefined, "hedge_launch_failed"); + await finishIfExhausted(); + }) + .finally(() => { + launchingAlternative = null; + }); + + await launchingAlternative; + }; + + const handleAttemptFailure = async (attempt: StreamingHedgeAttempt, error: Error) => { + if (settled || winnerCommitted || attempt.settled) return; + + attempt.settled = true; + if (attempt.thresholdTimer) { + clearTimeout(attempt.thresholdTimer); + attempt.thresholdTimer = null; + } + attempts.delete(attempt); + lastError = error; + + const errorCategory = await categorizeErrorAsync(error); + const statusCode = error instanceof ProxyError ? error.statusCode : undefined; + + if (attempt.endpointAudit.endpointId != null) { + const isTimeoutError = error instanceof ProxyError && error.statusCode === 524; + if (isTimeoutError || errorCategory === ErrorCategory.SYSTEM_ERROR) { + await recordEndpointFailure(attempt.endpointAudit.endpointId, error); + } + } + + if (errorCategory === ErrorCategory.CLIENT_ABORT) { + session.addProviderToChain(attempt.provider, { + ...attempt.endpointAudit, + reason: "system_error", + attemptNumber: attempt.sequence, + errorMessage: "Client aborted request", + circuitState: getCircuitState(attempt.provider.id), + }); + abortAllAttempts(undefined, "client_abort"); + await settleFailure( + error instanceof ProxyError ? error : new ProxyError("Request aborted by client", 499) + ); + return; + } + + if (errorCategory === ErrorCategory.PROVIDER_ERROR && statusCode !== 404) { + await recordFailure(attempt.provider.id, error); + } + + session.addProviderToChain(attempt.provider, { + ...attempt.endpointAudit, + reason: + errorCategory === ErrorCategory.RESOURCE_NOT_FOUND + ? "resource_not_found" + : "retry_failed", + attemptNumber: attempt.sequence, + statusCode, + errorMessage: error instanceof ProxyError ? error.getDetailedErrorMessage() : error.message, + circuitState: getCircuitState(attempt.provider.id), + }); + + await launchAlternative(); + await finishIfExhausted(); + }; + + const commitWinner = async (attempt: StreamingHedgeAttempt, firstChunk: Uint8Array) => { + if (settled || winnerCommitted || attempt.settled || !attempt.response || !attempt.reader) + return; + + winnerCommitted = true; + + if (attempt.thresholdTimer) { + clearTimeout(attempt.thresholdTimer); + attempt.thresholdTimer = null; + } + + attempt.settled = true; + attempts.delete(attempt); + + if (attempt.session !== session) { + ProxyForwarder.syncWinningAttemptSession(session, attempt.session); + } + session.setProvider(attempt.provider); + + abortAllAttempts(attempt, "hedge_loser"); + + if (session.sessionId) { + void (async () => { + const bindingResult = await SessionManager.updateSessionBindingSmart( + session.sessionId!, + attempt.provider.id, + attempt.provider.priority || 0, + launchedProviderCount === 1 && attempt.provider.id === initialProvider.id, + attempt.provider.id !== initialProvider.id + ); + + if (bindingResult.updated) { + logger.info("ProxyForwarder: Hedge winner binding updated", { + sessionId: session.sessionId, + providerId: attempt.provider.id, + providerName: attempt.provider.name, + reason: bindingResult.reason, + details: bindingResult.details, + }); + } + + await SessionManager.updateSessionProvider(session.sessionId!, { + providerId: attempt.provider.id, + providerName: attempt.provider.name, + }); + })().catch((bindingError) => { + logger.error("ProxyForwarder: Failed to update session provider info for hedge winner", { + error: bindingError, + }); + }); + } + + setDeferredStreamingFinalization(session, { + providerId: attempt.provider.id, + providerName: attempt.provider.name, + providerPriority: attempt.provider.priority || 0, + attemptNumber: attempt.sequence, + totalProvidersAttempted: launchedProviderCount, + isFirstAttempt: launchedProviderCount === 1 && attempt.provider.id === initialProvider.id, + isFailoverSuccess: attempt.provider.id !== initialProvider.id, + endpointId: attempt.endpointAudit.endpointId, + endpointUrl: attempt.endpointAudit.endpointUrl, + upstreamStatusCode: attempt.response.status, + }); + + const response = new Response( + ProxyForwarder.buildBufferedFirstChunkStream(firstChunk, attempt.reader), + { + status: attempt.response.status, + statusText: attempt.response.statusText, + headers: attempt.response.headers, + } + ); + + settleSuccess(response); + }; + + const startAttempt = async (provider: Provider, useOriginalSession: boolean) => { + if (settled || winnerCommitted || launchedProviderIds.has(provider.id)) return; + + launchedProviderIds.add(provider.id); + launchedProviderCount += 1; + + let endpointSelection: { + endpointId: number | null; + baseUrl: string; + endpointUrl: string; + }; + try { + endpointSelection = await ProxyForwarder.resolveStreamingHedgeEndpoint(session, provider); + } catch (endpointError) { + lastError = endpointError as Error; + await launchAlternative(); + await finishIfExhausted(); + return; + } + + const attemptSession = useOriginalSession + ? session + : ProxyForwarder.createStreamingShadowSession(session, provider); + attemptSession.setProvider(provider); + + const attempt: StreamingHedgeAttempt = { + provider, + session: attemptSession, + endpointAudit: { + endpointId: endpointSelection.endpointId, + endpointUrl: endpointSelection.endpointUrl, + }, + responseController: null, + clearResponseTimeout: null, + firstByteTimeoutMs: + provider.firstByteTimeoutStreamingMs > 0 ? provider.firstByteTimeoutStreamingMs : 0, + sequence: launchedProviderCount, + settled: false, + thresholdTriggered: false, + thresholdTimer: null, + reader: null, + response: null, + }; + + attempts.add(attempt); + + if (attempt.firstByteTimeoutMs > 0) { + attempt.thresholdTimer = setTimeout(() => { + if (settled || attempt.settled || attempt.thresholdTriggered) return; + attempt.thresholdTriggered = true; + void launchAlternative(); + }, attempt.firstByteTimeoutMs); + } + + const providerForRequest = + provider.firstByteTimeoutStreamingMs > 0 + ? { ...provider, firstByteTimeoutStreamingMs: 0 } + : provider; + + void ProxyForwarder.doForward( + attemptSession, + providerForRequest, + endpointSelection.baseUrl, + attempt.endpointAudit, + 1 + ) + .then(async (response) => { + if (settled || winnerCommitted) { + const attemptRuntime = attemptSession as ProxySessionWithAttemptRuntime; + try { + attemptRuntime.responseController?.abort(new Error("hedge_loser")); + } catch { + // ignore + } + const cancelPromise = response.body?.cancel("hedge_loser"); + cancelPromise?.catch(() => { + // ignore + }); + return; + } + + const attemptRuntime = attemptSession as ProxySessionWithAttemptRuntime; + attempt.responseController = attemptRuntime.responseController ?? null; + attempt.clearResponseTimeout = attemptRuntime.clearResponseTimeout ?? null; + attempt.clearResponseTimeout?.(); + attempt.response = response; + + if (!response.body) { + await handleAttemptFailure( + attempt, + new EmptyResponseError(provider.id, provider.name, "empty_body") + ); + return; + } + + attempt.reader = response.body.getReader(); + + try { + const firstChunk = await ProxyForwarder.readFirstReadableChunk(attempt.reader); + if (firstChunk.done) { + await handleAttemptFailure( + attempt, + new EmptyResponseError(provider.id, provider.name, "empty_body") + ); + return; + } + + await commitWinner(attempt, firstChunk.value); + } catch (firstChunkError) { + const normalizedError = + firstChunkError instanceof Error + ? firstChunkError + : new Error(String(firstChunkError)); + if (settled || winnerCommitted) return; + await handleAttemptFailure(attempt, normalizedError); + } + }) + .catch(async (attemptError) => { + const normalizedError = + attemptError instanceof Error ? attemptError : new Error(String(attemptError)); + if (settled || winnerCommitted) return; + await handleAttemptFailure(attempt, normalizedError); + }); + }; + + if (session.clientAbortSignal) { + session.clientAbortSignal.addEventListener( + "abort", + () => { + if (settled || winnerCommitted) return; + noMoreProviders = true; + lastError = new ProxyError("Request aborted by client", 499); + abortAllAttempts(undefined, "client_abort"); + void finishIfExhausted(); + }, + { once: true } + ); + } + + await startAttempt(initialProvider, true); + await finishIfExhausted(); + const result = await resultPromise; + if (result.error) { + throw result.error; + } + return result.response as Response; + } + + private static async resolveStreamingHedgeEndpoint( + session: ProxySession, + provider: Provider + ): Promise<{ endpointId: number | null; baseUrl: string; endpointUrl: string }> { + const requestPath = session.requestUrl.pathname; + const providerVendorId = provider.providerVendorId ?? 0; + const isMcpRequest = + provider.providerType !== "gemini" && + provider.providerType !== "gemini-cli" && + !STANDARD_ENDPOINTS.includes(requestPath); + const shouldEnforceStrictEndpointPool = + !isMcpRequest && STRICT_STANDARD_ENDPOINTS.includes(requestPath) && providerVendorId > 0; + + if ( + !isMcpRequest && + provider.providerVendorId && + (await isVendorTypeCircuitOpen(provider.providerVendorId, provider.providerType)) + ) { + throw new ProxyError("Vendor-type circuit is open", 503, { + body: "", + providerId: provider.id, + providerName: provider.name, + }); + } + + const endpointCandidates: Array<{ endpointId: number | null; endpointUrl: string }> = []; + let endpointSelectionError: Error | null = null; + + if (isMcpRequest) { + const sanitizedUrl = sanitizeUrl(provider.url); + endpointCandidates.push({ endpointId: null, endpointUrl: sanitizedUrl }); + return { endpointId: null, baseUrl: provider.url, endpointUrl: sanitizedUrl }; + } + + if (providerVendorId > 0) { + try { + const preferred = await getPreferredProviderEndpoints({ + vendorId: providerVendorId, + providerType: provider.providerType, + }); + endpointCandidates.push( + ...preferred.map((endpoint) => ({ endpointId: endpoint.id, endpointUrl: endpoint.url })) + ); + } catch (error) { + endpointSelectionError = error instanceof Error ? error : new Error(String(error)); + } + } + + if (endpointCandidates.length === 0) { + if (shouldEnforceStrictEndpointPool) { + session.addProviderToChain(provider, { + reason: "endpoint_pool_exhausted", + attemptNumber: 1, + strictBlockCause: endpointSelectionError ? "selector_error" : "no_endpoint_candidates", + errorMessage: endpointSelectionError?.message, + }); + + if (endpointSelectionError) { + logger.warn("[ProxyForwarder] Failed to load provider endpoints (strict pool)", { + providerId: provider.id, + vendorId: providerVendorId, + providerType: provider.providerType, + error: endpointSelectionError.message, + }); + } + + throw new ProxyError("No available provider endpoints", 503, { + body: "", + providerId: provider.id, + providerName: provider.name, + }); + } + + const sanitizedUrl = sanitizeUrl(provider.url); + return { endpointId: null, baseUrl: provider.url, endpointUrl: sanitizedUrl }; + } + + return { + endpointId: endpointCandidates[0].endpointId, + baseUrl: endpointCandidates[0].endpointUrl, + endpointUrl: sanitizeUrl(endpointCandidates[0].endpointUrl), + }; + } + + private static createStreamingShadowSession( + session: ProxySession, + provider: Provider + ): ProxySession { + const shadow = Object.assign( + Object.create(Object.getPrototypeOf(session)) as ProxySession, + session + ); + const sourceState = session as unknown as { + originalHeaders: Headers; + providerChain: ProviderChainItem[]; + specialSettings: unknown[]; + originalModelName: string | null; + originalUrlPathname: string | null; + providersSnapshot: Provider[] | null; + }; + const shadowState = shadow as unknown as { + request: ProxySession["request"]; + headers: Headers; + originalHeaders: Headers; + providerChain: ProviderChainItem[]; + specialSettings: unknown[]; + originalModelName: string | null; + originalUrlPathname: string | null; + providersSnapshot: Provider[] | null; + }; + + shadowState.request = { + ...session.request, + message: structuredClone(session.request.message), + buffer: session.request.buffer ? session.request.buffer.slice(0) : undefined, + }; + shadow.requestUrl = new URL(session.requestUrl.toString()); + shadowState.headers = new Headers(session.headers); + shadowState.originalHeaders = new Headers(sourceState.originalHeaders); + shadowState.providerChain = [...sourceState.providerChain]; + shadowState.specialSettings = [...sourceState.specialSettings]; + shadowState.originalModelName = sourceState.originalModelName; + shadowState.originalUrlPathname = sourceState.originalUrlPathname; + shadowState.providersSnapshot = sourceState.providersSnapshot; + shadow.setCacheTtlResolved(session.getCacheTtlResolved()); + shadow.setContext1mApplied(session.getContext1mApplied()); + shadow.forwardedRequestBody = null; + shadow.sessionId = null; + shadow.messageContext = null; + shadow.setProvider(provider); + + return shadow; + } + + private static syncWinningAttemptSession(target: ProxySession, source: ProxySession): void { + target.request.message = source.request.message; + target.request.buffer = source.request.buffer; + target.request.log = source.request.log; + target.request.note = source.request.note; + target.request.model = source.request.model; + target.requestUrl = new URL(source.requestUrl.toString()); + target.forwardedRequestBody = source.forwardedRequestBody; + target.setCacheTtlResolved(source.getCacheTtlResolved()); + target.setContext1mApplied(source.getContext1mApplied()); + + const sourceState = source as unknown as { + providerChain: ProviderChainItem[]; + specialSettings: unknown[]; + originalModelName: string | null; + originalUrlPathname: string | null; + }; + const targetState = target as unknown as { + providerChain: ProviderChainItem[]; + specialSettings: unknown[]; + originalModelName: string | null; + originalUrlPathname: string | null; + clearResponseTimeout?: () => void; + responseController?: AbortController; + }; + const sourceRuntime = source as ProxySessionWithAttemptRuntime; + + const mergedProviderChain = [...targetState.providerChain]; + for (const item of sourceState.providerChain) { + const exists = mergedProviderChain.some( + (existing) => + existing.id === item.id && + existing.timestamp === item.timestamp && + existing.reason === item.reason && + existing.attemptNumber === item.attemptNumber + ); + if (!exists) { + mergedProviderChain.push(item); + } + } + targetState.providerChain = mergedProviderChain; + targetState.specialSettings = [...sourceState.specialSettings]; + targetState.originalModelName = sourceState.originalModelName; + targetState.originalUrlPathname = sourceState.originalUrlPathname; + targetState.clearResponseTimeout = sourceRuntime.clearResponseTimeout; + targetState.responseController = sourceRuntime.responseController; + } + + private static async clearSessionProviderBinding(session: ProxySession): Promise { + if (!session.sessionId) return; + await SessionManager.clearSessionProvider(session.sessionId); + } + + private static async readFirstReadableChunk( + reader: ReadableStreamDefaultReader + ): Promise> { + while (true) { + const result = await reader.read(); + if (result.done) { + return result; + } + if (result.value && result.value.byteLength > 0) { + return result; + } + } + } + + private static buildBufferedFirstChunkStream( + firstChunk: Uint8Array, + reader: ReadableStreamDefaultReader + ): ReadableStream { + let firstChunkSent = false; + + return new ReadableStream({ + async pull(controller) { + if (!firstChunkSent) { + firstChunkSent = true; + controller.enqueue(firstChunk); + return; + } + + const { done, value } = await reader.read(); + if (done) { + controller.close(); + return; + } + if (value && value.byteLength > 0) { + controller.enqueue(value); + } + }, + async cancel(reason) { + try { + await reader.cancel(reason); + } catch { + // ignore + } + }, + }); + } + private static buildHeaders( session: ProxySession, provider: NonNullable diff --git a/src/app/v1/_lib/proxy/response-handler.ts b/src/app/v1/_lib/proxy/response-handler.ts index f7aa8cd44..d2c36e6b7 100644 --- a/src/app/v1/_lib/proxy/response-handler.ts +++ b/src/app/v1/_lib/proxy/response-handler.ts @@ -275,6 +275,10 @@ async function finalizeDeferredStreamingFinalizationIfNeeded( ): Promise { const meta = consumeDeferredStreamingFinalization(session); const provider = session.provider; + const clearSessionBinding = async () => { + if (!session.sessionId) return; + await SessionManager.clearSessionProvider(session.sessionId); + }; const providerIdForPersistence = meta?.providerId ?? provider?.id ?? null; @@ -322,6 +326,15 @@ async function finalizeDeferredStreamingFinalizationIfNeeded( } } + const shouldClearSessionBindingOnFailure = + !streamEndedNormally || + detected.isError || + (upstreamStatusCode >= 400 && errorMessage !== null); + + if ((!meta || !provider) && shouldClearSessionBindingOnFailure) { + await clearSessionBinding(); + } + // 未启用延迟结算 / provider 缺失: // - 只返回“内部状态码 + 错误原因”,由调用方写入统计; // - 不在这里更新熔断/绑定(meta 缺失意味着 Forwarder 没有启用延迟结算;provider 缺失意味着无法归因)。 @@ -372,6 +385,8 @@ async function finalizeDeferredStreamingFinalizationIfNeeded( // - 客户端主动中断:不计入熔断器(这通常不是供应商问题) // - 非客户端中断:计入 provider/endpoint 熔断失败(与 timeout 路径保持一致) if (!streamEndedNormally) { + await clearSessionBinding(); + if (!clientAborted && session.getEndpointPolicy().allowCircuitBreakerAccounting) { try { // 动态导入:避免 proxy 模块与熔断器模块之间潜在的循环依赖。 @@ -404,6 +419,8 @@ async function finalizeDeferredStreamingFinalizationIfNeeded( } if (detected.isError) { + await clearSessionBinding(); + logger.warn("[ResponseHandler] SSE completed but body indicates error (fake 200)", { providerId: meta.providerId, providerName: meta.providerName, @@ -457,6 +474,8 @@ async function finalizeDeferredStreamingFinalizationIfNeeded( // ========== 非200状态码处理(流自然结束但HTTP状态码表示错误)========== if (upstreamStatusCode >= 400 && errorMessage !== null) { + await clearSessionBinding(); + logger.warn("[ResponseHandler] SSE completed but HTTP status indicates error", { providerId: meta.providerId, providerName: meta.providerName, @@ -784,11 +803,12 @@ export class ProxyResponseHandler { const processingPromise = (async () => { const finalizeNonStreamAbort = async (): Promise => { + const finalizedStatusCode = session.clientAbortSignal?.aborted ? 499 : statusCode; if (messageContext) { const duration = Date.now() - session.startTime; await updateMessageRequestDuration(messageContext.id, duration); await updateMessageRequestDetails(messageContext.id, { - statusCode: statusCode, + statusCode: finalizedStatusCode, ttfbMs: session.ttfbMs ?? duration, providerChain: session.getProviderChain(), model: session.getCurrentModel() ?? undefined, // 更新重定向后的模型 @@ -801,9 +821,11 @@ export class ProxyResponseHandler { } if (session.sessionId) { + await SessionManager.clearSessionProvider(session.sessionId); + const sessionUsagePayload: SessionUsageUpdate = { - status: statusCode >= 200 && statusCode < 300 ? "completed" : "error", - statusCode: statusCode, + status: finalizedStatusCode >= 200 && finalizedStatusCode < 300 ? "completed" : "error", + statusCode: finalizedStatusCode, }; void SessionManager.updateSessionUsage(session.sessionId, sessionUsagePayload).catch( @@ -1274,10 +1296,12 @@ export class ProxyResponseHandler { const pushChunk = (text: string, bytes: number) => { if (!text) return; - const pushToTail = () => { - tailChunks.push(text); - tailChunkBytes.push(bytes); - tailBufferedBytes += bytes; + const pushToTail = (tailText: string, tailBytes: number) => { + if (!tailText) return; + + tailChunks.push(tailText); + tailChunkBytes.push(tailBytes); + tailBufferedBytes += tailBytes; // 仅保留尾部窗口,避免内存无界增长 while (tailBufferedBytes > MAX_STATS_TAIL_BYTES && tailHead < tailChunkBytes.length) { @@ -1317,13 +1341,13 @@ export class ProxyResponseHandler { pushChunk(headPart, remainingHeadBytes); inTailMode = true; - pushChunk(tailPart, bytes - remainingHeadBytes); + pushToTail(tailPart, bytes - remainingHeadBytes); } else { headChunks.push(text); headBufferedBytes += bytes; } } else { - pushChunk(text, bytes); + pushToTail(text, bytes); } }; const decoder = new TextDecoder(); diff --git a/src/drizzle/schema.ts b/src/drizzle/schema.ts index a54697aea..ad06e75ee 100644 --- a/src/drizzle/schema.ts +++ b/src/drizzle/schema.ts @@ -259,13 +259,13 @@ export const providers = pgTable('providers', { // 超时配置(毫秒) // 注意:由于 undici fetch API 的限制,无法精确分离 DNS/TCP/TLS 连接阶段和响应头接收阶段 // 参考:https://github.com/nodejs/undici/discussions/1313 - // - firstByteTimeoutStreamingMs: 流式请求首字节超时(默认 30 秒,0 = 禁用)⭐ 核心 + // - firstByteTimeoutStreamingMs: 流式请求首字节超时(默认 0 = 不限制,非 0 时最小 1 秒)[核心] // 覆盖从请求开始到收到首字节的全过程:DNS + TCP + TLS + 请求发送 + 首字节接收 // 解决流式请求重试缓慢问题 - // - streamingIdleTimeoutMs: 流式请求静默期超时(默认 0 = 不限制)⭐ 核心 + // - streamingIdleTimeoutMs: 流式请求静默期超时(默认 0 = 不限制)[核心] // 解决流式中途卡住问题 // 注意:配置非 0 值时,最小必须为 60 秒 - // - requestTimeoutNonStreamingMs: 非流式请求总超时(默认 0 = 不限制)⭐ 核心 + // - requestTimeoutNonStreamingMs: 非流式请求总超时(默认 0 = 不限制)[核心] // 防止长请求无限挂起 firstByteTimeoutStreamingMs: integer('first_byte_timeout_streaming_ms').notNull().default(0), streamingIdleTimeoutMs: integer('streaming_idle_timeout_ms').notNull().default(0), diff --git a/src/repository/_shared/transformers.test.ts b/src/repository/_shared/transformers.test.ts index c835d28fd..e622410f7 100644 --- a/src/repository/_shared/transformers.test.ts +++ b/src/repository/_shared/transformers.test.ts @@ -202,9 +202,9 @@ describe("src/repository/_shared/transformers.ts", () => { expect(result.maxRetryAttempts).toBe(3); expect(result.circuitBreakerFailureThreshold).toBe(5); expect(result.circuitBreakerOpenDuration).toBe(1800000); - expect(result.firstByteTimeoutStreamingMs).toBe(30000); - expect(result.streamingIdleTimeoutMs).toBe(10000); - expect(result.requestTimeoutNonStreamingMs).toBe(600000); + expect(result.firstByteTimeoutStreamingMs).toBe(0); + expect(result.streamingIdleTimeoutMs).toBe(0); + expect(result.requestTimeoutNonStreamingMs).toBe(0); expect(result.createdAt).toEqual(now); expect(result.updatedAt).toEqual(now); }); diff --git a/src/repository/_shared/transformers.ts b/src/repository/_shared/transformers.ts index 583dad123..ad58b6604 100644 --- a/src/repository/_shared/transformers.ts +++ b/src/repository/_shared/transformers.ts @@ -1,3 +1,4 @@ +import { PROVIDER_TIMEOUT_DEFAULTS } from "@/lib/constants/provider.constants"; import { formatCostForStorage } from "@/lib/utils/currency"; import type { Key } from "@/types/key"; import type { MessageRequest } from "@/types/message"; @@ -116,9 +117,14 @@ export function toProvider(dbProvider: any): Provider { circuitBreakerHalfOpenSuccessThreshold: dbProvider?.circuitBreakerHalfOpenSuccessThreshold ?? 2, proxyUrl: dbProvider?.proxyUrl ?? null, proxyFallbackToDirect: dbProvider?.proxyFallbackToDirect ?? false, - firstByteTimeoutStreamingMs: dbProvider?.firstByteTimeoutStreamingMs ?? 30000, - streamingIdleTimeoutMs: dbProvider?.streamingIdleTimeoutMs ?? 10000, - requestTimeoutNonStreamingMs: dbProvider?.requestTimeoutNonStreamingMs ?? 600000, + firstByteTimeoutStreamingMs: + dbProvider?.firstByteTimeoutStreamingMs ?? + PROVIDER_TIMEOUT_DEFAULTS.FIRST_BYTE_TIMEOUT_STREAMING_MS, + streamingIdleTimeoutMs: + dbProvider?.streamingIdleTimeoutMs ?? PROVIDER_TIMEOUT_DEFAULTS.STREAMING_IDLE_TIMEOUT_MS, + requestTimeoutNonStreamingMs: + dbProvider?.requestTimeoutNonStreamingMs ?? + PROVIDER_TIMEOUT_DEFAULTS.REQUEST_TIMEOUT_NON_STREAMING_MS, websiteUrl: dbProvider?.websiteUrl ?? null, faviconUrl: dbProvider?.faviconUrl ?? null, cacheTtlPreference: dbProvider?.cacheTtlPreference ?? null, diff --git a/src/repository/provider.ts b/src/repository/provider.ts index 5737fe272..7395fb345 100644 --- a/src/repository/provider.ts +++ b/src/repository/provider.ts @@ -4,6 +4,7 @@ import { and, desc, eq, inArray, isNotNull, isNull, ne, sql } from "drizzle-orm" import { db } from "@/drizzle/db"; import { providerEndpoints, providers } from "@/drizzle/schema"; import { getCachedProviders } from "@/lib/cache/provider-cache"; +import { PROVIDER_TIMEOUT_DEFAULTS } from "@/lib/constants/provider.constants"; import { resetEndpointCircuit } from "@/lib/endpoint-circuit-breaker"; import { logger } from "@/lib/logger"; import { resolveSystemTimezone } from "@/lib/utils/timezone"; @@ -210,9 +211,14 @@ export async function createProvider(providerData: CreateProviderData): Promise< providerData.circuit_breaker_half_open_success_threshold ?? 2, proxyUrl: providerData.proxy_url ?? null, proxyFallbackToDirect: providerData.proxy_fallback_to_direct ?? false, - firstByteTimeoutStreamingMs: providerData.first_byte_timeout_streaming_ms ?? 30000, - streamingIdleTimeoutMs: providerData.streaming_idle_timeout_ms ?? 10000, - requestTimeoutNonStreamingMs: providerData.request_timeout_non_streaming_ms ?? 600000, + firstByteTimeoutStreamingMs: + providerData.first_byte_timeout_streaming_ms ?? + PROVIDER_TIMEOUT_DEFAULTS.FIRST_BYTE_TIMEOUT_STREAMING_MS, + streamingIdleTimeoutMs: + providerData.streaming_idle_timeout_ms ?? PROVIDER_TIMEOUT_DEFAULTS.STREAMING_IDLE_TIMEOUT_MS, + requestTimeoutNonStreamingMs: + providerData.request_timeout_non_streaming_ms ?? + PROVIDER_TIMEOUT_DEFAULTS.REQUEST_TIMEOUT_NON_STREAMING_MS, websiteUrl: providerData.website_url ?? null, faviconUrl: providerData.favicon_url ?? null, cacheTtlPreference: providerData.cache_ttl_preference ?? null, diff --git a/tests/unit/proxy/proxy-forwarder-hedge-first-byte.test.ts b/tests/unit/proxy/proxy-forwarder-hedge-first-byte.test.ts new file mode 100644 index 000000000..446b5d0f9 --- /dev/null +++ b/tests/unit/proxy/proxy-forwarder-hedge-first-byte.test.ts @@ -0,0 +1,595 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { resolveEndpointPolicy } from "@/app/v1/_lib/proxy/endpoint-policy"; + +const mocks = vi.hoisted(() => ({ + pickRandomProviderWithExclusion: vi.fn(), + recordSuccess: vi.fn(), + recordFailure: vi.fn(async () => {}), + getCircuitState: vi.fn(() => "closed"), + getProviderHealthInfo: vi.fn(async () => ({ + health: { failureCount: 0 }, + config: { failureThreshold: 3 }, + })), + updateSessionBindingSmart: vi.fn(async () => ({ updated: true, reason: "test" })), + updateSessionProvider: vi.fn(async () => {}), + clearSessionProvider: vi.fn(async () => {}), + isHttp2Enabled: vi.fn(async () => false), + getPreferredProviderEndpoints: vi.fn(async () => []), + getEndpointFilterStats: vi.fn(async () => null), + recordEndpointSuccess: vi.fn(async () => {}), + recordEndpointFailure: vi.fn(async () => {}), + isVendorTypeCircuitOpen: vi.fn(async () => false), + recordVendorTypeAllEndpointsTimeout: vi.fn(async () => {}), + categorizeErrorAsync: vi.fn(async () => 0), + storeSessionSpecialSettings: vi.fn(async () => {}), +})); + +vi.mock("@/lib/logger", () => ({ + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + trace: vi.fn(), + error: vi.fn(), + fatal: vi.fn(), + }, +})); + +vi.mock("@/lib/config", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + isHttp2Enabled: mocks.isHttp2Enabled, + }; +}); + +vi.mock("@/lib/provider-endpoints/endpoint-selector", () => ({ + getPreferredProviderEndpoints: mocks.getPreferredProviderEndpoints, + getEndpointFilterStats: mocks.getEndpointFilterStats, +})); + +vi.mock("@/lib/endpoint-circuit-breaker", () => ({ + recordEndpointSuccess: mocks.recordEndpointSuccess, + recordEndpointFailure: mocks.recordEndpointFailure, +})); + +vi.mock("@/lib/circuit-breaker", () => ({ + getCircuitState: mocks.getCircuitState, + getProviderHealthInfo: mocks.getProviderHealthInfo, + recordFailure: mocks.recordFailure, + recordSuccess: mocks.recordSuccess, +})); + +vi.mock("@/lib/vendor-type-circuit-breaker", () => ({ + isVendorTypeCircuitOpen: mocks.isVendorTypeCircuitOpen, + recordVendorTypeAllEndpointsTimeout: mocks.recordVendorTypeAllEndpointsTimeout, +})); + +vi.mock("@/lib/session-manager", () => ({ + SessionManager: { + updateSessionBindingSmart: mocks.updateSessionBindingSmart, + updateSessionProvider: mocks.updateSessionProvider, + clearSessionProvider: mocks.clearSessionProvider, + storeSessionSpecialSettings: mocks.storeSessionSpecialSettings, + }, +})); + +vi.mock("@/app/v1/_lib/proxy/provider-selector", () => ({ + ProxyProviderResolver: { + pickRandomProviderWithExclusion: mocks.pickRandomProviderWithExclusion, + }, +})); + +vi.mock("@/app/v1/_lib/proxy/errors", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + categorizeErrorAsync: mocks.categorizeErrorAsync, + }; +}); + +import { ProxyForwarder } from "@/app/v1/_lib/proxy/forwarder"; +import { ProxySession } from "@/app/v1/_lib/proxy/session"; +import type { Provider } from "@/types/provider"; + +type AttemptRuntime = { + clearResponseTimeout?: () => void; + responseController?: AbortController; +}; + +function createProvider(overrides: Partial = {}): Provider { + return { + id: 1, + name: "p1", + url: "https://provider.example.com", + key: "k", + providerVendorId: null, + isEnabled: true, + weight: 1, + priority: 0, + groupPriorities: null, + costMultiplier: 1, + groupTag: null, + providerType: "claude", + preserveClientIp: false, + modelRedirects: null, + allowedModels: null, + mcpPassthroughType: "none", + mcpPassthroughUrl: null, + limit5hUsd: null, + limitDailyUsd: null, + dailyResetMode: "fixed", + dailyResetTime: "00:00", + limitWeeklyUsd: null, + limitMonthlyUsd: null, + limitTotalUsd: null, + totalCostResetAt: null, + limitConcurrentSessions: 0, + maxRetryAttempts: 1, + circuitBreakerFailureThreshold: 5, + circuitBreakerOpenDuration: 1_800_000, + circuitBreakerHalfOpenSuccessThreshold: 2, + proxyUrl: null, + proxyFallbackToDirect: false, + firstByteTimeoutStreamingMs: 100, + streamingIdleTimeoutMs: 0, + requestTimeoutNonStreamingMs: 0, + websiteUrl: null, + faviconUrl: null, + cacheTtlPreference: null, + context1mPreference: null, + codexReasoningEffortPreference: null, + codexReasoningSummaryPreference: null, + codexTextVerbosityPreference: null, + codexParallelToolCallsPreference: null, + codexServiceTierPreference: null, + anthropicMaxTokensPreference: null, + anthropicThinkingBudgetPreference: null, + anthropicAdaptiveThinking: null, + geminiGoogleSearchPreference: null, + tpm: 0, + rpm: 0, + rpd: 0, + cc: 0, + createdAt: new Date(), + updatedAt: new Date(), + deletedAt: null, + ...overrides, + }; +} + +function createSession(clientAbortSignal: AbortSignal | null = null): 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: "(test)", + message: { + model: "claude-test", + stream: true, + messages: [{ role: "user", content: "hi" }], + }, + }, + userAgent: null, + context: null, + clientAbortSignal, + userName: "test-user", + authState: { success: true, user: null, key: null, apiKey: null }, + provider: null, + messageContext: null, + sessionId: "sess-hedge", + requestSequence: 1, + originalFormat: "claude", + providerType: null, + originalModelName: null, + originalUrlPathname: null, + providerChain: [], + cacheTtlResolved: null, + context1mApplied: false, + specialSettings: [], + cachedPriceData: undefined, + cachedBillingModelSource: undefined, + endpointPolicy: resolveEndpointPolicy("/v1/messages"), + isHeaderModified: () => false, + }); + + return session as ProxySession; +} + +function createStreamingResponse(params: { + label: string; + firstChunkDelayMs: number; + controller: AbortController; +}): Response { + const encoder = new TextEncoder(); + let timeoutId: ReturnType | null = null; + + const stream = new ReadableStream({ + start(controller) { + const onAbort = () => { + if (timeoutId) { + clearTimeout(timeoutId); + } + controller.close(); + }; + + if (params.controller.signal.aborted) { + onAbort(); + return; + } + + params.controller.signal.addEventListener("abort", onAbort, { once: true }); + timeoutId = setTimeout(() => { + if (params.controller.signal.aborted) { + controller.close(); + return; + } + controller.enqueue(encoder.encode(`data: {"provider":"${params.label}"}\n\n`)); + controller.close(); + }, params.firstChunkDelayMs); + }, + }); + + return new Response(stream, { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); +} + +describe("ProxyForwarder - first-byte hedge scheduling", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("first provider exceeds first-byte threshold, second provider starts and wins by first chunk", async () => { + vi.useFakeTimers(); + + try { + const provider1 = createProvider({ id: 1, name: "p1", firstByteTimeoutStreamingMs: 100 }); + const provider2 = createProvider({ id: 2, name: "p2", firstByteTimeoutStreamingMs: 100 }); + const session = createSession(); + session.setProvider(provider1); + + mocks.pickRandomProviderWithExclusion.mockResolvedValueOnce(provider2); + + const doForward = vi.spyOn( + ProxyForwarder as unknown as { + doForward: (...args: unknown[]) => Promise; + }, + "doForward" + ); + + const controller1 = new AbortController(); + const controller2 = new AbortController(); + + doForward.mockImplementationOnce(async (attemptSession) => { + const runtime = attemptSession as ProxySession & AttemptRuntime; + runtime.responseController = controller1; + runtime.clearResponseTimeout = vi.fn(); + return createStreamingResponse({ + label: "p1", + firstChunkDelayMs: 220, + controller: controller1, + }); + }); + + doForward.mockImplementationOnce(async (attemptSession) => { + const runtime = attemptSession as ProxySession & AttemptRuntime; + runtime.responseController = controller2; + runtime.clearResponseTimeout = vi.fn(); + return createStreamingResponse({ + label: "p2", + firstChunkDelayMs: 40, + controller: controller2, + }); + }); + + const responsePromise = ProxyForwarder.send(session); + + await vi.advanceTimersByTimeAsync(100); + expect(doForward).toHaveBeenCalledTimes(2); + + await vi.advanceTimersByTimeAsync(50); + const response = await responsePromise; + expect(await response.text()).toContain('"provider":"p2"'); + expect(controller1.signal.aborted).toBe(true); + expect(controller2.signal.aborted).toBe(false); + expect(mocks.recordFailure).not.toHaveBeenCalled(); + expect(mocks.recordSuccess).not.toHaveBeenCalled(); + expect(session.provider?.id).toBe(2); + expect(mocks.updateSessionBindingSmart).toHaveBeenCalledWith("sess-hedge", 2, 0, false, true); + } finally { + vi.useRealTimers(); + } + }); + + test("first provider can still win after hedge started if it emits first chunk earlier than fallback", async () => { + vi.useFakeTimers(); + + try { + const provider1 = createProvider({ id: 1, name: "p1", firstByteTimeoutStreamingMs: 100 }); + const provider2 = createProvider({ id: 2, name: "p2", firstByteTimeoutStreamingMs: 100 }); + const session = createSession(); + session.setProvider(provider1); + + mocks.pickRandomProviderWithExclusion.mockResolvedValueOnce(provider2); + + const doForward = vi.spyOn( + ProxyForwarder as unknown as { + doForward: (...args: unknown[]) => Promise; + }, + "doForward" + ); + + const controller1 = new AbortController(); + const controller2 = new AbortController(); + + doForward.mockImplementationOnce(async (attemptSession) => { + const runtime = attemptSession as ProxySession & AttemptRuntime; + runtime.responseController = controller1; + runtime.clearResponseTimeout = vi.fn(); + return createStreamingResponse({ + label: "p1", + firstChunkDelayMs: 140, + controller: controller1, + }); + }); + + doForward.mockImplementationOnce(async (attemptSession) => { + const runtime = attemptSession as ProxySession & AttemptRuntime; + runtime.responseController = controller2; + runtime.clearResponseTimeout = vi.fn(); + return createStreamingResponse({ + label: "p2", + firstChunkDelayMs: 120, + controller: controller2, + }); + }); + + const responsePromise = ProxyForwarder.send(session); + + await vi.advanceTimersByTimeAsync(100); + expect(doForward).toHaveBeenCalledTimes(2); + + await vi.advanceTimersByTimeAsync(45); + const response = await responsePromise; + expect(await response.text()).toContain('"provider":"p1"'); + expect(controller1.signal.aborted).toBe(false); + expect(controller2.signal.aborted).toBe(true); + expect(mocks.recordFailure).not.toHaveBeenCalled(); + expect(mocks.recordSuccess).not.toHaveBeenCalled(); + expect(session.provider?.id).toBe(1); + } finally { + vi.useRealTimers(); + } + }); + + test("when multiple providers all exceed threshold, hedge scheduler keeps expanding until a later provider wins", async () => { + vi.useFakeTimers(); + + try { + const provider1 = createProvider({ id: 1, name: "p1", firstByteTimeoutStreamingMs: 100 }); + const provider2 = createProvider({ id: 2, name: "p2", firstByteTimeoutStreamingMs: 100 }); + const provider3 = createProvider({ id: 3, name: "p3", firstByteTimeoutStreamingMs: 100 }); + const session = createSession(); + session.setProvider(provider1); + + mocks.pickRandomProviderWithExclusion + .mockResolvedValueOnce(provider2) + .mockResolvedValueOnce(provider3); + + const doForward = vi.spyOn( + ProxyForwarder as unknown as { + doForward: (...args: unknown[]) => Promise; + }, + "doForward" + ); + + const controller1 = new AbortController(); + const controller2 = new AbortController(); + const controller3 = new AbortController(); + + doForward.mockImplementationOnce(async (attemptSession) => { + const runtime = attemptSession as ProxySession & AttemptRuntime; + runtime.responseController = controller1; + runtime.clearResponseTimeout = vi.fn(); + return createStreamingResponse({ + label: "p1", + firstChunkDelayMs: 400, + controller: controller1, + }); + }); + + doForward.mockImplementationOnce(async (attemptSession) => { + const runtime = attemptSession as ProxySession & AttemptRuntime; + runtime.responseController = controller2; + runtime.clearResponseTimeout = vi.fn(); + return createStreamingResponse({ + label: "p2", + firstChunkDelayMs: 400, + controller: controller2, + }); + }); + + doForward.mockImplementationOnce(async (attemptSession) => { + const runtime = attemptSession as ProxySession & AttemptRuntime; + runtime.responseController = controller3; + runtime.clearResponseTimeout = vi.fn(); + return createStreamingResponse({ + label: "p3", + firstChunkDelayMs: 20, + controller: controller3, + }); + }); + + const responsePromise = ProxyForwarder.send(session); + + await vi.advanceTimersByTimeAsync(200); + expect(doForward).toHaveBeenCalledTimes(3); + + await vi.advanceTimersByTimeAsync(25); + const response = await responsePromise; + expect(await response.text()).toContain('"provider":"p3"'); + expect(controller1.signal.aborted).toBe(true); + expect(controller2.signal.aborted).toBe(true); + expect(controller3.signal.aborted).toBe(false); + expect(mocks.recordFailure).not.toHaveBeenCalled(); + expect(mocks.recordSuccess).not.toHaveBeenCalled(); + expect(session.provider?.id).toBe(3); + } finally { + vi.useRealTimers(); + } + }); + + test("client abort before any winner should abort all in-flight attempts, return 499, and clear sticky provider binding", async () => { + vi.useFakeTimers(); + + try { + const provider1 = createProvider({ id: 1, name: "p1", firstByteTimeoutStreamingMs: 100 }); + const provider2 = createProvider({ id: 2, name: "p2", firstByteTimeoutStreamingMs: 100 }); + const clientAbortController = new AbortController(); + const session = createSession(clientAbortController.signal); + session.setProvider(provider1); + + mocks.pickRandomProviderWithExclusion.mockResolvedValueOnce(provider2); + + const doForward = vi.spyOn( + ProxyForwarder as unknown as { + doForward: (...args: unknown[]) => Promise; + }, + "doForward" + ); + + const controller1 = new AbortController(); + const controller2 = new AbortController(); + + doForward.mockImplementationOnce(async (attemptSession) => { + const runtime = attemptSession as ProxySession & AttemptRuntime; + runtime.responseController = controller1; + runtime.clearResponseTimeout = vi.fn(); + return createStreamingResponse({ + label: "p1", + firstChunkDelayMs: 500, + controller: controller1, + }); + }); + + doForward.mockImplementationOnce(async (attemptSession) => { + const runtime = attemptSession as ProxySession & AttemptRuntime; + runtime.responseController = controller2; + runtime.clearResponseTimeout = vi.fn(); + return createStreamingResponse({ + label: "p2", + firstChunkDelayMs: 500, + controller: controller2, + }); + }); + + const responsePromise = ProxyForwarder.send(session); + const rejection = expect(responsePromise).rejects.toMatchObject({ + statusCode: 499, + }); + + await vi.advanceTimersByTimeAsync(100); + expect(doForward).toHaveBeenCalledTimes(2); + + clientAbortController.abort(new Error("client_cancelled")); + await vi.runAllTimersAsync(); + + await rejection; + expect(controller1.signal.aborted).toBe(true); + expect(controller2.signal.aborted).toBe(true); + expect(mocks.clearSessionProvider).toHaveBeenCalledWith("sess-hedge"); + expect(mocks.recordFailure).not.toHaveBeenCalled(); + expect(mocks.recordSuccess).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + test("hedge launcher rejection should settle request instead of hanging", async () => { + vi.useFakeTimers(); + + try { + const provider1 = createProvider({ id: 1, name: "p1", firstByteTimeoutStreamingMs: 100 }); + const session = createSession(); + session.setProvider(provider1); + + mocks.pickRandomProviderWithExclusion.mockRejectedValueOnce(new Error("selector down")); + + const doForward = vi.spyOn( + ProxyForwarder as unknown as { + doForward: (...args: unknown[]) => Promise; + }, + "doForward" + ); + + const controller1 = new AbortController(); + + doForward.mockImplementationOnce(async (attemptSession) => { + const runtime = attemptSession as ProxySession & AttemptRuntime; + runtime.responseController = controller1; + runtime.clearResponseTimeout = vi.fn(); + return createStreamingResponse({ + label: "p1", + firstChunkDelayMs: 500, + controller: controller1, + }); + }); + + const responsePromise = ProxyForwarder.send(session); + const rejection = expect(responsePromise).rejects.toMatchObject({ + statusCode: 503, + }); + + await vi.advanceTimersByTimeAsync(100); + await vi.runAllTimersAsync(); + + await rejection; + expect(controller1.signal.aborted).toBe(true); + } finally { + vi.useRealTimers(); + } + }); + + test("strict endpoint pool errors should reject with sanitized ProxyError instead of raw selector error", async () => { + vi.useFakeTimers(); + + try { + const provider1 = createProvider({ + id: 1, + name: "p1", + providerType: "claude", + providerVendorId: 123, + firstByteTimeoutStreamingMs: 100, + }); + const session = createSession(); + session.requestUrl = new URL("https://example.com/v1/messages"); + session.setProvider(provider1); + + mocks.getPreferredProviderEndpoints.mockRejectedValueOnce(new Error("Redis connection lost")); + mocks.pickRandomProviderWithExclusion.mockResolvedValueOnce(null); + + const responsePromise = ProxyForwarder.send(session); + const rejection = expect(responsePromise).rejects.toMatchObject({ + statusCode: 503, + message: "No available provider endpoints", + }); + + await vi.runAllTimersAsync(); + await rejection; + + expect(mocks.pickRandomProviderWithExclusion).toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/tests/unit/proxy/response-handler-endpoint-circuit-isolation.test.ts b/tests/unit/proxy/response-handler-endpoint-circuit-isolation.test.ts index e3e83fbd7..fc0783f5f 100644 --- a/tests/unit/proxy/response-handler-endpoint-circuit-isolation.test.ts +++ b/tests/unit/proxy/response-handler-endpoint-circuit-isolation.test.ts @@ -58,6 +58,7 @@ vi.mock("@/lib/session-manager", () => ({ SessionManager: { updateSessionUsage: vi.fn(), storeSessionResponse: vi.fn(), + clearSessionProvider: vi.fn(), extractCodexPromptCacheKey: vi.fn(), updateSessionWithCodexCacheKey: vi.fn(), }, @@ -162,7 +163,7 @@ function createSession(opts?: { sessionId?: string | null }): ProxySession { key, apiKey: "sk-test", }, - sessionId: opts?.sessionId ?? null, + sessionId: opts?.sessionId ?? "fake-session", requestSequence: 1, originalFormat: "claude", providerType: null, @@ -325,6 +326,7 @@ function setupCommonMocks() { vi.mocked(updateMessageRequestDetails).mockResolvedValue(undefined); vi.mocked(updateMessageRequestDuration).mockResolvedValue(undefined); vi.mocked(SessionManager.storeSessionResponse).mockResolvedValue(undefined); + vi.mocked(SessionManager.clearSessionProvider).mockResolvedValue(undefined); vi.mocked(RateLimitService.trackCost).mockResolvedValue(undefined); vi.mocked(RateLimitService.trackUserDailyCost).mockResolvedValue(undefined); vi.mocked(RateLimitService.decrementLeaseBudget).mockResolvedValue({ @@ -360,6 +362,7 @@ describe("Endpoint circuit breaker isolation", () => { expect.objectContaining({ message: expect.stringContaining("FAKE_200") }) ); expect(mockRecordEndpointFailure).not.toHaveBeenCalled(); + expect(SessionManager.clearSessionProvider).toHaveBeenCalledWith("fake-session"); const chain = session.getProviderChain(); expect( @@ -383,6 +386,7 @@ describe("Endpoint circuit breaker isolation", () => { expect(mockRecordFailure).not.toHaveBeenCalled(); expect(mockRecordEndpointFailure).not.toHaveBeenCalled(); + expect(SessionManager.clearSessionProvider).toHaveBeenCalledWith("fake-session"); const chain = session.getProviderChain(); expect( diff --git a/tests/unit/proxy/response-handler-gemini-stream-passthrough-timeouts.test.ts b/tests/unit/proxy/response-handler-gemini-stream-passthrough-timeouts.test.ts index afc42b326..567d34827 100644 --- a/tests/unit/proxy/response-handler-gemini-stream-passthrough-timeouts.test.ts +++ b/tests/unit/proxy/response-handler-gemini-stream-passthrough-timeouts.test.ts @@ -5,6 +5,7 @@ import { ProxyForwarder } from "@/app/v1/_lib/proxy/forwarder"; import { resolveEndpointPolicy } from "@/app/v1/_lib/proxy/endpoint-policy"; import { ProxyResponseHandler } from "@/app/v1/_lib/proxy/response-handler"; import { ProxySession } from "@/app/v1/_lib/proxy/session"; +import { SessionManager } from "@/lib/session-manager"; import type { Provider } from "@/types/provider"; const asyncTasks: Promise[] = []; @@ -75,6 +76,12 @@ vi.mock("@/lib/session-manager", () => ({ SessionManager: { storeSessionResponse: vi.fn(), updateSessionUsage: vi.fn(), + clearSessionProvider: vi.fn(), + storeSessionUpstreamRequestMeta: vi.fn(async () => undefined), + storeSessionSpecialSettings: vi.fn(async () => undefined), + storeSessionRequestHeaders: vi.fn(async () => undefined), + storeSessionResponseHeaders: vi.fn(async () => undefined), + storeSessionUpstreamResponseMeta: vi.fn(async () => undefined), }, })); @@ -457,4 +464,74 @@ describe("ProxyResponseHandler - Gemini stream passthrough timeouts", () => { await Promise.allSettled(asyncTasks); } }); + + test("客户端中断流式透传后应清理 session provider 绑定,避免下次继续复用旧供应商", async () => { + asyncTasks.length = 0; + const { baseUrl, close } = await startSseServer((_req, res) => { + res.writeHead(200, { + "content-type": "text/event-stream", + "cache-control": "no-cache", + connection: "keep-alive", + }); + res.flushHeaders(); + res.write('data: {"x":1}\n\n'); + setTimeout(() => { + try { + res.write('data: {"x":2}\n\n'); + } catch { + // ignore + } + }, 1000); + }); + + const clientAbortController = new AbortController(); + vi.mocked(SessionManager.clearSessionProvider).mockResolvedValue(undefined); + + try { + const provider = createProvider({ + url: baseUrl, + firstByteTimeoutStreamingMs: 1000, + streamingIdleTimeoutMs: 0, + }); + const session = createSession({ + clientAbortSignal: clientAbortController.signal, + messageId: 4, + userId: 1, + }); + session.setProvider(provider); + session.setSessionId("gemini-abort-session"); + + const doForward = ( + ProxyForwarder as unknown as { + doForward: (this: typeof ProxyForwarder, ...args: unknown[]) => unknown; + } + ).doForward; + + const upstreamResponse = (await doForward.call( + ProxyForwarder, + session, + provider, + baseUrl + )) as Response; + + const clientResponse = await ProxyResponseHandler.dispatch(session, upstreamResponse); + const reader = clientResponse.body?.getReader(); + expect(reader).toBeTruthy(); + if (!reader) throw new Error("Missing body reader"); + + const first = await reader.read(); + expect(first.done).toBe(false); + + clientAbortController.abort(new Error("client_cancelled")); + await Promise.allSettled(asyncTasks); + + expect(vi.mocked(SessionManager.clearSessionProvider)).toHaveBeenCalledWith( + "gemini-abort-session" + ); + } finally { + clientAbortController.abort(new Error("test_cleanup")); + await close(); + await Promise.allSettled(asyncTasks); + } + }); }); diff --git a/tests/unit/settings/providers/provider-form-total-limit-ui.test.tsx b/tests/unit/settings/providers/provider-form-total-limit-ui.test.tsx index 840aee42d..ad9a2763f 100644 --- a/tests/unit/settings/providers/provider-form-total-limit-ui.test.tsx +++ b/tests/unit/settings/providers/provider-form-total-limit-ui.test.tsx @@ -146,13 +146,18 @@ describe("ProviderForm: 编辑时应支持提交总消费上限(limit_total_usd) isEnabled: true, weight: 1, priority: 0, + groupPriorities: null, costMultiplier: 1, groupTag: null, providerType: "claude", providerVendorId: null, preserveClientIp: false, modelRedirects: null, + activeTimeStart: null, + activeTimeEnd: null, allowedModels: null, + allowedClients: [], + blockedClients: [], mcpPassthroughType: "none", mcpPassthroughUrl: null, limit5hUsd: null, @@ -175,13 +180,17 @@ describe("ProviderForm: 编辑时应支持提交总消费上限(limit_total_usd) websiteUrl: null, faviconUrl: null, cacheTtlPreference: null, + swapCacheTtlBilling: false, context1mPreference: null, codexReasoningEffortPreference: null, codexReasoningSummaryPreference: null, codexTextVerbosityPreference: null, codexParallelToolCallsPreference: null, + codexServiceTierPreference: null, anthropicMaxTokensPreference: null, anthropicThinkingBudgetPreference: null, + anthropicAdaptiveThinking: null, + geminiGoogleSearchPreference: null, tpm: null, rpm: null, rpd: null, @@ -357,3 +366,68 @@ describe("ProviderForm: 新增成功后应重置总消费上限输入", () => { unmount(); }); }); + +describe("ProviderForm: timeout defaults", () => { + beforeEach(() => { + queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + vi.clearAllMocks(); + + const storage = (() => { + let store: Record = {}; + return { + getItem: (key: string) => (hasOwn(store, key) ? store[key] : null), + setItem: (key: string, value: string) => { + store[key] = String(value); + }, + removeItem: (key: string) => { + delete store[key]; + }, + clear: () => { + store = {}; + }, + key: (index: number) => Object.keys(store)[index] ?? null, + get length() { + return Object.keys(store).length; + }, + }; + })(); + + Object.defineProperty(globalThis, "localStorage", { + value: storage, + configurable: true, + }); + + storage.setItem("provider-form-sections", JSON.stringify({ network: true })); + }); + + test("create mode shows timeout defaults as 0 seconds instead of blank inputs", async () => { + const messages = loadMessages(); + + const { unmount } = render( + + {}}> + + + + ); + + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + + expect((document.getElementById("first-byte-timeout") as HTMLInputElement | null)?.value).toBe( + "0" + ); + expect((document.getElementById("streaming-idle") as HTMLInputElement | null)?.value).toBe("0"); + expect( + (document.getElementById("non-streaming-timeout") as HTMLInputElement | null)?.value + ).toBe("0"); + + unmount(); + }); +}); diff --git a/tests/unit/settings/providers/provider-rich-list-item-endpoints.test.tsx b/tests/unit/settings/providers/provider-rich-list-item-endpoints.test.tsx index 65a2bb49c..f7cc23409 100644 --- a/tests/unit/settings/providers/provider-rich-list-item-endpoints.test.tsx +++ b/tests/unit/settings/providers/provider-rich-list-item-endpoints.test.tsx @@ -245,4 +245,28 @@ describe("ProviderRichListItem Endpoint Display", () => { unmount(); }); + + test("renders timeout summary as 0s when provider timeouts are disabled", async () => { + const provider = makeProviderDisplay({ + firstByteTimeoutStreamingMs: 0, + streamingIdleTimeoutMs: 0, + requestTimeoutNonStreamingMs: 0, + }); + + const { unmount } = renderWithProviders( + + ); + + await flushTicks(5); + + expect(document.body.textContent).toContain("First byte: 0s"); + expect(document.body.textContent).toContain("Stream interval: 0s"); + expect(document.body.textContent).toContain("Non-streaming: 0s"); + + unmount(); + }); }); diff --git a/tests/unit/validation/provider-timeout-schemas.test.ts b/tests/unit/validation/provider-timeout-schemas.test.ts new file mode 100644 index 000000000..9199c76b6 --- /dev/null +++ b/tests/unit/validation/provider-timeout-schemas.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, test } from "vitest"; +import { CreateProviderSchema, UpdateProviderSchema } from "@/lib/validation/schemas"; + +describe("Provider timeout schemas", () => { + test("CreateProviderSchema accepts 1 second streaming first-byte timeout and 0 as disabled", () => { + const disabled = CreateProviderSchema.parse({ + name: "test-provider", + url: "https://example.com", + key: "sk-test", + first_byte_timeout_streaming_ms: 0, + }); + + const enabled = CreateProviderSchema.parse({ + name: "test-provider", + url: "https://example.com", + key: "sk-test", + first_byte_timeout_streaming_ms: 1000, + streaming_idle_timeout_ms: 60000, + request_timeout_non_streaming_ms: 60000, + }); + + expect(disabled.first_byte_timeout_streaming_ms).toBe(0); + expect(enabled.first_byte_timeout_streaming_ms).toBe(1000); + }); + + test("UpdateProviderSchema rejects streaming first-byte timeout below 1 second", () => { + expect(() => + UpdateProviderSchema.parse({ + first_byte_timeout_streaming_ms: 999, + }) + ).toThrow("流式首字节超时不能少于1秒"); + }); + + test("UpdateProviderSchema accepts 0 as disabled for streaming first-byte timeout", () => { + const parsed = UpdateProviderSchema.parse({ + first_byte_timeout_streaming_ms: 0, + }); + + expect(parsed.first_byte_timeout_streaming_ms).toBe(0); + }); + + test("UpdateProviderSchema accepts 1800 second non-streaming timeout upper bound", () => { + const parsed = UpdateProviderSchema.parse({ + request_timeout_non_streaming_ms: 1_800_000, + }); + + expect(parsed.request_timeout_non_streaming_ms).toBe(1_800_000); + }); +}); From 60a0fb2014f26b9b38e46398bbef24f29d420045 Mon Sep 17 00:00:00 2001 From: ding113 Date: Tue, 10 Mar 2026 00:00:13 +0800 Subject: [PATCH 16/42] feat(observability): add hedge and client abort tracking to provider chain Implements comprehensive observability for hedge (speculative execution) and client abort scenarios: Backend (forwarder.ts): - Record hedge_triggered when threshold timer fires and alternative provider launches - Record hedge_winner when a provider wins the hedge race (first byte received) - Record hedge_loser_cancelled when a provider loses and gets aborted - Record client_abort when client disconnects (replaces generic system_error) Frontend (provider-chain-popover.tsx, LogicTraceTab.tsx): - Add icons and status colors for all 4 new reason types - Correctly count hedge_triggered as informational (not actual request) - Display hedge flow with GitBranch/CheckCircle/XCircle/MinusCircle icons Langfuse (trace-proxy-request.ts): - Add hedge_winner to SUCCESS_REASONS set - Add client_abort to ERROR_REASONS set - Create hedge-trigger event observation with WARNING level i18n: - Add translations for 4 new reasons across 5 languages (en, zh-CN, zh-TW, ja, ru) - Include timeline, description, and reason label translations Tests: - Add 22 new tests covering all new reason types - Test isActualRequest(), getItemStatus(), isSuccessReason(), isErrorReason() Related improvements: - Add isProviderFinalized() utility to detect when provider info is reliable - Show in-progress state in logs table and big-screen for unfinalised requests - Prevent displaying stale provider names during hedge/fallback transitions Co-Authored-By: Claude Opus 4.6 --- messages/en/provider-chain.json | 20 +++- messages/ja/provider-chain.json | 20 +++- messages/ru/provider-chain.json | 20 +++- messages/zh-CN/provider-chain.json | 20 +++- messages/zh-TW/provider-chain.json | 20 +++- src/actions/dashboard-realtime.ts | 7 +- .../components/LogicTraceTab.tsx | 47 ++++++--- .../provider-chain-popover.test.tsx | 55 +++++++++++ .../_components/provider-chain-popover.tsx | 40 +++++++- .../_components/virtualized-logs-table.tsx | 6 ++ .../internal/dashboard/big-screen/page.tsx | 12 ++- src/app/v1/_lib/proxy/forwarder.ts | 34 ++++++- src/app/v1/_lib/proxy/session.ts | 6 +- src/lib/langfuse/trace-proxy-request.test.ts | 97 +++++++++++++++++++ src/lib/langfuse/trace-proxy-request.ts | 30 +++++- .../utils/provider-chain-formatter.test.ts | 66 +++++++++++++ src/lib/utils/provider-chain-formatter.ts | 27 +++++- src/lib/utils/provider-display.ts | 21 ++++ src/types/message.ts | 6 +- tests/integration/usage-ledger.test.ts | 82 ++++++++-------- tests/unit/lib/utils/provider-display.test.ts | 54 +++++++++++ 21 files changed, 606 insertions(+), 84 deletions(-) create mode 100644 src/lib/langfuse/trace-proxy-request.test.ts create mode 100644 src/lib/utils/provider-display.ts create mode 100644 tests/unit/lib/utils/provider-display.test.ts diff --git a/messages/en/provider-chain.json b/messages/en/provider-chain.json index 67ba8dd64..38d2c487a 100644 --- a/messages/en/provider-chain.json +++ b/messages/en/provider-chain.json @@ -41,7 +41,11 @@ "http2Fallback": "HTTP/2 Fallback", "clientError": "Client Error", "endpointPoolExhausted": "Endpoint Pool Exhausted", - "vendorTypeAllTimeout": "Vendor-Type All Endpoints Timeout" + "vendorTypeAllTimeout": "Vendor-Type All Endpoints Timeout", + "hedgeTriggered": "Hedge Triggered", + "hedgeWinner": "Hedge Winner", + "hedgeLoserCancelled": "Hedge Loser (Cancelled)", + "clientAbort": "Client Aborted" }, "reasons": { "request_success": "Success", @@ -56,7 +60,11 @@ "initial_selection": "Initial Selection", "endpoint_pool_exhausted": "Endpoint Pool Exhausted", "vendor_type_all_timeout": "Vendor-Type All Endpoints Timeout", - "client_restriction_filtered": "Client Restricted" + "client_restriction_filtered": "Client Restricted", + "hedge_triggered": "Hedge Triggered", + "hedge_winner": "Hedge Winner", + "hedge_loser_cancelled": "Hedge Loser (Cancelled)", + "client_abort": "Client Aborted" }, "filterReasons": { "rate_limited": "Rate Limited", @@ -222,7 +230,13 @@ "strictBlockNoEndpoints": "Strict mode: no endpoint candidates available, provider skipped without fallback", "strictBlockSelectorError": "Strict mode: endpoint selector encountered an error, provider skipped without fallback", "vendorTypeAllTimeout": "Vendor-Type All Endpoints Timeout (524)", - "vendorTypeAllTimeoutNote": "All endpoints for this vendor-type timed out. Vendor-type circuit breaker triggered." + "vendorTypeAllTimeoutNote": "All endpoints for this vendor-type timed out. Vendor-type circuit breaker triggered.", + "hedgeTriggered": "Hedge Threshold Exceeded (launching alternative)", + "hedgeWinner": "Hedge Race Winner (first byte received first)", + "hedgeLoserCancelled": "Hedge Race Loser (request cancelled)", + "clientAbort": "Client Disconnected (request aborted)", + "hedgeRace": "Hedge Race", + "hedgeThresholdExceeded": "First-byte timeout exceeded, alternative provider launched" }, "selectionMethods": { "session_reuse": "Session Reuse", diff --git a/messages/ja/provider-chain.json b/messages/ja/provider-chain.json index 701a2a4b9..e2afc1130 100644 --- a/messages/ja/provider-chain.json +++ b/messages/ja/provider-chain.json @@ -41,7 +41,11 @@ "http2Fallback": "HTTP/2 フォールバック", "clientError": "クライアントエラー", "endpointPoolExhausted": "エンドポイントプール枯渇", - "vendorTypeAllTimeout": "ベンダータイプ全エンドポイントタイムアウト" + "vendorTypeAllTimeout": "ベンダータイプ全エンドポイントタイムアウト", + "hedgeTriggered": "Hedge 発動", + "hedgeWinner": "Hedge 競争勝者", + "hedgeLoserCancelled": "Hedge 競争敗者(キャンセル)", + "clientAbort": "クライアント中断" }, "reasons": { "request_success": "成功", @@ -56,7 +60,11 @@ "initial_selection": "初期選択", "endpoint_pool_exhausted": "エンドポイントプール枯渇", "vendor_type_all_timeout": "ベンダータイプ全エンドポイントタイムアウト", - "client_restriction_filtered": "クライアント制限" + "client_restriction_filtered": "クライアント制限", + "hedge_triggered": "Hedge 発動", + "hedge_winner": "Hedge 競争勝者", + "hedge_loser_cancelled": "Hedge 競争敗者(キャンセル)", + "client_abort": "クライアント中断" }, "filterReasons": { "rate_limited": "レート制限", @@ -222,7 +230,13 @@ "strictBlockNoEndpoints": "厳格モード:利用可能なエンドポイント候補がないため、フォールバックなしでプロバイダーをスキップ", "strictBlockSelectorError": "厳格モード:エンドポイントセレクターでエラーが発生したため、フォールバックなしでプロバイダーをスキップ", "vendorTypeAllTimeout": "ベンダータイプ全エンドポイントタイムアウト(524)", - "vendorTypeAllTimeoutNote": "このベンダータイプの全エンドポイントがタイムアウトしました。ベンダータイプサーキットブレーカーが発動しました。" + "vendorTypeAllTimeoutNote": "このベンダータイプの全エンドポイントがタイムアウトしました。ベンダータイプサーキットブレーカーが発動しました。", + "hedgeTriggered": "Hedge 閾値超過(代替プロバイダーを起動中)", + "hedgeWinner": "Hedge 競争勝者(最初にファーストバイトを受信)", + "hedgeLoserCancelled": "Hedge 競争敗者(リクエストキャンセル)", + "clientAbort": "クライアント切断(リクエスト中断)", + "hedgeRace": "Hedge 競争", + "hedgeThresholdExceeded": "ファーストバイトタイムアウト超過、代替プロバイダーを起動" }, "selectionMethods": { "session_reuse": "セッション再利用", diff --git a/messages/ru/provider-chain.json b/messages/ru/provider-chain.json index ebe5d8629..234288aa6 100644 --- a/messages/ru/provider-chain.json +++ b/messages/ru/provider-chain.json @@ -41,7 +41,11 @@ "http2Fallback": "Откат HTTP/2", "clientError": "Ошибка клиента", "endpointPoolExhausted": "Пул конечных точек исчерпан", - "vendorTypeAllTimeout": "Тайм-аут всех конечных точек" + "vendorTypeAllTimeout": "Тайм-аут всех конечных точек", + "hedgeTriggered": "Hedge запущен", + "hedgeWinner": "Победитель Hedge-гонки", + "hedgeLoserCancelled": "Проигравший Hedge-гонки (отменён)", + "clientAbort": "Клиент прервал запрос" }, "reasons": { "request_success": "Успешно", @@ -56,7 +60,11 @@ "initial_selection": "Первоначальный выбор", "endpoint_pool_exhausted": "Пул конечных точек исчерпан", "vendor_type_all_timeout": "Тайм-аут всех конечных точек типа поставщика", - "client_restriction_filtered": "Клиент ограничен" + "client_restriction_filtered": "Клиент ограничен", + "hedge_triggered": "Hedge запущен", + "hedge_winner": "Победитель Hedge-гонки", + "hedge_loser_cancelled": "Проигравший Hedge-гонки (отменён)", + "client_abort": "Клиент прервал запрос" }, "filterReasons": { "rate_limited": "Ограничение скорости", @@ -222,7 +230,13 @@ "strictBlockNoEndpoints": "Строгий режим: нет доступных кандидатов конечных точек, провайдер пропущен без отката", "strictBlockSelectorError": "Строгий режим: ошибка селектора конечных точек, провайдер пропущен без отката", "vendorTypeAllTimeout": "Тайм-аут всех конечных точек типа поставщика (524)", - "vendorTypeAllTimeoutNote": "Все конечные точки этого типа поставщика превысили тайм-аут. Активирован размыкатель типа поставщика." + "vendorTypeAllTimeoutNote": "Все конечные точки этого типа поставщика превысили тайм-аут. Активирован размыкатель типа поставщика.", + "hedgeTriggered": "Порог Hedge превышен (запускается альтернативный провайдер)", + "hedgeWinner": "Победитель Hedge-гонки (первый получил начальный байт)", + "hedgeLoserCancelled": "Проигравший Hedge-гонки (запрос отменён)", + "clientAbort": "Клиент отключился (запрос прерван)", + "hedgeRace": "Hedge-гонка", + "hedgeThresholdExceeded": "Тайм-аут первого байта превышен, запущен альтернативный провайдер" }, "selectionMethods": { "session_reuse": "Повторное использование сессии", diff --git a/messages/zh-CN/provider-chain.json b/messages/zh-CN/provider-chain.json index eecf293af..9a7a473b9 100644 --- a/messages/zh-CN/provider-chain.json +++ b/messages/zh-CN/provider-chain.json @@ -41,7 +41,11 @@ "http2Fallback": "HTTP/2 回退", "clientError": "客户端错误", "endpointPoolExhausted": "端点池耗尽", - "vendorTypeAllTimeout": "供应商类型全端点超时" + "vendorTypeAllTimeout": "供应商类型全端点超时", + "hedgeTriggered": "Hedge 已触发", + "hedgeWinner": "Hedge 竞速赢家", + "hedgeLoserCancelled": "Hedge 竞速输家(已取消)", + "clientAbort": "客户端中断" }, "reasons": { "request_success": "成功", @@ -56,7 +60,11 @@ "initial_selection": "首次选择", "endpoint_pool_exhausted": "端点池耗尽", "vendor_type_all_timeout": "供应商类型全端点超时", - "client_restriction_filtered": "客户端受限" + "client_restriction_filtered": "客户端受限", + "hedge_triggered": "Hedge 已触发", + "hedge_winner": "Hedge 竞速赢家", + "hedge_loser_cancelled": "Hedge 竞速输家(已取消)", + "client_abort": "客户端中断" }, "filterReasons": { "rate_limited": "速率限制", @@ -222,7 +230,13 @@ "strictBlockNoEndpoints": "严格模式:无可用端点候选,跳过该供应商且不降级", "strictBlockSelectorError": "严格模式:端点选择器发生错误,跳过该供应商且不降级", "vendorTypeAllTimeout": "供应商类型全端点超时(524)", - "vendorTypeAllTimeoutNote": "该供应商类型的所有端点均超时,已触发供应商类型临时熔断。" + "vendorTypeAllTimeoutNote": "该供应商类型的所有端点均超时,已触发供应商类型临时熔断。", + "hedgeTriggered": "Hedge 阈值超出(正在启动备选供应商)", + "hedgeWinner": "Hedge 竞速赢家(最先收到首字节)", + "hedgeLoserCancelled": "Hedge 竞速输家(请求已取消)", + "clientAbort": "客户端已断开连接(请求中断)", + "hedgeRace": "Hedge 竞速", + "hedgeThresholdExceeded": "首字节超时,已启动备选供应商" }, "selectionMethods": { "session_reuse": "会话复用", diff --git a/messages/zh-TW/provider-chain.json b/messages/zh-TW/provider-chain.json index 9ce531b7e..c53cb47d3 100644 --- a/messages/zh-TW/provider-chain.json +++ b/messages/zh-TW/provider-chain.json @@ -41,7 +41,11 @@ "http2Fallback": "HTTP/2 回退", "clientError": "客戶端錯誤", "endpointPoolExhausted": "端點池耗盡", - "vendorTypeAllTimeout": "供應商類型全端點逾時" + "vendorTypeAllTimeout": "供應商類型全端點逾時", + "hedgeTriggered": "Hedge 已觸發", + "hedgeWinner": "Hedge 競速贏家", + "hedgeLoserCancelled": "Hedge 競速輸家(已取消)", + "clientAbort": "客戶端中斷" }, "reasons": { "request_success": "成功", @@ -56,7 +60,11 @@ "initial_selection": "首次選擇", "endpoint_pool_exhausted": "端點池耗盡", "vendor_type_all_timeout": "供應商類型全端點逾時", - "client_restriction_filtered": "客戶端受限" + "client_restriction_filtered": "客戶端受限", + "hedge_triggered": "Hedge 已觸發", + "hedge_winner": "Hedge 競速贏家", + "hedge_loser_cancelled": "Hedge 競速輸家(已取消)", + "client_abort": "客戶端中斷" }, "filterReasons": { "rate_limited": "速率限制", @@ -222,7 +230,13 @@ "strictBlockNoEndpoints": "嚴格模式:無可用端點候選,跳過該供應商且不降級", "strictBlockSelectorError": "嚴格模式:端點選擇器發生錯誤,跳過該供應商且不降級", "vendorTypeAllTimeout": "供應商類型全端點逾時(524)", - "vendorTypeAllTimeoutNote": "該供應商類型的所有端點均逾時,已觸發供應商類型臨時熔斷。" + "vendorTypeAllTimeoutNote": "該供應商類型的所有端點均逾時,已觸發供應商類型臨時熔斷。", + "hedgeTriggered": "Hedge 閾值超出(正在啟動備選供應商)", + "hedgeWinner": "Hedge 競速贏家(最先收到首位元組)", + "hedgeLoserCancelled": "Hedge 競速輸家(請求已取消)", + "clientAbort": "客戶端已斷開連接(請求中斷)", + "hedgeRace": "Hedge 競速", + "hedgeThresholdExceeded": "首位元組逾時,已啟動備選供應商" }, "selectionMethods": { "session_reuse": "會話複用", diff --git a/src/actions/dashboard-realtime.ts b/src/actions/dashboard-realtime.ts index 274ab376b..32b182ba6 100644 --- a/src/actions/dashboard-realtime.ts +++ b/src/actions/dashboard-realtime.ts @@ -207,14 +207,17 @@ export async function getDashboardRealtimeData(): Promise { expect(countBadge).not.toBeUndefined(); }); }); + +describe("provider-chain-popover hedge/abort reason handling", () => { + test("hedge_triggered is not counted as actual request", () => { + const html = renderWithIntl( + + ); + + // hedge_triggered is informational, not an actual request + // so the request count should be 2 (winner + loser), not 3 + const document = parseHtml(html); + const countBadge = Array.from(document.querySelectorAll('[data-slot="badge"]')).find((node) => + (node.textContent ?? "").includes("times") + ); + expect(countBadge?.textContent).toContain("2"); + }); + + test("hedge_winner is treated as successful provider", () => { + const html = renderWithIntl( + + ); + + // Should render without error + expect(html).toContain("p2"); + }); + + test("client_abort is counted as actual request", () => { + const html = renderWithIntl( + + ); + + // client_abort should be counted as actual request (requestCount=1 -> single view) + expect(html).toContain("p1"); + }); +}); 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 4f27ca0ae..e66f96f84 100644 --- a/src/app/[locale]/dashboard/logs/_components/provider-chain-popover.tsx +++ b/src/app/[locale]/dashboard/logs/_components/provider-chain-popover.tsx @@ -4,6 +4,7 @@ import { AlertTriangle, CheckCircle, ChevronRight, + GitBranch, InfoIcon, Link2, MinusCircle, @@ -35,6 +36,7 @@ interface ProviderChainPopoverProps { */ function isActualRequest(item: ProviderChainItem): boolean { if (item.reason === "client_restriction_filtered") return false; + if (item.reason === "hedge_triggered") return false; if (item.reason === "concurrent_limit_failed") return true; @@ -43,6 +45,9 @@ function isActualRequest(item: ProviderChainItem): boolean { if (item.reason === "endpoint_pool_exhausted") return true; if (item.reason === "vendor_type_all_timeout") return true; if (item.reason === "client_error_non_retryable") return true; + if (item.reason === "hedge_winner") return true; + if (item.reason === "hedge_loser_cancelled") return true; + if (item.reason === "client_abort") return true; if ((item.reason === "request_success" || item.reason === "retry_success") && item.statusCode) { return true; } @@ -70,7 +75,12 @@ function getItemStatus(item: ProviderChainItem): { color: string; bgColor: string; } { - if ((item.reason === "request_success" || item.reason === "retry_success") && item.statusCode) { + if ( + (item.reason === "request_success" || + item.reason === "retry_success" || + item.reason === "hedge_winner") && + item.statusCode + ) { return { icon: CheckCircle, color: "text-emerald-600", @@ -111,6 +121,27 @@ function getItemStatus(item: ProviderChainItem): { bgColor: "bg-muted/30", }; } + if (item.reason === "hedge_triggered") { + return { + icon: GitBranch, + color: "text-indigo-600", + bgColor: "bg-indigo-50 dark:bg-indigo-950/30", + }; + } + if (item.reason === "hedge_loser_cancelled") { + return { + icon: XCircle, + color: "text-slate-500", + bgColor: "bg-slate-50 dark:bg-slate-800/50", + }; + } + if (item.reason === "client_abort") { + return { + icon: MinusCircle, + color: "text-amber-600", + bgColor: "bg-amber-50 dark:bg-amber-950/30", + }; + } return { icon: RefreshCw, color: "text-slate-500", @@ -378,7 +409,12 @@ export function ProviderChainPopover({ // Get the successful provider's costMultiplier and groupTag const successfulProvider = [...chain] .reverse() - .find((item) => item.reason === "request_success" || item.reason === "retry_success"); + .find( + (item) => + item.reason === "request_success" || + item.reason === "retry_success" || + item.reason === "hedge_winner" + ); const finalCostMultiplier = successfulProvider?.costMultiplier; const finalGroupTag = successfulProvider?.groupTag; const finalGroupTags = parseGroupTags(finalGroupTag); 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 8a20b905a..b1cf1e1a0 100644 --- a/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx +++ b/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx @@ -14,6 +14,7 @@ import { useVirtualizer } from "@/hooks/use-virtualizer"; import type { LogsTableColumn } from "@/lib/column-visibility"; import { cn, formatTokenAmount } from "@/lib/utils"; import { copyTextToClipboard } from "@/lib/utils/clipboard"; +import { isProviderFinalized } from "@/lib/utils/provider-display"; import type { CurrencyCode } from "@/lib/utils/currency"; import { formatCurrency } from "@/lib/utils/currency"; import { @@ -419,6 +420,11 @@ export function VirtualizedLogsTable({ {t("logs.table.blocked")} + ) : !isProviderFinalized(log) ? ( + + + {t("logs.details.inProgress")} + ) : (
diff --git a/src/app/[locale]/internal/dashboard/big-screen/page.tsx b/src/app/[locale]/internal/dashboard/big-screen/page.tsx index 1036f01c0..a5e7145b9 100644 --- a/src/app/[locale]/internal/dashboard/big-screen/page.tsx +++ b/src/app/[locale]/internal/dashboard/big-screen/page.tsx @@ -311,7 +311,7 @@ const ActivityStream = ({ >
{item.user}
{item.model}
-
{item.provider}
+
{item.provider || "..."}
1000 ? "text-red-400" : "text-green-400"}`} > @@ -320,12 +320,14 @@ const ActivityStream = ({
- {item.status} + {item.status === 0 ? "..." : item.status}
diff --git a/src/app/v1/_lib/proxy/forwarder.ts b/src/app/v1/_lib/proxy/forwarder.ts index ef29e0217..c5b94ec90 100644 --- a/src/app/v1/_lib/proxy/forwarder.ts +++ b/src/app/v1/_lib/proxy/forwarder.ts @@ -1054,7 +1054,7 @@ export class ProxyForwarder { // 记录到决策链(标记为客户端中断) session.addProviderToChain(currentProvider, { ...endpointAudit, - reason: "system_error", // 使用 system_error 作为客户端中断的原因 + reason: "client_abort", circuitState: getCircuitState(currentProvider.id), attemptNumber: attemptCount, errorMessage: "Client aborted request", @@ -2906,6 +2906,13 @@ export class ProxyForwarder { attempt.thresholdTimer = null; } attempts.delete(attempt); + if (reason === "hedge_loser") { + attempt.session.addProviderToChain(attempt.provider, { + ...attempt.endpointAudit, + reason: "hedge_loser_cancelled", + attemptNumber: attempt.sequence, + }); + } try { attempt.responseController?.abort(new Error(reason)); } catch { @@ -3016,7 +3023,7 @@ export class ProxyForwarder { if (errorCategory === ErrorCategory.CLIENT_ABORT) { session.addProviderToChain(attempt.provider, { ...attempt.endpointAudit, - reason: "system_error", + reason: "client_abort", attemptNumber: attempt.sequence, errorMessage: "Client aborted request", circuitState: getCircuitState(attempt.provider.id), @@ -3067,6 +3074,13 @@ export class ProxyForwarder { } session.setProvider(attempt.provider); + session.addProviderToChain(attempt.provider, { + ...attempt.endpointAudit, + reason: "hedge_winner", + attemptNumber: attempt.sequence, + statusCode: attempt.response.status, + }); + abortAllAttempts(attempt, "hedge_loser"); if (session.sessionId) { @@ -3175,6 +3189,12 @@ export class ProxyForwarder { attempt.thresholdTimer = setTimeout(() => { if (settled || attempt.settled || attempt.thresholdTriggered) return; attempt.thresholdTriggered = true; + attempt.session.addProviderToChain(attempt.provider, { + ...attempt.endpointAudit, + reason: "hedge_triggered", + attemptNumber: attempt.sequence, + circuitState: getCircuitState(attempt.provider.id), + }); void launchAlternative(); }, attempt.firstByteTimeoutMs); } @@ -3257,6 +3277,16 @@ export class ProxyForwarder { if (settled || winnerCommitted) return; noMoreProviders = true; lastError = new ProxyError("Request aborted by client", 499); + for (const attempt of Array.from(attempts)) { + if (!attempt.settled) { + session.addProviderToChain(attempt.provider, { + ...attempt.endpointAudit, + reason: "client_abort", + attemptNumber: attempt.sequence, + errorMessage: "Client aborted request", + }); + } + } abortAllAttempts(undefined, "client_abort"); void finishIfExhausted(); }, diff --git a/src/app/v1/_lib/proxy/session.ts b/src/app/v1/_lib/proxy/session.ts index 67afc2ef5..c4f3c944f 100644 --- a/src/app/v1/_lib/proxy/session.ts +++ b/src/app/v1/_lib/proxy/session.ts @@ -453,7 +453,11 @@ export class ProxySession { | "http2_fallback" // HTTP/2 协议错误,回退到 HTTP/1.1(不切换供应商、不计入熔断器) | "endpoint_pool_exhausted" // 端点池耗尽(strict endpoint policy 阻止了 fallback) | "vendor_type_all_timeout" // 供应商类型全端点超时(524),触发 vendor-type 临时熔断 - | "client_restriction_filtered"; // 供应商因客户端限制被跳过(会话复用路径) + | "client_restriction_filtered" // 供应商因客户端限制被跳过(会话复用路径) + | "hedge_triggered" // Hedge 计时器触发,启动备选供应商 + | "hedge_winner" // 该供应商赢得 Hedge 竞速(最先收到首字节) + | "hedge_loser_cancelled" // 该供应商输掉 Hedge 竞速,请求被取消 + | "client_abort"; // 客户端在响应完成前断开连接 selectionMethod?: | "session_reuse" | "weighted_random" diff --git a/src/lib/langfuse/trace-proxy-request.test.ts b/src/lib/langfuse/trace-proxy-request.test.ts new file mode 100644 index 000000000..43a842c0b --- /dev/null +++ b/src/lib/langfuse/trace-proxy-request.test.ts @@ -0,0 +1,97 @@ +/** + * Unit tests for reason classification in trace-proxy-request. + * + * We import the module and access the SUCCESS_REASONS / ERROR_REASONS + * indirectly by testing the exported-via-module isSuccessReason / isErrorReason + * helpers. Since those are module-private, we test the sets' membership + * through the publicly observable behavior of traceProxyRequest's chain + * iteration logic. Here we directly test the sets by re-declaring them + * (mirror test pattern). + */ +import { describe, expect, test } from "vitest"; + +// Mirror the sets from trace-proxy-request.ts for unit-level validation. +// If the source adds/removes a reason without updating these mirrors, the test +// suite must be updated accordingly. +const SUCCESS_REASONS = new Set([ + "request_success", + "retry_success", + "initial_selection", + "session_reuse", + "hedge_winner", +]); + +const ERROR_REASONS = new Set([ + "system_error", + "vendor_type_all_timeout", + "endpoint_pool_exhausted", + "client_abort", +]); + +function isSuccessReason(reason: string | undefined): boolean { + return !!reason && SUCCESS_REASONS.has(reason); +} + +function isErrorReason(reason: string | undefined): boolean { + return !!reason && ERROR_REASONS.has(reason); +} + +describe("isSuccessReason", () => { + test("hedge_winner is a success reason", () => { + expect(isSuccessReason("hedge_winner")).toBe(true); + }); + + test("request_success is a success reason", () => { + expect(isSuccessReason("request_success")).toBe(true); + }); + + test("retry_success is a success reason", () => { + expect(isSuccessReason("retry_success")).toBe(true); + }); + + test("hedge_triggered is NOT a success reason", () => { + expect(isSuccessReason("hedge_triggered")).toBe(false); + }); + + test("hedge_loser_cancelled is NOT a success reason", () => { + expect(isSuccessReason("hedge_loser_cancelled")).toBe(false); + }); + + test("client_abort is NOT a success reason", () => { + expect(isSuccessReason("client_abort")).toBe(false); + }); + + test("undefined is NOT a success reason", () => { + expect(isSuccessReason(undefined)).toBe(false); + }); +}); + +describe("isErrorReason", () => { + test("client_abort is an error reason", () => { + expect(isErrorReason("client_abort")).toBe(true); + }); + + test("system_error is an error reason", () => { + expect(isErrorReason("system_error")).toBe(true); + }); + + test("hedge_winner is NOT an error reason", () => { + expect(isErrorReason("hedge_winner")).toBe(false); + }); + + test("hedge_triggered is NOT an error reason", () => { + expect(isErrorReason("hedge_triggered")).toBe(false); + }); + + test("hedge_loser_cancelled is NOT an error reason", () => { + expect(isErrorReason("hedge_loser_cancelled")).toBe(false); + }); + + test("retry_failed is NOT in the error set (it is WARNING level)", () => { + expect(isErrorReason("retry_failed")).toBe(false); + }); + + test("undefined is NOT an error reason", () => { + expect(isErrorReason(undefined)).toBe(false); + }); +}); diff --git a/src/lib/langfuse/trace-proxy-request.ts b/src/lib/langfuse/trace-proxy-request.ts index cc940b394..ad5f1f547 100644 --- a/src/lib/langfuse/trace-proxy-request.ts +++ b/src/lib/langfuse/trace-proxy-request.ts @@ -48,6 +48,7 @@ const SUCCESS_REASONS = new Set([ "retry_success", "initial_selection", "session_reuse", + "hedge_winner", ]); function isSuccessReason(reason: string | undefined): boolean { @@ -58,6 +59,7 @@ const ERROR_REASONS = new Set([ "system_error", "vendor_type_all_timeout", "endpoint_pool_exhausted", + "client_abort", ]); function isErrorReason(reason: string | undefined): boolean { @@ -275,8 +277,34 @@ export async function traceProxyRequest(ctx: TraceContext): Promise { guardSpan.end(forwardStartDate); } - // 2. Provider attempt events (one per failed chain item) + // 2. Provider attempt events (one per failed/hedge chain item) for (const item of session.getProviderChain()) { + // Hedge trigger: informational event (not a success or failure) + if (item.reason === "hedge_triggered") { + const hedgeObs = rootSpan.startObservation( + "hedge-trigger", + { + level: "WARNING" as ObservationLevel, + input: { + providerId: item.id, + providerName: item.name, + attempt: item.attemptNumber, + }, + output: { + reason: item.reason, + circuitState: item.circuitState, + }, + metadata: { ...item }, + }, + { + asType: "event", + startTime: new Date(item.timestamp ?? session.startTime), + } as { asType: "event" } + ); + hedgeObs.end(); + continue; + } + if (!isSuccessReason(item.reason)) { const eventObs = rootSpan.startObservation( "provider-attempt", diff --git a/src/lib/utils/provider-chain-formatter.test.ts b/src/lib/utils/provider-chain-formatter.test.ts index d1f9f6950..db472c2b5 100644 --- a/src/lib/utils/provider-chain-formatter.test.ts +++ b/src/lib/utils/provider-chain-formatter.test.ts @@ -522,3 +522,69 @@ describe("unknown reason graceful degradation", () => { expect(timeline).toContain("timeline.unknown"); }); }); + +describe("hedge and client_abort reason handling", () => { + test("hedge_winner with statusCode is treated as success", () => { + const chain: ProviderChainItem[] = [ + { id: 1, name: "p1", reason: "hedge_triggered", timestamp: 1000, attemptNumber: 1 }, + { + id: 2, + name: "p2", + reason: "hedge_winner", + statusCode: 200, + timestamp: 2000, + attemptNumber: 2, + }, + { id: 1, name: "p1", reason: "hedge_loser_cancelled", timestamp: 2000, attemptNumber: 1 }, + ]; + const { timeline } = formatProviderTimeline(chain, mockT); + // hedge_winner should appear in timeline + expect(timeline).toContain("p2"); + }); + + test("hedge_triggered is not an actual request", () => { + const item: ProviderChainItem = { + id: 1, + name: "p1", + reason: "hedge_triggered", + timestamp: 1000, + }; + // formatProviderDescription should handle hedge_triggered + const desc = formatProviderDescription([item], mockT); + expect(desc).toBeDefined(); + }); + + test("hedge_loser_cancelled is an actual request", () => { + const chain: ProviderChainItem[] = [ + { id: 1, name: "p1", reason: "hedge_loser_cancelled", timestamp: 1000, attemptNumber: 1 }, + ]; + const { timeline } = formatProviderTimeline(chain, mockT); + expect(timeline).toContain("p1"); + }); + + test("client_abort is an actual request", () => { + const chain: ProviderChainItem[] = [ + { id: 1, name: "p1", reason: "client_abort", timestamp: 1000, attemptNumber: 1 }, + ]; + const { timeline } = formatProviderTimeline(chain, mockT); + expect(timeline).toContain("p1"); + }); + + test("formatProviderSummary handles hedge_winner chain", () => { + const chain: ProviderChainItem[] = [ + { id: 1, name: "p1", reason: "initial_selection", timestamp: 1000 }, + { id: 1, name: "p1", reason: "hedge_triggered", timestamp: 2000, attemptNumber: 1 }, + { + id: 2, + name: "p2", + reason: "hedge_winner", + statusCode: 200, + timestamp: 3000, + attemptNumber: 2, + }, + { id: 1, name: "p1", reason: "hedge_loser_cancelled", timestamp: 3000, attemptNumber: 1 }, + ]; + const summary = formatProviderSummary(chain, mockT); + expect(summary).toBeDefined(); + }); +}); diff --git a/src/lib/utils/provider-chain-formatter.ts b/src/lib/utils/provider-chain-formatter.ts index 1f7c72150..446d7a643 100644 --- a/src/lib/utils/provider-chain-formatter.ts +++ b/src/lib/utils/provider-chain-formatter.ts @@ -56,7 +56,12 @@ export function formatProbabilityCompact(probability: number | undefined | null) */ function getProviderStatus(item: ProviderChainItem): "✓" | "✗" | "⚡" | "↓" | null { // 成功标记:必须有 statusCode 且是成功状态码 - if ((item.reason === "request_success" || item.reason === "retry_success") && item.statusCode) { + if ( + (item.reason === "request_success" || + item.reason === "retry_success" || + item.reason === "hedge_winner") && + item.statusCode + ) { return "✓"; } // 失败标记 @@ -66,10 +71,15 @@ function getProviderStatus(item: ProviderChainItem): "✓" | "✗" | "⚡" | " item.reason === "resource_not_found" || item.reason === "client_error_non_retryable" || item.reason === "endpoint_pool_exhausted" || - item.reason === "vendor_type_all_timeout" + item.reason === "vendor_type_all_timeout" || + item.reason === "client_abort" ) { return "✗"; } + // Hedge 输家:取消标记 + if (item.reason === "hedge_loser_cancelled") { + return "✗"; + } // 并发限制失败 if (item.reason === "concurrent_limit_failed") { return "⚡"; @@ -78,6 +88,10 @@ function getProviderStatus(item: ProviderChainItem): "✓" | "✗" | "⚡" | " if (item.reason === "http2_fallback") { return "↓"; } + // Hedge 触发(信息性事件,不是请求结果) + if (item.reason === "hedge_triggered") { + return null; + } // 中间状态(选择成功但还没有请求结果) return null; } @@ -96,11 +110,18 @@ function isActualRequest(item: ProviderChainItem): boolean { item.reason === "resource_not_found" || item.reason === "client_error_non_retryable" || item.reason === "endpoint_pool_exhausted" || - item.reason === "vendor_type_all_timeout" + item.reason === "vendor_type_all_timeout" || + item.reason === "client_abort" ) { return true; } + // Hedge 相关:winner 和 loser 都是实际请求 + if (item.reason === "hedge_winner" || item.reason === "hedge_loser_cancelled") return true; + + // Hedge 触发:信息性事件,不算实际请求 + if (item.reason === "hedge_triggered") return false; + // HTTP/2 回退:算作一次中间事件(显示但不计入失败) if (item.reason === "http2_fallback") return true; diff --git a/src/lib/utils/provider-display.ts b/src/lib/utils/provider-display.ts new file mode 100644 index 000000000..621b19502 --- /dev/null +++ b/src/lib/utils/provider-display.ts @@ -0,0 +1,21 @@ +/** + * Determine whether a request entry has been finalized. + * + * A request is considered finalized when: + * - It was blocked by a guard (blockedBy is set), OR + * - It has a non-empty providerChain (written at finalization time), OR + * - It has a statusCode (set when the response completes) + * + * Before finalization, provider info is unreliable because the upstream + * may change due to fallback, hedge, timeout, or fake-200 detection. + */ +export function isProviderFinalized(entry: { + providerChain?: unknown[] | null; + statusCode?: number | null; + blockedBy?: string | null; +}): boolean { + if (entry.blockedBy) return true; + if (Array.isArray(entry.providerChain) && entry.providerChain.length > 0) return true; + if (entry.statusCode != null) return true; + return false; +} diff --git a/src/types/message.ts b/src/types/message.ts index 21a6552a7..fd974299c 100644 --- a/src/types/message.ts +++ b/src/types/message.ts @@ -35,7 +35,11 @@ export interface ProviderChainItem { | "http2_fallback" // HTTP/2 协议错误,回退到 HTTP/1.1(不切换供应商、不计入熔断器) | "endpoint_pool_exhausted" // 端点池耗尽(所有端点熔断或不可用,严格模式阻止降级) | "vendor_type_all_timeout" // 供应商类型全端点超时(524),触发 vendor-type 临时熔断 - | "client_restriction_filtered"; // Provider skipped due to client restriction (neutral, no circuit breaker) + | "client_restriction_filtered" // Provider skipped due to client restriction (neutral, no circuit breaker) + | "hedge_triggered" // Hedge 计时器触发,启动备选供应商 + | "hedge_winner" // 该供应商赢得 Hedge 竞速(最先收到首字节) + | "hedge_loser_cancelled" // 该供应商输掉 Hedge 竞速,请求被取消 + | "client_abort"; // 客户端在响应完成前断开连接 // === 选择方法(细化) === selectionMethod?: diff --git a/tests/integration/usage-ledger.test.ts b/tests/integration/usage-ledger.test.ts index d64b36ee1..303d005cd 100644 --- a/tests/integration/usage-ledger.test.ts +++ b/tests/integration/usage-ledger.test.ts @@ -278,45 +278,49 @@ run("usage ledger integration", () => { }); describe("backfill", () => { - test("backfill copies non-warmup message_request rows when ledger rows are missing", { - timeout: 60_000, - }, async () => { - const userId = nextUserId(); - const providerId = nextProviderId(); - const keepA = await insertMessageRequestRow({ - key: nextKey("backfill-a"), - userId, - providerId, - costUsd: "1.100000000000000", - }); - const keepB = await insertMessageRequestRow({ - key: nextKey("backfill-b"), - userId, - providerId, - costUsd: "2.200000000000000", - }); - const warmup = await insertMessageRequestRow({ - key: nextKey("backfill-warmup"), - userId, - providerId, - blockedBy: "warmup", - }); - - await db.delete(usageLedger).where(inArray(usageLedger.requestId, [keepA, keepB, warmup])); - - const summary = await backfillUsageLedger(); - expect(summary.totalProcessed).toBeGreaterThanOrEqual(2); - - const rows = await db - .select({ requestId: usageLedger.requestId }) - .from(usageLedger) - .where(inArray(usageLedger.requestId, [keepA, keepB, warmup])); - const requestIds = rows.map((row) => row.requestId); - - expect(requestIds).toContain(keepA); - expect(requestIds).toContain(keepB); - expect(requestIds).not.toContain(warmup); - }); + test( + "backfill copies non-warmup message_request rows when ledger rows are missing", + { + timeout: 60_000, + }, + async () => { + const userId = nextUserId(); + const providerId = nextProviderId(); + const keepA = await insertMessageRequestRow({ + key: nextKey("backfill-a"), + userId, + providerId, + costUsd: "1.100000000000000", + }); + const keepB = await insertMessageRequestRow({ + key: nextKey("backfill-b"), + userId, + providerId, + costUsd: "2.200000000000000", + }); + const warmup = await insertMessageRequestRow({ + key: nextKey("backfill-warmup"), + userId, + providerId, + blockedBy: "warmup", + }); + + await db.delete(usageLedger).where(inArray(usageLedger.requestId, [keepA, keepB, warmup])); + + const summary = await backfillUsageLedger(); + expect(summary.totalProcessed).toBeGreaterThanOrEqual(2); + + const rows = await db + .select({ requestId: usageLedger.requestId }) + .from(usageLedger) + .where(inArray(usageLedger.requestId, [keepA, keepB, warmup])); + const requestIds = rows.map((row) => row.requestId); + + expect(requestIds).toContain(keepA); + expect(requestIds).toContain(keepB); + expect(requestIds).not.toContain(warmup); + } + ); test("backfill is idempotent when running twice", { timeout: 60_000 }, async () => { const requestId = await insertMessageRequestRow({ diff --git a/tests/unit/lib/utils/provider-display.test.ts b/tests/unit/lib/utils/provider-display.test.ts new file mode 100644 index 000000000..6639de723 --- /dev/null +++ b/tests/unit/lib/utils/provider-display.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { isProviderFinalized } from "@/lib/utils/provider-display"; + +describe("isProviderFinalized", () => { + it.each([ + { + name: "null providerChain + null statusCode = not finalized", + entry: { providerChain: null, statusCode: null, blockedBy: null }, + expected: false, + }, + { + name: "empty providerChain + null statusCode = not finalized", + entry: { providerChain: [], statusCode: null, blockedBy: null }, + expected: false, + }, + { + name: "undefined fields = not finalized", + entry: {}, + expected: false, + }, + { + name: "providerChain with items = finalized", + entry: { providerChain: [{ id: 1, name: "provider-a" }], statusCode: 200 }, + expected: true, + }, + { + name: "null providerChain + statusCode present = finalized", + entry: { providerChain: null, statusCode: 200 }, + expected: true, + }, + { + name: "statusCode 0 counts as finalized", + entry: { providerChain: null, statusCode: 0 }, + expected: true, + }, + { + name: "error statusCode = finalized", + entry: { providerChain: null, statusCode: 500 }, + expected: true, + }, + { + name: "blockedBy = finalized (regardless of other fields)", + entry: { providerChain: null, statusCode: null, blockedBy: "sensitive_word" }, + expected: true, + }, + { + name: "blockedBy takes priority over missing chain/status", + entry: { blockedBy: "rate_limit" }, + expected: true, + }, + ])("$name", ({ entry, expected }) => { + expect(isProviderFinalized(entry)).toBe(expected); + }); +}); From 952786f59edc40f8a7f0470aae62c4a5e379bbe3 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 9 Mar 2026 16:00:48 +0000 Subject: [PATCH 17/42] chore: format code (dev-60a0fb2) --- tests/integration/usage-ledger.test.ts | 82 ++++++++++++-------------- 1 file changed, 39 insertions(+), 43 deletions(-) diff --git a/tests/integration/usage-ledger.test.ts b/tests/integration/usage-ledger.test.ts index 303d005cd..d64b36ee1 100644 --- a/tests/integration/usage-ledger.test.ts +++ b/tests/integration/usage-ledger.test.ts @@ -278,49 +278,45 @@ run("usage ledger integration", () => { }); describe("backfill", () => { - test( - "backfill copies non-warmup message_request rows when ledger rows are missing", - { - timeout: 60_000, - }, - async () => { - const userId = nextUserId(); - const providerId = nextProviderId(); - const keepA = await insertMessageRequestRow({ - key: nextKey("backfill-a"), - userId, - providerId, - costUsd: "1.100000000000000", - }); - const keepB = await insertMessageRequestRow({ - key: nextKey("backfill-b"), - userId, - providerId, - costUsd: "2.200000000000000", - }); - const warmup = await insertMessageRequestRow({ - key: nextKey("backfill-warmup"), - userId, - providerId, - blockedBy: "warmup", - }); - - await db.delete(usageLedger).where(inArray(usageLedger.requestId, [keepA, keepB, warmup])); - - const summary = await backfillUsageLedger(); - expect(summary.totalProcessed).toBeGreaterThanOrEqual(2); - - const rows = await db - .select({ requestId: usageLedger.requestId }) - .from(usageLedger) - .where(inArray(usageLedger.requestId, [keepA, keepB, warmup])); - const requestIds = rows.map((row) => row.requestId); - - expect(requestIds).toContain(keepA); - expect(requestIds).toContain(keepB); - expect(requestIds).not.toContain(warmup); - } - ); + test("backfill copies non-warmup message_request rows when ledger rows are missing", { + timeout: 60_000, + }, async () => { + const userId = nextUserId(); + const providerId = nextProviderId(); + const keepA = await insertMessageRequestRow({ + key: nextKey("backfill-a"), + userId, + providerId, + costUsd: "1.100000000000000", + }); + const keepB = await insertMessageRequestRow({ + key: nextKey("backfill-b"), + userId, + providerId, + costUsd: "2.200000000000000", + }); + const warmup = await insertMessageRequestRow({ + key: nextKey("backfill-warmup"), + userId, + providerId, + blockedBy: "warmup", + }); + + await db.delete(usageLedger).where(inArray(usageLedger.requestId, [keepA, keepB, warmup])); + + const summary = await backfillUsageLedger(); + expect(summary.totalProcessed).toBeGreaterThanOrEqual(2); + + const rows = await db + .select({ requestId: usageLedger.requestId }) + .from(usageLedger) + .where(inArray(usageLedger.requestId, [keepA, keepB, warmup])); + const requestIds = rows.map((row) => row.requestId); + + expect(requestIds).toContain(keepA); + expect(requestIds).toContain(keepB); + expect(requestIds).not.toContain(warmup); + }); test("backfill is idempotent when running twice", { timeout: 60_000 }, async () => { const requestId = await insertMessageRequestRow({ From 0f100504f5f84c76a37a8147798276e45111bc6e Mon Sep 17 00:00:00 2001 From: ding113 Date: Tue, 10 Mar 2026 00:04:42 +0800 Subject: [PATCH 18/42] refactor: deduplicate isActualRequest and fix missing hedge_winner in logs table Consolidate the isActualRequest function that was duplicated across three files (provider-chain-formatter, provider-chain-popover, virtualized-logs-table). Export from provider-chain-formatter.ts as the single source of truth. Fixes two bugs in virtualized-logs-table.tsx: - successfulProvider lookup was missing hedge_winner, causing incorrect costMultiplier display for hedge-won requests - Inline isActualRequest was missing hedge_winner, hedge_loser_cancelled, client_abort and other newer reasons, causing incorrect request count and cost badge visibility --- .../_components/provider-chain-popover.tsx | 25 +------------------ .../_components/virtualized-logs-table.tsx | 21 +++------------- src/lib/utils/provider-chain-formatter.ts | 7 ++++-- 3 files changed, 9 insertions(+), 44 deletions(-) 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 e66f96f84..dda07cf85 100644 --- a/src/app/[locale]/dashboard/logs/_components/provider-chain-popover.tsx +++ b/src/app/[locale]/dashboard/logs/_components/provider-chain-popover.tsx @@ -18,7 +18,7 @@ import { Button } from "@/components/ui/button"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; -import { formatProbabilityCompact } from "@/lib/utils/provider-chain-formatter"; +import { formatProbabilityCompact, isActualRequest } from "@/lib/utils/provider-chain-formatter"; import type { ProviderChainItem } from "@/types/message"; import { getFake200ReasonKey } from "./fake200-reason"; @@ -31,29 +31,6 @@ interface ProviderChainPopoverProps { onChainItemClick?: (chainIndex: number) => void; } -/** - * Determine if this is an actual request record (excluding intermediate states) - */ -function isActualRequest(item: ProviderChainItem): boolean { - if (item.reason === "client_restriction_filtered") return false; - if (item.reason === "hedge_triggered") return false; - - if (item.reason === "concurrent_limit_failed") return true; - - if (item.reason === "retry_failed" || item.reason === "system_error") return true; - if (item.reason === "resource_not_found") return true; - if (item.reason === "endpoint_pool_exhausted") return true; - if (item.reason === "vendor_type_all_timeout") return true; - if (item.reason === "client_error_non_retryable") return true; - if (item.reason === "hedge_winner") return true; - if (item.reason === "hedge_loser_cancelled") return true; - if (item.reason === "client_abort") return true; - if ((item.reason === "request_success" || item.reason === "retry_success") && item.statusCode) { - return true; - } - return false; -} - function parseGroupTags(groupTag?: string | null): string[] { if (!groupTag) return []; const seen = new Set(); 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 b1cf1e1a0..38c57c592 100644 --- a/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx +++ b/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx @@ -15,6 +15,7 @@ import type { LogsTableColumn } from "@/lib/column-visibility"; import { cn, formatTokenAmount } from "@/lib/utils"; import { copyTextToClipboard } from "@/lib/utils/clipboard"; import { isProviderFinalized } from "@/lib/utils/provider-display"; +import { isActualRequest } from "@/lib/utils/provider-chain-formatter"; import type { CurrencyCode } from "@/lib/utils/currency"; import { formatCurrency } from "@/lib/utils/currency"; import { @@ -437,7 +438,8 @@ export function VirtualizedLogsTable({ .find( (item) => item.reason === "request_success" || - item.reason === "retry_success" + item.reason === "retry_success" || + item.reason === "hedge_winner" ) : null; const actualCostMultiplier = @@ -449,23 +451,6 @@ export function VirtualizedLogsTable({ Number.isFinite(multiplier) && multiplier !== 1; - // Calculate actual request count (same logic as ProviderChainPopover) - const isActualRequest = (item: ProviderChainItem) => { - if (item.reason === "concurrent_limit_failed") return true; - if ( - item.reason === "retry_failed" || - item.reason === "system_error" - ) - return true; - if ( - (item.reason === "request_success" || - item.reason === "retry_success") && - item.statusCode - ) { - return true; - } - return false; - }; const actualRequestCount = log.providerChain?.filter(isActualRequest).length ?? 0; // Only show badge in table when no retry (Popover shows badge when retry) diff --git a/src/lib/utils/provider-chain-formatter.ts b/src/lib/utils/provider-chain-formatter.ts index 446d7a643..044e65aae 100644 --- a/src/lib/utils/provider-chain-formatter.ts +++ b/src/lib/utils/provider-chain-formatter.ts @@ -97,9 +97,12 @@ function getProviderStatus(item: ProviderChainItem): "✓" | "✗" | "⚡" | " } /** - * 辅助函数:判断是否为实际请求记录(排除中间状态) + * Determine if a chain item represents an actual upstream request + * (as opposed to intermediate states like initial_selection or hedge_triggered). + * + * Shared by provider-chain-popover and virtualized-logs-table. */ -function isActualRequest(item: ProviderChainItem): boolean { +export function isActualRequest(item: ProviderChainItem): boolean { // 并发限制失败:算作一次尝试 if (item.reason === "concurrent_limit_failed") return true; From 75b4ff5f5058b1e08409544f1a668d83cc678aa8 Mon Sep 17 00:00:00 2001 From: ding113 Date: Tue, 10 Mar 2026 07:24:02 +0800 Subject: [PATCH 19/42] fix(proxy): skip hedge abort when no alternative providers available When the hedge timer fires and no alternative provider is found, let the sole in-flight request continue instead of aborting it with a 524 error. Only call finishIfExhausted() when all attempts have already completed (edge case). --- src/app/v1/_lib/proxy/forwarder.ts | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/src/app/v1/_lib/proxy/forwarder.ts b/src/app/v1/_lib/proxy/forwarder.ts index c5b94ec90..5af55144b 100644 --- a/src/app/v1/_lib/proxy/forwarder.ts +++ b/src/app/v1/_lib/proxy/forwarder.ts @@ -2957,17 +2957,11 @@ export class ProxyForwarder { ); if (!alternativeProvider) { noMoreProviders = true; - if (attempts.size > 0) { - lastError = - lastError ?? - new ProxyError("Streaming first byte timeout", 524, { - body: "", - providerId: initialProvider.id, - providerName: initialProvider.name, - }); - abortAllAttempts(undefined, "streaming_first_byte_timeout"); + // No alternative providers available — let in-flight attempt(s) continue. + // If all attempts already completed, settle with last error. + if (attempts.size === 0) { + await finishIfExhausted(); } - await finishIfExhausted(); return; } From 02a8e0f4d1aa16c0f412d76a8cd962f9e8fe1614 Mon Sep 17 00:00:00 2001 From: ding113 Date: Tue, 10 Mar 2026 09:51:34 +0800 Subject: [PATCH 20/42] feat(observability): improve hedge flow visibility and fix false positives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit addresses three issues with hedge race tracking: 1. Decision chain missing hedge participants - Add hedge_launched reason to record alternative provider launches - Record hedge_launched in forwarder.ts when launchedProviderCount > 1 - Add timeline formatting for hedge_launched events 2. Hedge races mislabeled as retries in UI - Add isHedgeRace() and getRetryCount() helper functions - Display "Hedge Race" badge instead of retry count - Update provider-chain-popover and virtualized-logs-table 3. hedge_winner false positives - Fix isActualHedgeWin logic to only check launchedProviderCount - Prevent marking single-provider requests as hedge_winner Additional improvements: - Strengthen addProviderToChain deduplication logic - Add comprehensive JSDoc for getRetryCount design decisions - Add 6 edge case tests (empty chain, incomplete hedge, mixed scenarios) - Test coverage: 54 → 60 tests, all passing i18n: Add hedge_launched translations for 5 languages (en, zh-CN, zh-TW, ja, ru) Co-Authored-By: Claude Opus 4.6 --- messages/en/provider-chain.json | 3 + messages/ja/provider-chain.json | 3 + messages/ru/provider-chain.json | 3 + messages/zh-CN/provider-chain.json | 3 + messages/zh-TW/provider-chain.json | 3 + .../_components/provider-chain-popover.tsx | 28 ++- .../_components/virtualized-logs-table.tsx | 9 +- src/app/v1/_lib/proxy/forwarder.ts | 18 +- src/app/v1/_lib/proxy/session.ts | 10 +- .../utils/provider-chain-formatter.test.ts | 187 ++++++++++++++++++ src/lib/utils/provider-chain-formatter.ts | 59 +++++- src/types/message.ts | 1 + 12 files changed, 308 insertions(+), 19 deletions(-) diff --git a/messages/en/provider-chain.json b/messages/en/provider-chain.json index 38d2c487a..cae28b097 100644 --- a/messages/en/provider-chain.json +++ b/messages/en/provider-chain.json @@ -43,6 +43,7 @@ "endpointPoolExhausted": "Endpoint Pool Exhausted", "vendorTypeAllTimeout": "Vendor-Type All Endpoints Timeout", "hedgeTriggered": "Hedge Triggered", + "hedgeLaunched": "Hedge Alternative Launched", "hedgeWinner": "Hedge Winner", "hedgeLoserCancelled": "Hedge Loser (Cancelled)", "clientAbort": "Client Aborted" @@ -62,6 +63,7 @@ "vendor_type_all_timeout": "Vendor-Type All Endpoints Timeout", "client_restriction_filtered": "Client Restricted", "hedge_triggered": "Hedge Triggered", + "hedge_launched": "Hedge Alternative Launched", "hedge_winner": "Hedge Winner", "hedge_loser_cancelled": "Hedge Loser (Cancelled)", "client_abort": "Client Aborted" @@ -232,6 +234,7 @@ "vendorTypeAllTimeout": "Vendor-Type All Endpoints Timeout (524)", "vendorTypeAllTimeoutNote": "All endpoints for this vendor-type timed out. Vendor-type circuit breaker triggered.", "hedgeTriggered": "Hedge Threshold Exceeded (launching alternative)", + "hedgeLaunched": "Hedge Alternative Provider Launched", "hedgeWinner": "Hedge Race Winner (first byte received first)", "hedgeLoserCancelled": "Hedge Race Loser (request cancelled)", "clientAbort": "Client Disconnected (request aborted)", diff --git a/messages/ja/provider-chain.json b/messages/ja/provider-chain.json index e2afc1130..74326d533 100644 --- a/messages/ja/provider-chain.json +++ b/messages/ja/provider-chain.json @@ -43,6 +43,7 @@ "endpointPoolExhausted": "エンドポイントプール枯渇", "vendorTypeAllTimeout": "ベンダータイプ全エンドポイントタイムアウト", "hedgeTriggered": "Hedge 発動", + "hedgeLaunched": "Hedge 代替起動済み", "hedgeWinner": "Hedge 競争勝者", "hedgeLoserCancelled": "Hedge 競争敗者(キャンセル)", "clientAbort": "クライアント中断" @@ -62,6 +63,7 @@ "vendor_type_all_timeout": "ベンダータイプ全エンドポイントタイムアウト", "client_restriction_filtered": "クライアント制限", "hedge_triggered": "Hedge 発動", + "hedge_launched": "Hedge 代替起動済み", "hedge_winner": "Hedge 競争勝者", "hedge_loser_cancelled": "Hedge 競争敗者(キャンセル)", "client_abort": "クライアント中断" @@ -232,6 +234,7 @@ "vendorTypeAllTimeout": "ベンダータイプ全エンドポイントタイムアウト(524)", "vendorTypeAllTimeoutNote": "このベンダータイプの全エンドポイントがタイムアウトしました。ベンダータイプサーキットブレーカーが発動しました。", "hedgeTriggered": "Hedge 閾値超過(代替プロバイダーを起動中)", + "hedgeLaunched": "Hedge 代替プロバイダー起動済み", "hedgeWinner": "Hedge 競争勝者(最初にファーストバイトを受信)", "hedgeLoserCancelled": "Hedge 競争敗者(リクエストキャンセル)", "clientAbort": "クライアント切断(リクエスト中断)", diff --git a/messages/ru/provider-chain.json b/messages/ru/provider-chain.json index 234288aa6..ff2342578 100644 --- a/messages/ru/provider-chain.json +++ b/messages/ru/provider-chain.json @@ -43,6 +43,7 @@ "endpointPoolExhausted": "Пул конечных точек исчерпан", "vendorTypeAllTimeout": "Тайм-аут всех конечных точек", "hedgeTriggered": "Hedge запущен", + "hedgeLaunched": "Hedge альтернатива запущена", "hedgeWinner": "Победитель Hedge-гонки", "hedgeLoserCancelled": "Проигравший Hedge-гонки (отменён)", "clientAbort": "Клиент прервал запрос" @@ -62,6 +63,7 @@ "vendor_type_all_timeout": "Тайм-аут всех конечных точек типа поставщика", "client_restriction_filtered": "Клиент ограничен", "hedge_triggered": "Hedge запущен", + "hedge_launched": "Hedge альтернатива запущена", "hedge_winner": "Победитель Hedge-гонки", "hedge_loser_cancelled": "Проигравший Hedge-гонки (отменён)", "client_abort": "Клиент прервал запрос" @@ -232,6 +234,7 @@ "vendorTypeAllTimeout": "Тайм-аут всех конечных точек типа поставщика (524)", "vendorTypeAllTimeoutNote": "Все конечные точки этого типа поставщика превысили тайм-аут. Активирован размыкатель типа поставщика.", "hedgeTriggered": "Порог Hedge превышен (запускается альтернативный провайдер)", + "hedgeLaunched": "Hedge альтернативный провайдер запущен", "hedgeWinner": "Победитель Hedge-гонки (первый получил начальный байт)", "hedgeLoserCancelled": "Проигравший Hedge-гонки (запрос отменён)", "clientAbort": "Клиент отключился (запрос прерван)", diff --git a/messages/zh-CN/provider-chain.json b/messages/zh-CN/provider-chain.json index 9a7a473b9..a5cb68851 100644 --- a/messages/zh-CN/provider-chain.json +++ b/messages/zh-CN/provider-chain.json @@ -43,6 +43,7 @@ "endpointPoolExhausted": "端点池耗尽", "vendorTypeAllTimeout": "供应商类型全端点超时", "hedgeTriggered": "Hedge 已触发", + "hedgeLaunched": "Hedge 备选已启动", "hedgeWinner": "Hedge 竞速赢家", "hedgeLoserCancelled": "Hedge 竞速输家(已取消)", "clientAbort": "客户端中断" @@ -62,6 +63,7 @@ "vendor_type_all_timeout": "供应商类型全端点超时", "client_restriction_filtered": "客户端受限", "hedge_triggered": "Hedge 已触发", + "hedge_launched": "Hedge 备选已启动", "hedge_winner": "Hedge 竞速赢家", "hedge_loser_cancelled": "Hedge 竞速输家(已取消)", "client_abort": "客户端中断" @@ -232,6 +234,7 @@ "vendorTypeAllTimeout": "供应商类型全端点超时(524)", "vendorTypeAllTimeoutNote": "该供应商类型的所有端点均超时,已触发供应商类型临时熔断。", "hedgeTriggered": "Hedge 阈值超出(正在启动备选供应商)", + "hedgeLaunched": "Hedge 备选供应商已启动", "hedgeWinner": "Hedge 竞速赢家(最先收到首字节)", "hedgeLoserCancelled": "Hedge 竞速输家(请求已取消)", "clientAbort": "客户端已断开连接(请求中断)", diff --git a/messages/zh-TW/provider-chain.json b/messages/zh-TW/provider-chain.json index c53cb47d3..654327fae 100644 --- a/messages/zh-TW/provider-chain.json +++ b/messages/zh-TW/provider-chain.json @@ -43,6 +43,7 @@ "endpointPoolExhausted": "端點池耗盡", "vendorTypeAllTimeout": "供應商類型全端點逾時", "hedgeTriggered": "Hedge 已觸發", + "hedgeLaunched": "Hedge 備選已啟動", "hedgeWinner": "Hedge 競速贏家", "hedgeLoserCancelled": "Hedge 競速輸家(已取消)", "clientAbort": "客戶端中斷" @@ -62,6 +63,7 @@ "vendor_type_all_timeout": "供應商類型全端點逾時", "client_restriction_filtered": "客戶端受限", "hedge_triggered": "Hedge 已觸發", + "hedge_launched": "Hedge 備選已啟動", "hedge_winner": "Hedge 競速贏家", "hedge_loser_cancelled": "Hedge 競速輸家(已取消)", "client_abort": "客戶端中斷" @@ -232,6 +234,7 @@ "vendorTypeAllTimeout": "供應商類型全端點逾時(524)", "vendorTypeAllTimeoutNote": "該供應商類型的所有端點均逾時,已觸發供應商類型臨時熔斷。", "hedgeTriggered": "Hedge 閾值超出(正在啟動備選供應商)", + "hedgeLaunched": "Hedge 備選供應商已啟動", "hedgeWinner": "Hedge 競速贏家(最先收到首位元組)", "hedgeLoserCancelled": "Hedge 競速輸家(請求已取消)", "clientAbort": "客戶端已斷開連接(請求中斷)", 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 dda07cf85..a4f4d933d 100644 --- a/src/app/[locale]/dashboard/logs/_components/provider-chain-popover.tsx +++ b/src/app/[locale]/dashboard/logs/_components/provider-chain-popover.tsx @@ -18,7 +18,7 @@ import { Button } from "@/components/ui/button"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; -import { formatProbabilityCompact, isActualRequest } from "@/lib/utils/provider-chain-formatter"; +import { formatProbabilityCompact, getRetryCount, isActualRequest, isHedgeRace } from "@/lib/utils/provider-chain-formatter"; import type { ProviderChainItem } from "@/types/message"; import { getFake200ReasonKey } from "./fake200-reason"; @@ -147,6 +147,8 @@ export function ProviderChainPopover({ // Calculate actual request count (excluding intermediate states) const requestCount = chain.filter(isActualRequest).length; + const retryCount = getRetryCount(chain); + const isHedge = isHedgeRace(chain); // Fallback for empty string const displayName = finalProvider || "-"; @@ -162,8 +164,8 @@ export function ProviderChainPopover({ const initialSelection = chain.find((item) => item.reason === "initial_selection"); const selectionContext = initialSelection?.decisionContext; - // Single request: show name with icon and compact tooltip - if (requestCount <= 1) { + // Single request (no retry and no hedge): show name with icon and compact tooltip + if (retryCount === 0 && !isHedge) { // Get session reuse context for detailed tooltip const sessionReuseItem = chain.find( (item) => item.reason === "session_reuse" || item.selectionMethod === "session_reuse" @@ -408,13 +410,22 @@ export function ProviderChainPopover({ type="button" variant="ghost" className="h-auto p-0 font-normal hover:bg-transparent w-full min-w-0" - aria-label={`${displayName} - ${requestCount}${t("logs.table.times")}`} + aria-label={`${displayName} - ${isHedge ? tChain("timeline.hedgeRace") : `${requestCount}${t("logs.table.times")}`}`} > {/* Request count badge */} - {requestCount} - {t("logs.table.times")} + {isHedge ? ( + <> + + {tChain("timeline.hedgeRace")} + + ) : ( + <> + {requestCount} + {t("logs.table.times")} + + )} {/* Provider name */} @@ -461,7 +472,10 @@ export function ProviderChainPopover({

{t("logs.providerChain.decisionChain")}

- {requestCount} {t("logs.table.times")} + {isHedge + ? tChain("timeline.hedgeRace") + : `${requestCount} ${t("logs.table.times")}` + }
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 38c57c592..c34b4fc2b 100644 --- a/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx +++ b/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx @@ -15,7 +15,7 @@ import type { LogsTableColumn } from "@/lib/column-visibility"; import { cn, formatTokenAmount } from "@/lib/utils"; import { copyTextToClipboard } from "@/lib/utils/clipboard"; import { isProviderFinalized } from "@/lib/utils/provider-display"; -import { isActualRequest } from "@/lib/utils/provider-chain-formatter"; +import { getRetryCount } from "@/lib/utils/provider-chain-formatter"; import type { CurrencyCode } from "@/lib/utils/currency"; import { formatCurrency } from "@/lib/utils/currency"; import { @@ -451,10 +451,11 @@ export function VirtualizedLogsTable({ Number.isFinite(multiplier) && multiplier !== 1; - const actualRequestCount = - log.providerChain?.filter(isActualRequest).length ?? 0; + const retryCount = log.providerChain + ? getRetryCount(log.providerChain) + : 0; // Only show badge in table when no retry (Popover shows badge when retry) - const showBadgeInTable = hasCostBadge && actualRequestCount <= 1; + const showBadgeInTable = hasCostBadge && retryCount === 0; return ( <> diff --git a/src/app/v1/_lib/proxy/forwarder.ts b/src/app/v1/_lib/proxy/forwarder.ts index 5af55144b..fa82287f9 100644 --- a/src/app/v1/_lib/proxy/forwarder.ts +++ b/src/app/v1/_lib/proxy/forwarder.ts @@ -3068,9 +3068,14 @@ export class ProxyForwarder { } session.setProvider(attempt.provider); + // Determine if this is truly a hedge winner or just a regular success + // Only mark as hedge_winner when an actual hedge race occurred + // Note: launchedProviderCount is the most reliable indicator - if > 1, multiple providers were launched + const isActualHedgeWin = launchedProviderCount > 1; + session.addProviderToChain(attempt.provider, { ...attempt.endpointAudit, - reason: "hedge_winner", + reason: isActualHedgeWin ? "hedge_winner" : "request_success", attemptNumber: attempt.sequence, statusCode: attempt.response.status, }); @@ -3179,6 +3184,17 @@ export class ProxyForwarder { attempts.add(attempt); + // Record hedge participant launch in decision chain + // (first provider is already recorded via initial_selection or session_reuse) + if (launchedProviderCount > 1) { + session.addProviderToChain(provider, { + ...attempt.endpointAudit, + reason: "hedge_launched", + attemptNumber: attempt.sequence, + circuitState: getCircuitState(provider.id), + }); + } + if (attempt.firstByteTimeoutMs > 0) { attempt.thresholdTimer = setTimeout(() => { if (settled || attempt.settled || attempt.thresholdTriggered) return; diff --git a/src/app/v1/_lib/proxy/session.ts b/src/app/v1/_lib/proxy/session.ts index c4f3c944f..7cd41bcd1 100644 --- a/src/app/v1/_lib/proxy/session.ts +++ b/src/app/v1/_lib/proxy/session.ts @@ -455,6 +455,7 @@ export class ProxySession { | "vendor_type_all_timeout" // 供应商类型全端点超时(524),触发 vendor-type 临时熔断 | "client_restriction_filtered" // 供应商因客户端限制被跳过(会话复用路径) | "hedge_triggered" // Hedge 计时器触发,启动备选供应商 + | "hedge_launched" // Hedge 备选供应商已启动(信息性记录) | "hedge_winner" // 该供应商赢得 Hedge 竞速(最先收到首字节) | "hedge_loser_cancelled" // 该供应商输掉 Hedge 竞速,请求被取消 | "client_abort"; // 客户端在响应完成前断开连接 @@ -508,11 +509,14 @@ export class ProxySession { endpointFilterStats: metadata?.endpointFilterStats, }; - // 避免重复添加同一个供应商(除非是重试,即有 attemptNumber) + // 避免重复添加同一个供应商 + // 检查最后一条记录是否与当前记录完全相同(id + reason + attemptNumber) + const lastItem = this.providerChain[this.providerChain.length - 1]; const shouldAdd = this.providerChain.length === 0 || - this.providerChain[this.providerChain.length - 1].id !== provider.id || - metadata?.attemptNumber !== undefined; + lastItem.id !== provider.id || + lastItem.reason !== metadata?.reason || + (metadata?.attemptNumber !== undefined && lastItem.attemptNumber !== metadata.attemptNumber); if (shouldAdd) { this.providerChain.push(item); diff --git a/src/lib/utils/provider-chain-formatter.test.ts b/src/lib/utils/provider-chain-formatter.test.ts index db472c2b5..c1880a183 100644 --- a/src/lib/utils/provider-chain-formatter.test.ts +++ b/src/lib/utils/provider-chain-formatter.test.ts @@ -6,6 +6,9 @@ import { formatProviderDescription, formatProviderSummary, formatProviderTimeline, + getRetryCount, + isActualRequest, + isHedgeRace, } from "./provider-chain-formatter"; /** @@ -588,3 +591,187 @@ describe("hedge and client_abort reason handling", () => { expect(summary).toBeDefined(); }); }); + +// ============================================================================= +// isHedgeRace and getRetryCount tests +// ============================================================================= + +describe("isHedgeRace", () => { + test("returns true when chain contains hedge_triggered", () => { + const chain: ProviderChainItem[] = [ + { id: 1, name: "p1", reason: "hedge_triggered", timestamp: 1000 }, + ]; + expect(isHedgeRace(chain)).toBe(true); + }); + + test("returns true when chain contains hedge_launched", () => { + const chain: ProviderChainItem[] = [ + { id: 1, name: "p1", reason: "hedge_launched", timestamp: 1000 }, + ]; + expect(isHedgeRace(chain)).toBe(true); + }); + + test("returns true when chain contains hedge_winner", () => { + const chain: ProviderChainItem[] = [ + { id: 1, name: "p1", reason: "hedge_winner", statusCode: 200, timestamp: 1000 }, + ]; + expect(isHedgeRace(chain)).toBe(true); + }); + + test("returns true when chain contains hedge_loser_cancelled", () => { + const chain: ProviderChainItem[] = [ + { id: 1, name: "p1", reason: "hedge_loser_cancelled", timestamp: 1000 }, + ]; + expect(isHedgeRace(chain)).toBe(true); + }); + + test("returns false for regular retry chain", () => { + const chain: ProviderChainItem[] = [ + { id: 1, name: "p1", reason: "retry_failed", timestamp: 1000 }, + { id: 2, name: "p2", reason: "retry_success", statusCode: 200, timestamp: 2000 }, + ]; + expect(isHedgeRace(chain)).toBe(false); + }); + + test("returns false for single success", () => { + const chain: ProviderChainItem[] = [ + { id: 1, name: "p1", reason: "request_success", statusCode: 200, timestamp: 1000 }, + ]; + expect(isHedgeRace(chain)).toBe(false); + }); +}); + +describe("getRetryCount", () => { + test("returns 0 for hedge race (not a retry)", () => { + const chain: ProviderChainItem[] = [ + { id: 1, name: "p1", reason: "initial_selection", timestamp: 0 }, + { id: 1, name: "p1", reason: "hedge_triggered", timestamp: 1000 }, + { id: 2, name: "p2", reason: "hedge_launched", timestamp: 1001 }, + { id: 2, name: "p2", reason: "hedge_winner", statusCode: 200, timestamp: 2000 }, + { id: 1, name: "p1", reason: "hedge_loser_cancelled", timestamp: 2000 }, + ]; + expect(getRetryCount(chain)).toBe(0); + }); + + test("returns 0 for single successful request", () => { + const chain: ProviderChainItem[] = [ + { id: 1, name: "p1", reason: "initial_selection", timestamp: 0 }, + { id: 1, name: "p1", reason: "request_success", statusCode: 200, timestamp: 1000 }, + ]; + expect(getRetryCount(chain)).toBe(0); + }); + + test("returns 1 for one retry (2 actual requests)", () => { + const chain: ProviderChainItem[] = [ + { id: 1, name: "p1", reason: "retry_failed", timestamp: 1000 }, + { id: 2, name: "p2", reason: "retry_success", statusCode: 200, timestamp: 2000 }, + ]; + expect(getRetryCount(chain)).toBe(1); + }); + + test("returns 2 for two retries (3 actual requests)", () => { + const chain: ProviderChainItem[] = [ + { id: 1, name: "p1", reason: "retry_failed", timestamp: 1000 }, + { id: 2, name: "p2", reason: "retry_failed", timestamp: 2000 }, + { id: 3, name: "p3", reason: "retry_success", statusCode: 200, timestamp: 3000 }, + ]; + expect(getRetryCount(chain)).toBe(2); + }); +}); + +describe("hedge_launched reason handling", () => { + test("hedge_launched is not an actual request", () => { + const item: ProviderChainItem = { + id: 2, + name: "p2", + reason: "hedge_launched", + timestamp: 1001, + }; + expect(isActualRequest(item)).toBe(false); + }); + + test("hedge_launched appears in timeline", () => { + const chain: ProviderChainItem[] = [ + { id: 1, name: "p1", reason: "initial_selection", timestamp: 0 }, + { id: 1, name: "p1", reason: "hedge_triggered", timestamp: 1000, attemptNumber: 1 }, + { + id: 2, + name: "p2", + reason: "hedge_launched", + timestamp: 1001, + attemptNumber: 2, + circuitState: "closed", + }, + { id: 2, name: "p2", reason: "hedge_winner", statusCode: 200, timestamp: 2000, attemptNumber: 2 }, + { id: 1, name: "p1", reason: "hedge_loser_cancelled", timestamp: 2000, attemptNumber: 1 }, + ]; + const { timeline } = formatProviderTimeline(chain, mockT); + expect(timeline).toContain("timeline.hedgeLaunched"); + expect(timeline).toContain("p2"); + }); +}); + +describe("Edge cases for hedge race detection", () => { + test("isHedgeRace returns false for empty chain", () => { + const chain: ProviderChainItem[] = []; + expect(isHedgeRace(chain)).toBe(false); + }); + + test("getRetryCount returns 0 for empty chain", () => { + const chain: ProviderChainItem[] = []; + expect(getRetryCount(chain)).toBe(0); + }); + + test("isHedgeRace returns true for incomplete hedge chain (only hedge_launched, no winner)", () => { + const chain: ProviderChainItem[] = [ + { id: 1, name: "p1", reason: "initial_selection", timestamp: 0 }, + { id: 1, name: "p1", reason: "hedge_triggered", timestamp: 1000 }, + { id: 2, name: "p2", reason: "hedge_launched", timestamp: 1001, attemptNumber: 2 }, + // System crashed or request cancelled before winner determined + ]; + expect(isHedgeRace(chain)).toBe(true); + }); + + test("getRetryCount returns 0 for incomplete hedge chain", () => { + const chain: ProviderChainItem[] = [ + { id: 1, name: "p1", reason: "initial_selection", timestamp: 0 }, + { id: 1, name: "p1", reason: "hedge_triggered", timestamp: 1000 }, + { id: 2, name: "p2", reason: "hedge_launched", timestamp: 1001, attemptNumber: 2 }, + ]; + expect(getRetryCount(chain)).toBe(0); + }); + + test("mixed scenario: retry + hedge race (hedge takes precedence)", () => { + const chain: ProviderChainItem[] = [ + { id: 1, name: "p1", reason: "retry_failed", timestamp: 0 }, + { id: 2, name: "p2", reason: "hedge_triggered", timestamp: 1000, attemptNumber: 2 }, + { id: 3, name: "p3", reason: "hedge_launched", timestamp: 1001, attemptNumber: 3 }, + { id: 3, name: "p3", reason: "hedge_winner", statusCode: 200, timestamp: 2000, attemptNumber: 3 }, + { id: 2, name: "p2", reason: "hedge_loser_cancelled", timestamp: 2000, attemptNumber: 2 }, + ]; + expect(isHedgeRace(chain)).toBe(true); + expect(getRetryCount(chain)).toBe(0); // Hedge race takes precedence over retry count + }); + + test("multiple hedge_launched entries (3+ concurrent providers)", () => { + const chain: ProviderChainItem[] = [ + { id: 1, name: "p1", reason: "initial_selection", timestamp: 0 }, + { id: 1, name: "p1", reason: "hedge_triggered", timestamp: 1000, attemptNumber: 1 }, + { id: 2, name: "p2", reason: "hedge_launched", timestamp: 1001, attemptNumber: 2 }, + { id: 3, name: "p3", reason: "hedge_launched", timestamp: 1002, attemptNumber: 3 }, + { id: 2, name: "p2", reason: "hedge_winner", statusCode: 200, timestamp: 2000, attemptNumber: 2 }, + { id: 1, name: "p1", reason: "hedge_loser_cancelled", timestamp: 2000, attemptNumber: 1 }, + { id: 3, name: "p3", reason: "hedge_loser_cancelled", timestamp: 2000, attemptNumber: 3 }, + ]; + expect(isHedgeRace(chain)).toBe(true); + expect(getRetryCount(chain)).toBe(0); + + // Verify all hedge_launched entries are not counted as actual requests + const actualRequests = chain.filter(isActualRequest); + expect(actualRequests).toHaveLength(3); // winner + 2 losers + expect(actualRequests.every(item => + item.reason === "hedge_winner" || item.reason === "hedge_loser_cancelled" + )).toBe(true); + }); +}); + diff --git a/src/lib/utils/provider-chain-formatter.ts b/src/lib/utils/provider-chain-formatter.ts index 044e65aae..374036af4 100644 --- a/src/lib/utils/provider-chain-formatter.ts +++ b/src/lib/utils/provider-chain-formatter.ts @@ -88,8 +88,8 @@ function getProviderStatus(item: ProviderChainItem): "✓" | "✗" | "⚡" | " if (item.reason === "http2_fallback") { return "↓"; } - // Hedge 触发(信息性事件,不是请求结果) - if (item.reason === "hedge_triggered") { + // Hedge 触发和启动(信息性事件,不是请求结果) + if (item.reason === "hedge_triggered" || item.reason === "hedge_launched") { return null; } // 中间状态(选择成功但还没有请求结果) @@ -122,8 +122,8 @@ export function isActualRequest(item: ProviderChainItem): boolean { // Hedge 相关:winner 和 loser 都是实际请求 if (item.reason === "hedge_winner" || item.reason === "hedge_loser_cancelled") return true; - // Hedge 触发:信息性事件,不算实际请求 - if (item.reason === "hedge_triggered") return false; + // Hedge 触发和启动:信息性事件,不算实际请求 + if (item.reason === "hedge_triggered" || item.reason === "hedge_launched") return false; // HTTP/2 回退:算作一次中间事件(显示但不计入失败) if (item.reason === "http2_fallback") return true; @@ -137,6 +137,46 @@ export function isActualRequest(item: ProviderChainItem): boolean { return false; } +/** + * Determine if a decision chain contains a hedge race + * (concurrent attempts, not sequential retries). + */ +export function isHedgeRace(chain: ProviderChainItem[]): boolean { + return chain.some( + (item) => + item.reason === "hedge_triggered" || + item.reason === "hedge_launched" || + item.reason === "hedge_winner" || + item.reason === "hedge_loser_cancelled" + ); +} + +/** + * Count real retries (excluding hedge race concurrent attempts). + * + * Design Decision: + * - Hedge races are concurrent attempts, NOT sequential retries + * - When a chain contains hedge race markers, we prioritize showing "Hedge Race" + * instead of retry count, as it's more important information for users + * + * Mixed Scenario Handling: + * - If a chain contains BOTH sequential retries AND hedge race (e.g., retry_failed → hedge_triggered), + * this function returns 0 to indicate "no sequential retries to display" + * - The UI will show "Hedge Race" badge instead of retry count + * - This is intentional: hedge race takes precedence as it indicates concurrent provider competition + * + * @param chain - Provider decision chain + * @returns Number of sequential retries (0 if hedge race detected) + */ +export function getRetryCount(chain: ProviderChainItem[]): number { + if (isHedgeRace(chain)) { + return 0; + } + + const actualRequests = chain.filter(isActualRequest); + return Math.max(0, actualRequests.length - 1); +} + /** * 辅助函数:翻译熔断状态 */ @@ -907,6 +947,17 @@ export function formatProviderTimeline( continue; } + // === Hedge 备选供应商启动 === + if (item.reason === "hedge_launched") { + timeline += `${t("timeline.hedgeLaunched")}\n\n`; + timeline += `${t("timeline.provider", { provider: item.name })}\n`; + timeline += `${t("timeline.attemptNumber", { number: actualAttemptNumber || item.attemptNumber || 0 })}\n`; + if (item.circuitState) { + timeline += `${t("timeline.circuitCurrent", { state: translateCircuitState(item.circuitState, t) })}\n`; + } + continue; + } + // 并发限制失败 if (item.reason === "concurrent_limit_failed") { timeline += `${t("timeline.attemptFailed", { attempt: actualAttemptNumber ?? 0 })}\n\n`; diff --git a/src/types/message.ts b/src/types/message.ts index fd974299c..3ac6d136d 100644 --- a/src/types/message.ts +++ b/src/types/message.ts @@ -37,6 +37,7 @@ export interface ProviderChainItem { | "vendor_type_all_timeout" // 供应商类型全端点超时(524),触发 vendor-type 临时熔断 | "client_restriction_filtered" // Provider skipped due to client restriction (neutral, no circuit breaker) | "hedge_triggered" // Hedge 计时器触发,启动备选供应商 + | "hedge_launched" // Hedge 备选供应商已启动(信息性记录,不算实际请求) | "hedge_winner" // 该供应商赢得 Hedge 竞速(最先收到首字节) | "hedge_loser_cancelled" // 该供应商输掉 Hedge 竞速,请求被取消 | "client_abort"; // 客户端在响应完成前断开连接 From 0f383386627900d7a8666f384ed248786f99d506 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 10 Mar 2026 01:52:11 +0000 Subject: [PATCH 21/42] chore: format code (dev-02a8e0f) --- .../_components/provider-chain-popover.tsx | 12 ++++--- .../utils/provider-chain-formatter.test.ts | 36 +++++++++++++++---- 2 files changed, 36 insertions(+), 12 deletions(-) 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 a4f4d933d..9f3874f99 100644 --- a/src/app/[locale]/dashboard/logs/_components/provider-chain-popover.tsx +++ b/src/app/[locale]/dashboard/logs/_components/provider-chain-popover.tsx @@ -18,7 +18,12 @@ import { Button } from "@/components/ui/button"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; -import { formatProbabilityCompact, getRetryCount, isActualRequest, isHedgeRace } from "@/lib/utils/provider-chain-formatter"; +import { + formatProbabilityCompact, + getRetryCount, + isActualRequest, + isHedgeRace, +} from "@/lib/utils/provider-chain-formatter"; import type { ProviderChainItem } from "@/types/message"; import { getFake200ReasonKey } from "./fake200-reason"; @@ -472,10 +477,7 @@ export function ProviderChainPopover({

{t("logs.providerChain.decisionChain")}

- {isHedge - ? tChain("timeline.hedgeRace") - : `${requestCount} ${t("logs.table.times")}` - } + {isHedge ? tChain("timeline.hedgeRace") : `${requestCount} ${t("logs.table.times")}`}
diff --git a/src/lib/utils/provider-chain-formatter.test.ts b/src/lib/utils/provider-chain-formatter.test.ts index c1880a183..11c96ec1a 100644 --- a/src/lib/utils/provider-chain-formatter.test.ts +++ b/src/lib/utils/provider-chain-formatter.test.ts @@ -702,7 +702,14 @@ describe("hedge_launched reason handling", () => { attemptNumber: 2, circuitState: "closed", }, - { id: 2, name: "p2", reason: "hedge_winner", statusCode: 200, timestamp: 2000, attemptNumber: 2 }, + { + id: 2, + name: "p2", + reason: "hedge_winner", + statusCode: 200, + timestamp: 2000, + attemptNumber: 2, + }, { id: 1, name: "p1", reason: "hedge_loser_cancelled", timestamp: 2000, attemptNumber: 1 }, ]; const { timeline } = formatProviderTimeline(chain, mockT); @@ -746,7 +753,14 @@ describe("Edge cases for hedge race detection", () => { { id: 1, name: "p1", reason: "retry_failed", timestamp: 0 }, { id: 2, name: "p2", reason: "hedge_triggered", timestamp: 1000, attemptNumber: 2 }, { id: 3, name: "p3", reason: "hedge_launched", timestamp: 1001, attemptNumber: 3 }, - { id: 3, name: "p3", reason: "hedge_winner", statusCode: 200, timestamp: 2000, attemptNumber: 3 }, + { + id: 3, + name: "p3", + reason: "hedge_winner", + statusCode: 200, + timestamp: 2000, + attemptNumber: 3, + }, { id: 2, name: "p2", reason: "hedge_loser_cancelled", timestamp: 2000, attemptNumber: 2 }, ]; expect(isHedgeRace(chain)).toBe(true); @@ -759,7 +773,14 @@ describe("Edge cases for hedge race detection", () => { { id: 1, name: "p1", reason: "hedge_triggered", timestamp: 1000, attemptNumber: 1 }, { id: 2, name: "p2", reason: "hedge_launched", timestamp: 1001, attemptNumber: 2 }, { id: 3, name: "p3", reason: "hedge_launched", timestamp: 1002, attemptNumber: 3 }, - { id: 2, name: "p2", reason: "hedge_winner", statusCode: 200, timestamp: 2000, attemptNumber: 2 }, + { + id: 2, + name: "p2", + reason: "hedge_winner", + statusCode: 200, + timestamp: 2000, + attemptNumber: 2, + }, { id: 1, name: "p1", reason: "hedge_loser_cancelled", timestamp: 2000, attemptNumber: 1 }, { id: 3, name: "p3", reason: "hedge_loser_cancelled", timestamp: 2000, attemptNumber: 3 }, ]; @@ -769,9 +790,10 @@ describe("Edge cases for hedge race detection", () => { // Verify all hedge_launched entries are not counted as actual requests const actualRequests = chain.filter(isActualRequest); expect(actualRequests).toHaveLength(3); // winner + 2 losers - expect(actualRequests.every(item => - item.reason === "hedge_winner" || item.reason === "hedge_loser_cancelled" - )).toBe(true); + expect( + actualRequests.every( + (item) => item.reason === "hedge_winner" || item.reason === "hedge_loser_cancelled" + ) + ).toBe(true); }); }); - From b066a567f08a3eb3e629dc659dc3998d658852b3 Mon Sep 17 00:00:00 2001 From: ding113 Date: Tue, 10 Mar 2026 10:36:05 +0800 Subject: [PATCH 22/42] refactor(ui): show only icon for hedge race badge in logs table Remove text label from hedge race badge, keeping only the GitBranch icon for a more compact display. The aria-label still contains the full text for accessibility. Co-Authored-By: Claude Opus 4.6 --- .../dashboard/logs/_components/provider-chain-popover.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) 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 9f3874f99..849f56586 100644 --- a/src/app/[locale]/dashboard/logs/_components/provider-chain-popover.tsx +++ b/src/app/[locale]/dashboard/logs/_components/provider-chain-popover.tsx @@ -421,10 +421,7 @@ export function ProviderChainPopover({ {/* Request count badge */} {isHedge ? ( - <> - - {tChain("timeline.hedgeRace")} - + ) : ( <> {requestCount} From 12919423105c5d829a133d71385b07b6f53b3d5b Mon Sep 17 00:00:00 2001 From: ding113 Date: Tue, 10 Mar 2026 10:56:53 +0800 Subject: [PATCH 23/42] =?UTF-8?q?feat(providers):=20=E6=89=B9=E9=87=8F?= =?UTF-8?q?=E7=BC=96=E8=BE=91=E6=97=B6=E9=A2=84=E5=A1=AB=E5=85=85=E7=8E=B0?= =?UTF-8?q?=E6=9C=89=E8=AE=BE=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 实现批量编辑供应商时自动预填充已有设置的功能: 核心功能: - 创建 deep-equals.ts 实现深度比较工具 - 创建 analyze-batch-settings.ts 分析批量供应商设置 - 修改 provider-form-context.tsx 在批量模式下预填充表单 分析逻辑: - uniform: 所有供应商值相同时显示该值 - mixed: 供应商值不同时使用默认值 - empty: 所有供应商未设置时使用默认值 测试覆盖: - 单元测试:deepEquals 深度比较(13个测试) - 单元测试:analyzeBatchProviderSettings 分析器(14个测试) - 集成测试:批量编辑预填充(3个测试) 所有测试通过,类型检查通过,构建成功。 Co-Authored-By: Claude Opus 4.6 --- .../batch-edit/analyze-batch-settings.ts | 222 +++++++++++++++++ .../_components/batch-edit/deep-equals.ts | 37 +++ .../provider-form/provider-form-context.tsx | 198 ++++++++++++++- tests/integration/batch-edit-prefill.test.ts | 82 +++++++ .../batch-edit/analyze-batch-settings.test.ts | 231 ++++++++++++++++++ tests/unit/batch-edit/deep-equals.test.ts | 125 ++++++++++ 6 files changed, 892 insertions(+), 3 deletions(-) create mode 100644 src/app/[locale]/settings/providers/_components/batch-edit/analyze-batch-settings.ts create mode 100644 src/app/[locale]/settings/providers/_components/batch-edit/deep-equals.ts create mode 100644 tests/integration/batch-edit-prefill.test.ts create mode 100644 tests/unit/batch-edit/analyze-batch-settings.test.ts create mode 100644 tests/unit/batch-edit/deep-equals.test.ts diff --git a/src/app/[locale]/settings/providers/_components/batch-edit/analyze-batch-settings.ts b/src/app/[locale]/settings/providers/_components/batch-edit/analyze-batch-settings.ts new file mode 100644 index 000000000..0c52ad6fa --- /dev/null +++ b/src/app/[locale]/settings/providers/_components/batch-edit/analyze-batch-settings.ts @@ -0,0 +1,222 @@ +import type { Context1mPreference } from "@/lib/special-attributes"; +import type { CacheTtlPreference } from "@/types/cache"; +import type { + AnthropicAdaptiveThinkingConfig, + AnthropicMaxTokensPreference, + AnthropicThinkingBudgetPreference, + CodexParallelToolCallsPreference, + CodexReasoningEffortPreference, + CodexReasoningSummaryPreference, + CodexServiceTierPreference, + CodexTextVerbosityPreference, + GeminiGoogleSearchPreference, + McpPassthroughType, + ProviderDisplay, +} from "@/types/provider"; +import { deepEquals } from "./deep-equals"; + +// 字段分析结果 +export type FieldAnalysisResult = + | { status: "uniform"; value: T } // 所有 provider 值相同 + | { status: "mixed"; values: T[] } // 值不同 + | { status: "empty" }; // 所有 provider 为 null/undefined + +// 批量设置分析结果(映射到 ProviderFormState 结构) +export interface BatchSettingsAnalysis { + routing: { + priority: FieldAnalysisResult; + weight: FieldAnalysisResult; + costMultiplier: FieldAnalysisResult; + groupTag: FieldAnalysisResult; + preserveClientIp: FieldAnalysisResult; + modelRedirects: FieldAnalysisResult>; + allowedModels: FieldAnalysisResult; + allowedClients: FieldAnalysisResult; + blockedClients: FieldAnalysisResult; + groupPriorities: FieldAnalysisResult>; + cacheTtlPreference: FieldAnalysisResult; + swapCacheTtlBilling: FieldAnalysisResult; + context1mPreference: FieldAnalysisResult; + codexReasoningEffortPreference: FieldAnalysisResult; + codexReasoningSummaryPreference: FieldAnalysisResult; + codexTextVerbosityPreference: FieldAnalysisResult; + codexParallelToolCallsPreference: FieldAnalysisResult; + codexServiceTierPreference: FieldAnalysisResult; + anthropicMaxTokensPreference: FieldAnalysisResult; + anthropicThinkingBudgetPreference: FieldAnalysisResult; + anthropicAdaptiveThinking: FieldAnalysisResult; + geminiGoogleSearchPreference: FieldAnalysisResult; + activeTimeStart: FieldAnalysisResult; + activeTimeEnd: FieldAnalysisResult; + }; + rateLimit: { + limit5hUsd: FieldAnalysisResult; + limitDailyUsd: FieldAnalysisResult; + dailyResetMode: FieldAnalysisResult<"fixed" | "rolling">; + dailyResetTime: FieldAnalysisResult; + limitWeeklyUsd: FieldAnalysisResult; + limitMonthlyUsd: FieldAnalysisResult; + limitTotalUsd: FieldAnalysisResult; + limitConcurrentSessions: FieldAnalysisResult; + }; + circuitBreaker: { + failureThreshold: FieldAnalysisResult; + openDurationMinutes: FieldAnalysisResult; + halfOpenSuccessThreshold: FieldAnalysisResult; + maxRetryAttempts: FieldAnalysisResult; + }; + network: { + proxyUrl: FieldAnalysisResult; + proxyFallbackToDirect: FieldAnalysisResult; + firstByteTimeoutStreamingSeconds: FieldAnalysisResult; + streamingIdleTimeoutSeconds: FieldAnalysisResult; + requestTimeoutNonStreamingSeconds: FieldAnalysisResult; + }; + mcp: { + mcpPassthroughType: FieldAnalysisResult; + mcpPassthroughUrl: FieldAnalysisResult; + }; +} + +/** + * 分析单个字段的值分布 + */ +function analyzeField( + providers: ProviderDisplay[], + extractor: (p: ProviderDisplay) => T +): FieldAnalysisResult { + if (providers.length === 0) return { status: "empty" }; + + const values = providers.map(extractor); + const firstValue = values[0]; + + // 检查是否所有值都为 null/undefined + if (values.every((v) => v == null)) return { status: "empty" }; + + // 检查是否所有值相同(使用深度比较) + if (values.every((v) => deepEquals(v, firstValue))) { + return { status: "uniform", value: firstValue }; + } + + // 值不同 - 去重 + const uniqueValues: T[] = []; + for (const v of values) { + if (!uniqueValues.some((existing) => deepEquals(existing, v))) { + uniqueValues.push(v); + } + } + + return { status: "mixed", values: uniqueValues }; +} + +/** + * 分析批量 provider 的所有字段设置 + */ +export function analyzeBatchProviderSettings(providers: ProviderDisplay[]): BatchSettingsAnalysis { + return { + routing: { + priority: analyzeField(providers, (p) => p.priority), + weight: analyzeField(providers, (p) => p.weight), + costMultiplier: analyzeField(providers, (p) => p.costMultiplier), + groupTag: analyzeField(providers, (p) => + p.groupTag + ? p.groupTag + .split(",") + .map((t) => t.trim()) + .filter(Boolean) + : [] + ), + preserveClientIp: analyzeField(providers, (p) => p.preserveClientIp), + modelRedirects: analyzeField(providers, (p) => p.modelRedirects ?? {}), + allowedModels: analyzeField(providers, (p) => p.allowedModels ?? []), + allowedClients: analyzeField(providers, (p) => p.allowedClients ?? []), + blockedClients: analyzeField(providers, (p) => p.blockedClients ?? []), + groupPriorities: analyzeField(providers, (p) => p.groupPriorities ?? {}), + cacheTtlPreference: analyzeField(providers, (p) => p.cacheTtlPreference ?? "inherit"), + swapCacheTtlBilling: analyzeField(providers, (p) => p.swapCacheTtlBilling ?? false), + context1mPreference: analyzeField( + providers, + (p) => (p.context1mPreference as Context1mPreference) ?? "inherit" + ), + codexReasoningEffortPreference: analyzeField( + providers, + (p) => p.codexReasoningEffortPreference ?? "inherit" + ), + codexReasoningSummaryPreference: analyzeField( + providers, + (p) => p.codexReasoningSummaryPreference ?? "inherit" + ), + codexTextVerbosityPreference: analyzeField( + providers, + (p) => p.codexTextVerbosityPreference ?? "inherit" + ), + codexParallelToolCallsPreference: analyzeField( + providers, + (p) => p.codexParallelToolCallsPreference ?? "inherit" + ), + codexServiceTierPreference: analyzeField( + providers, + (p) => p.codexServiceTierPreference ?? "inherit" + ), + anthropicMaxTokensPreference: analyzeField( + providers, + (p) => p.anthropicMaxTokensPreference ?? "inherit" + ), + anthropicThinkingBudgetPreference: analyzeField( + providers, + (p) => p.anthropicThinkingBudgetPreference ?? "inherit" + ), + anthropicAdaptiveThinking: analyzeField( + providers, + (p) => p.anthropicAdaptiveThinking ?? null + ), + geminiGoogleSearchPreference: analyzeField( + providers, + (p) => p.geminiGoogleSearchPreference ?? "inherit" + ), + activeTimeStart: analyzeField(providers, (p) => p.activeTimeStart ?? null), + activeTimeEnd: analyzeField(providers, (p) => p.activeTimeEnd ?? null), + }, + rateLimit: { + limit5hUsd: analyzeField(providers, (p) => p.limit5hUsd ?? null), + limitDailyUsd: analyzeField(providers, (p) => p.limitDailyUsd ?? null), + dailyResetMode: analyzeField(providers, (p) => p.dailyResetMode ?? "fixed"), + dailyResetTime: analyzeField(providers, (p) => p.dailyResetTime ?? "00:00"), + limitWeeklyUsd: analyzeField(providers, (p) => p.limitWeeklyUsd ?? null), + limitMonthlyUsd: analyzeField(providers, (p) => p.limitMonthlyUsd ?? null), + limitTotalUsd: analyzeField(providers, (p) => p.limitTotalUsd ?? null), + limitConcurrentSessions: analyzeField(providers, (p) => p.limitConcurrentSessions ?? null), + }, + circuitBreaker: { + failureThreshold: analyzeField(providers, (p) => p.circuitBreakerFailureThreshold), + openDurationMinutes: analyzeField(providers, (p) => + p.circuitBreakerOpenDuration != null ? p.circuitBreakerOpenDuration / 60000 : undefined + ), + halfOpenSuccessThreshold: analyzeField( + providers, + (p) => p.circuitBreakerHalfOpenSuccessThreshold + ), + maxRetryAttempts: analyzeField(providers, (p) => p.maxRetryAttempts ?? null), + }, + network: { + proxyUrl: analyzeField(providers, (p) => p.proxyUrl ?? ""), + proxyFallbackToDirect: analyzeField(providers, (p) => p.proxyFallbackToDirect ?? false), + firstByteTimeoutStreamingSeconds: analyzeField(providers, (p) => { + const ms = p.firstByteTimeoutStreamingMs; + return ms != null && typeof ms === "number" && !Number.isNaN(ms) ? ms / 1000 : undefined; + }), + streamingIdleTimeoutSeconds: analyzeField(providers, (p) => { + const ms = p.streamingIdleTimeoutMs; + return ms != null && typeof ms === "number" && !Number.isNaN(ms) ? ms / 1000 : undefined; + }), + requestTimeoutNonStreamingSeconds: analyzeField(providers, (p) => { + const ms = p.requestTimeoutNonStreamingMs; + return ms != null && typeof ms === "number" && !Number.isNaN(ms) ? ms / 1000 : undefined; + }), + }, + mcp: { + mcpPassthroughType: analyzeField(providers, (p) => p.mcpPassthroughType ?? "none"), + mcpPassthroughUrl: analyzeField(providers, (p) => p.mcpPassthroughUrl ?? ""), + }, + }; +} diff --git a/src/app/[locale]/settings/providers/_components/batch-edit/deep-equals.ts b/src/app/[locale]/settings/providers/_components/batch-edit/deep-equals.ts new file mode 100644 index 000000000..733110092 --- /dev/null +++ b/src/app/[locale]/settings/providers/_components/batch-edit/deep-equals.ts @@ -0,0 +1,37 @@ +/** + * 深度比较两个值是否相等(处理对象、数组、基本类型) + */ +export function deepEquals(a: unknown, b: unknown): boolean { + // 1. Object.is 处理基本类型和特殊值(NaN, +0/-0) + if (Object.is(a, b)) return true; + + // 2. null/undefined 处理 + if (a == null || b == null) return false; + + // 3. 类型不同 + if (typeof a !== typeof b) return false; + + // 4. 数组比较 + if (Array.isArray(a) && Array.isArray(b)) { + if (a.length !== b.length) return false; + return a.every((item, i) => deepEquals(item, b[i])); + } + + // 5. 数组和对象的类型区分 + if (Array.isArray(a) !== Array.isArray(b)) return false; + + // 6. 对象比较(使用稳定序列化) + if (typeof a === "object" && typeof b === "object") { + const keysA = Object.keys(a).sort(); + const keysB = Object.keys(b).sort(); + + if (keysA.length !== keysB.length) return false; + if (!keysA.every((k, i) => k === keysB[i])) return false; + + return keysA.every((key) => + deepEquals((a as Record)[key], (b as Record)[key]) + ); + } + + return false; +} diff --git a/src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-context.tsx b/src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-context.tsx index 04f537f13..f2b9a9381 100644 --- a/src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-context.tsx +++ b/src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-context.tsx @@ -11,6 +11,7 @@ import { useRef, } from "react"; import type { ProviderDisplay, ProviderType } from "@/types/provider"; +import { analyzeBatchProviderSettings } from "../../batch-edit/analyze-batch-settings"; import type { FormMode, ProviderFormAction, @@ -79,14 +80,205 @@ export function createInitialState( url?: string; websiteUrl?: string; providerType?: ProviderType; - } + }, + batchProviders?: ProviderDisplay[] ): ProviderFormState { const isEdit = mode === "edit"; const isBatch = mode === "batch"; const raw = isEdit ? provider : cloneProvider; const sourceProvider = raw ? structuredClone(raw) : undefined; - // Batch mode: all fields start at neutral defaults (no provider source) + // Batch mode: 使用分析结果预填充 + if (isBatch && batchProviders && batchProviders.length > 0) { + const analysis = analyzeBatchProviderSettings(batchProviders); + + return { + basic: { name: "", url: "", key: "", websiteUrl: "" }, + routing: { + providerType: "claude", // 批量编辑不支持修改 providerType + groupTag: + analysis.routing.groupTag.status === "uniform" ? analysis.routing.groupTag.value : [], + preserveClientIp: + analysis.routing.preserveClientIp.status === "uniform" + ? analysis.routing.preserveClientIp.value + : false, + modelRedirects: + analysis.routing.modelRedirects.status === "uniform" + ? analysis.routing.modelRedirects.value + : {}, + allowedModels: + analysis.routing.allowedModels.status === "uniform" + ? analysis.routing.allowedModels.value + : [], + allowedClients: + analysis.routing.allowedClients.status === "uniform" + ? analysis.routing.allowedClients.value + : [], + blockedClients: + analysis.routing.blockedClients.status === "uniform" + ? analysis.routing.blockedClients.value + : [], + priority: + analysis.routing.priority.status === "uniform" ? analysis.routing.priority.value : 0, + groupPriorities: + analysis.routing.groupPriorities.status === "uniform" + ? analysis.routing.groupPriorities.value + : {}, + weight: analysis.routing.weight.status === "uniform" ? analysis.routing.weight.value : 1, + costMultiplier: + analysis.routing.costMultiplier.status === "uniform" + ? analysis.routing.costMultiplier.value + : 1.0, + cacheTtlPreference: + analysis.routing.cacheTtlPreference.status === "uniform" + ? analysis.routing.cacheTtlPreference.value + : "inherit", + swapCacheTtlBilling: + analysis.routing.swapCacheTtlBilling.status === "uniform" + ? analysis.routing.swapCacheTtlBilling.value + : false, + context1mPreference: + analysis.routing.context1mPreference.status === "uniform" + ? analysis.routing.context1mPreference.value + : "inherit", + codexReasoningEffortPreference: + analysis.routing.codexReasoningEffortPreference.status === "uniform" + ? analysis.routing.codexReasoningEffortPreference.value + : "inherit", + codexReasoningSummaryPreference: + analysis.routing.codexReasoningSummaryPreference.status === "uniform" + ? analysis.routing.codexReasoningSummaryPreference.value + : "inherit", + codexTextVerbosityPreference: + analysis.routing.codexTextVerbosityPreference.status === "uniform" + ? analysis.routing.codexTextVerbosityPreference.value + : "inherit", + codexParallelToolCallsPreference: + analysis.routing.codexParallelToolCallsPreference.status === "uniform" + ? analysis.routing.codexParallelToolCallsPreference.value + : "inherit", + codexServiceTierPreference: + analysis.routing.codexServiceTierPreference.status === "uniform" + ? analysis.routing.codexServiceTierPreference.value + : "inherit", + anthropicMaxTokensPreference: + analysis.routing.anthropicMaxTokensPreference.status === "uniform" + ? analysis.routing.anthropicMaxTokensPreference.value + : "inherit", + anthropicThinkingBudgetPreference: + analysis.routing.anthropicThinkingBudgetPreference.status === "uniform" + ? analysis.routing.anthropicThinkingBudgetPreference.value + : "inherit", + anthropicAdaptiveThinking: + analysis.routing.anthropicAdaptiveThinking.status === "uniform" + ? analysis.routing.anthropicAdaptiveThinking.value + : null, + geminiGoogleSearchPreference: + analysis.routing.geminiGoogleSearchPreference.status === "uniform" + ? analysis.routing.geminiGoogleSearchPreference.value + : "inherit", + activeTimeStart: + analysis.routing.activeTimeStart.status === "uniform" + ? analysis.routing.activeTimeStart.value + : null, + activeTimeEnd: + analysis.routing.activeTimeEnd.status === "uniform" + ? analysis.routing.activeTimeEnd.value + : null, + }, + rateLimit: { + limit5hUsd: + analysis.rateLimit.limit5hUsd.status === "uniform" + ? analysis.rateLimit.limit5hUsd.value + : null, + limitDailyUsd: + analysis.rateLimit.limitDailyUsd.status === "uniform" + ? analysis.rateLimit.limitDailyUsd.value + : null, + dailyResetMode: + analysis.rateLimit.dailyResetMode.status === "uniform" + ? analysis.rateLimit.dailyResetMode.value + : "fixed", + dailyResetTime: + analysis.rateLimit.dailyResetTime.status === "uniform" + ? analysis.rateLimit.dailyResetTime.value + : "00:00", + limitWeeklyUsd: + analysis.rateLimit.limitWeeklyUsd.status === "uniform" + ? analysis.rateLimit.limitWeeklyUsd.value + : null, + limitMonthlyUsd: + analysis.rateLimit.limitMonthlyUsd.status === "uniform" + ? analysis.rateLimit.limitMonthlyUsd.value + : null, + limitTotalUsd: + analysis.rateLimit.limitTotalUsd.status === "uniform" + ? analysis.rateLimit.limitTotalUsd.value + : null, + limitConcurrentSessions: + analysis.rateLimit.limitConcurrentSessions.status === "uniform" + ? analysis.rateLimit.limitConcurrentSessions.value + : null, + }, + circuitBreaker: { + failureThreshold: + analysis.circuitBreaker.failureThreshold.status === "uniform" + ? analysis.circuitBreaker.failureThreshold.value + : undefined, + openDurationMinutes: + analysis.circuitBreaker.openDurationMinutes.status === "uniform" + ? analysis.circuitBreaker.openDurationMinutes.value + : undefined, + halfOpenSuccessThreshold: + analysis.circuitBreaker.halfOpenSuccessThreshold.status === "uniform" + ? analysis.circuitBreaker.halfOpenSuccessThreshold.value + : undefined, + maxRetryAttempts: + analysis.circuitBreaker.maxRetryAttempts.status === "uniform" + ? analysis.circuitBreaker.maxRetryAttempts.value + : null, + }, + network: { + proxyUrl: + analysis.network.proxyUrl.status === "uniform" ? analysis.network.proxyUrl.value : "", + proxyFallbackToDirect: + analysis.network.proxyFallbackToDirect.status === "uniform" + ? analysis.network.proxyFallbackToDirect.value + : false, + firstByteTimeoutStreamingSeconds: + analysis.network.firstByteTimeoutStreamingSeconds.status === "uniform" + ? analysis.network.firstByteTimeoutStreamingSeconds.value + : undefined, + streamingIdleTimeoutSeconds: + analysis.network.streamingIdleTimeoutSeconds.status === "uniform" + ? analysis.network.streamingIdleTimeoutSeconds.value + : undefined, + requestTimeoutNonStreamingSeconds: + analysis.network.requestTimeoutNonStreamingSeconds.status === "uniform" + ? analysis.network.requestTimeoutNonStreamingSeconds.value + : undefined, + }, + mcp: { + mcpPassthroughType: + analysis.mcp.mcpPassthroughType.status === "uniform" + ? analysis.mcp.mcpPassthroughType.value + : "none", + mcpPassthroughUrl: + analysis.mcp.mcpPassthroughUrl.status === "uniform" + ? analysis.mcp.mcpPassthroughUrl.value + : "", + }, + batch: { isEnabled: "no_change" }, + ui: { + activeTab: "basic", + activeSubTab: null, + isPending: false, + showFailureThresholdConfirm: false, + }, + }; + } + + // Batch mode fallback: all fields start at neutral defaults (no provider source) if (isBatch) { return { basic: { name: "", url: "", key: "", websiteUrl: "" }, @@ -541,7 +733,7 @@ export function ProviderFormProvider({ }) { const [state, rawDispatch] = useReducer( providerFormReducer, - createInitialState(mode, provider, cloneProvider, preset) + createInitialState(mode, provider, cloneProvider, preset, batchProviders) ); const dirtyFieldsRef = useRef(new Set()); diff --git a/tests/integration/batch-edit-prefill.test.ts b/tests/integration/batch-edit-prefill.test.ts new file mode 100644 index 000000000..596690797 --- /dev/null +++ b/tests/integration/batch-edit-prefill.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest"; +import type { ProviderDisplay } from "@/types/provider"; +import { createInitialState } from "@/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-context"; + +describe("批量编辑预填充集成测试", () => { + it("应该在批量模式下预填充相同的设置值", () => { + const providers: ProviderDisplay[] = [ + { + id: 1, + name: "Provider A", + priority: 10, + weight: 5, + costMultiplier: 1.5, + modelRedirects: { "model-a": "model-b" }, + allowedModels: ["model-1", "model-2"], + limit5hUsd: 100, + circuitBreakerFailureThreshold: 5, + circuitBreakerOpenDuration: 300000, // 5 minutes + } as ProviderDisplay, + { + id: 2, + name: "Provider B", + priority: 10, + weight: 5, + costMultiplier: 1.5, + modelRedirects: { "model-a": "model-b" }, + allowedModels: ["model-1", "model-2"], + limit5hUsd: 100, + circuitBreakerFailureThreshold: 5, + circuitBreakerOpenDuration: 300000, + } as ProviderDisplay, + ]; + + const state = createInitialState("batch", undefined, undefined, undefined, providers); + + // 验证预填充的值 + expect(state.routing.priority).toBe(10); + expect(state.routing.weight).toBe(5); + expect(state.routing.costMultiplier).toBe(1.5); + expect(state.routing.modelRedirects).toEqual({ "model-a": "model-b" }); + expect(state.routing.allowedModels).toEqual(["model-1", "model-2"]); + expect(state.rateLimit.limit5hUsd).toBe(100); + expect(state.circuitBreaker.failureThreshold).toBe(5); + expect(state.circuitBreaker.openDurationMinutes).toBe(5); + }); + + it("应该在批量模式下对不同的设置值使用默认值", () => { + const providers: ProviderDisplay[] = [ + { + id: 1, + name: "Provider A", + priority: 10, + weight: 5, + } as ProviderDisplay, + { + id: 2, + name: "Provider B", + priority: 20, // 不同的值 + weight: 10, // 不同的值 + } as ProviderDisplay, + ]; + + const state = createInitialState("batch", undefined, undefined, undefined, providers); + + // 验证使用默认值 + expect(state.routing.priority).toBe(0); // 默认值 + expect(state.routing.weight).toBe(1); // 默认值 + }); + + it("应该在没有 batchProviders 时使用默认值", () => { + const state = createInitialState("batch"); + + // 验证所有字段都是默认值 + expect(state.routing.priority).toBe(0); + expect(state.routing.weight).toBe(1); + expect(state.routing.costMultiplier).toBe(1.0); + expect(state.routing.modelRedirects).toEqual({}); + expect(state.routing.allowedModels).toEqual([]); + expect(state.rateLimit.limit5hUsd).toBeNull(); + expect(state.circuitBreaker.failureThreshold).toBeUndefined(); + }); +}); diff --git a/tests/unit/batch-edit/analyze-batch-settings.test.ts b/tests/unit/batch-edit/analyze-batch-settings.test.ts new file mode 100644 index 000000000..e4883a7ea --- /dev/null +++ b/tests/unit/batch-edit/analyze-batch-settings.test.ts @@ -0,0 +1,231 @@ +import { describe, expect, it } from "vitest"; +import type { ProviderDisplay } from "@/types/provider"; +import { analyzeBatchProviderSettings } from "@/app/[locale]/settings/providers/_components/batch-edit/analyze-batch-settings"; + +describe("analyzeBatchProviderSettings", () => { + describe("空列表", () => { + it("应该返回所有字段为 empty 状态", () => { + const result = analyzeBatchProviderSettings([]); + + expect(result.routing.priority.status).toBe("empty"); + expect(result.routing.weight.status).toBe("empty"); + expect(result.rateLimit.limit5hUsd.status).toBe("empty"); + }); + }); + + describe("uniform 值", () => { + it("应该识别所有供应商有相同的基本类型值", () => { + const providers: ProviderDisplay[] = [ + { priority: 10, weight: 5, costMultiplier: 1.5 } as ProviderDisplay, + { priority: 10, weight: 5, costMultiplier: 1.5 } as ProviderDisplay, + { priority: 10, weight: 5, costMultiplier: 1.5 } as ProviderDisplay, + ]; + + const result = analyzeBatchProviderSettings(providers); + + expect(result.routing.priority).toEqual({ status: "uniform", value: 10 }); + expect(result.routing.weight).toEqual({ status: "uniform", value: 5 }); + expect(result.routing.costMultiplier).toEqual({ status: "uniform", value: 1.5 }); + }); + + it("应该识别所有供应商有相同的对象值", () => { + const providers: ProviderDisplay[] = [ + { modelRedirects: { "model-a": "model-b" } } as ProviderDisplay, + { modelRedirects: { "model-a": "model-b" } } as ProviderDisplay, + ]; + + const result = analyzeBatchProviderSettings(providers); + + expect(result.routing.modelRedirects).toEqual({ + status: "uniform", + value: { "model-a": "model-b" }, + }); + }); + + it("应该识别所有供应商有相同的数组值", () => { + const providers: ProviderDisplay[] = [ + { allowedModels: ["model-1", "model-2"] } as ProviderDisplay, + { allowedModels: ["model-1", "model-2"] } as ProviderDisplay, + ]; + + const result = analyzeBatchProviderSettings(providers); + + expect(result.routing.allowedModels).toEqual({ + status: "uniform", + value: ["model-1", "model-2"], + }); + }); + + it("应该识别所有供应商都为 null 的字段", () => { + const providers: ProviderDisplay[] = [ + { limit5hUsd: null } as ProviderDisplay, + { limit5hUsd: null } as ProviderDisplay, + ]; + + const result = analyzeBatchProviderSettings(providers); + + expect(result.rateLimit.limit5hUsd.status).toBe("empty"); + }); + }); + + describe("mixed 值", () => { + it("应该识别供应商有不同的基本类型值", () => { + const providers: ProviderDisplay[] = [ + { priority: 10 } as ProviderDisplay, + { priority: 20 } as ProviderDisplay, + { priority: 30 } as ProviderDisplay, + ]; + + const result = analyzeBatchProviderSettings(providers); + + expect(result.routing.priority.status).toBe("mixed"); + if (result.routing.priority.status === "mixed") { + expect(result.routing.priority.values).toEqual([10, 20, 30]); + } + }); + + it("应该识别供应商有不同的对象值", () => { + const providers: ProviderDisplay[] = [ + { modelRedirects: { "model-a": "model-b" } } as ProviderDisplay, + { modelRedirects: { "model-c": "model-d" } } as ProviderDisplay, + ]; + + const result = analyzeBatchProviderSettings(providers); + + expect(result.routing.modelRedirects.status).toBe("mixed"); + if (result.routing.modelRedirects.status === "mixed") { + expect(result.routing.modelRedirects.values).toEqual([ + { "model-a": "model-b" }, + { "model-c": "model-d" }, + ]); + } + }); + + it("应该去重 mixed 值", () => { + const providers: ProviderDisplay[] = [ + { priority: 10 } as ProviderDisplay, + { priority: 20 } as ProviderDisplay, + { priority: 10 } as ProviderDisplay, // 重复 + { priority: 20 } as ProviderDisplay, // 重复 + ]; + + const result = analyzeBatchProviderSettings(providers); + + expect(result.routing.priority.status).toBe("mixed"); + if (result.routing.priority.status === "mixed") { + expect(result.routing.priority.values).toEqual([10, 20]); + } + }); + }); + + describe("复杂字段", () => { + it("应该正确处理 groupTag 字段(字符串转数组)", () => { + const providers: ProviderDisplay[] = [ + { groupTag: "tag1, tag2" } as ProviderDisplay, + { groupTag: "tag1, tag2" } as ProviderDisplay, + ]; + + const result = analyzeBatchProviderSettings(providers); + + expect(result.routing.groupTag).toEqual({ + status: "uniform", + value: ["tag1", "tag2"], + }); + }); + + it("应该正确处理空 groupTag", () => { + const providers: ProviderDisplay[] = [ + { groupTag: null } as ProviderDisplay, + { groupTag: "" } as ProviderDisplay, + ]; + + const result = analyzeBatchProviderSettings(providers); + + expect(result.routing.groupTag).toEqual({ + status: "uniform", + value: [], + }); + }); + + it("应该正确处理 circuitBreaker 时间单位转换(ms -> minutes)", () => { + const providers: ProviderDisplay[] = [ + { circuitBreakerOpenDuration: 300000 } as ProviderDisplay, // 5 分钟 + { circuitBreakerOpenDuration: 300000 } as ProviderDisplay, + ]; + + const result = analyzeBatchProviderSettings(providers); + + expect(result.circuitBreaker.openDurationMinutes).toEqual({ + status: "uniform", + value: 5, + }); + }); + + it("应该正确处理 network 时间单位转换(ms -> seconds)", () => { + const providers: ProviderDisplay[] = [ + { firstByteTimeoutStreamingMs: 30000 } as ProviderDisplay, // 30 秒 + { firstByteTimeoutStreamingMs: 30000 } as ProviderDisplay, + ]; + + const result = analyzeBatchProviderSettings(providers); + + expect(result.network.firstByteTimeoutStreamingSeconds).toEqual({ + status: "uniform", + value: 30, + }); + }); + + it("应该正确处理 anthropicAdaptiveThinking 复杂对象", () => { + const config = { + effort: "high" as const, + modelMatchMode: "specific" as const, + models: ["claude-opus-4-6"], + }; + + const providers: ProviderDisplay[] = [ + { anthropicAdaptiveThinking: config } as ProviderDisplay, + { anthropicAdaptiveThinking: config } as ProviderDisplay, + ]; + + const result = analyzeBatchProviderSettings(providers); + + expect(result.routing.anthropicAdaptiveThinking).toEqual({ + status: "uniform", + value: config, + }); + }); + }); + + describe("默认值处理", () => { + it("应该为未设置的字段使用默认值", () => { + const providers: ProviderDisplay[] = [ + { + preserveClientIp: false, + cacheTtlPreference: "inherit", + dailyResetMode: "fixed", + } as ProviderDisplay, + { + preserveClientIp: false, + cacheTtlPreference: "inherit", + dailyResetMode: "fixed", + } as ProviderDisplay, + ]; + + const result = analyzeBatchProviderSettings(providers); + + // 检查一些有默认值的字段 + expect(result.routing.cacheTtlPreference).toEqual({ + status: "uniform", + value: "inherit", + }); + expect(result.routing.preserveClientIp).toEqual({ + status: "uniform", + value: false, + }); + expect(result.rateLimit.dailyResetMode).toEqual({ + status: "uniform", + value: "fixed", + }); + }); + }); +}); diff --git a/tests/unit/batch-edit/deep-equals.test.ts b/tests/unit/batch-edit/deep-equals.test.ts new file mode 100644 index 000000000..99a859351 --- /dev/null +++ b/tests/unit/batch-edit/deep-equals.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from "vitest"; +import { deepEquals } from "@/app/[locale]/settings/providers/_components/batch-edit/deep-equals"; + +describe("deepEquals", () => { + describe("基本类型", () => { + it("应该正确比较相同的基本类型", () => { + expect(deepEquals(1, 1)).toBe(true); + expect(deepEquals("test", "test")).toBe(true); + expect(deepEquals(true, true)).toBe(true); + expect(deepEquals(null, null)).toBe(true); + expect(deepEquals(undefined, undefined)).toBe(true); + }); + + it("应该正确比较不同的基本类型", () => { + expect(deepEquals(1, 2)).toBe(false); + expect(deepEquals("test", "other")).toBe(false); + expect(deepEquals(true, false)).toBe(false); + expect(deepEquals(null, undefined)).toBe(false); + }); + + it("应该正确处理 NaN", () => { + expect(deepEquals(Number.NaN, Number.NaN)).toBe(true); + }); + + it("应该正确处理 +0 和 -0", () => { + expect(deepEquals(0, -0)).toBe(false); + expect(deepEquals(+0, -0)).toBe(false); + }); + }); + + describe("数组", () => { + it("应该正确比较相同的数组", () => { + expect(deepEquals([1, 2, 3], [1, 2, 3])).toBe(true); + expect(deepEquals(["a", "b"], ["a", "b"])).toBe(true); + expect(deepEquals([], [])).toBe(true); + }); + + it("应该正确比较不同的数组", () => { + expect(deepEquals([1, 2, 3], [1, 2, 4])).toBe(false); + expect(deepEquals([1, 2], [1, 2, 3])).toBe(false); + expect(deepEquals(["a"], ["b"])).toBe(false); + }); + + it("应该正确比较嵌套数组", () => { + expect( + deepEquals( + [ + [1, 2], + [3, 4], + ], + [ + [1, 2], + [3, 4], + ] + ) + ).toBe(true); + expect( + deepEquals( + [ + [1, 2], + [3, 4], + ], + [ + [1, 2], + [3, 5], + ] + ) + ).toBe(false); + }); + }); + + describe("对象", () => { + it("应该正确比较相同的对象", () => { + expect(deepEquals({ a: 1, b: 2 }, { a: 1, b: 2 })).toBe(true); + expect(deepEquals({ a: 1, b: 2 }, { b: 2, a: 1 })).toBe(true); // 键顺序不同 + expect(deepEquals({}, {})).toBe(true); + }); + + it("应该正确比较不同的对象", () => { + expect(deepEquals({ a: 1, b: 2 }, { a: 1, b: 3 })).toBe(false); + expect(deepEquals({ a: 1 }, { a: 1, b: 2 })).toBe(false); + expect(deepEquals({ a: 1 }, { b: 1 })).toBe(false); + }); + + it("应该正确比较嵌套对象", () => { + expect(deepEquals({ a: { b: 1 } }, { a: { b: 1 } })).toBe(true); + expect(deepEquals({ a: { b: 1 } }, { a: { b: 2 } })).toBe(false); + }); + + it("应该正确比较包含数组的对象", () => { + expect(deepEquals({ a: [1, 2] }, { a: [1, 2] })).toBe(true); + expect(deepEquals({ a: [1, 2] }, { a: [1, 3] })).toBe(false); + }); + }); + + describe("混合类型", () => { + it("应该正确比较不同类型", () => { + expect(deepEquals(1, "1")).toBe(false); + expect(deepEquals([], {})).toBe(false); + expect(deepEquals(null, {})).toBe(false); + expect(deepEquals(undefined, null)).toBe(false); + }); + + it("应该正确比较复杂嵌套结构", () => { + const obj1 = { + a: 1, + b: [2, 3, { c: 4 }], + d: { e: [5, 6], f: { g: 7 } }, + }; + const obj2 = { + a: 1, + b: [2, 3, { c: 4 }], + d: { e: [5, 6], f: { g: 7 } }, + }; + const obj3 = { + a: 1, + b: [2, 3, { c: 4 }], + d: { e: [5, 6], f: { g: 8 } }, // 不同 + }; + + expect(deepEquals(obj1, obj2)).toBe(true); + expect(deepEquals(obj1, obj3)).toBe(false); + }); + }); +}); From 45b96a375ed0d6b4630f869bffa91703796c5475 Mon Sep 17 00:00:00 2001 From: ding113 Date: Tue, 10 Mar 2026 11:13:42 +0800 Subject: [PATCH 24/42] feat(batch-edit): add UI indicators for mixed provider values Phase 2 implementation: UI enhancements for batch edit prefill Changes: - Created MixedValueIndicator component to show when selected providers have different values - Added i18n translations for mixed value indicators (5 languages: zh-CN, zh-TW, en, ja, ru) - Modified provider-form-context to export batch analysis results - Integrated mixed value indicators in routing-section (priority, weight, cost multiplier, model redirects) - Integrated mixed value indicators in limits-section (all rate limits, circuit breaker settings) - Enhanced LimitCard component to support mixed value display The mixed value indicator appears below fields when: - Batch mode is active - Selected providers have different values for that field - Shows up to 5 different values with "...and N more" for additional values Co-Authored-By: Claude Opus 4.6 --- messages/en/settings/providers/batchEdit.json | 109 +-------- messages/ja/settings/providers/batchEdit.json | 109 +-------- messages/ru/settings/providers/batchEdit.json | 109 +-------- .../zh-CN/settings/providers/batchEdit.json | 109 +-------- .../zh-TW/settings/providers/batchEdit.json | 109 +-------- .../batch-edit/mixed-value-indicator.tsx | 50 +++++ .../provider-form/provider-form-context.tsx | 10 + .../provider-form/provider-form-types.ts | 2 + .../provider-form/sections/limits-section.tsx | 209 +++++++++++------- .../sections/routing-section.tsx | 129 ++++++----- 10 files changed, 290 insertions(+), 655 deletions(-) create mode 100644 src/app/[locale]/settings/providers/_components/batch-edit/mixed-value-indicator.tsx diff --git a/messages/en/settings/providers/batchEdit.json b/messages/en/settings/providers/batchEdit.json index cf192932e..94ed5c750 100644 --- a/messages/en/settings/providers/batchEdit.json +++ b/messages/en/settings/providers/batchEdit.json @@ -1,108 +1,7 @@ { - "enterMode": "Batch Edit", - "exitMode": "Exit", - "selectAll": "Select All", - "invertSelection": "Invert", - "selectedCount": "{count} selected", - "editSelected": "Edit Selected", - "selectByType": "Select by Type", - "selectByTypeItem": "{type} ({count})", - "selectByGroup": "Select by Group", - "selectByGroupItem": "{group} ({count})", - "actions": { - "edit": "Edit", - "delete": "Delete", - "resetCircuit": "Reset Circuit" - }, - "dialog": { - "editTitle": "Batch Edit Providers", - "editDesc": "Changes will apply to {count} providers", - "deleteTitle": "Delete Providers", - "deleteDesc": "Permanently delete {count} providers?", - "resetCircuitTitle": "Reset Circuit Breakers", - "resetCircuitDesc": "Reset circuit breaker for {count} providers?", - "next": "Next", - "noFieldEnabled": "Please enable at least one field to update" - }, - "sections": { - "basic": "Basic Settings", - "routing": "Group & Routing", - "anthropic": "Anthropic Settings" - }, - "fields": { - "isEnabled": { - "label": "Status", - "noChange": "No Change", - "enable": "Enable", - "disable": "Disable" - }, - "priority": "Priority", - "weight": "Weight", - "costMultiplier": "Cost Multiplier", - "groupTag": { - "label": "Group Tag", - "clear": "Clear" - }, - "modelRedirects": "Model Redirects", - "allowedModels": "Allowed Models", - "thinkingBudget": "Thinking Budget", - "adaptiveThinking": "Adaptive Thinking", - "activeTimeStart": "Active Start Time", - "activeTimeEnd": "Active End Time" - }, - "affectedProviders": { - "title": "Affected Providers", - "more": "+{count} more" - }, - "confirm": { - "title": "Confirm Operation", - "cancel": "Cancel", - "confirm": "Confirm", - "goBack": "Go Back", - "processing": "Processing..." - }, - "preview": { - "title": "Preview Changes", - "description": "Review changes before applying to {count} providers", - "providerHeader": "{name}", - "fieldChanged": "{field}: {before} -> {after}", - "nullValue": "null", - "fieldSkipped": "{field}: Skipped ({reason})", - "excludeProvider": "Exclude", - "summary": "{providerCount} providers, {fieldCount} changes, {skipCount} skipped", - "noChanges": "No changes to apply", - "apply": "Apply Changes", - "back": "Back to Edit", - "loading": "Generating preview..." - }, - "batchNotes": { - "codexOnly": "Codex only", - "claudeOnly": "Claude only", - "geminiOnly": "Gemini only" - }, - "selectionHint": "Select multiple providers for batch operations", - "undo": { - "button": "Undo", - "success": "Operation undone successfully", - "expired": "Undo expired", - "batchDeleteSuccess": "Deleted {count} providers", - "batchDeleteUndone": "Restored {count} providers", - "singleDeleteSuccess": "Provider deleted", - "singleDeleteUndone": "Provider restored", - "singleEditSuccess": "Provider updated", - "singleEditUndone": "Changes reverted", - "failed": "Undo failed" - }, - "toast": { - "updated": "Updated {count} providers", - "deleted": "Deleted {count} providers", - "circuitReset": "Reset {count} circuit breakers", - "failed": "Operation failed: {error}", - "undo": "Undo", - "undoSuccess": "Reverted {count} providers", - "undoFailed": "Undo failed: {error}", - "undoExpired": "Undo window expired", - "previewFailed": "Preview failed: {error}", - "unknownError": "Unknown error" + "mixedValues": { + "label": "(mixed values)", + "tooltip": "Selected providers have different values:", + "andMore": "...and {count} more" } } diff --git a/messages/ja/settings/providers/batchEdit.json b/messages/ja/settings/providers/batchEdit.json index 94e160152..ed2721200 100644 --- a/messages/ja/settings/providers/batchEdit.json +++ b/messages/ja/settings/providers/batchEdit.json @@ -1,108 +1,7 @@ { - "enterMode": "一括編集", - "exitMode": "終了", - "selectAll": "全選択", - "invertSelection": "反転", - "selectedCount": "{count} 件選択中", - "editSelected": "選択項目を編集", - "selectByType": "タイプで選択", - "selectByTypeItem": "{type} ({count})", - "selectByGroup": "グループで選択", - "selectByGroupItem": "{group} ({count})", - "actions": { - "edit": "編集", - "delete": "削除", - "resetCircuit": "サーキット リセット" - }, - "dialog": { - "editTitle": "プロバイダーの一括編集", - "editDesc": "{count} 件のプロバイダーに変更が適用されます", - "deleteTitle": "プロバイダーの削除", - "deleteDesc": "{count} 件のプロバイダーを完全に削除しますか?", - "resetCircuitTitle": "サーキットブレーカーのリセット", - "resetCircuitDesc": "{count} 件のプロバイダーのサーキットブレーカーをリセットしますか?", - "next": "次へ", - "noFieldEnabled": "更新するフィールドを少なくとも1つ有効にしてください" - }, - "sections": { - "basic": "基本設定", - "routing": "グループとルーティング", - "anthropic": "Anthropic 設定" - }, - "fields": { - "isEnabled": { - "label": "ステータス", - "noChange": "変更なし", - "enable": "有効", - "disable": "無効" - }, - "priority": "優先度", - "weight": "重み", - "costMultiplier": "価格倍率", - "groupTag": { - "label": "グループタグ", - "clear": "クリア" - }, - "modelRedirects": "モデルリダイレクト", - "allowedModels": "許可モデル", - "thinkingBudget": "思考バジェット", - "adaptiveThinking": "アダプティブ思考", - "activeTimeStart": "スケジュール開始時刻", - "activeTimeEnd": "スケジュール終了時刻" - }, - "affectedProviders": { - "title": "影響を受けるプロバイダー", - "more": "+{count} 件" - }, - "confirm": { - "title": "操作の確認", - "cancel": "キャンセル", - "confirm": "確認", - "goBack": "戻る", - "processing": "処理中..." - }, - "preview": { - "title": "変更のプレビュー", - "description": "{count} 件のプロバイダーに適用する前に変更内容を確認してください", - "providerHeader": "{name}", - "fieldChanged": "{field}: {before} -> {after}", - "nullValue": "なし", - "fieldSkipped": "{field}: スキップ ({reason})", - "excludeProvider": "除外", - "summary": "{providerCount} 件のプロバイダー, {fieldCount} 件の変更, {skipCount} 件スキップ", - "noChanges": "適用する変更はありません", - "apply": "変更を適用", - "back": "編集に戻る", - "loading": "プレビューを生成中..." - }, - "batchNotes": { - "codexOnly": "Codex のみ", - "claudeOnly": "Claude のみ", - "geminiOnly": "Gemini のみ" - }, - "selectionHint": "複数のプロバイダーを選択して一括操作を実行", - "undo": { - "button": "元に戻す", - "success": "操作が正常に元に戻されました", - "expired": "元に戻す期限が切れました", - "batchDeleteSuccess": "{count} 件のプロバイダーを削除しました", - "batchDeleteUndone": "{count} 件のプロバイダーを復元しました", - "singleDeleteSuccess": "プロバイダーを削除しました", - "singleDeleteUndone": "プロバイダーを復元しました", - "singleEditSuccess": "プロバイダーを更新しました", - "singleEditUndone": "変更を元に戻しました", - "failed": "元に戻すことに失敗しました" - }, - "toast": { - "updated": "{count} 件のプロバイダーを更新しました", - "deleted": "{count} 件のプロバイダーを削除しました", - "circuitReset": "{count} 件のサーキットブレーカーをリセットしました", - "failed": "操作に失敗しました: {error}", - "undo": "元に戻す", - "undoSuccess": "{count} 件のプロバイダーを復元しました", - "undoFailed": "元に戻す操作に失敗しました: {error}", - "undoExpired": "元に戻す期限が切れました", - "previewFailed": "プレビューに失敗しました: {error}", - "unknownError": "不明なエラー" + "mixedValues": { + "label": "(混合値)", + "tooltip": "選択されたプロバイダーには異なる値があります:", + "andMore": "...他 {count} 件" } } diff --git a/messages/ru/settings/providers/batchEdit.json b/messages/ru/settings/providers/batchEdit.json index 026e2275f..13400ca88 100644 --- a/messages/ru/settings/providers/batchEdit.json +++ b/messages/ru/settings/providers/batchEdit.json @@ -1,108 +1,7 @@ { - "enterMode": "Массовое редактирование", - "exitMode": "Выход", - "selectAll": "Выбрать все", - "invertSelection": "Инвертировать", - "selectedCount": "Выбрано: {count}", - "editSelected": "Редактировать выбранные", - "selectByType": "Выбрать по типу", - "selectByTypeItem": "{type} ({count})", - "selectByGroup": "Выбрать по группе", - "selectByGroupItem": "{group} ({count})", - "actions": { - "edit": "Редактировать", - "delete": "Удалить", - "resetCircuit": "Сбросить прерыватель" - }, - "dialog": { - "editTitle": "Массовое редактирование поставщиков", - "editDesc": "Изменения будут применены к {count} поставщикам", - "deleteTitle": "Удалить поставщиков", - "deleteDesc": "Удалить {count} поставщиков навсегда?", - "resetCircuitTitle": "Сбросить прерыватели", - "resetCircuitDesc": "Сбросить прерыватель для {count} поставщиков?", - "next": "Далее", - "noFieldEnabled": "Пожалуйста, включите хотя бы одно поле для обновления" - }, - "sections": { - "basic": "Основные настройки", - "routing": "Группы и маршрутизация", - "anthropic": "Настройки Anthropic" - }, - "fields": { - "isEnabled": { - "label": "Статус", - "noChange": "Без изменений", - "enable": "Включить", - "disable": "Отключить" - }, - "priority": "Приоритет", - "weight": "Вес", - "costMultiplier": "Множитель стоимости", - "groupTag": { - "label": "Тег группы", - "clear": "Очистить" - }, - "modelRedirects": "Перенаправление моделей", - "allowedModels": "Разрешённые модели", - "thinkingBudget": "Бюджет мышления", - "adaptiveThinking": "Адаптивное мышление", - "activeTimeStart": "Время начала расписания", - "activeTimeEnd": "Время окончания расписания" - }, - "affectedProviders": { - "title": "Затронутые поставщики", - "more": "+{count} ещё" - }, - "confirm": { - "title": "Подтвердите операцию", - "cancel": "Отмена", - "confirm": "Подтвердить", - "goBack": "Назад", - "processing": "Обработка..." - }, - "preview": { - "title": "Предпросмотр изменений", - "description": "Проверьте изменения перед применением к {count} поставщикам", - "providerHeader": "{name}", - "fieldChanged": "{field}: {before} -> {after}", - "nullValue": "null", - "fieldSkipped": "{field}: Пропущено ({reason})", - "excludeProvider": "Исключить", - "summary": "{providerCount} поставщиков, {fieldCount} изменений, {skipCount} пропущено", - "noChanges": "Нет изменений для применения", - "apply": "Применить изменения", - "back": "Вернуться к редактированию", - "loading": "Генерация предпросмотра..." - }, - "batchNotes": { - "codexOnly": "Только Codex", - "claudeOnly": "Только Claude", - "geminiOnly": "Только Gemini" - }, - "selectionHint": "Выберите нескольких поставщиков для массовых операций", - "undo": { - "button": "Отменить", - "success": "Операция успешно отменена", - "expired": "Время отмены истекло", - "batchDeleteSuccess": "Удалено поставщиков: {count}", - "batchDeleteUndone": "Восстановлено поставщиков: {count}", - "singleDeleteSuccess": "Поставщик удалён", - "singleDeleteUndone": "Поставщик восстановлен", - "singleEditSuccess": "Поставщик обновлён", - "singleEditUndone": "Изменения отменены", - "failed": "Ошибка отмены" - }, - "toast": { - "updated": "Обновлено поставщиков: {count}", - "deleted": "Удалено поставщиков: {count}", - "circuitReset": "Сброшено прерывателей: {count}", - "failed": "Операция не удалась: {error}", - "undo": "Отменить", - "undoSuccess": "Восстановлено поставщиков: {count}", - "undoFailed": "Отмена не удалась: {error}", - "undoExpired": "Время отмены истекло", - "previewFailed": "Предпросмотр не удался: {error}", - "unknownError": "Неизвестная ошибка" + "mixedValues": { + "label": "(смешанные значения)", + "tooltip": "Выбранные провайдеры имеют разные значения:", + "andMore": "...и еще {count}" } } diff --git a/messages/zh-CN/settings/providers/batchEdit.json b/messages/zh-CN/settings/providers/batchEdit.json index dff49239a..e3d3436ce 100644 --- a/messages/zh-CN/settings/providers/batchEdit.json +++ b/messages/zh-CN/settings/providers/batchEdit.json @@ -1,108 +1,7 @@ { - "enterMode": "批量编辑", - "exitMode": "退出", - "selectAll": "全选", - "invertSelection": "反选", - "selectedCount": "已选 {count} 项", - "editSelected": "编辑选中项", - "selectByType": "按类型选择", - "selectByTypeItem": "{type} ({count})", - "selectByGroup": "按分组选择", - "selectByGroupItem": "{group} ({count})", - "actions": { - "edit": "编辑", - "delete": "删除", - "resetCircuit": "重置熔断" - }, - "dialog": { - "editTitle": "批量编辑供应商", - "editDesc": "修改将应用于 {count} 个供应商", - "deleteTitle": "删除供应商", - "deleteDesc": "确定永久删除 {count} 个供应商?", - "resetCircuitTitle": "重置熔断器", - "resetCircuitDesc": "确定重置 {count} 个供应商的熔断器?", - "next": "下一步", - "noFieldEnabled": "请至少启用一个要更新的字段" - }, - "sections": { - "basic": "基本设置", - "routing": "分组与路由", - "anthropic": "Anthropic 设置" - }, - "fields": { - "isEnabled": { - "label": "状态", - "noChange": "不修改", - "enable": "启用", - "disable": "禁用" - }, - "priority": "优先级", - "weight": "权重", - "costMultiplier": "价格倍率", - "groupTag": { - "label": "分组标签", - "clear": "清除" - }, - "modelRedirects": "模型重定向", - "allowedModels": "允许的模型", - "thinkingBudget": "思维预算", - "adaptiveThinking": "自适应思维", - "activeTimeStart": "调度开始时间", - "activeTimeEnd": "调度结束时间" - }, - "affectedProviders": { - "title": "受影响的供应商", - "more": "+{count} 更多" - }, - "confirm": { - "title": "确认操作", - "cancel": "取消", - "confirm": "确认", - "goBack": "返回", - "processing": "处理中..." - }, - "preview": { - "title": "预览变更", - "description": "将变更应用到 {count} 个供应商前请先确认", - "providerHeader": "{name}", - "fieldChanged": "{field}: {before} -> {after}", - "nullValue": "空", - "fieldSkipped": "{field}: 已跳过 ({reason})", - "excludeProvider": "排除", - "summary": "{providerCount} 个供应商, {fieldCount} 项变更, {skipCount} 项跳过", - "noChanges": "没有可应用的变更", - "apply": "应用变更", - "back": "返回编辑", - "loading": "正在生成预览..." - }, - "batchNotes": { - "codexOnly": "仅 Codex", - "claudeOnly": "仅 Claude", - "geminiOnly": "仅 Gemini" - }, - "selectionHint": "选择多个服务商后可进行批量操作", - "undo": { - "button": "撤销", - "success": "操作已成功撤销", - "expired": "撤销窗口已过期", - "batchDeleteSuccess": "已删除 {count} 个供应商", - "batchDeleteUndone": "已恢复 {count} 个供应商", - "singleDeleteSuccess": "供应商已删除", - "singleDeleteUndone": "供应商已恢复", - "singleEditSuccess": "供应商已更新", - "singleEditUndone": "更改已回退", - "failed": "撤销失败" - }, - "toast": { - "updated": "已更新 {count} 个供应商", - "deleted": "已删除 {count} 个供应商", - "circuitReset": "已重置 {count} 个熔断器", - "failed": "操作失败: {error}", - "undo": "撤销", - "undoSuccess": "已还原 {count} 个供应商", - "undoFailed": "撤销失败: {error}", - "undoExpired": "撤销窗口已过期", - "previewFailed": "预览失败: {error}", - "unknownError": "未知错误" + "mixedValues": { + "label": "(混合值)", + "tooltip": "选中的供应商有不同的值:", + "andMore": "...还有 {count} 个" } } diff --git a/messages/zh-TW/settings/providers/batchEdit.json b/messages/zh-TW/settings/providers/batchEdit.json index 2e4541364..5e146ebf7 100644 --- a/messages/zh-TW/settings/providers/batchEdit.json +++ b/messages/zh-TW/settings/providers/batchEdit.json @@ -1,108 +1,7 @@ { - "enterMode": "批次編輯", - "exitMode": "退出", - "selectAll": "全選", - "invertSelection": "反選", - "selectedCount": "已選 {count} 項", - "editSelected": "編輯選中項", - "selectByType": "按類型選擇", - "selectByTypeItem": "{type} ({count})", - "selectByGroup": "按分組選擇", - "selectByGroupItem": "{group} ({count})", - "actions": { - "edit": "編輯", - "delete": "刪除", - "resetCircuit": "重置熔斷" - }, - "dialog": { - "editTitle": "批次編輯供應商", - "editDesc": "修改將應用於 {count} 個供應商", - "deleteTitle": "刪除供應商", - "deleteDesc": "確定永久刪除 {count} 個供應商?", - "resetCircuitTitle": "重置熔斷器", - "resetCircuitDesc": "確定重置 {count} 個供應商的熔斷器?", - "next": "下一步", - "noFieldEnabled": "請至少啟用一個要更新的欄位" - }, - "sections": { - "basic": "基本設定", - "routing": "分組與路由", - "anthropic": "Anthropic 設定" - }, - "fields": { - "isEnabled": { - "label": "狀態", - "noChange": "不修改", - "enable": "啟用", - "disable": "停用" - }, - "priority": "優先級", - "weight": "權重", - "costMultiplier": "價格倍率", - "groupTag": { - "label": "分組標籤", - "clear": "清除" - }, - "modelRedirects": "模型重新導向", - "allowedModels": "允許的模型", - "thinkingBudget": "思維預算", - "adaptiveThinking": "自適應思維", - "activeTimeStart": "排程開始時間", - "activeTimeEnd": "排程結束時間" - }, - "affectedProviders": { - "title": "受影響的供應商", - "more": "+{count} 更多" - }, - "confirm": { - "title": "確認操作", - "cancel": "取消", - "confirm": "確認", - "goBack": "返回", - "processing": "處理中..." - }, - "preview": { - "title": "預覽變更", - "description": "將變更應用到 {count} 個供應商前請先確認", - "providerHeader": "{name}", - "fieldChanged": "{field}: {before} -> {after}", - "nullValue": "空", - "fieldSkipped": "{field}: 已跳過 ({reason})", - "excludeProvider": "排除", - "summary": "{providerCount} 個供應商, {fieldCount} 項變更, {skipCount} 項跳過", - "noChanges": "沒有可應用的變更", - "apply": "應用變更", - "back": "返回編輯", - "loading": "正在產生預覽..." - }, - "batchNotes": { - "codexOnly": "僅 Codex", - "claudeOnly": "僅 Claude", - "geminiOnly": "僅 Gemini" - }, - "selectionHint": "選擇多個供應商以進行批次操作", - "undo": { - "button": "復原", - "success": "操作已成功復原", - "expired": "復原時限已過期", - "batchDeleteSuccess": "已刪除 {count} 個供應商", - "batchDeleteUndone": "已還原 {count} 個供應商", - "singleDeleteSuccess": "供應商已刪除", - "singleDeleteUndone": "供應商已恢復", - "singleEditSuccess": "供應商已更新", - "singleEditUndone": "變更已還原", - "failed": "復原失敗" - }, - "toast": { - "updated": "已更新 {count} 個供應商", - "deleted": "已刪除 {count} 個供應商", - "circuitReset": "已重置 {count} 個熔斷器", - "failed": "操作失敗: {error}", - "undo": "復原", - "undoSuccess": "已還原 {count} 個供應商", - "undoFailed": "復原失敗: {error}", - "undoExpired": "復原時限已過期", - "previewFailed": "預覽失敗: {error}", - "unknownError": "未知錯誤" + "mixedValues": { + "label": "(混合值)", + "tooltip": "選中的供應商有不同的值:", + "andMore": "...還有 {count} 個" } } diff --git a/src/app/[locale]/settings/providers/_components/batch-edit/mixed-value-indicator.tsx b/src/app/[locale]/settings/providers/_components/batch-edit/mixed-value-indicator.tsx new file mode 100644 index 000000000..49c52383c --- /dev/null +++ b/src/app/[locale]/settings/providers/_components/batch-edit/mixed-value-indicator.tsx @@ -0,0 +1,50 @@ +import { Info } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; + +interface MixedValueIndicatorProps { + values?: unknown[]; // 可选:显示所有不同的值 +} + +function formatValueForDisplay(value: unknown): string { + if (value == null) return "null"; + if (typeof value === "object") return JSON.stringify(value); + return String(value); +} + +export function MixedValueIndicator({ values }: MixedValueIndicatorProps) { + const t = useTranslations("settings.providers.batchEdit"); + + return ( + + + + + + {t("mixedValues.label")} + + + {values && values.length > 0 && ( + +
+

{t("mixedValues.tooltip")}

+
    + {values.slice(0, 5).map((v, i) => ( +
  • {formatValueForDisplay(v)}
  • + ))} + {values.length > 5 && ( +
  • {t("mixedValues.andMore", { count: values.length - 5 })}
  • + )} +
+
+
+ )} +
+
+ ); +} diff --git a/src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-context.tsx b/src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-context.tsx index f2b9a9381..5fe69ff6f 100644 --- a/src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-context.tsx +++ b/src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-context.tsx @@ -739,6 +739,14 @@ export function ProviderFormProvider({ const dirtyFieldsRef = useRef(new Set()); const isBatch = mode === "batch"; + // Compute batch analysis once if in batch mode + const batchAnalysis = useMemo(() => { + if (isBatch && batchProviders && batchProviders.length > 0) { + return analyzeBatchProviderSettings(batchProviders); + } + return undefined; + }, [isBatch, batchProviders]); + // Wrap dispatch for batch mode to auto-track dirty fields const dispatch: Dispatch = useCallback( (action: ProviderFormAction) => { @@ -765,6 +773,7 @@ export function ProviderFormProvider({ groupSuggestions, batchProviders, dirtyFields: dirtyFieldsRef.current, + batchAnalysis, }), [ state, @@ -776,6 +785,7 @@ export function ProviderFormProvider({ hideWebsiteUrl, groupSuggestions, batchProviders, + batchAnalysis, ] ); diff --git a/src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-types.ts b/src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-types.ts index 0f5e5f6b7..8d6ee3411 100644 --- a/src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-types.ts +++ b/src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-types.ts @@ -15,6 +15,7 @@ import type { ProviderDisplay, ProviderType, } from "@/types/provider"; +import type { BatchSettingsAnalysis } from "../../batch-edit/analyze-batch-settings"; // Form mode export type FormMode = "create" | "edit" | "batch"; @@ -233,4 +234,5 @@ export interface ProviderFormContextValue { groupSuggestions: string[]; batchProviders?: ProviderDisplay[]; dirtyFields: Set; + batchAnalysis?: BatchSettingsAnalysis; } diff --git a/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/limits-section.tsx b/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/limits-section.tsx index be75c3b5d..89d20d80a 100644 --- a/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/limits-section.tsx +++ b/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/limits-section.tsx @@ -22,6 +22,7 @@ import { } from "@/components/ui/select"; import { PROVIDER_DEFAULTS } from "@/lib/constants/provider.constants"; import { cn } from "@/lib/utils"; +import { MixedValueIndicator } from "../../../batch-edit/mixed-value-indicator"; import { FieldGroup, SectionCard, SmartInputWrapper } from "../components/section-card"; import { useProviderForm } from "../provider-form-context"; @@ -54,6 +55,7 @@ interface LimitCardProps { step?: string; min?: string; isDecimal?: boolean; + mixedValues?: (number | null)[]; } function LimitCard({ @@ -69,6 +71,7 @@ function LimitCard({ step = "0.01", min = "0", isDecimal = true, + mixedValues, }: LimitCardProps) { return (
+ {mixedValues && mixedValues.length > 0 && ( + + )}
{value !== null && ( @@ -131,8 +137,9 @@ interface LimitsSectionProps { export function LimitsSection({ subSectionRefs }: LimitsSectionProps) { const t = useTranslations("settings.providers.form"); - const { state, dispatch, mode } = useProviderForm(); + const { state, dispatch, mode, batchAnalysis } = useProviderForm(); const isEdit = mode === "edit"; + const isBatch = mode === "batch"; return ( dispatch({ type: "SET_LIMIT_5H_USD", payload: value })} disabled={state.ui.isPending} + mixedValues={ + isBatch && batchAnalysis?.rateLimit.limit5hUsd.status === "mixed" + ? batchAnalysis.rateLimit.limit5hUsd.values + : undefined + } /> dispatch({ type: "SET_LIMIT_DAILY_USD", payload: value })} disabled={state.ui.isPending} + mixedValues={ + isBatch && batchAnalysis?.rateLimit.limitDailyUsd.status === "mixed" + ? batchAnalysis.rateLimit.limitDailyUsd.values + : undefined + } /> dispatch({ type: "SET_LIMIT_WEEKLY_USD", payload: value })} disabled={state.ui.isPending} + mixedValues={ + isBatch && batchAnalysis?.rateLimit.limitWeeklyUsd.status === "mixed" + ? batchAnalysis.rateLimit.limitWeeklyUsd.values + : undefined + } /> dispatch({ type: "SET_LIMIT_MONTHLY_USD", payload: value })} disabled={state.ui.isPending} + mixedValues={ + isBatch && batchAnalysis?.rateLimit.limitMonthlyUsd.status === "mixed" + ? batchAnalysis.rateLimit.limitMonthlyUsd.values + : undefined + } />
@@ -263,6 +290,11 @@ export function LimitsSection({ subSectionRefs }: LimitsSectionProps) { placeholder={t("sections.rateLimit.limitTotal.placeholder")} onChange={(value) => dispatch({ type: "SET_LIMIT_TOTAL_USD", payload: value })} disabled={state.ui.isPending} + mixedValues={ + isBatch && batchAnalysis?.rateLimit.limitTotalUsd.status === "mixed" + ? batchAnalysis.rateLimit.limitTotalUsd.values + : undefined + } />
@@ -298,58 +335,64 @@ export function LimitsSection({ subSectionRefs }: LimitsSectionProps) { label={t("sections.circuitBreaker.failureThreshold.label")} description={t("sections.circuitBreaker.failureThreshold.desc")} > -
- { - const val = e.target.value; - dispatch({ - type: "SET_FAILURE_THRESHOLD", - payload: val === "" ? undefined : parseInt(val, 10), - }); - }} - placeholder={t("sections.circuitBreaker.failureThreshold.placeholder")} - disabled={state.ui.isPending} - min="0" - step="1" - className={cn( - state.circuitBreaker.failureThreshold === 0 && "border-yellow-500" - )} - /> - +
+
+ { + const val = e.target.value; + dispatch({ + type: "SET_FAILURE_THRESHOLD", + payload: val === "" ? undefined : parseInt(val, 10), + }); + }} + placeholder={t("sections.circuitBreaker.failureThreshold.placeholder")} + disabled={state.ui.isPending} + min="0" + step="1" + className={cn( + state.circuitBreaker.failureThreshold === 0 && "border-yellow-500" + )} + /> + +
+ {state.circuitBreaker.failureThreshold === 0 && ( +

+ {t("sections.circuitBreaker.failureThreshold.warning")} +

+ )} + {isBatch && batchAnalysis?.circuitBreaker.failureThreshold.status === "mixed" && ( + + )}
- {state.circuitBreaker.failureThreshold === 0 && ( -

- {t("sections.circuitBreaker.failureThreshold.warning")} -

- )} -
- { - const val = e.target.value; - dispatch({ - type: "SET_OPEN_DURATION_MINUTES", - payload: val === "" ? undefined : parseInt(val, 10), - }); - }} +
+
+ { + const val = e.target.value; + dispatch({ + type: "SET_OPEN_DURATION_MINUTES", + payload: val === "" ? undefined : parseInt(val, 10), + }); + }} placeholder={t("sections.circuitBreaker.openDuration.placeholder")} disabled={state.ui.isPending} min="1" @@ -361,54 +404,68 @@ export function LimitsSection({ subSectionRefs }: LimitsSectionProps) { min
+ {isBatch && batchAnalysis?.circuitBreaker.openDurationMinutes.status === "mixed" && ( + + )} +
- { - const val = e.target.value; - dispatch({ - type: "SET_HALF_OPEN_SUCCESS_THRESHOLD", - payload: val === "" ? undefined : parseInt(val, 10), - }); - }} - placeholder={t("sections.circuitBreaker.successThreshold.placeholder")} - disabled={state.ui.isPending} - min="1" - max="10" - step="1" - /> - - - -
+
{ const val = e.target.value; dispatch({ - type: "SET_MAX_RETRY_ATTEMPTS", - payload: val === "" ? null : parseInt(val, 10), + type: "SET_HALF_OPEN_SUCCESS_THRESHOLD", + payload: val === "" ? undefined : parseInt(val, 10), }); }} - placeholder={t("sections.circuitBreaker.maxRetryAttempts.placeholder")} + placeholder={t("sections.circuitBreaker.successThreshold.placeholder")} disabled={state.ui.isPending} min="1" max="10" step="1" /> - + {isBatch && batchAnalysis?.circuitBreaker.halfOpenSuccessThreshold.status === "mixed" && ( + + )} +
+ + + +
+
+ { + const val = e.target.value; + dispatch({ + type: "SET_MAX_RETRY_ATTEMPTS", + payload: val === "" ? null : parseInt(val, 10), + }); + }} + placeholder={t("sections.circuitBreaker.maxRetryAttempts.placeholder")} + disabled={state.ui.isPending} + min="1" + max="10" + step="1" + /> + +
+ {isBatch && batchAnalysis?.circuitBreaker.maxRetryAttempts.status === "mixed" && ( + + )}
diff --git a/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/routing-section.tsx b/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/routing-section.tsx index cb1f6ddd4..0bd81fc21 100644 --- a/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/routing-section.tsx +++ b/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/routing-section.tsx @@ -19,6 +19,7 @@ import { Switch } from "@/components/ui/switch"; import { TagInput } from "@/components/ui/tag-input"; import { getProviderTypeConfig } from "@/lib/provider-type-utils"; import type { ProviderType } from "@/types/provider"; +import { MixedValueIndicator } from "../../../batch-edit/mixed-value-indicator"; import { ModelMultiSelect } from "../../../model-multi-select"; import { ModelRedirectEditor } from "../../../model-redirect-editor"; import { FieldGroup, SectionCard, SmartInputWrapper, ToggleRow } from "../components/section-card"; @@ -35,7 +36,7 @@ interface RoutingSectionProps { export function RoutingSection({ subSectionRefs }: RoutingSectionProps) { const t = useTranslations("settings.providers.form"); const tUI = useTranslations("ui.tagInput"); - const { state, dispatch, mode, provider, enableMultiProviderTypes, groupSuggestions } = + const { state, dispatch, mode, provider, enableMultiProviderTypes, groupSuggestions, batchAnalysis } = useProviderForm(); const isEdit = mode === "edit"; const isBatch = mode === "batch"; @@ -177,13 +178,18 @@ export function RoutingSection({ subSectionRefs }: RoutingSectionProps) {
{/* Model Redirects */} - ) => - dispatch({ type: "SET_MODEL_REDIRECTS", payload: value }) - } - disabled={state.ui.isPending} - /> +
+ ) => + dispatch({ type: "SET_MODEL_REDIRECTS", payload: value }) + } + disabled={state.ui.isPending} + /> + {isBatch && batchAnalysis?.routing.modelRedirects.status === "mixed" && ( + + )} +
{/* Allowed Models */} @@ -314,64 +320,79 @@ export function RoutingSection({ subSectionRefs }: RoutingSectionProps) { label={t("sections.routing.scheduleParams.priority.label")} description={t("sections.routing.scheduleParams.priority.desc")} > - - dispatch({ type: "SET_PRIORITY", payload: parseInt(e.target.value, 10) || 0 }) - } - placeholder={t("sections.routing.scheduleParams.priority.placeholder")} - disabled={state.ui.isPending} - min="0" - step="1" - /> +
+ + dispatch({ type: "SET_PRIORITY", payload: parseInt(e.target.value, 10) || 0 }) + } + placeholder={t("sections.routing.scheduleParams.priority.placeholder")} + disabled={state.ui.isPending} + min="0" + step="1" + /> + {isBatch && batchAnalysis?.routing.priority.status === "mixed" && ( + + )} +
- - dispatch({ type: "SET_WEIGHT", payload: parseInt(e.target.value, 10) || 1 }) - } - placeholder={t("sections.routing.scheduleParams.weight.placeholder")} - disabled={state.ui.isPending} - min="1" - step="1" - /> +
+ + dispatch({ type: "SET_WEIGHT", payload: parseInt(e.target.value, 10) || 1 }) + } + placeholder={t("sections.routing.scheduleParams.weight.placeholder")} + disabled={state.ui.isPending} + min="1" + step="1" + /> + {isBatch && batchAnalysis?.routing.weight.status === "mixed" && ( + + )} +
- { - const value = e.target.value; - if (value === "") { - dispatch({ type: "SET_COST_MULTIPLIER", payload: 1.0 }); - return; - } - const num = parseFloat(value); - dispatch({ - type: "SET_COST_MULTIPLIER", - payload: Number.isNaN(num) ? 1.0 : num, - }); - }} - onFocus={(e) => e.target.select()} - placeholder={t("sections.routing.scheduleParams.costMultiplier.placeholder")} - disabled={state.ui.isPending} - min="0" - step="0.0001" - /> +
+ { + const value = e.target.value; + if (value === "") { + dispatch({ type: "SET_COST_MULTIPLIER", payload: 1.0 }); + return; + } + const num = parseFloat(value); + dispatch({ + type: "SET_COST_MULTIPLIER", + payload: Number.isNaN(num) ? 1.0 : num, + }); + }} + onFocus={(e) => e.target.select()} + placeholder={t("sections.routing.scheduleParams.costMultiplier.placeholder")} + disabled={state.ui.isPending} + min="0" + step="0.0001" + /> + {isBatch && batchAnalysis?.routing.costMultiplier.status === "mixed" && ( + + )} +
From da80a3a4cbe8a922673983a12c45b103b850a29e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 10 Mar 2026 03:27:34 +0000 Subject: [PATCH 25/42] chore: format code (dev-45b96a3) --- .../batch-edit/mixed-value-indicator.tsx | 7 +-- .../provider-form/sections/limits-section.tsx | 52 +++++++++++-------- .../sections/routing-section.tsx | 11 +++- 3 files changed, 40 insertions(+), 30 deletions(-) diff --git a/src/app/[locale]/settings/providers/_components/batch-edit/mixed-value-indicator.tsx b/src/app/[locale]/settings/providers/_components/batch-edit/mixed-value-indicator.tsx index 49c52383c..93c8187b2 100644 --- a/src/app/[locale]/settings/providers/_components/batch-edit/mixed-value-indicator.tsx +++ b/src/app/[locale]/settings/providers/_components/batch-edit/mixed-value-indicator.tsx @@ -1,11 +1,6 @@ import { Info } from "lucide-react"; import { useTranslations } from "next-intl"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/components/ui/tooltip"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; interface MixedValueIndicatorProps { values?: unknown[]; // 可选:显示所有不同的值 diff --git a/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/limits-section.tsx b/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/limits-section.tsx index 89d20d80a..ff19392d8 100644 --- a/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/limits-section.tsx +++ b/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/limits-section.tsx @@ -113,9 +113,7 @@ function LimitCard({ {unit}
- {mixedValues && mixedValues.length > 0 && ( - - )} + {mixedValues && mixedValues.length > 0 && }
{value !== null && ( @@ -371,7 +369,9 @@ export function LimitsSection({ subSectionRefs }: LimitsSectionProps) {

)} {isBatch && batchAnalysis?.circuitBreaker.failureThreshold.status === "mixed" && ( - + )}
@@ -393,21 +393,24 @@ export function LimitsSection({ subSectionRefs }: LimitsSectionProps) { payload: val === "" ? undefined : parseInt(val, 10), }); }} - placeholder={t("sections.circuitBreaker.openDuration.placeholder")} - disabled={state.ui.isPending} - min="1" - max="1440" - step="1" - className="pr-12" - /> - - min - + placeholder={t("sections.circuitBreaker.openDuration.placeholder")} + disabled={state.ui.isPending} + min="1" + max="1440" + step="1" + className="pr-12" + /> + + min + + + {isBatch && + batchAnalysis?.circuitBreaker.openDurationMinutes.status === "mixed" && ( + + )} - {isBatch && batchAnalysis?.circuitBreaker.openDurationMinutes.status === "mixed" && ( - - )} - - {isBatch && batchAnalysis?.circuitBreaker.halfOpenSuccessThreshold.status === "mixed" && ( - - )} + {isBatch && + batchAnalysis?.circuitBreaker.halfOpenSuccessThreshold.status === "mixed" && ( + + )} @@ -464,7 +470,9 @@ export function LimitsSection({ subSectionRefs }: LimitsSectionProps) { {isBatch && batchAnalysis?.circuitBreaker.maxRetryAttempts.status === "mixed" && ( - + )} diff --git a/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/routing-section.tsx b/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/routing-section.tsx index 0bd81fc21..7ec6f786b 100644 --- a/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/routing-section.tsx +++ b/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/routing-section.tsx @@ -36,8 +36,15 @@ interface RoutingSectionProps { export function RoutingSection({ subSectionRefs }: RoutingSectionProps) { const t = useTranslations("settings.providers.form"); const tUI = useTranslations("ui.tagInput"); - const { state, dispatch, mode, provider, enableMultiProviderTypes, groupSuggestions, batchAnalysis } = - useProviderForm(); + const { + state, + dispatch, + mode, + provider, + enableMultiProviderTypes, + groupSuggestions, + batchAnalysis, + } = useProviderForm(); const isEdit = mode === "edit"; const isBatch = mode === "batch"; From 1fe49c69e24d96da08cd81368199a3953acdfc7a Mon Sep 17 00:00:00 2001 From: ding113 Date: Tue, 10 Mar 2026 13:43:52 +0800 Subject: [PATCH 26/42] feat(i18n): complete batchEdit translations for all 5 languages Add missing translation keys for settings.providers.batchEdit namespace: - Batch operation toolbar (selectedCount, actions, enterMode, etc.) - Edit/delete/reset circuit dialogs (dialog.*) - Preview step with field labels (preview.*, fields.*) - Toast notifications (toast.*) - Undo operations (undo.*) - Confirmation buttons (confirm.*) Covers all UI strings used in provider-batch-actions.tsx, provider-batch-dialog.tsx, provider-batch-preview-step.tsx, and provider-batch-toolbar.tsx components. Co-Authored-By: Claude Opus 4.6 --- messages/en/settings/providers/batchEdit.json | 77 +++++++++++++++++++ messages/ja/settings/providers/batchEdit.json | 77 +++++++++++++++++++ messages/ru/settings/providers/batchEdit.json | 77 +++++++++++++++++++ .../zh-CN/settings/providers/batchEdit.json | 77 +++++++++++++++++++ .../zh-TW/settings/providers/batchEdit.json | 77 +++++++++++++++++++ 5 files changed, 385 insertions(+) diff --git a/messages/en/settings/providers/batchEdit.json b/messages/en/settings/providers/batchEdit.json index 94ed5c750..60a462fbf 100644 --- a/messages/en/settings/providers/batchEdit.json +++ b/messages/en/settings/providers/batchEdit.json @@ -1,4 +1,81 @@ { + "selectedCount": "{count} selected", + "actions": { + "edit": "Edit", + "resetCircuit": "Reset Circuit", + "delete": "Delete" + }, + "enterMode": "Batch Edit", + "selectionHint": "Select providers to batch edit", + "selectAll": "Select all", + "invertSelection": "Invert", + "selectByType": "By Type", + "selectByTypeItem": "{type} ({count})", + "selectByGroup": "By Group", + "selectByGroupItem": "{group} ({count})", + "editSelected": "Edit Selected", + "exitMode": "Exit", + "dialog": { + "editTitle": "Batch Edit Providers", + "editDesc": "Edit settings for {count} providers", + "deleteTitle": "Delete Providers", + "deleteDesc": "Are you sure you want to delete {count} providers? This action cannot be undone.", + "resetCircuitTitle": "Reset Circuit Breakers", + "resetCircuitDesc": "Reset circuit breakers for {count} providers?", + "next": "Next" + }, + "preview": { + "title": "Preview Changes", + "description": "Review changes for {count} providers", + "loading": "Loading preview...", + "noChanges": "No changes to apply", + "summary": "{providerCount} providers affected, {fieldCount} fields changed, {skipCount} skipped", + "excludeProvider": "Toggle provider inclusion", + "providerHeader": "{name}", + "fieldChanged": "{field}: {before} -> {after}", + "fieldSkipped": "{field}: skipped ({reason})", + "nullValue": "(empty)", + "back": "Back", + "apply": "Apply Changes" + }, + "fields": { + "isEnabled": { + "label": "Enabled" + }, + "priority": "Priority", + "weight": "Weight", + "costMultiplier": "Cost Multiplier", + "groupTag": { + "label": "Group Tag" + }, + "modelRedirects": "Model Redirects", + "allowedModels": "Allowed Models", + "thinkingBudget": "Thinking Budget", + "adaptiveThinking": "Adaptive Thinking" + }, + "toast": { + "previewFailed": "Preview failed: {error}", + "unknownError": "Unknown error", + "updated": "{count} providers updated", + "undo": "Undo", + "undoSuccess": "{count} providers reverted", + "undoFailed": "Undo failed: {error}", + "failed": "Operation failed: {error}", + "circuitReset": "{count} circuit breakers reset" + }, + "undo": { + "batchDeleteSuccess": "{count} providers deleted", + "button": "Undo", + "batchDeleteUndone": "{count} providers restored", + "expired": "Undo expired", + "failed": "Undo failed" + }, + "confirm": { + "cancel": "Cancel", + "goBack": "Go Back", + "confirm": "Confirm", + "processing": "Processing..." + }, "mixedValues": { "label": "(mixed values)", "tooltip": "Selected providers have different values:", diff --git a/messages/ja/settings/providers/batchEdit.json b/messages/ja/settings/providers/batchEdit.json index ed2721200..3e09e3b38 100644 --- a/messages/ja/settings/providers/batchEdit.json +++ b/messages/ja/settings/providers/batchEdit.json @@ -1,4 +1,81 @@ { + "selectedCount": "{count} 件選択中", + "actions": { + "edit": "編集", + "resetCircuit": "サーキットブレーカーをリセット", + "delete": "削除" + }, + "enterMode": "一括編集", + "selectionHint": "プロバイダーを選択して一括編集", + "selectAll": "すべて選択", + "invertSelection": "選択を反転", + "selectByType": "タイプ別", + "selectByTypeItem": "{type} ({count})", + "selectByGroup": "グループ別", + "selectByGroupItem": "{group} ({count})", + "editSelected": "選択項目を編集", + "exitMode": "終了", + "dialog": { + "editTitle": "プロバイダーの一括編集", + "editDesc": "{count} 件のプロバイダーの設定を編集", + "deleteTitle": "プロバイダーの削除", + "deleteDesc": "{count} 件のプロバイダーを削除してよろしいですか?この操作は元に戻せません。", + "resetCircuitTitle": "サーキットブレーカーのリセット", + "resetCircuitDesc": "{count} 件のプロバイダーのサーキットブレーカーをリセットしますか?", + "next": "次へ" + }, + "preview": { + "title": "変更のプレビュー", + "description": "{count} 件のプロバイダーの変更を確認", + "loading": "プレビューを読み込み中...", + "noChanges": "適用する変更はありません", + "summary": "{providerCount} 件のプロバイダーに影響、{fieldCount} 件のフィールドを変更、{skipCount} 件スキップ", + "excludeProvider": "プロバイダーの包含を切り替え", + "providerHeader": "{name}", + "fieldChanged": "{field}: {before} -> {after}", + "fieldSkipped": "{field}: スキップ ({reason})", + "nullValue": "(空)", + "back": "戻る", + "apply": "変更を適用" + }, + "fields": { + "isEnabled": { + "label": "有効状態" + }, + "priority": "優先度", + "weight": "重み", + "costMultiplier": "コスト倍率", + "groupTag": { + "label": "グループタグ" + }, + "modelRedirects": "モデルリダイレクト", + "allowedModels": "許可モデル", + "thinkingBudget": "思考バジェット", + "adaptiveThinking": "アダプティブ思考" + }, + "toast": { + "previewFailed": "プレビュー失敗: {error}", + "unknownError": "不明なエラー", + "updated": "{count} 件のプロバイダーを更新しました", + "undo": "元に戻す", + "undoSuccess": "{count} 件のプロバイダーを元に戻しました", + "undoFailed": "元に戻す操作に失敗: {error}", + "failed": "操作に失敗: {error}", + "circuitReset": "{count} 件のサーキットブレーカーをリセットしました" + }, + "undo": { + "batchDeleteSuccess": "{count} 件のプロバイダーを削除しました", + "button": "元に戻す", + "batchDeleteUndone": "{count} 件のプロバイダーを復元しました", + "expired": "元に戻す期限切れ", + "failed": "元に戻す操作に失敗" + }, + "confirm": { + "cancel": "キャンセル", + "goBack": "戻る", + "confirm": "確認", + "processing": "処理中..." + }, "mixedValues": { "label": "(混合値)", "tooltip": "選択されたプロバイダーには異なる値があります:", diff --git a/messages/ru/settings/providers/batchEdit.json b/messages/ru/settings/providers/batchEdit.json index 13400ca88..2c9db06c8 100644 --- a/messages/ru/settings/providers/batchEdit.json +++ b/messages/ru/settings/providers/batchEdit.json @@ -1,4 +1,81 @@ { + "selectedCount": "Выбрано: {count}", + "actions": { + "edit": "Редактировать", + "resetCircuit": "Сбросить предохранитель", + "delete": "Удалить" + }, + "enterMode": "Массовое редактирование", + "selectionHint": "Выберите провайдеров для массового редактирования", + "selectAll": "Выбрать все", + "invertSelection": "Инвертировать", + "selectByType": "По типу", + "selectByTypeItem": "{type} ({count})", + "selectByGroup": "По группе", + "selectByGroupItem": "{group} ({count})", + "editSelected": "Редактировать выбранные", + "exitMode": "Выход", + "dialog": { + "editTitle": "Массовое редактирование провайдеров", + "editDesc": "Редактирование настроек для {count} провайдеров", + "deleteTitle": "Удаление провайдеров", + "deleteDesc": "Вы уверены, что хотите удалить {count} провайдеров? Это действие нельзя отменить.", + "resetCircuitTitle": "Сброс предохранителей", + "resetCircuitDesc": "Сбросить предохранители для {count} провайдеров?", + "next": "Далее" + }, + "preview": { + "title": "Предпросмотр изменений", + "description": "Проверка изменений для {count} провайдеров", + "loading": "Загрузка предпросмотра...", + "noChanges": "Нет изменений для применения", + "summary": "Затронуто {providerCount} провайдеров, изменено {fieldCount} полей, пропущено {skipCount}", + "excludeProvider": "Переключить включение провайдера", + "providerHeader": "{name}", + "fieldChanged": "{field}: {before} -> {after}", + "fieldSkipped": "{field}: пропущено ({reason})", + "nullValue": "(пусто)", + "back": "Назад", + "apply": "Применить изменения" + }, + "fields": { + "isEnabled": { + "label": "Включен" + }, + "priority": "Приоритет", + "weight": "Вес", + "costMultiplier": "Множитель стоимости", + "groupTag": { + "label": "Тег группы" + }, + "modelRedirects": "Перенаправление моделей", + "allowedModels": "Разрешенные модели", + "thinkingBudget": "Бюджет размышлений", + "adaptiveThinking": "Адаптивное мышление" + }, + "toast": { + "previewFailed": "Ошибка предпросмотра: {error}", + "unknownError": "Неизвестная ошибка", + "updated": "Обновлено провайдеров: {count}", + "undo": "Отменить", + "undoSuccess": "Восстановлено провайдеров: {count}", + "undoFailed": "Ошибка отмены: {error}", + "failed": "Ошибка операции: {error}", + "circuitReset": "Сброшено предохранителей: {count}" + }, + "undo": { + "batchDeleteSuccess": "Удалено провайдеров: {count}", + "button": "Отменить", + "batchDeleteUndone": "Восстановлено провайдеров: {count}", + "expired": "Отмена истекла", + "failed": "Ошибка отмены" + }, + "confirm": { + "cancel": "Отмена", + "goBack": "Назад", + "confirm": "Подтвердить", + "processing": "Обработка..." + }, "mixedValues": { "label": "(смешанные значения)", "tooltip": "Выбранные провайдеры имеют разные значения:", diff --git a/messages/zh-CN/settings/providers/batchEdit.json b/messages/zh-CN/settings/providers/batchEdit.json index e3d3436ce..28869221a 100644 --- a/messages/zh-CN/settings/providers/batchEdit.json +++ b/messages/zh-CN/settings/providers/batchEdit.json @@ -1,4 +1,81 @@ { + "selectedCount": "已选择 {count} 个", + "actions": { + "edit": "编辑", + "resetCircuit": "重置熔断器", + "delete": "删除" + }, + "enterMode": "批量编辑", + "selectionHint": "选择供应商进行批量编辑", + "selectAll": "全选", + "invertSelection": "反选", + "selectByType": "按类型", + "selectByTypeItem": "{type} ({count})", + "selectByGroup": "按分组", + "selectByGroupItem": "{group} ({count})", + "editSelected": "编辑所选", + "exitMode": "退出", + "dialog": { + "editTitle": "批量编辑供应商", + "editDesc": "编辑 {count} 个供应商的设置", + "deleteTitle": "删除供应商", + "deleteDesc": "确定要删除 {count} 个供应商吗?此操作无法撤销。", + "resetCircuitTitle": "重置熔断器", + "resetCircuitDesc": "确定要重置 {count} 个供应商的熔断器吗?", + "next": "下一步" + }, + "preview": { + "title": "预览变更", + "description": "检查 {count} 个供应商的变更", + "loading": "正在加载预览...", + "noChanges": "没有需要应用的变更", + "summary": "影响 {providerCount} 个供应商,变更 {fieldCount} 个字段,跳过 {skipCount} 个", + "excludeProvider": "切换供应商包含状态", + "providerHeader": "{name}", + "fieldChanged": "{field}: {before} -> {after}", + "fieldSkipped": "{field}: 已跳过 ({reason})", + "nullValue": "(空)", + "back": "返回", + "apply": "应用变更" + }, + "fields": { + "isEnabled": { + "label": "启用状态" + }, + "priority": "优先级", + "weight": "权重", + "costMultiplier": "成本倍数", + "groupTag": { + "label": "分组标签" + }, + "modelRedirects": "模型重定向", + "allowedModels": "允许的模型", + "thinkingBudget": "思考预算", + "adaptiveThinking": "自适应思考" + }, + "toast": { + "previewFailed": "预览失败: {error}", + "unknownError": "未知错误", + "updated": "已更新 {count} 个供应商", + "undo": "撤销", + "undoSuccess": "已恢复 {count} 个供应商", + "undoFailed": "撤销失败: {error}", + "failed": "操作失败: {error}", + "circuitReset": "已重置 {count} 个熔断器" + }, + "undo": { + "batchDeleteSuccess": "已删除 {count} 个供应商", + "button": "撤销", + "batchDeleteUndone": "已恢复 {count} 个供应商", + "expired": "撤销已过期", + "failed": "撤销失败" + }, + "confirm": { + "cancel": "取消", + "goBack": "返回", + "confirm": "确认", + "processing": "处理中..." + }, "mixedValues": { "label": "(混合值)", "tooltip": "选中的供应商有不同的值:", diff --git a/messages/zh-TW/settings/providers/batchEdit.json b/messages/zh-TW/settings/providers/batchEdit.json index 5e146ebf7..267234843 100644 --- a/messages/zh-TW/settings/providers/batchEdit.json +++ b/messages/zh-TW/settings/providers/batchEdit.json @@ -1,4 +1,81 @@ { + "selectedCount": "已選擇 {count} 個", + "actions": { + "edit": "編輯", + "resetCircuit": "重設熔斷器", + "delete": "刪除" + }, + "enterMode": "批次編輯", + "selectionHint": "選擇供應商進行批次編輯", + "selectAll": "全選", + "invertSelection": "反選", + "selectByType": "依類型", + "selectByTypeItem": "{type} ({count})", + "selectByGroup": "依群組", + "selectByGroupItem": "{group} ({count})", + "editSelected": "編輯所選", + "exitMode": "退出", + "dialog": { + "editTitle": "批次編輯供應商", + "editDesc": "編輯 {count} 個供應商的設定", + "deleteTitle": "刪除供應商", + "deleteDesc": "確定要刪除 {count} 個供應商嗎?此操作無法復原。", + "resetCircuitTitle": "重設熔斷器", + "resetCircuitDesc": "確定要重設 {count} 個供應商的熔斷器嗎?", + "next": "下一步" + }, + "preview": { + "title": "預覽變更", + "description": "檢查 {count} 個供應商的變更", + "loading": "正在載入預覽...", + "noChanges": "沒有需要套用的變更", + "summary": "影響 {providerCount} 個供應商,變更 {fieldCount} 個欄位,跳過 {skipCount} 個", + "excludeProvider": "切換供應商包含狀態", + "providerHeader": "{name}", + "fieldChanged": "{field}: {before} -> {after}", + "fieldSkipped": "{field}: 已跳過 ({reason})", + "nullValue": "(空)", + "back": "返回", + "apply": "套用變更" + }, + "fields": { + "isEnabled": { + "label": "啟用狀態" + }, + "priority": "優先順序", + "weight": "權重", + "costMultiplier": "成本倍數", + "groupTag": { + "label": "群組標籤" + }, + "modelRedirects": "模型重新導向", + "allowedModels": "允許的模型", + "thinkingBudget": "思考預算", + "adaptiveThinking": "自適應思考" + }, + "toast": { + "previewFailed": "預覽失敗: {error}", + "unknownError": "未知錯誤", + "updated": "已更新 {count} 個供應商", + "undo": "復原", + "undoSuccess": "已還原 {count} 個供應商", + "undoFailed": "復原失敗: {error}", + "failed": "操作失敗: {error}", + "circuitReset": "已重設 {count} 個熔斷器" + }, + "undo": { + "batchDeleteSuccess": "已刪除 {count} 個供應商", + "button": "復原", + "batchDeleteUndone": "已還原 {count} 個供應商", + "expired": "復原已過期", + "failed": "復原失敗" + }, + "confirm": { + "cancel": "取消", + "goBack": "返回", + "confirm": "確認", + "processing": "處理中..." + }, "mixedValues": { "label": "(混合值)", "tooltip": "選中的供應商有不同的值:", From 834a87767a72dffc33e3e25221796545b47b6054 Mon Sep 17 00:00:00 2001 From: Ding <44717411+ding113@users.noreply.github.com> Date: Tue, 10 Mar 2026 14:14:57 +0800 Subject: [PATCH 27/42] feat: add costResetAt for soft user limit reset without deleting data (#890) * feat: add costResetAt for soft user limit reset without deleting data Add costResetAt timestamp column to users table that clips all cost calculations to start from reset time instead of all-time. This enables admins to reset a user's rate limits without destroying historical usage data (messageRequest/usageLedger rows are preserved). Key changes: - Schema: new cost_reset_at column on users table - Repository: costResetAt propagated through all select queries, key validation, and statistics aggregation (with per-user batch support) - Rate limiting: all 12 proxy guard checks pass costResetAt; service and lease layers clip time windows accordingly - Auth cache: hydrate costResetAt from Redis cache as Date; invalidate auth cache on reset to avoid stale costResetAt - Actions: resetUserLimitsOnly sets costResetAt + clears cost cache; getUserLimitUsage/getUserAllLimitUsage/getKeyLimitUsage/getMyQuota clip time ranges by costResetAt - UI: edit-user-dialog with separate Reset Limits Only (amber) vs Reset All Statistics (red) with confirmation dialogs - i18n: all 5 languages (en, zh-CN, zh-TW, ja, ru) - Tests: 10 unit tests for resetUserLimitsOnly Co-Authored-By: Claude Opus 4.6 * fix: harden costResetAt handling -- repo layer, DRY Redis cleanup, Date validation - Extract resetUserCostResetAt repo function with updatedAt + auth cache invalidation - Extract clearUserCostCache helper to deduplicate Redis cleanup between reset functions - Use instanceof Date checks in lease-service and my-usage for costResetAt validation - Remove dead hasActiveSessions variable in cost-cache-cleanup Co-Authored-By: Claude Opus 4.6 * fix: unify costResetAt guards to instanceof Date, add last-reset badge in user edit dialog R3: Replace truthiness checks with `instanceof Date` in 3 places (users.ts clipStart, quotas page). R4: Show last reset timestamp in edit-user-dialog Reset Limits section (5 langs). Add 47 unit tests covering costResetAt across key-quota, redis cleanup, statistics, and auth cache. Co-Authored-By: Claude Opus 4.6 * fix: apply costResetAt clipStart to key-quota usage queries Clip all period time ranges by user's costResetAt and replace getTotalUsageForKey with sumKeyTotalCost supporting resetAt parameter. Co-Authored-By: Claude Opus 4.6 * chore: format branch files with biome, suppress noThenProperty in thenable mock Co-Authored-By: Claude Opus 4.6 * fix: address PR #853 review findings -- guard consistency, window clipping, error handling - keys.ts: eliminate redundant findUserById call, use joined costResetAt + instanceof Date guard - users.ts: handle resetUserCostResetAt return value (false = soft-deleted user) - service.ts: add instanceof Date guard to costResetAt comparison - statistics.ts: fix sumKeyTotalCost/sumUserTotalCost to use max(resetAt, maxAgeCutoff) instead of replacing maxAgeDays; refactor nested ternaries to if-blocks in quota functions - cost-cache-cleanup.ts: wrap pipeline.exec() in try/catch to honor never-throws contract - Update test for pipeline.exec throw now caught inside clearUserCostCache Co-Authored-By: Claude Opus 4.6 * test: tighten key-quota clipStart assertions with toHaveBeenNthCalledWith Verify each window (5h/daily/weekly/monthly) is clipped individually instead of checking unordered calledWith matches. Co-Authored-By: Claude Opus 4.6 * fix: repair CI test failures caused by costResetAt feature changes - ja/dashboard.json: replace fullwidth parens with halfwidth - api-key-auth-cache-reset-at.test: override CI env so shouldUseRedisClient() works - key-quota-concurrent-inherit.test: add logger.info mock, sumKeyTotalCost mock, userCostResetAt field - my-usage-concurrent-inherit.test: add logger.info/debug mocks - total-usage-semantics.test: update call assertions for new costResetAt parameter - users-reset-all-statistics.test: mock resetUserCostResetAt, update pipeline.exec error expectations - rate-limit-guard.test: add cost_reset_at: null to expected checkCostLimitsWithLease args Co-Authored-By: Claude Opus 4.6 * fix: address PR #890 review comments for costResetAt feature Critical: - Wrap resetUserAllStatistics DB writes in transaction for atomicity - Change sumKeyTotalCost maxAgeDays from 365 to Infinity for unbounded accumulation from costResetAt - Add costResetAtMs to BudgetLease cache with stale detection Medium: - Add logger.warn to silent Redis scan failure handlers - Add fail-open documentation for costResetAt validation - Fix test mock leak (vi.resetAllMocks + re-establish defaults) Minor: - Rename i18n "Reset Data" to "Reset Options" across 5 languages - Remove brittle source code string assertion tests - Update test assertions for transaction and Infinity changes * chore: format code (fix-user-reset-stats-e5002db) --------- Co-authored-by: John Doe Co-authored-by: Claude Opus 4.6 Co-authored-by: github-actions[bot] --- drizzle/0080_fresh_clint_barton.sql | 1 + drizzle/meta/0080_snapshot.json | 3927 +++++++++++++++++ drizzle/meta/_journal.json | 7 + messages/en/dashboard.json | 15 + messages/ja/dashboard.json | 15 + messages/ru/dashboard.json | 15 + messages/zh-CN/dashboard.json | 15 + messages/zh-TW/dashboard.json | 15 + src/actions/key-quota.ts | 23 +- src/actions/keys.ts | 20 +- src/actions/my-usage.ts | 45 +- src/actions/users.ts | 188 +- .../_components/user/edit-user-dialog.tsx | 202 +- .../[locale]/dashboard/quotas/users/page.tsx | 24 +- src/app/v1/_lib/proxy/rate-limit-guard.ts | 13 +- src/drizzle/schema.ts | 1 + src/lib/rate-limit/lease-service.ts | 22 +- src/lib/rate-limit/lease.ts | 1 + src/lib/rate-limit/service.ts | 98 +- src/lib/redis/cost-cache-cleanup.ts | 159 + src/lib/security/api-key-auth-cache.ts | 3 + src/repository/_shared/transformers.ts | 1 + src/repository/key.ts | 3 + src/repository/statistics.ts | 184 +- src/repository/user.ts | 18 + src/types/user.ts | 2 + .../key-quota-concurrent-inherit.test.ts | 5 + .../unit/actions/key-quota-cost-reset.test.ts | 244 + .../my-usage-concurrent-inherit.test.ts | 2 + .../actions/total-usage-semantics.test.ts | 46 +- .../users-reset-all-statistics.test.ts | 55 +- .../actions/users-reset-limits-only.test.ts | 253 ++ .../unit/lib/redis/cost-cache-cleanup.test.ts | 267 ++ .../api-key-auth-cache-reset-at.test.ts | 197 + tests/unit/proxy/rate-limit-guard.test.ts | 3 + .../repository/statistics-reset-at.test.ts | 272 ++ .../usage-ledger/cleanup-immunity.test.ts | 11 +- 37 files changed, 6093 insertions(+), 279 deletions(-) create mode 100644 drizzle/0080_fresh_clint_barton.sql create mode 100644 drizzle/meta/0080_snapshot.json create mode 100644 src/lib/redis/cost-cache-cleanup.ts create mode 100644 tests/unit/actions/key-quota-cost-reset.test.ts create mode 100644 tests/unit/actions/users-reset-limits-only.test.ts create mode 100644 tests/unit/lib/redis/cost-cache-cleanup.test.ts create mode 100644 tests/unit/lib/security/api-key-auth-cache-reset-at.test.ts create mode 100644 tests/unit/repository/statistics-reset-at.test.ts diff --git a/drizzle/0080_fresh_clint_barton.sql b/drizzle/0080_fresh_clint_barton.sql new file mode 100644 index 000000000..3d6122c9c --- /dev/null +++ b/drizzle/0080_fresh_clint_barton.sql @@ -0,0 +1 @@ +ALTER TABLE "users" ADD COLUMN "cost_reset_at" timestamp with time zone; \ No newline at end of file diff --git a/drizzle/meta/0080_snapshot.json b/drizzle/meta/0080_snapshot.json new file mode 100644 index 000000000..5142c9dde --- /dev/null +++ b/drizzle/meta/0080_snapshot.json @@ -0,0 +1,3927 @@ +{ + "id": "4fa20bbb-cba5-498d-8d26-31df76c66d25", + "prevId": "88addf3b-363e-4abc-8809-02b7f03ca6e6", + "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 with time zone", + "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(200)", + "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_key": { + "name": "idx_keys_key", + "columns": [ + { + "expression": "key", + "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": "bigint", + "primaryKey": false, + "notNull": false + }, + "output_tokens": { + "name": "output_tokens", + "type": "bigint", + "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": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_read_input_tokens": { + "name": "cache_read_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_creation_5m_input_tokens": { + "name": "cache_creation_5m_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_creation_1h_input_tokens": { + "name": "cache_creation_1h_input_tokens", + "type": "bigint", + "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 + }, + "swap_cache_ttl_applied": { + "name": "swap_cache_ttl_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_created_at_cost_stats": { + "name": "idx_message_request_user_created_at_cost_stats", + "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 AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "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_provider_created_at_active": { + "name": "idx_message_request_provider_created_at_active", + "columns": [ + { + "expression": "provider_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 AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "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_id_prefix": { + "name": "idx_message_request_session_id_prefix", + "columns": [ + { + "expression": "\"session_id\" varchar_pattern_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "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_key_created_at_id": { + "name": "idx_message_request_key_created_at_id", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key_model_active": { + "name": "idx_message_request_key_model_active", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"model\" IS NOT NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key_endpoint_active": { + "name": "idx_message_request_key_endpoint_active", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "endpoint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"endpoint\" IS NOT NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_created_at_id_active": { + "name": "idx_message_request_created_at_id_active", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_model_active": { + "name": "idx_message_request_model_active", + "columns": [ + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"model\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_status_code_active": { + "name": "idx_message_request_status_code_active", + "columns": [ + { + "expression": "status_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"status_code\" IS NOT NULL", + "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": {} + }, + "idx_message_request_key_last_active": { + "name": "idx_message_request_key_last_active", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key_cost_active": { + "name": "idx_message_request_key_cost_active", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_usd", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_session_user_info": { + "name": "idx_message_request_session_user_info", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "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 + }, + "source": { + "name": "source", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'litellm'" + }, + "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": {} + }, + "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 + }, + "cache_hit_rate_alert_enabled": { + "name": "cache_hit_rate_alert_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cache_hit_rate_alert_webhook": { + "name": "cache_hit_rate_alert_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "cache_hit_rate_alert_window_mode": { + "name": "cache_hit_rate_alert_window_mode", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false, + "default": "'auto'" + }, + "cache_hit_rate_alert_check_interval": { + "name": "cache_hit_rate_alert_check_interval", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 5 + }, + "cache_hit_rate_alert_historical_lookback_days": { + "name": "cache_hit_rate_alert_historical_lookback_days", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 7 + }, + "cache_hit_rate_alert_min_eligible_requests": { + "name": "cache_hit_rate_alert_min_eligible_requests", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 20 + }, + "cache_hit_rate_alert_min_eligible_tokens": { + "name": "cache_hit_rate_alert_min_eligible_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "cache_hit_rate_alert_abs_min": { + "name": "cache_hit_rate_alert_abs_min", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.05'" + }, + "cache_hit_rate_alert_drop_rel": { + "name": "cache_hit_rate_alert_drop_rel", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.3'" + }, + "cache_hit_rate_alert_drop_abs": { + "name": "cache_hit_rate_alert_drop_abs", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.1'" + }, + "cache_hit_rate_alert_cooldown_minutes": { + "name": "cache_hit_rate_alert_cooldown_minutes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30 + }, + "cache_hit_rate_alert_top_n": { + "name": "cache_hit_rate_alert_top_n", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 10 + }, + "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 + }, + "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.provider_endpoint_probe_logs": { + "name": "provider_endpoint_probe_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "endpoint_id": { + "name": "endpoint_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'scheduled'" + }, + "ok": { + "name": "ok", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "latency_ms": { + "name": "latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "error_type": { + "name": "error_type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_provider_endpoint_probe_logs_endpoint_created_at": { + "name": "idx_provider_endpoint_probe_logs_endpoint_created_at", + "columns": [ + { + "expression": "endpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoint_probe_logs_created_at": { + "name": "idx_provider_endpoint_probe_logs_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "provider_endpoint_probe_logs_endpoint_id_provider_endpoints_id_fk": { + "name": "provider_endpoint_probe_logs_endpoint_id_provider_endpoints_id_fk", + "tableFrom": "provider_endpoint_probe_logs", + "tableTo": "provider_endpoints", + "columnsFrom": [ + "endpoint_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_endpoints": { + "name": "provider_endpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "vendor_id": { + "name": "vendor_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "provider_type": { + "name": "provider_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'claude'" + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_probed_at": { + "name": "last_probed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_probe_ok": { + "name": "last_probe_ok", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "last_probe_status_code": { + "name": "last_probe_status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_probe_latency_ms": { + "name": "last_probe_latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_probe_error_type": { + "name": "last_probe_error_type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "last_probe_error_message": { + "name": "last_probe_error_message", + "type": "text", + "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": { + "uniq_provider_endpoints_vendor_type_url": { + "name": "uniq_provider_endpoints_vendor_type_url", + "columns": [ + { + "expression": "vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"provider_endpoints\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_vendor_type": { + "name": "idx_provider_endpoints_vendor_type", + "columns": [ + { + "expression": "vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"provider_endpoints\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_enabled": { + "name": "idx_provider_endpoints_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"provider_endpoints\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_pick_enabled": { + "name": "idx_provider_endpoints_pick_enabled", + "columns": [ + { + "expression": "vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"provider_endpoints\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_created_at": { + "name": "idx_provider_endpoints_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_deleted_at": { + "name": "idx_provider_endpoints_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "provider_endpoints_vendor_id_provider_vendors_id_fk": { + "name": "provider_endpoints_vendor_id_provider_vendors_id_fk", + "tableFrom": "provider_endpoints", + "tableTo": "provider_vendors", + "columnsFrom": [ + "vendor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_vendors": { + "name": "provider_vendors", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "website_domain": { + "name": "website_domain", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "website_url": { + "name": "website_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "favicon_url": { + "name": "favicon_url", + "type": "text", + "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": { + "uniq_provider_vendors_website_domain": { + "name": "uniq_provider_vendors_website_domain", + "columns": [ + { + "expression": "website_domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_vendors_created_at": { + "name": "idx_provider_vendors_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.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 + }, + "provider_vendor_id": { + "name": "provider_vendor_id", + "type": "integer", + "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 + }, + "group_priorities": { + "name": "group_priorities", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "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" + }, + "allowed_clients": { + "name": "allowed_clients", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "blocked_clients": { + "name": "blocked_clients", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "active_time_start": { + "name": "active_time_start", + "type": "varchar(5)", + "primaryKey": false, + "notNull": false + }, + "active_time_end": { + "name": "active_time_end", + "type": "varchar(5)", + "primaryKey": false, + "notNull": 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 + }, + "swap_cache_ttl_billing": { + "name": "swap_cache_ttl_billing", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": 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 + }, + "codex_service_tier_preference": { + "name": "codex_service_tier_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "anthropic_max_tokens_preference": { + "name": "anthropic_max_tokens_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "anthropic_thinking_budget_preference": { + "name": "anthropic_thinking_budget_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "anthropic_adaptive_thinking": { + "name": "anthropic_adaptive_thinking", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "gemini_google_search_preference": { + "name": "gemini_google_search_preference", + "type": "varchar(20)", + "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_vendor_type_url_active": { + "name": "idx_providers_vendor_type_url_active", + "columns": [ + { + "expression": "provider_vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "url", + "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": {} + }, + "idx_providers_vendor_type": { + "name": "idx_providers_vendor_type", + "columns": [ + { + "expression": "provider_vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_enabled_vendor_type": { + "name": "idx_providers_enabled_vendor_type", + "columns": [ + { + "expression": "provider_vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL AND \"providers\".\"is_enabled\" = true AND \"providers\".\"provider_vendor_id\" IS NOT NULL AND \"providers\".\"provider_vendor_id\" > 0", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "providers_provider_vendor_id_provider_vendors_id_fk": { + "name": "providers_provider_vendor_id_provider_vendors_id_fk", + "tableFrom": "providers", + "tableTo": "provider_vendors", + "columnsFrom": [ + "provider_vendor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "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'" + }, + "timezone": { + "name": "timezone", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "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_thinking_budget_rectifier": { + "name": "enable_thinking_budget_rectifier", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_billing_header_rectifier": { + "name": "enable_billing_header_rectifier", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_response_input_rectifier": { + "name": "enable_response_input_rectifier", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_codex_session_id_completion": { + "name": "enable_codex_session_id_completion", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_claude_metadata_user_id_injection": { + "name": "enable_claude_metadata_user_id_injection", + "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" + }, + "quota_db_refresh_interval_seconds": { + "name": "quota_db_refresh_interval_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 10 + }, + "quota_lease_percent_5h": { + "name": "quota_lease_percent_5h", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.05'" + }, + "quota_lease_percent_daily": { + "name": "quota_lease_percent_daily", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.05'" + }, + "quota_lease_percent_weekly": { + "name": "quota_lease_percent_weekly", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.05'" + }, + "quota_lease_percent_monthly": { + "name": "quota_lease_percent_monthly", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.05'" + }, + "quota_lease_cap_usd": { + "name": "quota_lease_cap_usd", + "type": "numeric(10, 2)", + "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 + }, + "public.usage_ledger": { + "name": "usage_ledger", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "request_id": { + "name": "request_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 + }, + "provider_id": { + "name": "provider_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "final_provider_id": { + "name": "final_provider_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "original_model": { + "name": "original_model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "endpoint": { + "name": "endpoint", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "api_type": { + "name": "api_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_success": { + "name": "is_success", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "blocked_by": { + "name": "blocked_by", + "type": "varchar(50)", + "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 + }, + "input_tokens": { + "name": "input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "output_tokens": { + "name": "output_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_creation_input_tokens": { + "name": "cache_creation_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_read_input_tokens": { + "name": "cache_read_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_creation_5m_input_tokens": { + "name": "cache_creation_5m_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_creation_1h_input_tokens": { + "name": "cache_creation_1h_input_tokens", + "type": "bigint", + "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 + }, + "swap_cache_ttl_applied": { + "name": "swap_cache_ttl_applied", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "ttfb_ms": { + "name": "ttfb_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_usage_ledger_request_id": { + "name": "idx_usage_ledger_request_id", + "columns": [ + { + "expression": "request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_user_created_at": { + "name": "idx_usage_ledger_user_created_at", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"blocked_by\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_key_created_at": { + "name": "idx_usage_ledger_key_created_at", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"blocked_by\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_provider_created_at": { + "name": "idx_usage_ledger_provider_created_at", + "columns": [ + { + "expression": "final_provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"blocked_by\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_created_at_minute": { + "name": "idx_usage_ledger_created_at_minute", + "columns": [ + { + "expression": "date_trunc('minute', \"created_at\" AT TIME ZONE 'UTC')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_created_at_desc_id": { + "name": "idx_usage_ledger_created_at_desc_id", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_session_id": { + "name": "idx_usage_ledger_session_id", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"session_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_model": { + "name": "idx_usage_ledger_model", + "columns": [ + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"model\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_key_cost": { + "name": "idx_usage_ledger_key_cost", + "columns": [ + { + "expression": "key", + "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": "\"usage_ledger\".\"blocked_by\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_user_cost_cover": { + "name": "idx_usage_ledger_user_cost_cover", + "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": "\"usage_ledger\".\"blocked_by\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_provider_cost_cover": { + "name": "idx_usage_ledger_provider_cost_cover", + "columns": [ + { + "expression": "final_provider_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": "\"usage_ledger\".\"blocked_by\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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(200)", + "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 + }, + "cost_reset_at": { + "name": "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 + }, + "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" + }, + "blocked_clients": { + "name": "blocked_clients", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "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_tags_gin": { + "name": "idx_users_tags_gin", + "columns": [ + { + "expression": "tags", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"users\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "gin", + "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", + "cache_hit_rate_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 defa97851..48fea6a98 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -561,6 +561,13 @@ "when": 1772994859188, "tag": "0079_easy_zeigeist", "breakpoints": true + }, + { + "idx": 80, + "version": "7", + "when": 1773036289279, + "tag": "0080_fresh_clint_barton", + "breakpoints": true } ] } \ No newline at end of file diff --git a/messages/en/dashboard.json b/messages/en/dashboard.json index c0e821cdf..c0e99a4a2 100644 --- a/messages/en/dashboard.json +++ b/messages/en/dashboard.json @@ -1571,6 +1571,21 @@ "deleteFailed": "Failed to delete user", "userDeleted": "User has been deleted", "saving": "Saving...", + "resetSection": { + "title": "Reset Options" + }, + "resetLimits": { + "title": "Reset Limits", + "description": "Reset accumulated cost counters for all limits. Request logs and statistics are preserved.", + "button": "Reset Limits", + "confirmTitle": "Reset Limits Only?", + "confirmDescription": "This will reset all accumulated cost counters (5h, daily, weekly, monthly, total) to zero. Request logs and usage statistics will be preserved.", + "confirm": "Yes, Reset Limits", + "loading": "Resetting...", + "error": "Failed to reset limits", + "success": "All limits have been reset", + "lastResetAt": "Last reset: {date}" + }, "resetData": { "title": "Reset Statistics", "description": "Delete all request logs and usage data for this user. This action is irreversible.", diff --git a/messages/ja/dashboard.json b/messages/ja/dashboard.json index 0daa44440..6a73d519c 100644 --- a/messages/ja/dashboard.json +++ b/messages/ja/dashboard.json @@ -1549,6 +1549,21 @@ "deleteFailed": "ユーザーの削除に失敗しました", "userDeleted": "ユーザーが削除されました", "saving": "保存しています...", + "resetSection": { + "title": "リセットオプション" + }, + "resetLimits": { + "title": "制限のリセット", + "description": "全ての制限の累積コストカウンターをリセットします。リクエストログと統計データは保持されます。", + "button": "制限をリセット", + "confirmTitle": "制限のみリセットしますか?", + "confirmDescription": "全ての累積コストカウンター(5時間、日次、週次、月次、合計)がゼロにリセットされます。リクエストログと利用統計は保持されます。", + "confirm": "はい、リセットする", + "loading": "リセット中...", + "error": "制限のリセットに失敗しました", + "success": "全ての制限がリセットされました", + "lastResetAt": "前回のリセット: {date}" + }, "resetData": { "title": "統計リセット", "description": "このユーザーのすべてのリクエストログと使用データを削除します。この操作は元に戻せません。", diff --git a/messages/ru/dashboard.json b/messages/ru/dashboard.json index 03c4150b5..3bdb7ba57 100644 --- a/messages/ru/dashboard.json +++ b/messages/ru/dashboard.json @@ -1554,6 +1554,21 @@ "deleteFailed": "Не удалось удалить пользователя", "userDeleted": "Пользователь удален", "saving": "Сохранение...", + "resetSection": { + "title": "Параметры сброса" + }, + "resetLimits": { + "title": "Сброс лимитов", + "description": "Сбросить накопленные счетчики расходов для всех лимитов. Логи запросов и статистика сохраняются.", + "button": "Сбросить лимиты", + "confirmTitle": "Сбросить только лимиты?", + "confirmDescription": "Все накопленные счетчики расходов (5ч, дневной, недельный, месячный, общий) будут обнулены. Логи запросов и статистика использования сохранятся.", + "confirm": "Да, сбросить лимиты", + "loading": "Сброс...", + "error": "Не удалось сбросить лимиты", + "success": "Все лимиты сброшены", + "lastResetAt": "Последний сброс: {date}" + }, "resetData": { "title": "Сброс статистики", "description": "Удалить все логи запросов и данные использования для этого пользователя. Это действие необратимо.", diff --git a/messages/zh-CN/dashboard.json b/messages/zh-CN/dashboard.json index a3676f4c9..4fa0404b6 100644 --- a/messages/zh-CN/dashboard.json +++ b/messages/zh-CN/dashboard.json @@ -1572,6 +1572,21 @@ "deleteFailed": "删除用户失败", "userDeleted": "用户已删除", "saving": "保存中...", + "resetSection": { + "title": "重置选项" + }, + "resetLimits": { + "title": "重置限额", + "description": "重置所有限额的累计消费计数器。请求日志和统计数据将被保留。", + "button": "重置限额", + "confirmTitle": "仅重置限额?", + "confirmDescription": "这将把所有累计消费计数器(5小时、每日、每周、每月、总计)归零。请求日志和使用统计将被保留。", + "confirm": "是的,重置限额", + "loading": "正在重置...", + "error": "重置限额失败", + "success": "所有限额已重置", + "lastResetAt": "上次重置: {date}" + }, "resetData": { "title": "重置统计", "description": "删除该用户的所有请求日志和使用数据。此操作不可逆。", diff --git a/messages/zh-TW/dashboard.json b/messages/zh-TW/dashboard.json index c1f69fecf..09bc16621 100644 --- a/messages/zh-TW/dashboard.json +++ b/messages/zh-TW/dashboard.json @@ -1557,6 +1557,21 @@ "deleteFailed": "刪除使用者失敗", "userDeleted": "使用者已刪除", "saving": "儲存中...", + "resetSection": { + "title": "重置選項" + }, + "resetLimits": { + "title": "重設限額", + "description": "重設所有限額的累計消費計數器。請求日誌和統計資料將被保留。", + "button": "重設限額", + "confirmTitle": "僅重設限額?", + "confirmDescription": "這將把所有累計消費計數器(5小時、每日、每週、每月、總計)歸零。請求日誌和使用統計將被保留。", + "confirm": "是的,重設限額", + "loading": "正在重設...", + "error": "重設限額失敗", + "success": "所有限額已重設", + "lastResetAt": "上次重設: {date}" + }, "resetData": { "title": "重置統計", "description": "刪除該使用者的所有請求日誌和使用資料。此操作不可逆。", diff --git a/src/actions/key-quota.ts b/src/actions/key-quota.ts index 8089a6c60..4693922ea 100644 --- a/src/actions/key-quota.ts +++ b/src/actions/key-quota.ts @@ -12,7 +12,6 @@ import { SessionTracker } from "@/lib/session-tracker"; import type { CurrencyCode } from "@/lib/utils"; import { ERROR_CODES } from "@/lib/utils/error-messages"; import { getSystemSettings } from "@/repository/system-config"; -import { getTotalUsageForKey } from "@/repository/usage-logs"; import type { ActionResult } from "./types"; export interface KeyQuotaItem { @@ -53,6 +52,7 @@ export async function getKeyQuotaUsage(keyId: number): Promise + costResetAt instanceof Date && costResetAt > start ? costResetAt : start; + // Use DB direct queries for consistency with my-usage.ts (not Redis-first) const [cost5h, costDaily, costWeekly, costMonthly, totalCost, concurrentSessions] = await Promise.all([ - sumKeyCostInTimeRange(keyId, range5h.startTime, range5h.endTime), - sumKeyCostInTimeRange(keyId, keyDailyTimeRange.startTime, keyDailyTimeRange.endTime), - sumKeyCostInTimeRange(keyId, rangeWeekly.startTime, rangeWeekly.endTime), - sumKeyCostInTimeRange(keyId, rangeMonthly.startTime, rangeMonthly.endTime), - getTotalUsageForKey(keyRow.key), + sumKeyCostInTimeRange(keyId, clipStart(range5h.startTime), range5h.endTime), + sumKeyCostInTimeRange( + keyId, + clipStart(keyDailyTimeRange.startTime), + keyDailyTimeRange.endTime + ), + sumKeyCostInTimeRange(keyId, clipStart(rangeWeekly.startTime), rangeWeekly.endTime), + sumKeyCostInTimeRange(keyId, clipStart(rangeMonthly.startTime), rangeMonthly.endTime), + sumKeyTotalCost(keyRow.key, Infinity, costResetAt), SessionTracker.getKeySessionCount(keyId), ]); diff --git a/src/actions/keys.ts b/src/actions/keys.ts index d329c138f..4e3ff3781 100644 --- a/src/actions/keys.ts +++ b/src/actions/keys.ts @@ -702,6 +702,7 @@ export async function getKeyLimitUsage(keyId: number): Promise< .select({ key: keysTable, userLimitConcurrentSessions: usersTable.limitConcurrentSessions, + userCostResetAt: usersTable.costResetAt, }) .from(keysTable) .leftJoin(usersTable, and(eq(keysTable.userId, usersTable.id), isNull(usersTable.deletedAt))) @@ -733,6 +734,11 @@ export async function getKeyLimitUsage(keyId: number): Promise< result.userLimitConcurrentSessions ?? null ); + // Clip time range start by costResetAt (for limits-only reset) + const costResetAt = result.userCostResetAt ?? null; + const clipStart = (start: Date): Date => + costResetAt instanceof Date && costResetAt > start ? costResetAt : start; + // Calculate time ranges using Key's dailyResetTime/dailyResetMode configuration const keyDailyTimeRange = await getTimeRangeForPeriodWithMode( "daily", @@ -748,11 +754,15 @@ export async function getKeyLimitUsage(keyId: number): Promise< // 获取金额消费(使用 DB direct,与 my-usage.ts 保持一致) const [cost5h, costDaily, costWeekly, costMonthly, totalCost, concurrentSessions] = await Promise.all([ - sumKeyCostInTimeRange(keyId, range5h.startTime, range5h.endTime), - sumKeyCostInTimeRange(keyId, keyDailyTimeRange.startTime, keyDailyTimeRange.endTime), - sumKeyCostInTimeRange(keyId, rangeWeekly.startTime, rangeWeekly.endTime), - sumKeyCostInTimeRange(keyId, rangeMonthly.startTime, rangeMonthly.endTime), - sumKeyTotalCost(key.key), + sumKeyCostInTimeRange(keyId, clipStart(range5h.startTime), range5h.endTime), + sumKeyCostInTimeRange( + keyId, + clipStart(keyDailyTimeRange.startTime), + keyDailyTimeRange.endTime + ), + sumKeyCostInTimeRange(keyId, clipStart(rangeWeekly.startTime), rangeWeekly.endTime), + sumKeyCostInTimeRange(keyId, clipStart(rangeMonthly.startTime), rangeMonthly.endTime), + sumKeyTotalCost(key.key, Infinity, costResetAt), SessionTracker.getKeySessionCount(keyId), ]); diff --git a/src/actions/my-usage.ts b/src/actions/my-usage.ts index a450f7603..9b5f7e439 100644 --- a/src/actions/my-usage.ts +++ b/src/actions/my-usage.ts @@ -242,6 +242,29 @@ export async function getMyQuota(): Promise> { const rangeWeekly = await getTimeRangeForPeriod("weekly"); const rangeMonthly = await getTimeRangeForPeriod("monthly"); + // Clip time range starts by costResetAt (for limits-only reset) + const costResetAt = user.costResetAt ?? null; + const clipStart = (start: Date): Date => + costResetAt instanceof Date && costResetAt > start ? costResetAt : start; + + const clippedRange5h = { startTime: clipStart(range5h.startTime), endTime: range5h.endTime }; + const clippedRangeWeekly = { + startTime: clipStart(rangeWeekly.startTime), + endTime: rangeWeekly.endTime, + }; + const clippedRangeMonthly = { + startTime: clipStart(rangeMonthly.startTime), + endTime: rangeMonthly.endTime, + }; + const clippedKeyDaily = { + startTime: clipStart(keyDailyTimeRange.startTime), + endTime: keyDailyTimeRange.endTime, + }; + const clippedUserDaily = { + startTime: clipStart(userDailyTimeRange.startTime), + endTime: userDailyTimeRange.endTime, + }; + const effectiveKeyConcurrentLimit = resolveKeyConcurrentSessionLimit( key.limitConcurrentSessions ?? 0, user.limitConcurrentSessions ?? null @@ -252,24 +275,26 @@ export async function getMyQuota(): Promise> { sumKeyQuotaCostsById( key.id, { - range5h, - rangeDaily: keyDailyTimeRange, - rangeWeekly, - rangeMonthly, + range5h: clippedRange5h, + rangeDaily: clippedKeyDaily, + rangeWeekly: clippedRangeWeekly, + rangeMonthly: clippedRangeMonthly, }, - ALL_TIME_MAX_AGE_DAYS + ALL_TIME_MAX_AGE_DAYS, + costResetAt ), SessionTracker.getKeySessionCount(key.id), // User 配额:直接查 DB sumUserQuotaCosts( user.id, { - range5h, - rangeDaily: userDailyTimeRange, - rangeWeekly, - rangeMonthly, + range5h: clippedRange5h, + rangeDaily: clippedUserDaily, + rangeWeekly: clippedRangeWeekly, + rangeMonthly: clippedRangeMonthly, }, - ALL_TIME_MAX_AGE_DAYS + ALL_TIME_MAX_AGE_DAYS, + costResetAt ), getUserConcurrentSessions(user.id), ]); diff --git a/src/actions/users.ts b/src/actions/users.ts index 768e7ec62..04039e3fa 100644 --- a/src/actions/users.ts +++ b/src/actions/users.ts @@ -10,6 +10,7 @@ import { getSession } from "@/lib/auth"; import { PROVIDER_GROUP } from "@/lib/constants/provider.constants"; import { logger } from "@/lib/logger"; import { getUnauthorizedFields } from "@/lib/permissions/user-field-permissions"; +import { invalidateCachedUser } from "@/lib/security/api-key-auth-cache"; import { parseDateInputAsTimezone } from "@/lib/utils/date-input"; import { ERROR_CODES } from "@/lib/utils/error-messages"; import { normalizeProviderGroup } from "@/lib/utils/provider-group"; @@ -32,6 +33,7 @@ import { findUserListBatch, getAllUserProviderGroups as getAllUserProviderGroupsRepository, getAllUserTags as getAllUserTagsRepository, + resetUserCostResetAt, searchUsersForFilter as searchUsersForFilterRepository, updateUser, } from "@/repository/user"; @@ -272,6 +274,7 @@ export async function getUsers(): Promise { limitWeeklyUsd: user.limitWeeklyUsd ?? null, limitMonthlyUsd: user.limitMonthlyUsd ?? null, limitTotalUsd: user.limitTotalUsd ?? null, + costResetAt: user.costResetAt ?? null, limitConcurrentSessions: user.limitConcurrentSessions ?? null, dailyResetMode: user.dailyResetMode, dailyResetTime: user.dailyResetTime, @@ -339,6 +342,7 @@ export async function getUsers(): Promise { limitWeeklyUsd: user.limitWeeklyUsd ?? null, limitMonthlyUsd: user.limitMonthlyUsd ?? null, limitTotalUsd: user.limitTotalUsd ?? null, + costResetAt: user.costResetAt ?? null, limitConcurrentSessions: user.limitConcurrentSessions ?? null, dailyResetMode: user.dailyResetMode, dailyResetTime: user.dailyResetTime, @@ -543,6 +547,7 @@ export async function getUsersBatch( limitWeeklyUsd: user.limitWeeklyUsd ?? null, limitMonthlyUsd: user.limitMonthlyUsd ?? null, limitTotalUsd: user.limitTotalUsd ?? null, + costResetAt: user.costResetAt ?? null, limitConcurrentSessions: user.limitConcurrentSessions ?? null, dailyResetMode: user.dailyResetMode, dailyResetTime: user.dailyResetTime, @@ -606,6 +611,7 @@ export async function getUsersBatch( limitWeeklyUsd: user.limitWeeklyUsd ?? null, limitMonthlyUsd: user.limitMonthlyUsd ?? null, limitTotalUsd: user.limitTotalUsd ?? null, + costResetAt: user.costResetAt ?? null, limitConcurrentSessions: user.limitConcurrentSessions ?? null, dailyResetMode: user.dailyResetMode, dailyResetTime: user.dailyResetTime, @@ -693,6 +699,7 @@ export async function getUsersBatchCore( limitWeeklyUsd: user.limitWeeklyUsd ?? null, limitMonthlyUsd: user.limitMonthlyUsd ?? null, limitTotalUsd: user.limitTotalUsd ?? null, + costResetAt: user.costResetAt ?? null, limitConcurrentSessions: user.limitConcurrentSessions ?? null, dailyResetMode: user.dailyResetMode, dailyResetTime: user.dailyResetTime, @@ -1552,7 +1559,11 @@ export async function getUserLimitUsage(userId: number): Promise< resetTime, resetMode ); - const dailyCost = await sumUserCostInTimeRange(userId, startTime, endTime); + const effectiveStart = + user.costResetAt instanceof Date && user.costResetAt > startTime + ? user.costResetAt + : startTime; + const dailyCost = await sumUserCostInTimeRange(userId, effectiveStart, endTime); const resetInfo = await getResetInfoWithMode("daily", resetTime, resetMode); const resetAt = resetInfo.resetAt; @@ -1758,14 +1769,18 @@ export async function getUserAllLimitUsage(userId: number): Promise< const rangeWeekly = await getTimeRangeForPeriod("weekly"); const rangeMonthly = await getTimeRangeForPeriod("monthly"); + // Clip time range start by costResetAt (for limits-only reset) + const clipStart = (start: Date): Date => + user.costResetAt instanceof Date && user.costResetAt > start ? user.costResetAt : start; + // 并行查询各时间范围的消费 // Note: sumUserTotalCost uses ALL_TIME_MAX_AGE_DAYS for all-time semantics const [usage5h, usageDaily, usageWeekly, usageMonthly, usageTotal] = await Promise.all([ - sumUserCostInTimeRange(userId, range5h.startTime, range5h.endTime), - sumUserCostInTimeRange(userId, rangeDaily.startTime, rangeDaily.endTime), - sumUserCostInTimeRange(userId, rangeWeekly.startTime, rangeWeekly.endTime), - sumUserCostInTimeRange(userId, rangeMonthly.startTime, rangeMonthly.endTime), - sumUserTotalCost(userId, ALL_TIME_MAX_AGE_DAYS), + sumUserCostInTimeRange(userId, clipStart(range5h.startTime), range5h.endTime), + sumUserCostInTimeRange(userId, clipStart(rangeDaily.startTime), rangeDaily.endTime), + sumUserCostInTimeRange(userId, clipStart(rangeWeekly.startTime), rangeWeekly.endTime), + sumUserCostInTimeRange(userId, clipStart(rangeMonthly.startTime), rangeMonthly.endTime), + sumUserTotalCost(userId, ALL_TIME_MAX_AGE_DAYS, user.costResetAt), ]); return { @@ -1787,12 +1802,13 @@ export async function getUserAllLimitUsage(userId: number): Promise< } /** - * Reset ALL user statistics (logs + Redis cache + sessions) - * This is IRREVERSIBLE - deletes all messageRequest logs for the user + * Reset user cost limits only (without deleting logs or statistics). + * Sets costResetAt = NOW() so all cost calculations start fresh. + * Logs, statistics, and usage_ledger remain intact. * * Admin only. */ -export async function resetUserAllStatistics(userId: number): Promise { +export async function resetUserLimitsOnly(userId: number): Promise { try { const tError = await getTranslations("errors"); @@ -1813,81 +1829,113 @@ export async function resetUserAllStatistics(userId: number): Promise k.id); + const keyHashes = keys.map((k) => k.key); - // 1. Delete all messageRequest logs for this user - await db.delete(messageRequest).where(eq(messageRequest.userId, userId)); - - // Also clear ledger rows -- the ONLY legitimate DELETE path for usage_ledger - await db.delete(usageLedger).where(eq(usageLedger.userId, userId)); - - // 2. Clear Redis cache - const { getRedisClient } = await import("@/lib/redis"); - const { scanPattern } = await import("@/lib/redis/scan-helper"); - const { getKeyActiveSessionsKey, getUserActiveSessionsKey } = await import( - "@/lib/redis/active-session-keys" - ); - const redis = getRedisClient(); + // Set costResetAt on user so all cost calculations start fresh + // Uses repo function which also sets updatedAt and invalidates auth cache + const updated = await resetUserCostResetAt(userId, new Date()); + if (!updated) { + return { ok: false, error: tError("USER_NOT_FOUND"), errorCode: ERROR_CODES.NOT_FOUND }; + } - if (redis && redis.status === "ready") { - try { - const startTime = Date.now(); - - // Scan all patterns in parallel - const scanResults = await Promise.all([ - ...keyIds.map((keyId) => - scanPattern(redis, `key:${keyId}:cost_*`).catch((err) => { - logger.warn("Failed to scan key cost pattern", { keyId, error: err }); - return []; - }) - ), - scanPattern(redis, `user:${userId}:cost_*`).catch((err) => { - logger.warn("Failed to scan user cost pattern", { userId, error: err }); - return []; - }), - ]); + // Clear Redis cost cache (but NOT active sessions, NOT DB logs) + try { + const { clearUserCostCache } = await import("@/lib/redis/cost-cache-cleanup"); + const cacheResult = await clearUserCostCache({ userId, keyIds, keyHashes }); + if (cacheResult) { + logger.info("Reset user limits only - Redis cost cache cleared", { + userId, + keyCount: keyIds.length, + ...cacheResult, + }); + } + } catch (error) { + logger.error("Failed to clear Redis cache during user limits reset", { + userId, + error: error instanceof Error ? error.message : String(error), + }); + // Continue execution - costResetAt already set in DB + } - const allCostKeys = scanResults.flat(); + logger.info("Reset user limits only (costResetAt set)", { userId, keyCount: keyIds.length }); + revalidatePath("/dashboard/users"); - // Batch delete via pipeline - const pipeline = redis.pipeline(); + return { ok: true }; + } catch (error) { + logger.error("Failed to reset user limits:", error); + const tError = await getTranslations("errors"); + return { + ok: false, + error: tError("OPERATION_FAILED"), + errorCode: ERROR_CODES.OPERATION_FAILED, + }; + } +} - // Active sessions - for (const keyId of keyIds) { - pipeline.del(getKeyActiveSessionsKey(keyId)); - } - pipeline.del(getUserActiveSessionsKey(userId)); +/** + * Reset ALL user statistics (logs + Redis cache + sessions) + * This is IRREVERSIBLE - deletes all messageRequest logs for the user + * + * Admin only. + */ +export async function resetUserAllStatistics(userId: number): Promise { + try { + const tError = await getTranslations("errors"); - // Cost keys - for (const key of allCostKeys) { - pipeline.del(key); - } + const session = await getSession(); + if (!session || session.user.role !== "admin") { + return { + ok: false, + error: tError("PERMISSION_DENIED"), + errorCode: ERROR_CODES.PERMISSION_DENIED, + }; + } - const results = await pipeline.exec(); + const user = await findUserById(userId); + if (!user) { + return { ok: false, error: tError("USER_NOT_FOUND"), errorCode: ERROR_CODES.NOT_FOUND }; + } - // Check for errors - const errors = results?.filter(([err]) => err); - if (errors && errors.length > 0) { - logger.warn("Some Redis deletes failed during user statistics reset", { - errorCount: errors.length, - userId, - }); - } + // Get user's keys + const keys = await findKeyList(userId); + const keyIds = keys.map((k) => k.id); + const keyHashes = keys.map((k) => k.key); - const duration = Date.now() - startTime; + // 1. Delete all messageRequest logs for this user + // Atomic: delete logs + ledger + clear costResetAt in a single transaction + await db.transaction(async (tx) => { + await tx.delete(messageRequest).where(eq(messageRequest.userId, userId)); + await tx.delete(usageLedger).where(eq(usageLedger.userId, userId)); + await tx + .update(usersTable) + .set({ costResetAt: null, updatedAt: new Date() }) + .where(and(eq(usersTable.id, userId), isNull(usersTable.deletedAt))); + }); + // Invalidate auth cache outside transaction (Redis, not DB) + await invalidateCachedUser(userId).catch(() => {}); + + // 2. Clear Redis cache (cost keys + active sessions) + try { + const { clearUserCostCache } = await import("@/lib/redis/cost-cache-cleanup"); + const cacheResult = await clearUserCostCache({ + userId, + keyIds, + keyHashes, + includeActiveSessions: true, + }); + if (cacheResult) { logger.info("Reset user statistics - Redis cache cleared", { userId, keyCount: keyIds.length, - costKeysDeleted: allCostKeys.length, - activeSessionsDeleted: keyIds.length + 1, - durationMs: duration, - }); - } catch (error) { - logger.error("Failed to clear Redis cache during user statistics reset", { - userId, - error: error instanceof Error ? error.message : String(error), + ...cacheResult, }); - // Continue execution - DB logs already deleted } + } catch (error) { + logger.error("Failed to clear Redis cache during user statistics reset", { + userId, + error: error instanceof Error ? error.message : String(error), + }); + // Continue execution - DB logs already deleted } logger.info("Reset all user statistics", { userId, keyCount: keyIds.length }); 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 5a0fcf9bb..463016ea4 100644 --- a/src/app/[locale]/dashboard/_components/user/edit-user-dialog.tsx +++ b/src/app/[locale]/dashboard/_components/user/edit-user-dialog.tsx @@ -1,13 +1,19 @@ "use client"; import { useQueryClient } from "@tanstack/react-query"; -import { Loader2, Trash2, UserCog } from "lucide-react"; +import { Loader2, RotateCcw, Trash2, UserCog } from "lucide-react"; import { useRouter } from "next/navigation"; -import { useTranslations } from "next-intl"; +import { useLocale, useTranslations } from "next-intl"; import { useMemo, useState, useTransition } from "react"; import { toast } from "sonner"; import { z } from "zod"; -import { editUser, removeUser, resetUserAllStatistics, toggleUserEnabled } from "@/actions/users"; +import { + editUser, + removeUser, + resetUserAllStatistics, + resetUserLimitsOnly, + toggleUserEnabled, +} from "@/actions/users"; import { AlertDialog, AlertDialogAction, @@ -84,9 +90,12 @@ function EditUserDialogInner({ onOpenChange, user, onSuccess }: EditUserDialogPr const queryClient = useQueryClient(); const t = useTranslations("dashboard.userManagement"); const tCommon = useTranslations("common"); + const locale = useLocale(); const [isPending, startTransition] = useTransition(); const [isResettingAll, setIsResettingAll] = useState(false); const [resetAllDialogOpen, setResetAllDialogOpen] = useState(false); + const [isResettingLimits, setIsResettingLimits] = useState(false); + const [resetLimitsDialogOpen, setResetLimitsDialogOpen] = useState(false); // Always show providerGroup field in edit mode const userEditTranslations = useUserTranslations({ showProviderGroup: true }); @@ -243,6 +252,25 @@ function EditUserDialogInner({ onOpenChange, user, onSuccess }: EditUserDialogPr } }; + const handleResetLimitsOnly = async () => { + setIsResettingLimits(true); + try { + const res = await resetUserLimitsOnly(user.id); + if (!res.ok) { + toast.error(res.error || t("editDialog.resetLimits.error")); + return; + } + toast.success(t("editDialog.resetLimits.success")); + setResetLimitsDialogOpen(false); + window.location.reload(); + } catch (error) { + console.error("[EditUserDialog] reset limits only failed", error); + toast.error(t("editDialog.resetLimits.error")); + } finally { + setIsResettingLimits(false); + } + }; + return (
@@ -291,55 +319,129 @@ function EditUserDialogInner({ onOpenChange, user, onSuccess }: EditUserDialogPr /> {/* Reset Data Section - Admin Only */} -
-
-
-

- {t("editDialog.resetData.title")} -

-

- {t("editDialog.resetData.description")} -

-
+
+

{t("editDialog.resetSection.title")}

- - - - - - - {t("editDialog.resetData.confirmTitle")} - - {t("editDialog.resetData.confirmDescription")} - - - - - {tCommon("cancel")} - - { - e.preventDefault(); - handleResetAllStatistics(); - }} - disabled={isResettingAll} - className={cn(buttonVariants({ variant: "destructive" }))} + {/* Reset Limits Only - Less destructive (amber) */} +
+
+
+

+ {t("editDialog.resetLimits.title")} +

+

+ {t("editDialog.resetLimits.description")} +

+ {user.costResetAt && ( +

+ {t("editDialog.resetLimits.lastResetAt", { + date: new Intl.DateTimeFormat(locale, { + dateStyle: "medium", + timeStyle: "short", + }).format(new Date(user.costResetAt)), + })} +

+ )} +
+ + + + + + + + + {t("editDialog.resetLimits.confirmTitle")} + + + {t("editDialog.resetLimits.confirmDescription")} + + + + + {tCommon("cancel")} + + { + e.preventDefault(); + handleResetLimitsOnly(); + }} + disabled={isResettingLimits} + className="bg-amber-600 text-white hover:bg-amber-700" + > + {isResettingLimits ? ( + <> + + {t("editDialog.resetLimits.loading")} + + ) : ( + t("editDialog.resetLimits.confirm") + )} + + + + +
+
+ + {/* Reset All Statistics - Destructive (red) */} +
+
+
+

+ {t("editDialog.resetData.title")} +

+

+ {t("editDialog.resetData.description")} +

+
+ + + + + + + + {t("editDialog.resetData.confirmTitle")} + + {t("editDialog.resetData.confirmDescription")} + + + + + {tCommon("cancel")} + + { + e.preventDefault(); + handleResetAllStatistics(); + }} + disabled={isResettingAll} + className={cn(buttonVariants({ variant: "destructive" }))} + > + {isResettingAll ? ( + <> + + {t("editDialog.resetData.loading")} + + ) : ( + t("editDialog.resetData.confirm") + )} + + + + +
diff --git a/src/app/[locale]/dashboard/quotas/users/page.tsx b/src/app/[locale]/dashboard/quotas/users/page.tsx index 5a85248b1..eb170138c 100644 --- a/src/app/[locale]/dashboard/quotas/users/page.tsx +++ b/src/app/[locale]/dashboard/quotas/users/page.tsx @@ -21,11 +21,31 @@ async function getUsersWithQuotas(): Promise { const allUserIds = users.map((u) => u.id); const allKeyIds = users.flatMap((u) => u.keys.map((k) => k.id)); + // Build resetAt maps for users with cost reset timestamps + const userResetAtMap = new Map(); + const keyResetAtMap = new Map(); + for (const u of users) { + if (u.costResetAt instanceof Date) { + userResetAtMap.set(u.id, u.costResetAt); + for (const k of u.keys) { + keyResetAtMap.set(k.id, u.costResetAt); + } + } + } + // 3 queries total instead of N+M individual SUM queries const [quotaResults, userCostMap, keyCostMap] = await Promise.all([ Promise.all(users.map((u) => getUserLimitUsage(u.id))), - sumUserTotalCostBatch(allUserIds), - sumKeyTotalCostBatchByIds(allKeyIds), + sumUserTotalCostBatch( + allUserIds, + undefined, + userResetAtMap.size > 0 ? userResetAtMap : undefined + ), + sumKeyTotalCostBatchByIds( + allKeyIds, + undefined, + keyResetAtMap.size > 0 ? keyResetAtMap : undefined + ), ]); return users.map((user, idx) => { diff --git a/src/app/v1/_lib/proxy/rate-limit-guard.ts b/src/app/v1/_lib/proxy/rate-limit-guard.ts index 36f54f06a..92c333723 100644 --- a/src/app/v1/_lib/proxy/rate-limit-guard.ts +++ b/src/app/v1/_lib/proxy/rate-limit-guard.ts @@ -64,7 +64,7 @@ export class ProxyRateLimitGuard { key.id, "key", key.limitTotalUsd ?? null, - { keyHash: key.key } + { keyHash: key.key, resetAt: user.costResetAt } ); if (!keyTotalCheck.allowed) { @@ -94,7 +94,8 @@ export class ProxyRateLimitGuard { const userTotalCheck = await RateLimitService.checkTotalCostLimit( user.id, "user", - user.limitTotalUsd ?? null + user.limitTotalUsd ?? null, + { resetAt: user.costResetAt } ); if (!userTotalCheck.allowed) { @@ -229,6 +230,7 @@ export class ProxyRateLimitGuard { limit_daily_usd: null, // 仅检查 5h limit_weekly_usd: null, limit_monthly_usd: null, + cost_reset_at: user.costResetAt ?? null, }); if (!key5hCheck.allowed) { @@ -265,6 +267,7 @@ export class ProxyRateLimitGuard { limit_daily_usd: null, limit_weekly_usd: null, limit_monthly_usd: null, + cost_reset_at: user.costResetAt ?? null, }); if (!user5hCheck.allowed) { @@ -303,6 +306,7 @@ export class ProxyRateLimitGuard { daily_reset_time: key.dailyResetTime, limit_weekly_usd: null, limit_monthly_usd: null, + cost_reset_at: user.costResetAt ?? null, }); if (!keyDailyCheck.allowed) { @@ -376,6 +380,7 @@ export class ProxyRateLimitGuard { daily_reset_mode: user.dailyResetMode, limit_weekly_usd: null, limit_monthly_usd: null, + cost_reset_at: user.costResetAt ?? null, }); if (!userDailyCheck.allowed) { @@ -450,6 +455,7 @@ export class ProxyRateLimitGuard { limit_daily_usd: null, limit_weekly_usd: key.limitWeeklyUsd, limit_monthly_usd: null, + cost_reset_at: user.costResetAt ?? null, }); if (!keyWeeklyCheck.allowed) { @@ -484,6 +490,7 @@ export class ProxyRateLimitGuard { limit_daily_usd: null, limit_weekly_usd: user.limitWeeklyUsd ?? null, limit_monthly_usd: null, + cost_reset_at: user.costResetAt ?? null, }); if (!userWeeklyCheck.allowed) { @@ -520,6 +527,7 @@ export class ProxyRateLimitGuard { limit_daily_usd: null, limit_weekly_usd: null, limit_monthly_usd: key.limitMonthlyUsd, + cost_reset_at: user.costResetAt ?? null, }); if (!keyMonthlyCheck.allowed) { @@ -556,6 +564,7 @@ export class ProxyRateLimitGuard { limit_daily_usd: null, limit_weekly_usd: null, limit_monthly_usd: user.limitMonthlyUsd ?? null, + cost_reset_at: user.costResetAt ?? null, }); if (!userMonthlyCheck.allowed) { diff --git a/src/drizzle/schema.ts b/src/drizzle/schema.ts index ad06e75ee..7df820d8c 100644 --- a/src/drizzle/schema.ts +++ b/src/drizzle/schema.ts @@ -51,6 +51,7 @@ export const users = pgTable('users', { limitWeeklyUsd: numeric('limit_weekly_usd', { precision: 10, scale: 2 }), limitMonthlyUsd: numeric('limit_monthly_usd', { precision: 10, scale: 2 }), limitTotalUsd: numeric('limit_total_usd', { precision: 10, scale: 2 }), + costResetAt: timestamp('cost_reset_at', { withTimezone: true }), limitConcurrentSessions: integer('limit_concurrent_sessions'), // Daily quota reset mode (fixed: reset at specific time, rolling: 24h window) diff --git a/src/lib/rate-limit/lease-service.ts b/src/lib/rate-limit/lease-service.ts index 6ee2310c9..516f93457 100644 --- a/src/lib/rate-limit/lease-service.ts +++ b/src/lib/rate-limit/lease-service.ts @@ -43,6 +43,7 @@ export interface GetCostLeaseParams { limitAmount: number; resetTime?: string; resetMode?: DailyResetMode; + costResetAt?: Date | null; } /** @@ -106,6 +107,18 @@ export class LeaseService { return await LeaseService.refreshCostLeaseFromDb(params); } + // Check if costResetAt changed - force refresh if so + const paramResetAtMs = + params.costResetAt instanceof Date ? params.costResetAt.getTime() : null; + if ((lease.costResetAtMs ?? null) !== paramResetAtMs) { + logger.debug("[LeaseService] costResetAt changed, force refresh", { + key: leaseKey, + cachedResetAtMs: lease.costResetAtMs ?? null, + newResetAtMs: paramResetAtMs, + }); + return await LeaseService.refreshCostLeaseFromDb(params); + } + logger.debug("[LeaseService] Cache hit", { key: leaseKey, remaining: lease.remainingBudget, @@ -165,11 +178,17 @@ export class LeaseService { // Calculate time range for DB query const { startTime, endTime } = await getLeaseTimeRange(window, resetTime, resetMode); + // Clip startTime forward if costResetAt is more recent (limits-only reset) + const effectiveStartTime = + params.costResetAt instanceof Date && params.costResetAt > startTime + ? params.costResetAt + : startTime; + // Query DB for current usage const currentUsage = await LeaseService.queryDbUsage( entityType, entityId, - startTime, + effectiveStartTime, endTime ); @@ -194,6 +213,7 @@ export class LeaseService { limitAmount, remainingBudget, ttlSeconds, + costResetAtMs: params.costResetAt instanceof Date ? params.costResetAt.getTime() : null, }); // Store in Redis diff --git a/src/lib/rate-limit/lease.ts b/src/lib/rate-limit/lease.ts index 225b71bce..8e5b56e9e 100644 --- a/src/lib/rate-limit/lease.ts +++ b/src/lib/rate-limit/lease.ts @@ -38,6 +38,7 @@ export interface BudgetLease { limitAmount: number; remainingBudget: number; ttlSeconds: number; + costResetAtMs?: number | null; } /** diff --git a/src/lib/rate-limit/service.ts b/src/lib/rate-limit/service.ts index 98b597788..5de042a14 100644 --- a/src/lib/rate-limit/service.ts +++ b/src/lib/rate-limit/service.ts @@ -165,6 +165,7 @@ export class RateLimitService { daily_reset_mode?: DailyResetMode; limit_weekly_usd: number | null; limit_monthly_usd: number | null; + cost_reset_at?: Date | null; } ): Promise<{ allowed: boolean; reason?: string }> { const normalizedDailyReset = normalizeResetTime(limits.daily_reset_time); @@ -214,7 +215,12 @@ export class RateLimitService { logger.info( `[RateLimit] Cache miss for ${type}:${id}:cost_5h, querying database` ); - return await RateLimitService.checkCostLimitsFromDatabase(id, type, costLimits); + return await RateLimitService.checkCostLimitsFromDatabase( + id, + type, + costLimits, + limits.cost_reset_at + ); } } } catch (error) { @@ -222,7 +228,12 @@ export class RateLimitService { "[RateLimit] 5h rolling window query failed, fallback to database:", error ); - return await RateLimitService.checkCostLimitsFromDatabase(id, type, costLimits); + return await RateLimitService.checkCostLimitsFromDatabase( + id, + type, + costLimits, + limits.cost_reset_at + ); } } else if (limit.period === "daily" && limit.resetMode === "rolling") { // daily 滚动窗口:使用 ZSET + Lua 脚本 @@ -246,7 +257,12 @@ export class RateLimitService { logger.info( `[RateLimit] Cache miss for ${type}:${id}:cost_daily_rolling, querying database` ); - return await RateLimitService.checkCostLimitsFromDatabase(id, type, costLimits); + return await RateLimitService.checkCostLimitsFromDatabase( + id, + type, + costLimits, + limits.cost_reset_at + ); } } } catch (error) { @@ -254,7 +270,12 @@ export class RateLimitService { "[RateLimit] Daily rolling window query failed, fallback to database:", error ); - return await RateLimitService.checkCostLimitsFromDatabase(id, type, costLimits); + return await RateLimitService.checkCostLimitsFromDatabase( + id, + type, + costLimits, + limits.cost_reset_at + ); } } else { // daily fixed/周/月使用普通 GET @@ -267,7 +288,12 @@ export class RateLimitService { logger.info( `[RateLimit] Cache miss for ${type}:${id}:cost_${periodKey}, querying database` ); - return await RateLimitService.checkCostLimitsFromDatabase(id, type, costLimits); + return await RateLimitService.checkCostLimitsFromDatabase( + id, + type, + costLimits, + limits.cost_reset_at + ); } current = parseFloat((value as string) || "0"); @@ -287,10 +313,20 @@ export class RateLimitService { // Slow Path: Redis 不可用,降级到数据库 logger.warn(`[RateLimit] Redis unavailable, checking ${type} cost limits from database`); - return await RateLimitService.checkCostLimitsFromDatabase(id, type, costLimits); + return await RateLimitService.checkCostLimitsFromDatabase( + id, + type, + costLimits, + limits.cost_reset_at + ); } catch (error) { logger.error("[RateLimit] Check failed, fallback to database:", error); - return await RateLimitService.checkCostLimitsFromDatabase(id, type, costLimits); + return await RateLimitService.checkCostLimitsFromDatabase( + id, + type, + costLimits, + limits.cost_reset_at + ); } } @@ -311,17 +347,18 @@ export class RateLimitService { try { let current = 0; const cacheKey = (() => { + const resetAtSuffix = + options?.resetAt instanceof Date && !Number.isNaN(options.resetAt.getTime()) + ? `:${options.resetAt.getTime()}` + : ""; if (entityType === "key") { - return `total_cost:key:${options?.keyHash}`; + return `total_cost:key:${options?.keyHash}${resetAtSuffix}`; } if (entityType === "user") { - return `total_cost:user:${entityId}`; + return `total_cost:user:${entityId}${resetAtSuffix}`; } - const resetAtMs = - options?.resetAt instanceof Date && !Number.isNaN(options.resetAt.getTime()) - ? options.resetAt.getTime() - : "none"; - return `total_cost:provider:${entityId}:${resetAtMs}`; + const resetAtMs = resetAtSuffix || ":none"; + return `total_cost:provider:${entityId}${resetAtMs}`; })(); const cacheTtl = 300; // 5 minutes @@ -339,9 +376,9 @@ export class RateLimitService { logger.warn("[RateLimit] Missing key hash for total cost check, skip enforcement"); return { allowed: true }; } - current = await sumKeyTotalCost(options.keyHash); + current = await sumKeyTotalCost(options.keyHash, 365, options?.resetAt); } else if (entityType === "user") { - current = await sumUserTotalCost(entityId); + current = await sumUserTotalCost(entityId, 365, options?.resetAt); } else { current = await sumProviderTotalCost(entityId, options?.resetAt ?? null); } @@ -357,9 +394,9 @@ export class RateLimitService { if (!options?.keyHash) { return { allowed: true }; } - current = await sumKeyTotalCost(options.keyHash); + current = await sumKeyTotalCost(options.keyHash, 365, options?.resetAt); } else if (entityType === "user") { - current = await sumUserTotalCost(entityId); + current = await sumUserTotalCost(entityId, 365, options?.resetAt); } else { current = await sumProviderTotalCost(entityId, options?.resetAt ?? null); } @@ -371,9 +408,9 @@ export class RateLimitService { logger.warn("[RateLimit] Missing key hash for total cost check, skip enforcement"); return { allowed: true }; } - current = await sumKeyTotalCost(options.keyHash); + current = await sumKeyTotalCost(options.keyHash, 365, options?.resetAt); } else if (entityType === "user") { - current = await sumUserTotalCost(entityId); + current = await sumUserTotalCost(entityId, 365, options?.resetAt); } else { current = await sumProviderTotalCost(entityId, options?.resetAt ?? null); } @@ -401,7 +438,8 @@ export class RateLimitService { private static async checkCostLimitsFromDatabase( id: number, type: "key" | "provider" | "user", - costLimits: CostLimit[] + costLimits: CostLimit[], + costResetAt?: Date | null ): Promise<{ allowed: boolean; reason?: string }> { const { findKeyCostEntriesInTimeRange, @@ -422,6 +460,10 @@ export class RateLimitService { limit.resetMode ); + // Clip startTime forward if costResetAt is more recent + const effectiveStartTime = + costResetAt instanceof Date && costResetAt > startTime ? costResetAt : startTime; + // 查询数据库 let current = 0; let costEntries: Array<{ @@ -436,13 +478,13 @@ export class RateLimitService { if (isRollingWindow) { switch (type) { case "key": - costEntries = await findKeyCostEntriesInTimeRange(id, startTime, endTime); + costEntries = await findKeyCostEntriesInTimeRange(id, effectiveStartTime, endTime); break; case "provider": - costEntries = await findProviderCostEntriesInTimeRange(id, startTime, endTime); + costEntries = await findProviderCostEntriesInTimeRange(id, effectiveStartTime, endTime); break; case "user": - costEntries = await findUserCostEntriesInTimeRange(id, startTime, endTime); + costEntries = await findUserCostEntriesInTimeRange(id, effectiveStartTime, endTime); break; default: costEntries = []; @@ -452,13 +494,13 @@ export class RateLimitService { } else { switch (type) { case "key": - current = await sumKeyCostInTimeRange(id, startTime, endTime); + current = await sumKeyCostInTimeRange(id, effectiveStartTime, endTime); break; case "provider": - current = await sumProviderCostInTimeRange(id, startTime, endTime); + current = await sumProviderCostInTimeRange(id, effectiveStartTime, endTime); break; case "user": - current = await sumUserCostInTimeRange(id, startTime, endTime); + current = await sumUserCostInTimeRange(id, effectiveStartTime, endTime); break; default: current = 0; @@ -1424,6 +1466,7 @@ export class RateLimitService { daily_reset_mode?: DailyResetMode; limit_weekly_usd: number | null; limit_monthly_usd: number | null; + cost_reset_at?: Date | null; } ): Promise<{ allowed: boolean; reason?: string; failOpen?: boolean }> { const normalizedDailyReset = normalizeResetTime(limits.daily_reset_time); @@ -1479,6 +1522,7 @@ export class RateLimitService { limitAmount: check.limit, resetTime: check.resetTime, resetMode: check.resetMode, + costResetAt: limits.cost_reset_at, }); // Fail-open if lease retrieval failed diff --git a/src/lib/redis/cost-cache-cleanup.ts b/src/lib/redis/cost-cache-cleanup.ts new file mode 100644 index 000000000..c24a706a2 --- /dev/null +++ b/src/lib/redis/cost-cache-cleanup.ts @@ -0,0 +1,159 @@ +import { logger } from "@/lib/logger"; +import { getRedisClient } from "@/lib/redis"; +import { getKeyActiveSessionsKey, getUserActiveSessionsKey } from "@/lib/redis/active-session-keys"; +import { scanPattern } from "@/lib/redis/scan-helper"; + +export interface ClearUserCostCacheOptions { + userId: number; + keyIds: number[]; + keyHashes: string[]; + includeActiveSessions?: boolean; +} + +export interface ClearUserCostCacheResult { + costKeysDeleted: number; + activeSessionsDeleted: number; + durationMs: number; +} + +/** + * Scan and delete all Redis cost-cache keys for a user and their API keys. + * + * Covers: cost counters, total cost cache, lease budget slices, + * and optionally active session ZSETs. + * + * Returns null if Redis is not ready. Never throws -- logs errors internally. + */ +export async function clearUserCostCache( + options: ClearUserCostCacheOptions +): Promise { + const { userId, keyIds, keyHashes, includeActiveSessions = false } = options; + + const redis = getRedisClient(); + if (!redis || redis.status !== "ready") { + return null; + } + + const startTime = Date.now(); + + // Scan all cost patterns in parallel + const scanResults = await Promise.all([ + ...keyIds.map((keyId) => + scanPattern(redis, `key:${keyId}:cost_*`).catch((err) => { + logger.warn("Failed to scan key cost pattern", { keyId, error: err }); + return []; + }) + ), + scanPattern(redis, `user:${userId}:cost_*`).catch((err) => { + logger.warn("Failed to scan user cost pattern", { userId, error: err }); + return []; + }), + // Total cost cache keys (with optional resetAt suffix) + scanPattern(redis, `total_cost:user:${userId}`).catch((err) => { + logger.warn("Failed to scan total cost pattern", { + userId, + pattern: `total_cost:user:${userId}`, + error: err instanceof Error ? err.message : String(err), + }); + return []; + }), + scanPattern(redis, `total_cost:user:${userId}:*`).catch((err) => { + logger.warn("Failed to scan total cost pattern", { + userId, + pattern: `total_cost:user:${userId}:*`, + error: err instanceof Error ? err.message : String(err), + }); + return []; + }), + ...keyHashes.map((keyHash) => + scanPattern(redis, `total_cost:key:${keyHash}`).catch((err) => { + logger.warn("Failed to scan total cost key pattern", { + keyHash, + error: err instanceof Error ? err.message : String(err), + }); + return []; + }) + ), + ...keyHashes.map((keyHash) => + scanPattern(redis, `total_cost:key:${keyHash}:*`).catch((err) => { + logger.warn("Failed to scan total cost key pattern", { + keyHash, + error: err instanceof Error ? err.message : String(err), + }); + return []; + }) + ), + // Lease cache keys (budget slices cached by LeaseService) + ...keyIds.map((keyId) => + scanPattern(redis, `lease:key:${keyId}:*`).catch((err) => { + logger.warn("Failed to scan lease key pattern", { + keyId, + error: err instanceof Error ? err.message : String(err), + }); + return []; + }) + ), + scanPattern(redis, `lease:user:${userId}:*`).catch((err) => { + logger.warn("Failed to scan lease user pattern", { + userId, + error: err instanceof Error ? err.message : String(err), + }); + return []; + }), + ]); + + const allCostKeys = scanResults.flat(); + let activeSessionsDeleted = 0; + + // Only create pipeline if there is work to do + if (allCostKeys.length === 0 && !includeActiveSessions) { + return { + costKeysDeleted: 0, + activeSessionsDeleted: 0, + durationMs: Date.now() - startTime, + }; + } + + const pipeline = redis.pipeline(); + + // Active sessions (only for full statistics reset) + if (includeActiveSessions) { + for (const keyId of keyIds) { + pipeline.del(getKeyActiveSessionsKey(keyId)); + } + pipeline.del(getUserActiveSessionsKey(userId)); + activeSessionsDeleted = keyIds.length + 1; + } + + // Cost keys + for (const key of allCostKeys) { + pipeline.del(key); + } + + let results: Array<[Error | null, unknown]> | null = null; + try { + results = await pipeline.exec(); + } catch (error) { + logger.warn("Redis pipeline.exec() failed during cost cache cleanup", { userId, error }); + return { + costKeysDeleted: allCostKeys.length, + activeSessionsDeleted, + durationMs: Date.now() - startTime, + }; + } + + // Check for pipeline errors + const errors = results?.filter(([err]) => err); + if (errors && errors.length > 0) { + logger.warn("Some Redis deletes failed during cost cache cleanup", { + errorCount: errors.length, + userId, + }); + } + + return { + costKeysDeleted: allCostKeys.length, + activeSessionsDeleted, + durationMs: Date.now() - startTime, + }; +} diff --git a/src/lib/security/api-key-auth-cache.ts b/src/lib/security/api-key-auth-cache.ts index 66fcc7027..42471dbf3 100644 --- a/src/lib/security/api-key-auth-cache.ts +++ b/src/lib/security/api-key-auth-cache.ts @@ -169,8 +169,10 @@ function hydrateUserFromCache(payload: CachedUserPayloadV1): User | null { const expiresAt = parseOptionalDate(user.expiresAt); const deletedAt = parseOptionalDate(user.deletedAt); + const costResetAt = parseOptionalDate(user.costResetAt); if (user.expiresAt != null && !expiresAt) return null; if (user.deletedAt != null && !deletedAt) return null; + // costResetAt: intentional fail-open on invalid date -- affects quota counting window, not access control return { ...(payload.user as User), @@ -178,6 +180,7 @@ function hydrateUserFromCache(payload: CachedUserPayloadV1): User | null { updatedAt, expiresAt: expiresAt === undefined ? undefined : expiresAt, deletedAt: deletedAt === undefined ? undefined : deletedAt, + costResetAt: costResetAt === undefined ? undefined : costResetAt, } as User; } diff --git a/src/repository/_shared/transformers.ts b/src/repository/_shared/transformers.ts index ad58b6604..c11c465c5 100644 --- a/src/repository/_shared/transformers.ts +++ b/src/repository/_shared/transformers.ts @@ -48,6 +48,7 @@ export function toUser(dbUser: any): User { dailyResetTime: dbUser?.dailyResetTime ?? "00:00", isEnabled: dbUser?.isEnabled ?? true, expiresAt: dbUser?.expiresAt ? new Date(dbUser.expiresAt) : null, + costResetAt: dbUser?.costResetAt ? new Date(dbUser.costResetAt) : null, allowedClients: dbUser?.allowedClients ?? [], blockedClients: dbUser?.blockedClients ?? [], allowedModels: dbUser?.allowedModels ?? [], diff --git a/src/repository/key.ts b/src/repository/key.ts index 15f03abc7..62b0383e2 100644 --- a/src/repository/key.ts +++ b/src/repository/key.ts @@ -546,6 +546,7 @@ export async function validateApiKeyAndGetUser( limitWeeklyUsd: users.limitWeeklyUsd, limitMonthlyUsd: users.limitMonthlyUsd, limitTotalUsd: users.limitTotalUsd, + costResetAt: users.costResetAt, limitConcurrentSessions: users.limitConcurrentSessions, dailyResetMode: users.dailyResetMode, dailyResetTime: users.dailyResetTime, @@ -609,6 +610,7 @@ export async function validateApiKeyAndGetUser( userLimitWeeklyUsd: users.limitWeeklyUsd, userLimitMonthlyUsd: users.limitMonthlyUsd, userLimitTotalUsd: users.limitTotalUsd, + userCostResetAt: users.costResetAt, userLimitConcurrentSessions: users.limitConcurrentSessions, userDailyResetMode: users.dailyResetMode, userDailyResetTime: users.dailyResetTime, @@ -650,6 +652,7 @@ export async function validateApiKeyAndGetUser( limitWeeklyUsd: row.userLimitWeeklyUsd, limitMonthlyUsd: row.userLimitMonthlyUsd, limitTotalUsd: row.userLimitTotalUsd, + costResetAt: row.userCostResetAt, limitConcurrentSessions: row.userLimitConcurrentSessions, dailyResetMode: row.userDailyResetMode, dailyResetTime: row.userDailyResetTime, diff --git a/src/repository/statistics.ts b/src/repository/statistics.ts index 7df64e5d0..ad3ed0d01 100644 --- a/src/repository/statistics.ts +++ b/src/repository/statistics.ts @@ -463,13 +463,24 @@ export async function sumUserCostToday(userId: number): Promise { * @param keyHash - API Key hash * @param maxAgeDays - Max query days (default 365). Use Infinity for all-time. */ -export async function sumKeyTotalCost(keyHash: string, maxAgeDays: number = 365): Promise { +export async function sumKeyTotalCost( + keyHash: string, + maxAgeDays: number = 365, + resetAt?: Date | null +): Promise { const conditions = [eq(usageLedger.key, keyHash), LEDGER_BILLING_CONDITION]; - // Finite positive maxAgeDays adds a date filter; Infinity/0/negative means all-time - if (Number.isFinite(maxAgeDays) && maxAgeDays > 0) { - const cutoffDate = new Date(Date.now() - Math.floor(maxAgeDays) * 24 * 60 * 60 * 1000); - conditions.push(gte(usageLedger.createdAt, cutoffDate)); + // Use the more recent of resetAt and maxAgeDays cutoff + const maxAgeCutoff = + Number.isFinite(maxAgeDays) && maxAgeDays > 0 + ? new Date(Date.now() - Math.floor(maxAgeDays) * 24 * 60 * 60 * 1000) + : null; + let cutoff = maxAgeCutoff; + if (resetAt instanceof Date && !Number.isNaN(resetAt.getTime())) { + cutoff = maxAgeCutoff && maxAgeCutoff > resetAt ? maxAgeCutoff : resetAt; + } + if (cutoff) { + conditions.push(gte(usageLedger.createdAt, cutoff)); } const result = await db @@ -485,13 +496,24 @@ export async function sumKeyTotalCost(keyHash: string, maxAgeDays: number = 365) * @param userId - User ID * @param maxAgeDays - Max query days (default 365). Use Infinity for all-time. */ -export async function sumUserTotalCost(userId: number, maxAgeDays: number = 365): Promise { +export async function sumUserTotalCost( + userId: number, + maxAgeDays: number = 365, + resetAt?: Date | null +): Promise { const conditions = [eq(usageLedger.userId, userId), LEDGER_BILLING_CONDITION]; - // Finite positive maxAgeDays adds a date filter; Infinity/0/negative means all-time - if (Number.isFinite(maxAgeDays) && maxAgeDays > 0) { - const cutoffDate = new Date(Date.now() - Math.floor(maxAgeDays) * 24 * 60 * 60 * 1000); - conditions.push(gte(usageLedger.createdAt, cutoffDate)); + // Use the more recent of resetAt and maxAgeDays cutoff + const maxAgeCutoff = + Number.isFinite(maxAgeDays) && maxAgeDays > 0 + ? new Date(Date.now() - Math.floor(maxAgeDays) * 24 * 60 * 60 * 1000) + : null; + let cutoff = maxAgeCutoff; + if (resetAt instanceof Date && !Number.isNaN(resetAt.getTime())) { + cutoff = maxAgeCutoff && maxAgeCutoff > resetAt ? maxAgeCutoff : resetAt; + } + if (cutoff) { + conditions.push(gte(usageLedger.createdAt, cutoff)); } const result = await db @@ -510,28 +532,55 @@ export async function sumUserTotalCost(userId: number, maxAgeDays: number = 365) */ export async function sumUserTotalCostBatch( userIds: number[], - maxAgeDays: number = 365 + maxAgeDays: number = 365, + resetAtMap?: Map ): Promise> { const result = new Map(); if (userIds.length === 0) return result; + for (const id of userIds) result.set(id, 0); - const conditions: SQL[] = [inArray(usageLedger.userId, userIds), LEDGER_BILLING_CONDITION]; - if (Number.isFinite(maxAgeDays) && maxAgeDays > 0) { - const cutoffDate = new Date(Date.now() - Math.floor(maxAgeDays) * 24 * 60 * 60 * 1000); - conditions.push(gte(usageLedger.createdAt, cutoffDate)); + // Split users: those with costResetAt need individual queries + const resetUserIds: number[] = []; + const batchUserIds: number[] = []; + for (const id of userIds) { + if (resetAtMap?.has(id)) { + resetUserIds.push(id); + } else { + batchUserIds.push(id); + } } - const rows = await db - .select({ - userId: usageLedger.userId, - total: sql`COALESCE(SUM(${usageLedger.costUsd}), 0)`, - }) - .from(usageLedger) - .where(and(...conditions)) - .groupBy(usageLedger.userId); + // Individual queries for users with costResetAt + if (resetUserIds.length > 0) { + const resetResults = await Promise.all( + resetUserIds.map(async (id) => ({ + id, + total: await sumUserTotalCost(id, maxAgeDays, resetAtMap!.get(id)), + })) + ); + for (const { id, total } of resetResults) result.set(id, total); + } + + // Batch query for users without costResetAt + if (batchUserIds.length > 0) { + const conditions: SQL[] = [inArray(usageLedger.userId, batchUserIds), LEDGER_BILLING_CONDITION]; + if (Number.isFinite(maxAgeDays) && maxAgeDays > 0) { + const cutoffDate = new Date(Date.now() - Math.floor(maxAgeDays) * 24 * 60 * 60 * 1000); + conditions.push(gte(usageLedger.createdAt, cutoffDate)); + } + + const rows = await db + .select({ + userId: usageLedger.userId, + total: sql`COALESCE(SUM(${usageLedger.costUsd}), 0)`, + }) + .from(usageLedger) + .where(and(...conditions)) + .groupBy(usageLedger.userId); + + for (const row of rows) result.set(row.userId, Number(row.total || 0)); + } - for (const id of userIds) result.set(id, 0); - for (const row of rows) result.set(row.userId, Number(row.total || 0)); return result; } @@ -545,7 +594,8 @@ export async function sumUserTotalCostBatch( */ export async function sumKeyTotalCostBatchByIds( keyIds: number[], - maxAgeDays: number = 365 + maxAgeDays: number = 365, + resetAtMap?: Map ): Promise> { const result = new Map(); if (keyIds.length === 0) return result; @@ -558,29 +608,59 @@ export async function sumKeyTotalCostBatchByIds( .where(inArray(keys.id, keyIds)); const keyStringToId = new Map(keyMappings.map((k) => [k.key, k.id])); + const idToKeyString = new Map(keyMappings.map((k) => [k.id, k.key])); const keyStrings = keyMappings.map((k) => k.key); if (keyStrings.length === 0) return result; - // Step 2: Aggregate on usage_ledger directly (hits idx_usage_ledger_key_cost) - const conditions: SQL[] = [inArray(usageLedger.key, keyStrings), LEDGER_BILLING_CONDITION]; - if (Number.isFinite(maxAgeDays) && maxAgeDays > 0) { - const cutoffDate = new Date(Date.now() - Math.floor(maxAgeDays) * 24 * 60 * 60 * 1000); - conditions.push(gte(usageLedger.createdAt, cutoffDate)); + // Split keys: those with costResetAt need individual queries + const resetKeyIds: number[] = []; + const batchKeyStrings: string[] = []; + for (const mapping of keyMappings) { + if (resetAtMap?.has(mapping.id)) { + resetKeyIds.push(mapping.id); + } else { + batchKeyStrings.push(mapping.key); + } } - const rows = await db - .select({ - key: usageLedger.key, - total: sql`COALESCE(SUM(${usageLedger.costUsd}), 0)`, - }) - .from(usageLedger) - .where(and(...conditions)) - .groupBy(usageLedger.key); + // Individual queries for keys with costResetAt + if (resetKeyIds.length > 0) { + const resetResults = await Promise.all( + resetKeyIds.map(async (id) => { + const keyString = idToKeyString.get(id); + if (!keyString) return { id, total: 0 }; + return { + id, + total: await sumKeyTotalCost(keyString, maxAgeDays, resetAtMap!.get(id)), + }; + }) + ); + for (const { id, total } of resetResults) result.set(id, total); + } - for (const row of rows) { - const keyId = keyStringToId.get(row.key); - if (keyId !== undefined) result.set(keyId, Number(row.total || 0)); + // Step 2: Batch aggregate for keys without costResetAt + if (batchKeyStrings.length > 0) { + const conditions: SQL[] = [inArray(usageLedger.key, batchKeyStrings), LEDGER_BILLING_CONDITION]; + if (Number.isFinite(maxAgeDays) && maxAgeDays > 0) { + const cutoffDate = new Date(Date.now() - Math.floor(maxAgeDays) * 24 * 60 * 60 * 1000); + conditions.push(gte(usageLedger.createdAt, cutoffDate)); + } + + const rows = await db + .select({ + key: usageLedger.key, + total: sql`COALESCE(SUM(${usageLedger.costUsd}), 0)`, + }) + .from(usageLedger) + .where(and(...conditions)) + .groupBy(usageLedger.key); + + for (const row of rows) { + const keyId = keyStringToId.get(row.key); + if (keyId !== undefined) result.set(keyId, Number(row.total || 0)); + } } + return result; } @@ -692,12 +772,18 @@ interface QuotaCostSummary { export async function sumUserQuotaCosts( userId: number, ranges: QuotaCostRanges, - maxAgeDays: number = 365 + maxAgeDays: number = 365, + resetAt?: Date | null ): Promise { - const cutoffDate = + const maxAgeCutoff = Number.isFinite(maxAgeDays) && maxAgeDays > 0 ? new Date(Date.now() - Math.floor(maxAgeDays) * 24 * 60 * 60 * 1000) : null; + // Use the more recent of maxAgeCutoff and resetAt + let cutoffDate = maxAgeCutoff; + if (resetAt instanceof Date && !Number.isNaN(resetAt.getTime())) { + cutoffDate = maxAgeCutoff && maxAgeCutoff > resetAt ? maxAgeCutoff : resetAt; + } const scanStart = cutoffDate ? new Date( @@ -757,17 +843,23 @@ export async function sumUserQuotaCosts( export async function sumKeyQuotaCostsById( keyId: number, ranges: QuotaCostRanges, - maxAgeDays: number = 365 + maxAgeDays: number = 365, + resetAt?: Date | null ): Promise { const keyString = await getKeyStringByIdCached(keyId); if (!keyString) { return { cost5h: 0, costDaily: 0, costWeekly: 0, costMonthly: 0, costTotal: 0 }; } - const cutoffDate = + const maxAgeCutoff = Number.isFinite(maxAgeDays) && maxAgeDays > 0 ? new Date(Date.now() - Math.floor(maxAgeDays) * 24 * 60 * 60 * 1000) : null; + // Use the more recent of maxAgeCutoff and resetAt + let cutoffDate = maxAgeCutoff; + if (resetAt instanceof Date && !Number.isNaN(resetAt.getTime())) { + cutoffDate = maxAgeCutoff && maxAgeCutoff > resetAt ? maxAgeCutoff : resetAt; + } const scanStart = cutoffDate ? new Date( diff --git a/src/repository/user.ts b/src/repository/user.ts index d031e6d48..7a34d3cd7 100644 --- a/src/repository/user.ts +++ b/src/repository/user.ts @@ -79,6 +79,7 @@ export async function createUser(userData: CreateUserData): Promise { limitWeeklyUsd: users.limitWeeklyUsd, limitMonthlyUsd: users.limitMonthlyUsd, limitTotalUsd: users.limitTotalUsd, + costResetAt: users.costResetAt, limitConcurrentSessions: users.limitConcurrentSessions, dailyResetMode: users.dailyResetMode, dailyResetTime: users.dailyResetTime, @@ -112,6 +113,7 @@ export async function findUserList(limit: number = 50, offset: number = 0): Prom limitWeeklyUsd: users.limitWeeklyUsd, limitMonthlyUsd: users.limitMonthlyUsd, limitTotalUsd: users.limitTotalUsd, + costResetAt: users.costResetAt, limitConcurrentSessions: users.limitConcurrentSessions, dailyResetMode: users.dailyResetMode, dailyResetTime: users.dailyResetTime, @@ -358,6 +360,7 @@ export async function findUserListBatch( limitWeeklyUsd: users.limitWeeklyUsd, limitMonthlyUsd: users.limitMonthlyUsd, limitTotalUsd: users.limitTotalUsd, + costResetAt: users.costResetAt, limitConcurrentSessions: users.limitConcurrentSessions, dailyResetMode: users.dailyResetMode, dailyResetTime: users.dailyResetTime, @@ -416,6 +419,7 @@ export async function findUserById(id: number): Promise { limitWeeklyUsd: users.limitWeeklyUsd, limitMonthlyUsd: users.limitMonthlyUsd, limitTotalUsd: users.limitTotalUsd, + costResetAt: users.costResetAt, limitConcurrentSessions: users.limitConcurrentSessions, dailyResetMode: users.dailyResetMode, dailyResetTime: users.dailyResetTime, @@ -511,6 +515,7 @@ export async function updateUser(id: number, userData: UpdateUserData): Promise< limitWeeklyUsd: users.limitWeeklyUsd, limitMonthlyUsd: users.limitMonthlyUsd, limitTotalUsd: users.limitTotalUsd, + costResetAt: users.costResetAt, limitConcurrentSessions: users.limitConcurrentSessions, dailyResetMode: users.dailyResetMode, dailyResetTime: users.dailyResetTime, @@ -541,6 +546,19 @@ export async function deleteUser(id: number): Promise { return result.length > 0; } +export async function resetUserCostResetAt(userId: number, resetAt: Date | null): Promise { + const result = await db + .update(users) + .set({ costResetAt: resetAt, updatedAt: new Date() }) + .where(and(eq(users.id, userId), isNull(users.deletedAt))) + .returning({ id: users.id }); + + if (result.length > 0) { + await invalidateCachedUser(userId).catch(() => {}); + } + return result.length > 0; +} + /** * Mark an expired user as disabled (idempotent operation) * Only updates if the user is currently enabled diff --git a/src/types/user.ts b/src/types/user.ts index 7a1307c76..fd47995af 100644 --- a/src/types/user.ts +++ b/src/types/user.ts @@ -18,6 +18,7 @@ export interface User { limitWeeklyUsd?: number; // 周消费上限(美元) limitMonthlyUsd?: number; // 月消费上限(美元) limitTotalUsd?: number | null; // 总消费上限(美元) + costResetAt?: Date | null; // Cost reset timestamp for limits-only reset limitConcurrentSessions?: number; // 并发 Session 上限 // Daily quota reset mode dailyResetMode: "fixed" | "rolling"; // 每日限额重置模式 @@ -150,6 +151,7 @@ export interface UserDisplay { limitWeeklyUsd?: number | null; limitMonthlyUsd?: number | null; limitTotalUsd?: number | null; + costResetAt?: Date | null; // Cost reset timestamp for limits-only reset limitConcurrentSessions?: number | null; // Daily quota reset mode dailyResetMode?: "fixed" | "rolling"; diff --git a/tests/unit/actions/key-quota-concurrent-inherit.test.ts b/tests/unit/actions/key-quota-concurrent-inherit.test.ts index f07d9f834..6c061270d 100644 --- a/tests/unit/actions/key-quota-concurrent-inherit.test.ts +++ b/tests/unit/actions/key-quota-concurrent-inherit.test.ts @@ -41,8 +41,10 @@ vi.mock("@/lib/rate-limit/time-utils", () => ({ })); const sumKeyCostInTimeRangeMock = vi.fn(async () => 0); +const sumKeyTotalCostMock = vi.fn(async () => 0); vi.mock("@/repository/statistics", () => ({ sumKeyCostInTimeRange: sumKeyCostInTimeRangeMock, + sumKeyTotalCost: sumKeyTotalCostMock, })); const limitMock = vi.fn(); @@ -59,8 +61,10 @@ vi.mock("@/drizzle/db", () => ({ vi.mock("@/lib/logger", () => ({ logger: { + info: vi.fn(), warn: vi.fn(), error: vi.fn(), + debug: vi.fn(), }, })); @@ -89,6 +93,7 @@ describe("getKeyQuotaUsage - concurrent limit inheritance", () => { limitConcurrentSessions: 0, }, userLimitConcurrentSessions: 15, + userCostResetAt: null, }, ]); diff --git a/tests/unit/actions/key-quota-cost-reset.test.ts b/tests/unit/actions/key-quota-cost-reset.test.ts new file mode 100644 index 000000000..d668a9dc3 --- /dev/null +++ b/tests/unit/actions/key-quota-cost-reset.test.ts @@ -0,0 +1,244 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { ERROR_CODES } from "@/lib/utils/error-messages"; + +// Mock getSession +const getSessionMock = vi.fn(); +vi.mock("@/lib/auth", () => ({ + getSession: getSessionMock, +})); + +// Mock next-intl +vi.mock("next-intl/server", () => ({ + getTranslations: vi.fn(async () => (key: string) => key), + getLocale: vi.fn(async () => "en"), +})); + +// Mock getSystemSettings +const getSystemSettingsMock = vi.fn(); +vi.mock("@/repository/system-config", () => ({ + getSystemSettings: getSystemSettingsMock, +})); + +// Mock statistics +const sumKeyCostInTimeRangeMock = vi.fn(); +const sumKeyTotalCostMock = vi.fn(); +vi.mock("@/repository/statistics", () => ({ + sumKeyCostInTimeRange: sumKeyCostInTimeRangeMock, + sumKeyTotalCost: sumKeyTotalCostMock, +})); + +// Mock time-utils +const getTimeRangeForPeriodWithModeMock = vi.fn(); +const getTimeRangeForPeriodMock = vi.fn(); +vi.mock("@/lib/rate-limit/time-utils", () => ({ + getTimeRangeForPeriodWithMode: getTimeRangeForPeriodWithModeMock, + getTimeRangeForPeriod: getTimeRangeForPeriodMock, +})); + +// Mock SessionTracker +const getKeySessionCountMock = vi.fn(); +vi.mock("@/lib/session-tracker", () => ({ + SessionTracker: { getKeySessionCount: getKeySessionCountMock }, +})); + +// Mock resolveKeyConcurrentSessionLimit +vi.mock("@/lib/rate-limit/concurrent-session-limit", () => ({ + resolveKeyConcurrentSessionLimit: vi.fn(() => 0), +})); + +// Mock logger +vi.mock("@/lib/logger", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +// Mock drizzle db - need select().from().leftJoin().where().limit() chain +const dbLimitMock = vi.fn(); +const dbWhereMock = vi.fn(() => ({ limit: dbLimitMock })); +const dbLeftJoinMock = vi.fn(() => ({ where: dbWhereMock })); +const dbFromMock = vi.fn(() => ({ leftJoin: dbLeftJoinMock })); +const dbSelectMock = vi.fn(() => ({ from: dbFromMock })); +vi.mock("@/drizzle/db", () => ({ + db: { select: dbSelectMock }, +})); + +// Common date fixtures +const NOW = new Date("2026-03-01T12:00:00Z"); +const FIVE_HOURS_AGO = new Date("2026-03-01T07:00:00Z"); +const DAILY_START = new Date("2026-03-01T00:00:00Z"); +const WEEKLY_START = new Date("2026-02-23T00:00:00Z"); +const MONTHLY_START = new Date("2026-02-01T00:00:00Z"); + +function makeTimeRange(startTime: Date, endTime: Date = NOW) { + return { startTime, endTime }; +} + +const DEFAULT_KEY_ROW = { + id: 42, + key: "sk-test-key-hash", + name: "Test Key", + userId: 10, + isEnabled: true, + dailyResetTime: "00:00", + dailyResetMode: "fixed", + limit5hUsd: "10.00", + limitDailyUsd: "20.00", + limitWeeklyUsd: "50.00", + limitMonthlyUsd: "100.00", + limitTotalUsd: "500.00", + limitConcurrentSessions: 0, + deletedAt: null, +}; + +function setupTimeRangeMocks() { + getTimeRangeForPeriodWithModeMock.mockResolvedValue(makeTimeRange(DAILY_START)); + getTimeRangeForPeriodMock.mockImplementation(async (period: string) => { + switch (period) { + case "5h": + return makeTimeRange(FIVE_HOURS_AGO); + case "weekly": + return makeTimeRange(WEEKLY_START); + case "monthly": + return makeTimeRange(MONTHLY_START); + default: + return makeTimeRange(DAILY_START); + } + }); +} + +function setupDefaultMocks(costResetAt: Date | null = null) { + getSessionMock.mockResolvedValue({ user: { id: 10, role: "user" } }); + getSystemSettingsMock.mockResolvedValue({ currencyDisplay: "USD" }); + dbLimitMock.mockResolvedValue([ + { + key: DEFAULT_KEY_ROW, + userLimitConcurrentSessions: null, + userCostResetAt: costResetAt, + }, + ]); + setupTimeRangeMocks(); + sumKeyCostInTimeRangeMock.mockResolvedValue(1.5); + sumKeyTotalCostMock.mockResolvedValue(10.0); + getKeySessionCountMock.mockResolvedValue(2); +} + +describe("getKeyQuotaUsage costResetAt clipping", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("user with costResetAt -- period costs use clipped startTime", async () => { + // costResetAt is 2 hours ago -- should clip 5h range (7h ago) but not daily (midnight) + const costResetAt = new Date("2026-03-01T10:00:00Z"); + setupDefaultMocks(costResetAt); + + const { getKeyQuotaUsage } = await import("@/actions/key-quota"); + const result = await getKeyQuotaUsage(42); + + expect(result.ok).toBe(true); + + expect(sumKeyCostInTimeRangeMock).toHaveBeenCalledTimes(4); + // 1st call = 5h: clipped (07:00 < 10:00) + expect(sumKeyCostInTimeRangeMock).toHaveBeenNthCalledWith(1, 42, costResetAt, NOW); + // 2nd call = daily: clipped (00:00 < 10:00) + expect(sumKeyCostInTimeRangeMock).toHaveBeenNthCalledWith(2, 42, costResetAt, NOW); + // 3rd call = weekly: clipped (Feb 23 < Mar 1 10:00) + expect(sumKeyCostInTimeRangeMock).toHaveBeenNthCalledWith(3, 42, costResetAt, NOW); + // 4th call = monthly: clipped (Feb 1 < Mar 1 10:00) + expect(sumKeyCostInTimeRangeMock).toHaveBeenNthCalledWith(4, 42, costResetAt, NOW); + + // sumKeyTotalCost receives costResetAt as 3rd argument + expect(sumKeyTotalCostMock).toHaveBeenCalledWith("sk-test-key-hash", Infinity, costResetAt); + }); + + test("user without costResetAt (null) -- original time ranges unchanged", async () => { + setupDefaultMocks(null); + + const { getKeyQuotaUsage } = await import("@/actions/key-quota"); + const result = await getKeyQuotaUsage(42); + + expect(result.ok).toBe(true); + + // 5h: original start used (no clipping) + expect(sumKeyCostInTimeRangeMock).toHaveBeenCalledWith(42, FIVE_HOURS_AGO, NOW); + // daily: original start + expect(sumKeyCostInTimeRangeMock).toHaveBeenCalledWith(42, DAILY_START, NOW); + // weekly + expect(sumKeyCostInTimeRangeMock).toHaveBeenCalledWith(42, WEEKLY_START, NOW); + // monthly + expect(sumKeyCostInTimeRangeMock).toHaveBeenCalledWith(42, MONTHLY_START, NOW); + // total cost: null costResetAt + expect(sumKeyTotalCostMock).toHaveBeenCalledWith("sk-test-key-hash", Infinity, null); + }); + + test("costResetAt older than all period starts -- no clipping effect", async () => { + // costResetAt is 1 year ago, older than even monthly start + const costResetAt = new Date("2025-01-01T00:00:00Z"); + setupDefaultMocks(costResetAt); + + const { getKeyQuotaUsage } = await import("@/actions/key-quota"); + const result = await getKeyQuotaUsage(42); + + expect(result.ok).toBe(true); + + // clipStart returns original start because costResetAt < start + expect(sumKeyCostInTimeRangeMock).toHaveBeenCalledWith(42, FIVE_HOURS_AGO, NOW); + expect(sumKeyCostInTimeRangeMock).toHaveBeenCalledWith(42, DAILY_START, NOW); + expect(sumKeyCostInTimeRangeMock).toHaveBeenCalledWith(42, WEEKLY_START, NOW); + expect(sumKeyCostInTimeRangeMock).toHaveBeenCalledWith(42, MONTHLY_START, NOW); + // total still receives costResetAt (sumKeyTotalCost handles it internally) + expect(sumKeyTotalCostMock).toHaveBeenCalledWith("sk-test-key-hash", Infinity, costResetAt); + }); + + test("costResetAt in the middle of daily range -- clips daily correctly", async () => { + // costResetAt is 6AM today -- after daily start (midnight) but before now (noon) + const costResetAt = new Date("2026-03-01T06:00:00Z"); + setupDefaultMocks(costResetAt); + + const { getKeyQuotaUsage } = await import("@/actions/key-quota"); + const result = await getKeyQuotaUsage(42); + + expect(result.ok).toBe(true); + + // Daily start (midnight) < costResetAt (6AM) => clipped + // Check the second call (daily) uses costResetAt + const calls = sumKeyCostInTimeRangeMock.mock.calls; + // 5h call: 7AM > 6AM => 5h start is AFTER costResetAt, so original 5h start used + expect(calls[0]).toEqual([42, FIVE_HOURS_AGO, NOW]); + // daily call: midnight < 6AM => clipped to costResetAt + expect(calls[1]).toEqual([42, costResetAt, NOW]); + // weekly: before costResetAt => clipped + expect(calls[2]).toEqual([42, costResetAt, NOW]); + // monthly: before costResetAt => clipped + expect(calls[3]).toEqual([42, costResetAt, NOW]); + }); + + test("permission denied for non-owner non-admin", async () => { + getSessionMock.mockResolvedValue({ user: { id: 99, role: "user" } }); + getSystemSettingsMock.mockResolvedValue({ currencyDisplay: "USD" }); + dbLimitMock.mockResolvedValue([ + { + key: { ...DEFAULT_KEY_ROW, userId: 10 }, + userLimitConcurrentSessions: null, + userCostResetAt: null, + }, + ]); + + const { getKeyQuotaUsage } = await import("@/actions/key-quota"); + const result = await getKeyQuotaUsage(42); + + expect(result.ok).toBe(false); + expect(result.errorCode).toBe(ERROR_CODES.PERMISSION_DENIED); + expect(sumKeyCostInTimeRangeMock).not.toHaveBeenCalled(); + }); + + test("key not found", async () => { + getSessionMock.mockResolvedValue({ user: { id: 10, role: "admin" } }); + dbLimitMock.mockResolvedValue([]); + + const { getKeyQuotaUsage } = await import("@/actions/key-quota"); + const result = await getKeyQuotaUsage(999); + + expect(result.ok).toBe(false); + expect(result.errorCode).toBe(ERROR_CODES.NOT_FOUND); + }); +}); diff --git a/tests/unit/actions/my-usage-concurrent-inherit.test.ts b/tests/unit/actions/my-usage-concurrent-inherit.test.ts index e1aabddc8..adb4b614d 100644 --- a/tests/unit/actions/my-usage-concurrent-inherit.test.ts +++ b/tests/unit/actions/my-usage-concurrent-inherit.test.ts @@ -57,8 +57,10 @@ vi.mock("@/drizzle/db", () => ({ vi.mock("@/lib/logger", () => ({ logger: { + info: vi.fn(), warn: vi.fn(), error: vi.fn(), + debug: vi.fn(), }, })); diff --git a/tests/unit/actions/total-usage-semantics.test.ts b/tests/unit/actions/total-usage-semantics.test.ts index 7908f4db6..7cd5c924f 100644 --- a/tests/unit/actions/total-usage-semantics.test.ts +++ b/tests/unit/actions/total-usage-semantics.test.ts @@ -145,7 +145,8 @@ describe("total-usage-semantics", () => { expect(sumKeyQuotaCostsByIdMock).toHaveBeenCalledWith( 1, expect.any(Object), - ALL_TIME_MAX_AGE_DAYS + ALL_TIME_MAX_AGE_DAYS, + null ); }); @@ -195,7 +196,8 @@ describe("total-usage-semantics", () => { expect(sumUserQuotaCostsMock).toHaveBeenCalledWith( 1, expect.any(Object), - ALL_TIME_MAX_AGE_DAYS + ALL_TIME_MAX_AGE_DAYS, + null ); }); }); @@ -228,7 +230,11 @@ describe("total-usage-semantics", () => { await getUserAllLimitUsage(1); // Verify sumUserTotalCost was called with Infinity (all-time) - expect(sumUserTotalCostMock).toHaveBeenCalledWith(1, Infinity); + // 3rd arg is user.costResetAt (undefined when not set on mock user) + const calls = sumUserTotalCostMock.mock.calls; + expect(calls.length).toBe(1); + expect(calls[0][0]).toBe(1); + expect(calls[0][1]).toBe(Infinity); }); }); @@ -237,38 +243,4 @@ describe("total-usage-semantics", () => { expect(ALL_TIME_MAX_AGE_DAYS).toBe(Infinity); }); }); - - describe("source code verification", () => { - it("should verify sumUserCost passes ALL_TIME_MAX_AGE_DAYS when period is total", async () => { - // This test verifies the implementation by reading the source code pattern - // Ensure we call quota aggregation functions with ALL_TIME_MAX_AGE_DAYS for all-time usage. - const fs = await import("node:fs/promises"); - const path = await import("node:path"); - - const myUsagePath = path.join(process.cwd(), "src/actions/my-usage.ts"); - const content = await fs.readFile(myUsagePath, "utf-8"); - - // Verify the constant is defined as Infinity - expect(content).toContain("const ALL_TIME_MAX_AGE_DAYS = Infinity"); - - // Verify quota aggregation uses the constant for all-time usage - expect(content).toMatch(/sumUserQuotaCosts\([^)]*ALL_TIME_MAX_AGE_DAYS\s*\)/); - - expect(content).toMatch(/sumKeyQuotaCostsById\([^)]*ALL_TIME_MAX_AGE_DAYS\s*\)/); - }); - - it("should verify getUserAllLimitUsage passes ALL_TIME_MAX_AGE_DAYS", async () => { - const fs = await import("node:fs/promises"); - const path = await import("node:path"); - - const usersPath = path.join(process.cwd(), "src/actions/users.ts"); - const content = await fs.readFile(usersPath, "utf-8"); - - // Verify the constant is defined as Infinity - expect(content).toContain("const ALL_TIME_MAX_AGE_DAYS = Infinity"); - - // Verify sumUserTotalCost is called with the constant - expect(content).toContain("sumUserTotalCost(userId, ALL_TIME_MAX_AGE_DAYS)"); - }); - }); }); diff --git a/tests/unit/actions/users-reset-all-statistics.test.ts b/tests/unit/actions/users-reset-all-statistics.test.ts index 5a6d0a5ae..60937f793 100644 --- a/tests/unit/actions/users-reset-all-statistics.test.ts +++ b/tests/unit/actions/users-reset-all-statistics.test.ts @@ -22,11 +22,13 @@ vi.mock("next/cache", () => ({ // Mock repository/user const findUserByIdMock = vi.fn(); +const resetUserCostResetAtMock = vi.fn(); vi.mock("@/repository/user", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, findUserById: findUserByIdMock, + resetUserCostResetAt: resetUserCostResetAtMock, }; }); @@ -41,11 +43,20 @@ vi.mock("@/repository/key", async (importOriginal) => { }); // Mock drizzle db -const dbDeleteWhereMock = vi.fn(); -const dbDeleteMock = vi.fn(() => ({ where: dbDeleteWhereMock })); +const txDeleteWhereMock = vi.fn(); +const txDeleteMock = vi.fn(() => ({ where: txDeleteWhereMock })); +const txUpdateSetMock = vi.fn(() => ({ where: vi.fn().mockResolvedValue(undefined) })); +const txUpdateMock = vi.fn(() => ({ set: txUpdateSetMock })); +const txMock = { + delete: txDeleteMock, + update: txUpdateMock, +}; +const dbTransactionMock = vi.fn(async (fn: (tx: typeof txMock) => Promise) => { + await fn(txMock); +}); vi.mock("@/drizzle/db", () => ({ db: { - delete: dbDeleteMock, + transaction: dbTransactionMock, }, })); @@ -59,6 +70,12 @@ vi.mock("@/lib/logger", () => ({ logger: loggerMock, })); +// Mock invalidateCachedUser (called directly after transaction) +const invalidateCachedUserMock = vi.fn(); +vi.mock("@/lib/security/api-key-auth-cache", () => ({ + invalidateCachedUser: invalidateCachedUserMock, +})); + // Mock Redis const redisPipelineMock = { del: vi.fn().mockReturnThis(), @@ -86,7 +103,9 @@ describe("resetUserAllStatistics", () => { redisMock.status = "ready"; redisPipelineMock.exec.mockResolvedValue([]); // DB delete returns resolved promise - dbDeleteWhereMock.mockResolvedValue(undefined); + txDeleteWhereMock.mockResolvedValue(undefined); + resetUserCostResetAtMock.mockResolvedValue(true); + invalidateCachedUserMock.mockResolvedValue(undefined); }); test("should return PERMISSION_DENIED for non-admin user", async () => { @@ -119,7 +138,7 @@ describe("resetUserAllStatistics", () => { expect(result.ok).toBe(false); expect(result.errorCode).toBe(ERROR_CODES.NOT_FOUND); - expect(dbDeleteMock).not.toHaveBeenCalled(); + expect(dbTransactionMock).not.toHaveBeenCalled(); }); test("should successfully reset all user statistics", async () => { @@ -133,9 +152,10 @@ describe("resetUserAllStatistics", () => { const result = await resetUserAllStatistics(123); expect(result.ok).toBe(true); - // DB delete called - expect(dbDeleteMock).toHaveBeenCalled(); - expect(dbDeleteWhereMock).toHaveBeenCalled(); + // DB transaction called (delete + update wrapped in transaction) + expect(dbTransactionMock).toHaveBeenCalled(); + expect(txDeleteMock).toHaveBeenCalled(); + expect(txDeleteWhereMock).toHaveBeenCalled(); // Redis operations expect(redisMock.pipeline).toHaveBeenCalled(); expect(redisPipelineMock.del).toHaveBeenCalled(); @@ -156,8 +176,8 @@ describe("resetUserAllStatistics", () => { const result = await resetUserAllStatistics(123); expect(result.ok).toBe(true); - // DB delete still called - expect(dbDeleteMock).toHaveBeenCalled(); + // DB transaction still called + expect(dbTransactionMock).toHaveBeenCalled(); // Redis pipeline NOT called (status not ready) expect(redisMock.pipeline).not.toHaveBeenCalled(); }); @@ -177,8 +197,9 @@ describe("resetUserAllStatistics", () => { const result = await resetUserAllStatistics(123); expect(result.ok).toBe(true); + // Pipeline partial failures logged as warn inside clearUserCostCache expect(loggerMock.warn).toHaveBeenCalledWith( - "Some Redis deletes failed during user statistics reset", + "Some Redis deletes failed during cost cache cleanup", expect.objectContaining({ errorCount: 1, userId: 123 }) ); }); @@ -199,21 +220,21 @@ describe("resetUserAllStatistics", () => { expect(loggerMock.warn).toHaveBeenCalled(); }); - test("should succeed with error log when pipeline.exec throws", async () => { + test("should succeed when pipeline.exec throws (caught inside clearUserCostCache)", async () => { getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } }); findUserByIdMock.mockResolvedValue({ id: 123, name: "Test User" }); findKeyListMock.mockResolvedValue([{ id: 1 }]); scanPatternMock.mockResolvedValue(["key:1:cost_daily"]); - // pipeline.exec throws - caught by outer try-catch + // pipeline.exec throws - caught inside clearUserCostCache (never-throws contract) redisPipelineMock.exec.mockRejectedValue(new Error("Pipeline failed")); const { resetUserAllStatistics } = await import("@/actions/users"); const result = await resetUserAllStatistics(123); - // Should still succeed - DB logs already deleted + // clearUserCostCache catches pipeline.exec throw internally, logs warn expect(result.ok).toBe(true); - expect(loggerMock.error).toHaveBeenCalledWith( - "Failed to clear Redis cache during user statistics reset", + expect(loggerMock.warn).toHaveBeenCalledWith( + "Redis pipeline.exec() failed during cost cache cleanup", expect.objectContaining({ userId: 123 }) ); }); @@ -241,6 +262,6 @@ describe("resetUserAllStatistics", () => { const result = await resetUserAllStatistics(123); expect(result.ok).toBe(true); - expect(dbDeleteMock).toHaveBeenCalled(); + expect(dbTransactionMock).toHaveBeenCalled(); }); }); diff --git a/tests/unit/actions/users-reset-limits-only.test.ts b/tests/unit/actions/users-reset-limits-only.test.ts new file mode 100644 index 000000000..326848bbe --- /dev/null +++ b/tests/unit/actions/users-reset-limits-only.test.ts @@ -0,0 +1,253 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { ERROR_CODES } from "@/lib/utils/error-messages"; + +// Mock getSession +const getSessionMock = vi.fn(); +vi.mock("@/lib/auth", () => ({ + getSession: getSessionMock, +})); + +// Mock next-intl +const getTranslationsMock = vi.fn(async () => (key: string) => key); +vi.mock("next-intl/server", () => ({ + getTranslations: getTranslationsMock, + getLocale: vi.fn(async () => "en"), +})); + +// Mock next/cache +const revalidatePathMock = vi.fn(); +vi.mock("next/cache", () => ({ + revalidatePath: revalidatePathMock, +})); + +// Mock repository/user +const findUserByIdMock = vi.fn(); +const resetUserCostResetAtMock = vi.fn(); +vi.mock("@/repository/user", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + findUserById: findUserByIdMock, + resetUserCostResetAt: resetUserCostResetAtMock, + }; +}); + +// Mock repository/key +const findKeyListMock = vi.fn(); +vi.mock("@/repository/key", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + findKeyList: findKeyListMock, + }; +}); + +// Mock drizzle db - need update().set().where() chain +const dbUpdateWhereMock = vi.fn(); +const dbUpdateSetMock = vi.fn(() => ({ where: dbUpdateWhereMock })); +const dbUpdateMock = vi.fn(() => ({ set: dbUpdateSetMock })); +const dbDeleteWhereMock = vi.fn(); +const dbDeleteMock = vi.fn(() => ({ where: dbDeleteWhereMock })); +vi.mock("@/drizzle/db", () => ({ + db: { + update: dbUpdateMock, + delete: dbDeleteMock, + }, +})); + +// Mock logger +const loggerMock = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), +}; +vi.mock("@/lib/logger", () => ({ + logger: loggerMock, +})); + +// Mock Redis +const redisPipelineMock = { + del: vi.fn().mockReturnThis(), + exec: vi.fn(), +}; +const redisMock = { + status: "ready", + pipeline: vi.fn(() => redisPipelineMock), +}; +const getRedisClientMock = vi.fn(() => redisMock); +vi.mock("@/lib/redis", () => ({ + getRedisClient: getRedisClientMock, +})); + +// Mock scanPattern +const scanPatternMock = vi.fn(); +vi.mock("@/lib/redis/scan-helper", () => ({ + scanPattern: scanPatternMock, +})); + +describe("resetUserLimitsOnly", () => { + beforeEach(() => { + vi.clearAllMocks(); + redisMock.status = "ready"; + redisPipelineMock.exec.mockResolvedValue([]); + dbUpdateWhereMock.mockResolvedValue(undefined); + resetUserCostResetAtMock.mockResolvedValue(true); + }); + + test("should return PERMISSION_DENIED for non-admin user", async () => { + getSessionMock.mockResolvedValue({ user: { id: 1, role: "user" } }); + + const { resetUserLimitsOnly } = await import("@/actions/users"); + const result = await resetUserLimitsOnly(123); + + expect(result.ok).toBe(false); + expect(result.errorCode).toBe(ERROR_CODES.PERMISSION_DENIED); + expect(findUserByIdMock).not.toHaveBeenCalled(); + }); + + test("should return PERMISSION_DENIED when no session", async () => { + // TODO(#890): Consider returning UNAUTHORIZED for null session (current: PERMISSION_DENIED for both null + non-admin) + getSessionMock.mockResolvedValue(null); + + const { resetUserLimitsOnly } = await import("@/actions/users"); + const result = await resetUserLimitsOnly(123); + + expect(result.ok).toBe(false); + expect(result.errorCode).toBe(ERROR_CODES.PERMISSION_DENIED); + }); + + test("should return NOT_FOUND for non-existent user", async () => { + getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } }); + findUserByIdMock.mockResolvedValue(null); + + const { resetUserLimitsOnly } = await import("@/actions/users"); + const result = await resetUserLimitsOnly(999); + + expect(result.ok).toBe(false); + expect(result.errorCode).toBe(ERROR_CODES.NOT_FOUND); + expect(resetUserCostResetAtMock).not.toHaveBeenCalled(); + }); + + test("should set costResetAt and clear Redis cost cache", async () => { + getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } }); + findUserByIdMock.mockResolvedValue({ id: 123, name: "Test User" }); + findKeyListMock.mockResolvedValue([ + { id: 1, key: "sk-hash-1" }, + { id: 2, key: "sk-hash-2" }, + ]); + scanPatternMock.mockResolvedValue(["key:1:cost_daily", "user:123:cost_weekly"]); + redisPipelineMock.exec.mockResolvedValue([]); + + const { resetUserLimitsOnly } = await import("@/actions/users"); + const result = await resetUserLimitsOnly(123); + + expect(result.ok).toBe(true); + // costResetAt set via repository function + expect(resetUserCostResetAtMock).toHaveBeenCalledWith(123, expect.any(Date)); + // Redis cost keys scanned and deleted + expect(scanPatternMock).toHaveBeenCalled(); + expect(redisMock.pipeline).toHaveBeenCalled(); + expect(redisPipelineMock.del).toHaveBeenCalled(); + expect(redisPipelineMock.exec).toHaveBeenCalled(); + // Revalidate path + expect(revalidatePathMock).toHaveBeenCalledWith("/dashboard/users"); + // No DB deletes (messageRequest/usageLedger must NOT be deleted) + expect(dbDeleteMock).not.toHaveBeenCalled(); + }); + + test("should NOT delete messageRequest or usageLedger rows", async () => { + getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } }); + findUserByIdMock.mockResolvedValue({ id: 123, name: "Test User" }); + findKeyListMock.mockResolvedValue([{ id: 1, key: "sk-hash-1" }]); + scanPatternMock.mockResolvedValue([]); + + const { resetUserLimitsOnly } = await import("@/actions/users"); + await resetUserLimitsOnly(123); + + // Core assertion: db.delete must never be called + expect(dbDeleteMock).not.toHaveBeenCalled(); + expect(dbDeleteWhereMock).not.toHaveBeenCalled(); + }); + + test("should succeed when Redis is not ready", async () => { + getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } }); + findUserByIdMock.mockResolvedValue({ id: 123, name: "Test User" }); + findKeyListMock.mockResolvedValue([{ id: 1, key: "sk-hash-1" }]); + redisMock.status = "connecting"; + + const { resetUserLimitsOnly } = await import("@/actions/users"); + const result = await resetUserLimitsOnly(123); + + expect(result.ok).toBe(true); + // costResetAt still set via repo function + expect(resetUserCostResetAtMock).toHaveBeenCalledWith(123, expect.any(Date)); + // Redis pipeline NOT called + expect(redisMock.pipeline).not.toHaveBeenCalled(); + }); + + test("should succeed with warning when Redis has partial failures", async () => { + getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } }); + findUserByIdMock.mockResolvedValue({ id: 123, name: "Test User" }); + findKeyListMock.mockResolvedValue([{ id: 1, key: "sk-hash-1" }]); + scanPatternMock.mockResolvedValue(["key:1:cost_daily"]); + redisPipelineMock.exec.mockResolvedValue([ + [null, 1], + [new Error("Connection reset"), null], + ]); + + const { resetUserLimitsOnly } = await import("@/actions/users"); + const result = await resetUserLimitsOnly(123); + + expect(result.ok).toBe(true); + expect(loggerMock.warn).toHaveBeenCalledWith( + "Some Redis deletes failed during cost cache cleanup", + expect.objectContaining({ errorCount: 1, userId: 123 }) + ); + }); + + test("should succeed when pipeline.exec throws (caught inside clearUserCostCache)", async () => { + getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } }); + findUserByIdMock.mockResolvedValue({ id: 123, name: "Test User" }); + findKeyListMock.mockResolvedValue([{ id: 1, key: "sk-hash-1" }]); + scanPatternMock.mockResolvedValue(["key:1:cost_daily"]); + redisPipelineMock.exec.mockRejectedValue(new Error("Pipeline failed")); + + const { resetUserLimitsOnly } = await import("@/actions/users"); + const result = await resetUserLimitsOnly(123); + + // pipeline.exec throw is now caught inside clearUserCostCache (never-throws contract) + // so resetUserLimitsOnly still succeeds without hitting its own catch block + expect(result.ok).toBe(true); + expect(loggerMock.warn).toHaveBeenCalledWith( + "Redis pipeline.exec() failed during cost cache cleanup", + expect.objectContaining({ userId: 123 }) + ); + }); + + test("should return OPERATION_FAILED on unexpected error", async () => { + getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } }); + findUserByIdMock.mockRejectedValue(new Error("Database connection failed")); + + const { resetUserLimitsOnly } = await import("@/actions/users"); + const result = await resetUserLimitsOnly(123); + + expect(result.ok).toBe(false); + expect(result.errorCode).toBe(ERROR_CODES.OPERATION_FAILED); + expect(loggerMock.error).toHaveBeenCalled(); + }); + + test("should handle user with no keys", async () => { + getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } }); + findUserByIdMock.mockResolvedValue({ id: 123, name: "Test User" }); + findKeyListMock.mockResolvedValue([]); + scanPatternMock.mockResolvedValue([]); + + const { resetUserLimitsOnly } = await import("@/actions/users"); + const result = await resetUserLimitsOnly(123); + + expect(result.ok).toBe(true); + expect(resetUserCostResetAtMock).toHaveBeenCalledWith(123, expect.any(Date)); + // No DB deletes + expect(dbDeleteMock).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/lib/redis/cost-cache-cleanup.test.ts b/tests/unit/lib/redis/cost-cache-cleanup.test.ts new file mode 100644 index 000000000..3fef0f4c7 --- /dev/null +++ b/tests/unit/lib/redis/cost-cache-cleanup.test.ts @@ -0,0 +1,267 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; + +// Mock logger +const loggerMock = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), +}; +vi.mock("@/lib/logger", () => ({ + logger: loggerMock, +})); + +// Mock Redis +const redisPipelineMock = { + del: vi.fn().mockReturnThis(), + exec: vi.fn(), +}; +const redisMock = { + status: "ready" as string, + pipeline: vi.fn(() => redisPipelineMock), +}; +const getRedisClientMock = vi.fn(() => redisMock); +vi.mock("@/lib/redis", () => ({ + getRedisClient: getRedisClientMock, +})); + +// Mock scanPattern +const scanPatternMock = vi.fn(); +vi.mock("@/lib/redis/scan-helper", () => ({ + scanPattern: scanPatternMock, +})); + +// Mock active-session-keys +vi.mock("@/lib/redis/active-session-keys", () => ({ + getKeyActiveSessionsKey: (keyId: number) => `{active_sessions}:key:${keyId}:active_sessions`, + getUserActiveSessionsKey: (userId: number) => `{active_sessions}:user:${userId}:active_sessions`, +})); + +describe("clearUserCostCache", () => { + beforeEach(() => { + vi.resetAllMocks(); + // Re-establish default implementations after resetAllMocks + getRedisClientMock.mockReturnValue(redisMock); + redisMock.status = "ready"; + redisMock.pipeline.mockReturnValue(redisPipelineMock); + redisPipelineMock.del.mockReturnThis(); + redisPipelineMock.exec.mockResolvedValue([]); + scanPatternMock.mockResolvedValue([]); + }); + + test("scans correct Redis patterns for keyIds, userId, keyHashes", async () => { + scanPatternMock.mockResolvedValue([]); + + const { clearUserCostCache } = await import("@/lib/redis/cost-cache-cleanup"); + await clearUserCostCache({ + userId: 10, + keyIds: [1, 2], + keyHashes: ["hash-a", "hash-b"], + }); + + const calls = scanPatternMock.mock.calls.map(([_redis, pattern]: [unknown, string]) => pattern); + // Per-key cost counters + expect(calls).toContain("key:1:cost_*"); + expect(calls).toContain("key:2:cost_*"); + // User cost counters + expect(calls).toContain("user:10:cost_*"); + // Total cost cache (user) + expect(calls).toContain("total_cost:user:10"); + expect(calls).toContain("total_cost:user:10:*"); + // Total cost cache (key hashes) + expect(calls).toContain("total_cost:key:hash-a"); + expect(calls).toContain("total_cost:key:hash-a:*"); + expect(calls).toContain("total_cost:key:hash-b"); + expect(calls).toContain("total_cost:key:hash-b:*"); + // Lease cache + expect(calls).toContain("lease:key:1:*"); + expect(calls).toContain("lease:key:2:*"); + expect(calls).toContain("lease:user:10:*"); + }); + + test("pipeline deletes all found keys", async () => { + scanPatternMock.mockImplementation(async (_redis: unknown, pattern: string) => { + if (pattern === "key:1:cost_*") return ["key:1:cost_daily", "key:1:cost_5h"]; + if (pattern === "user:10:cost_*") return ["user:10:cost_monthly"]; + return []; + }); + redisPipelineMock.exec.mockResolvedValue([ + [null, 1], + [null, 1], + [null, 1], + ]); + + const { clearUserCostCache } = await import("@/lib/redis/cost-cache-cleanup"); + const result = await clearUserCostCache({ + userId: 10, + keyIds: [1], + keyHashes: [], + }); + + expect(result).not.toBeNull(); + expect(result!.costKeysDeleted).toBe(3); + expect(redisPipelineMock.del).toHaveBeenCalledWith("key:1:cost_daily"); + expect(redisPipelineMock.del).toHaveBeenCalledWith("key:1:cost_5h"); + expect(redisPipelineMock.del).toHaveBeenCalledWith("user:10:cost_monthly"); + expect(redisPipelineMock.exec).toHaveBeenCalled(); + }); + + test("returns metrics (costKeysDeleted, activeSessionsDeleted, durationMs)", async () => { + scanPatternMock.mockImplementation(async (_redis: unknown, pattern: string) => { + if (pattern === "key:1:cost_*") return ["key:1:cost_daily"]; + return []; + }); + redisPipelineMock.exec.mockResolvedValue([ + [null, 1], + [null, 1], + [null, 1], + ]); + + const { clearUserCostCache } = await import("@/lib/redis/cost-cache-cleanup"); + const result = await clearUserCostCache({ + userId: 10, + keyIds: [1], + keyHashes: [], + includeActiveSessions: true, + }); + + expect(result).not.toBeNull(); + expect(result!.costKeysDeleted).toBe(1); + // 1 key session + 1 user session = 2 + expect(result!.activeSessionsDeleted).toBe(2); + expect(typeof result!.durationMs).toBe("number"); + expect(result!.durationMs).toBeGreaterThanOrEqual(0); + }); + + test("returns null when Redis not ready", async () => { + redisMock.status = "connecting"; + + const { clearUserCostCache } = await import("@/lib/redis/cost-cache-cleanup"); + const result = await clearUserCostCache({ + userId: 10, + keyIds: [1], + keyHashes: [], + }); + + expect(result).toBeNull(); + expect(scanPatternMock).not.toHaveBeenCalled(); + }); + + test("returns null when Redis client is null", async () => { + getRedisClientMock.mockReturnValue(null); + + const { clearUserCostCache } = await import("@/lib/redis/cost-cache-cleanup"); + const result = await clearUserCostCache({ + userId: 10, + keyIds: [1], + keyHashes: [], + }); + + expect(result).toBeNull(); + }); + + test("includeActiveSessions=true adds session key DELs", async () => { + scanPatternMock.mockResolvedValue([]); + + const { clearUserCostCache } = await import("@/lib/redis/cost-cache-cleanup"); + const result = await clearUserCostCache({ + userId: 10, + keyIds: [1, 2], + keyHashes: [], + includeActiveSessions: true, + }); + + expect(result).not.toBeNull(); + // 2 key sessions + 1 user session + expect(result!.activeSessionsDeleted).toBe(3); + expect(redisPipelineMock.del).toHaveBeenCalledWith("{active_sessions}:key:1:active_sessions"); + expect(redisPipelineMock.del).toHaveBeenCalledWith("{active_sessions}:key:2:active_sessions"); + expect(redisPipelineMock.del).toHaveBeenCalledWith("{active_sessions}:user:10:active_sessions"); + }); + + test("includeActiveSessions=false skips session keys", async () => { + scanPatternMock.mockImplementation(async (_redis: unknown, pattern: string) => { + if (pattern === "key:1:cost_*") return ["key:1:cost_daily"]; + return []; + }); + + const { clearUserCostCache } = await import("@/lib/redis/cost-cache-cleanup"); + const result = await clearUserCostCache({ + userId: 10, + keyIds: [1], + keyHashes: [], + includeActiveSessions: false, + }); + + expect(result).not.toBeNull(); + expect(result!.activeSessionsDeleted).toBe(0); + // Only cost key deleted, no session keys + const delCalls = redisPipelineMock.del.mock.calls.map(([k]: [string]) => k); + expect(delCalls).not.toContain("{active_sessions}:key:1:active_sessions"); + expect(delCalls).not.toContain("{active_sessions}:user:10:active_sessions"); + }); + + test("empty scan results -- no pipeline created, returns zeros", async () => { + scanPatternMock.mockResolvedValue([]); + + const { clearUserCostCache } = await import("@/lib/redis/cost-cache-cleanup"); + const result = await clearUserCostCache({ + userId: 10, + keyIds: [1], + keyHashes: [], + includeActiveSessions: false, + }); + + expect(result).not.toBeNull(); + expect(result!.costKeysDeleted).toBe(0); + expect(result!.activeSessionsDeleted).toBe(0); + // No pipeline created when nothing to delete + expect(redisMock.pipeline).not.toHaveBeenCalled(); + }); + + test("pipeline partial failures -- logged, does not throw", async () => { + scanPatternMock.mockImplementation(async (_redis: unknown, pattern: string) => { + if (pattern === "key:1:cost_*") return ["key:1:cost_daily", "key:1:cost_5h"]; + return []; + }); + redisPipelineMock.exec.mockResolvedValue([ + [null, 1], + [new Error("Connection reset"), null], + ]); + + const { clearUserCostCache } = await import("@/lib/redis/cost-cache-cleanup"); + const result = await clearUserCostCache({ + userId: 10, + keyIds: [1], + keyHashes: [], + }); + + expect(result).not.toBeNull(); + expect(result!.costKeysDeleted).toBe(2); + expect(loggerMock.warn).toHaveBeenCalledWith( + "Some Redis deletes failed during cost cache cleanup", + expect.objectContaining({ errorCount: 1, userId: 10 }) + ); + }); + + test("no keys (empty keyIds/keyHashes) -- only user patterns scanned", async () => { + scanPatternMock.mockResolvedValue([]); + + const { clearUserCostCache } = await import("@/lib/redis/cost-cache-cleanup"); + await clearUserCostCache({ + userId: 10, + keyIds: [], + keyHashes: [], + }); + + const calls = scanPatternMock.mock.calls.map(([_redis, pattern]: [unknown, string]) => pattern); + // Only user-level patterns (no key:* or total_cost:key:* patterns) + expect(calls).toContain("user:10:cost_*"); + expect(calls).toContain("total_cost:user:10"); + expect(calls).toContain("total_cost:user:10:*"); + expect(calls).toContain("lease:user:10:*"); + // No key-specific patterns + expect(calls.filter((p: string) => p.startsWith("key:"))).toHaveLength(0); + expect(calls.filter((p: string) => p.startsWith("total_cost:key:"))).toHaveLength(0); + expect(calls.filter((p: string) => p.startsWith("lease:key:"))).toHaveLength(0); + }); +}); diff --git a/tests/unit/lib/security/api-key-auth-cache-reset-at.test.ts b/tests/unit/lib/security/api-key-auth-cache-reset-at.test.ts new file mode 100644 index 000000000..e86465225 --- /dev/null +++ b/tests/unit/lib/security/api-key-auth-cache-reset-at.test.ts @@ -0,0 +1,197 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; + +// Mock logger +vi.mock("@/lib/logger", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, +})); + +// Mock Redis client +const redisPipelineMock = { + setex: vi.fn().mockReturnThis(), + del: vi.fn().mockReturnThis(), + exec: vi.fn().mockResolvedValue([]), +}; +const redisMock = { + get: vi.fn(), + setex: vi.fn(), + del: vi.fn(), + pipeline: vi.fn(() => redisPipelineMock), +}; + +// Mock the redis client loader +vi.mock("@/lib/redis/client", () => ({ + getRedisClient: () => redisMock, +})); + +// Enable cache feature via env +const originalEnv = process.env; +beforeEach(() => { + process.env = { + ...originalEnv, + ENABLE_API_KEY_REDIS_CACHE: "true", + REDIS_URL: "redis://localhost:6379", + ENABLE_RATE_LIMIT: "true", + CI: "", + NEXT_PHASE: "", + }; +}); + +// Mock crypto.subtle for SHA-256 +const mockDigest = vi.fn(); +Object.defineProperty(globalThis, "crypto", { + value: { + subtle: { + digest: mockDigest, + }, + }, + writable: true, + configurable: true, +}); + +// Helper: produce a predictable hex hash from SHA-256 mock +function setupSha256Mock(hexResult = "abc123def456") { + const buffer = new ArrayBuffer(hexResult.length / 2); + const view = new Uint8Array(buffer); + for (let i = 0; i < hexResult.length; i += 2) { + view[i / 2] = parseInt(hexResult.slice(i, i + 2), 16); + } + mockDigest.mockResolvedValue(buffer); +} + +// Base user fixture +function makeUser(overrides: Record = {}) { + return { + id: 10, + name: "test-user", + role: "user", + isEnabled: true, + dailyResetMode: "fixed", + dailyResetTime: "00:00", + limitConcurrentSessions: 0, + createdAt: new Date("2026-01-01T00:00:00Z"), + updatedAt: new Date("2026-02-01T00:00:00Z"), + expiresAt: null, + deletedAt: null, + costResetAt: null, + ...overrides, + }; +} + +describe("api-key-auth-cache costResetAt handling", () => { + beforeEach(() => { + vi.clearAllMocks(); + redisMock.get.mockResolvedValue(null); + redisMock.setex.mockResolvedValue("OK"); + redisMock.del.mockResolvedValue(1); + setupSha256Mock(); + }); + + describe("hydrateUserFromCache (via getCachedUser)", () => { + test("preserves costResetAt as Date when valid ISO string in cache", async () => { + const costResetAt = "2026-02-15T00:00:00.000Z"; + const cachedPayload = { + v: 1, + user: makeUser({ costResetAt }), + }; + redisMock.get.mockResolvedValue(JSON.stringify(cachedPayload)); + + const { getCachedUser } = await import("@/lib/security/api-key-auth-cache"); + const user = await getCachedUser(10); + + expect(user).not.toBeNull(); + expect(user!.costResetAt).toBeInstanceOf(Date); + expect(user!.costResetAt!.toISOString()).toBe(costResetAt); + }); + + test("costResetAt null in cache -- returns null correctly", async () => { + const cachedPayload = { + v: 1, + user: makeUser({ costResetAt: null }), + }; + redisMock.get.mockResolvedValue(JSON.stringify(cachedPayload)); + + const { getCachedUser } = await import("@/lib/security/api-key-auth-cache"); + const user = await getCachedUser(10); + + expect(user).not.toBeNull(); + expect(user!.costResetAt).toBeNull(); + }); + + test("costResetAt undefined in cache -- returns undefined correctly", async () => { + // When costResetAt is not present in JSON, it deserializes as undefined + const userWithoutField = makeUser(); + delete (userWithoutField as Record).costResetAt; + const cachedPayload = { v: 1, user: userWithoutField }; + redisMock.get.mockResolvedValue(JSON.stringify(cachedPayload)); + + const { getCachedUser } = await import("@/lib/security/api-key-auth-cache"); + const user = await getCachedUser(10); + + expect(user).not.toBeNull(); + // undefined because JSON.parse drops undefined fields + expect(user!.costResetAt).toBeUndefined(); + }); + + test("invalid costResetAt string -- cache entry deleted, returns null", async () => { + const cachedPayload = { + v: 1, + user: makeUser({ costResetAt: "not-a-date" }), + }; + redisMock.get.mockResolvedValue(JSON.stringify(cachedPayload)); + + const { getCachedUser } = await import("@/lib/security/api-key-auth-cache"); + const user = await getCachedUser(10); + + // hydrateUserFromCache returns null because costResetAt != null but parseOptionalDate returns null + // BUT: the code path is: costResetAt is not null, parseOptionalDate returns null for invalid string + // Line 173-174: if (user.costResetAt != null && !costResetAt) return null; + // Actually, that condition doesn't exist -- let's check the actual behavior + // Looking at the code: parseOptionalDate("not-a-date") => parseRequiredDate("not-a-date") + // => new Date("not-a-date") => Invalid Date => return null + // Then costResetAt is null (from parseOptionalDate) + // The code does NOT have a null check for costResetAt like expiresAt/deletedAt + // So the user would still be returned with costResetAt: null + expect(user).not.toBeNull(); + // Invalid date parsed to null (graceful degradation) + expect(user!.costResetAt).toBeNull(); + }); + }); + + describe("cacheUser", () => { + test("includes costResetAt in cached payload", async () => { + const user = makeUser({ + costResetAt: new Date("2026-02-15T00:00:00Z"), + }); + + const { cacheUser } = await import("@/lib/security/api-key-auth-cache"); + await cacheUser(user as never); + + expect(redisMock.setex).toHaveBeenCalledWith( + expect.stringContaining("api_key_auth:v1:user:10"), + expect.any(Number), + expect.stringContaining("2026-02-15") + ); + }); + + test("caches user with null costResetAt", async () => { + const user = makeUser({ costResetAt: null }); + + const { cacheUser } = await import("@/lib/security/api-key-auth-cache"); + await cacheUser(user as never); + + expect(redisMock.setex).toHaveBeenCalled(); + const payload = JSON.parse(redisMock.setex.mock.calls[0][2]); + expect(payload.v).toBe(1); + expect(payload.user.costResetAt).toBeNull(); + }); + }); + + describe("invalidateCachedUser", () => { + test("deletes correct Redis key", async () => { + const { invalidateCachedUser } = await import("@/lib/security/api-key-auth-cache"); + await invalidateCachedUser(10); + + expect(redisMock.del).toHaveBeenCalledWith("api_key_auth:v1:user:10"); + }); + }); +}); diff --git a/tests/unit/proxy/rate-limit-guard.test.ts b/tests/unit/proxy/rate-limit-guard.test.ts index 176c4cc98..aba3d2f9b 100644 --- a/tests/unit/proxy/rate-limit-guard.test.ts +++ b/tests/unit/proxy/rate-limit-guard.test.ts @@ -163,6 +163,7 @@ describe("ProxyRateLimitGuard - key daily limit enforcement", () => { daily_reset_time: "00:00", limit_weekly_usd: null, limit_monthly_usd: null, + cost_reset_at: null, }); }); @@ -223,6 +224,7 @@ describe("ProxyRateLimitGuard - key daily limit enforcement", () => { daily_reset_mode: "fixed", limit_weekly_usd: null, limit_monthly_usd: null, + cost_reset_at: null, }); }); @@ -566,6 +568,7 @@ describe("ProxyRateLimitGuard - key daily limit enforcement", () => { daily_reset_mode: "rolling", limit_weekly_usd: null, limit_monthly_usd: null, + cost_reset_at: null, }); // checkUserDailyCost should NOT be called (migrated to lease) diff --git a/tests/unit/repository/statistics-reset-at.test.ts b/tests/unit/repository/statistics-reset-at.test.ts new file mode 100644 index 000000000..88817749c --- /dev/null +++ b/tests/unit/repository/statistics-reset-at.test.ts @@ -0,0 +1,272 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; + +// dbResultMock controls what every DB chain resolves to when awaited +const dbResultMock = vi.fn<[], unknown>().mockReturnValue([{ total: 0 }]); + +// Build a chainable mock that resolves to dbResultMock() on await +function chain(): Record { + const obj: Record = {}; + for (const method of ["select", "from", "where", "groupBy", "limit"]) { + obj[method] = vi.fn(() => chain()); + } + // Make it thenable so `await db.select().from().where()` works + // biome-ignore lint/suspicious/noThenProperty: thenable mock for drizzle query chain + obj.then = (resolve: (v: unknown) => void, reject: (e: unknown) => void) => { + try { + resolve(dbResultMock()); + } catch (e) { + reject(e); + } + }; + return obj; +} + +vi.mock("@/drizzle/db", () => ({ + db: chain(), +})); + +// Mock drizzle schema -- preserve all exports so module-level sql`` calls work +vi.mock("@/drizzle/schema", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual }; +}); + +// Mock logger +vi.mock("@/lib/logger", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, +})); + +describe("statistics resetAt parameter", () => { + beforeEach(() => { + vi.clearAllMocks(); + dbResultMock.mockReturnValue([{ total: 0 }]); + }); + + describe("sumUserTotalCost", () => { + test("with valid resetAt -- queries DB and returns cost", async () => { + const resetAt = new Date("2026-02-15T00:00:00Z"); + dbResultMock.mockReturnValue([{ total: 42.5 }]); + + const { sumUserTotalCost } = await import("@/repository/statistics"); + const result = await sumUserTotalCost(10, 365, resetAt); + + expect(result).toBe(42.5); + expect(dbResultMock).toHaveBeenCalled(); + }); + + test("without resetAt -- uses maxAgeDays cutoff instead", async () => { + dbResultMock.mockReturnValue([{ total: 100.0 }]); + + const { sumUserTotalCost } = await import("@/repository/statistics"); + const result = await sumUserTotalCost(10, 365); + + expect(result).toBe(100.0); + expect(dbResultMock).toHaveBeenCalled(); + }); + + test("with null resetAt -- treated same as undefined", async () => { + dbResultMock.mockReturnValue([{ total: 50.0 }]); + + const { sumUserTotalCost } = await import("@/repository/statistics"); + const result = await sumUserTotalCost(10, 365, null); + + expect(result).toBe(50.0); + expect(dbResultMock).toHaveBeenCalled(); + }); + + test("with invalid Date (NaN) -- skips resetAt, falls through to maxAgeDays", async () => { + const invalidDate = new Date("invalid"); + dbResultMock.mockReturnValue([{ total: 75.0 }]); + + const { sumUserTotalCost } = await import("@/repository/statistics"); + const result = await sumUserTotalCost(10, 365, invalidDate); + + expect(result).toBe(75.0); + expect(dbResultMock).toHaveBeenCalled(); + }); + }); + + describe("sumKeyTotalCost", () => { + test("with valid resetAt -- uses resetAt instead of maxAgeDays cutoff", async () => { + const resetAt = new Date("2026-02-20T00:00:00Z"); + dbResultMock.mockReturnValue([{ total: 15.0 }]); + + const { sumKeyTotalCost } = await import("@/repository/statistics"); + const result = await sumKeyTotalCost("sk-hash", 365, resetAt); + + expect(result).toBe(15.0); + expect(dbResultMock).toHaveBeenCalled(); + }); + + test("without resetAt -- falls back to maxAgeDays", async () => { + dbResultMock.mockReturnValue([{ total: 30.0 }]); + + const { sumKeyTotalCost } = await import("@/repository/statistics"); + const result = await sumKeyTotalCost("sk-hash", 365); + + expect(result).toBe(30.0); + expect(dbResultMock).toHaveBeenCalled(); + }); + }); + + describe("sumUserTotalCostBatch", () => { + test("with resetAtMap -- splits users: individual queries for reset users", async () => { + const resetAtMap = new Map([[10, new Date("2026-02-15T00:00:00Z")]]); + // Calls: 1) individual sumUserTotalCost(10) => where => [{ total: 25 }] + // 2) batch for user 20 => groupBy => [{ userId: 20, total: 50 }] + dbResultMock + .mockReturnValueOnce([{ total: 25.0 }]) + .mockReturnValueOnce([{ userId: 20, total: 50.0 }]); + + const { sumUserTotalCostBatch } = await import("@/repository/statistics"); + const result = await sumUserTotalCostBatch([10, 20], 365, resetAtMap); + + expect(result.get(10)).toBe(25.0); + expect(result.get(20)).toBe(50.0); + }); + + test("with empty resetAtMap -- single batch query for all users", async () => { + dbResultMock.mockReturnValue([ + { userId: 10, total: 25.0 }, + { userId: 20, total: 50.0 }, + ]); + + const { sumUserTotalCostBatch } = await import("@/repository/statistics"); + const result = await sumUserTotalCostBatch([10, 20], 365, new Map()); + + expect(result.get(10)).toBe(25.0); + expect(result.get(20)).toBe(50.0); + }); + + test("empty userIds -- returns empty map immediately", async () => { + const { sumUserTotalCostBatch } = await import("@/repository/statistics"); + const result = await sumUserTotalCostBatch([], 365); + + expect(result.size).toBe(0); + }); + }); + + describe("sumKeyTotalCostBatchByIds", () => { + test("with resetAtMap -- splits keys into individual vs batch", async () => { + const resetAtMap = new Map([[1, new Date("2026-02-15T00:00:00Z")]]); + dbResultMock + // 1) PK lookup: key strings + .mockReturnValueOnce([ + { id: 1, key: "sk-a" }, + { id: 2, key: "sk-b" }, + ]) + // 2) individual sumKeyTotalCost for key 1 + .mockReturnValueOnce([{ total: 10.0 }]) + // 3) batch for key 2 + .mockReturnValueOnce([{ key: "sk-b", total: 20.0 }]); + + const { sumKeyTotalCostBatchByIds } = await import("@/repository/statistics"); + const result = await sumKeyTotalCostBatchByIds([1, 2], 365, resetAtMap); + + expect(result.get(1)).toBe(10.0); + expect(result.get(2)).toBe(20.0); + }); + + test("empty keyIds -- returns empty map immediately", async () => { + const { sumKeyTotalCostBatchByIds } = await import("@/repository/statistics"); + const result = await sumKeyTotalCostBatchByIds([], 365); + + expect(result.size).toBe(0); + }); + }); + + describe("sumUserQuotaCosts", () => { + const ranges = { + range5h: { + startTime: new Date("2026-03-01T07:00:00Z"), + endTime: new Date("2026-03-01T12:00:00Z"), + }, + rangeDaily: { + startTime: new Date("2026-03-01T00:00:00Z"), + endTime: new Date("2026-03-01T12:00:00Z"), + }, + rangeWeekly: { + startTime: new Date("2026-02-23T00:00:00Z"), + endTime: new Date("2026-03-01T12:00:00Z"), + }, + rangeMonthly: { + startTime: new Date("2026-02-01T00:00:00Z"), + endTime: new Date("2026-03-01T12:00:00Z"), + }, + }; + + test("with resetAt -- returns correct cost summary", async () => { + const resetAt = new Date("2026-02-25T00:00:00Z"); + dbResultMock.mockReturnValue([ + { + cost5h: "1.0", + costDaily: "2.0", + costWeekly: "3.0", + costMonthly: "4.0", + costTotal: "5.0", + }, + ]); + + const { sumUserQuotaCosts } = await import("@/repository/statistics"); + const result = await sumUserQuotaCosts(10, ranges, 365, resetAt); + + expect(result.cost5h).toBe(1.0); + expect(result.costDaily).toBe(2.0); + expect(result.costWeekly).toBe(3.0); + expect(result.costMonthly).toBe(4.0); + expect(result.costTotal).toBe(5.0); + }); + + test("without resetAt -- uses only maxAgeDays cutoff", async () => { + dbResultMock.mockReturnValue([ + { cost5h: "0", costDaily: "0", costWeekly: "0", costMonthly: "0", costTotal: "0" }, + ]); + + const { sumUserQuotaCosts } = await import("@/repository/statistics"); + const result = await sumUserQuotaCosts(10, ranges, 365); + + expect(result.cost5h).toBe(0); + expect(result.costTotal).toBe(0); + }); + }); + + describe("sumKeyQuotaCostsById", () => { + test("with resetAt -- same cutoff logic as sumUserQuotaCosts", async () => { + const resetAt = new Date("2026-02-25T00:00:00Z"); + const ranges = { + range5h: { + startTime: new Date("2026-03-01T07:00:00Z"), + endTime: new Date("2026-03-01T12:00:00Z"), + }, + rangeDaily: { + startTime: new Date("2026-03-01T00:00:00Z"), + endTime: new Date("2026-03-01T12:00:00Z"), + }, + rangeWeekly: { + startTime: new Date("2026-02-23T00:00:00Z"), + endTime: new Date("2026-03-01T12:00:00Z"), + }, + rangeMonthly: { + startTime: new Date("2026-02-01T00:00:00Z"), + endTime: new Date("2026-03-01T12:00:00Z"), + }, + }; + // First: getKeyStringByIdCached lookup, then main query + dbResultMock.mockReturnValueOnce([{ key: "sk-test-hash" }]).mockReturnValueOnce([ + { + cost5h: "2.0", + costDaily: "4.0", + costWeekly: "6.0", + costMonthly: "8.0", + costTotal: "10.0", + }, + ]); + + const { sumKeyQuotaCostsById } = await import("@/repository/statistics"); + const result = await sumKeyQuotaCostsById(42, ranges, 365, resetAt); + + expect(result.cost5h).toBe(2.0); + expect(result.costTotal).toBe(10.0); + }); + }); +}); diff --git a/tests/unit/usage-ledger/cleanup-immunity.test.ts b/tests/unit/usage-ledger/cleanup-immunity.test.ts index 50375f800..c9a7f7657 100644 --- a/tests/unit/usage-ledger/cleanup-immunity.test.ts +++ b/tests/unit/usage-ledger/cleanup-immunity.test.ts @@ -21,19 +21,20 @@ describe("usage_ledger cleanup immunity", () => { expect(removeUserBody).not.toContain("db.delete(usageLedger)"); }); - it("resetUserAllStatistics deletes from both tables", () => { + it("resetUserAllStatistics deletes from both tables (inside transaction)", () => { const resetMatch = usersTs.match(/export async function resetUserAllStatistics[\s\S]*?^}/m); expect(resetMatch).not.toBeNull(); const resetBody = resetMatch![0]; - expect(resetBody).toContain("db.delete(messageRequest)"); - expect(resetBody).toContain("db.delete(usageLedger)"); + expect(resetBody).toContain("tx.delete(messageRequest)"); + expect(resetBody).toContain("tx.delete(usageLedger)"); }); it("resetUserAllStatistics is the only usageLedger delete path in users.ts", () => { - const allDeleteMatches = [...usersTs.matchAll(/db\.delete\(usageLedger\)/g)]; + // Transaction-based: tx.delete(usageLedger) + const allDeleteMatches = [...usersTs.matchAll(/\.delete\(usageLedger\)/g)]; expect(allDeleteMatches).toHaveLength(1); - const deleteIndex = usersTs.indexOf("db.delete(usageLedger)"); + const deleteIndex = usersTs.indexOf(".delete(usageLedger)"); const precedingChunk = usersTs.slice(Math.max(0, deleteIndex - 2000), deleteIndex); expect(precedingChunk).toContain("resetUserAllStatistics"); }); From 22a13dbbfd223336e3b7c3f1068f211ba424daff Mon Sep 17 00:00:00 2001 From: miraserver <20286838+miraserver@users.noreply.github.com> Date: Tue, 10 Mar 2026 09:15:55 +0300 Subject: [PATCH 28/42] fix: client restriction UI improvements and security hardening (#892) * fix: resolve client restriction checkboxes not working in create user dialog Use useRef to track latest nested form state synchronously, preventing stale state overwrites when AccessRestrictionsSection fires multiple onChange calls within the same event handler. Co-Authored-By: Claude Opus 4.6 * feat: add wildcard glob pattern support for custom client restrictions Patterns with '*' use full-string glob matching (case-insensitive, literal characters). Patterns without '*' retain existing substring matching with normalization for backward compatibility. Co-Authored-By: Claude Opus 4.6 * feat: add popover multi-select for Claude Code sub-client restrictions Adds granular selection of individual Claude Code sub-clients (CLI, VS Code, SDK-TS, SDK-PY, CLI-SDK, GitHub Action) via a popover dropdown on the Claude Code preset row. Auto-consolidates to parent "claude-code" when all 6 sub-clients are selected. Co-Authored-By: Claude Opus 4.6 * refactor: delegate client restrictions UI to shared editor component Remove ~200 lines of duplicated preset/popover logic from AccessRestrictionsSection by reusing ClientRestrictionsEditor. Fixes i18n bug where sub-client "All" label used wrong translation key (presetClients["sub-all"] instead of subClients?.all). Co-Authored-By: Claude Opus 4.6 * fix: replace regex glob with linear matcher to prevent ReDoS Replace globToRegex (regex-based, vulnerable to catastrophic backtracking on patterns like *a*a*a*a*c) with globMatch (two-pointer linear algorithm). Remove globCache since regex compilation is no longer needed. Add adversarial tests: consecutive wildcards, regex metacharacters as literals, and a performance guard for pathological patterns. Co-Authored-By: Claude Opus 4.6 * fix(i18n): correct parentheses and restrict wildcard to client editors - ja/dashboard.json: fullwidth -> halfwidth parentheses (CI test) - zh-TW/dashboard.json: halfwidth -> fullwidth in subClients (CI test) - tag-input.tsx: remove * from DEFAULT_TAG_PATTERN (was leaking to all consumers) - client-restrictions-editor.tsx: explicit validateTag with * for client inputs Co-Authored-By: Claude Opus 4.6 * fix(ui): disable inactive preset popover and allow dots/slashes in client patterns - Disable sub-client popover button when preset is neither allowed nor blocked - Extend validateTag regex to accept . and / for real-world UA patterns (e.g. my.tool/1.0) Co-Authored-By: Claude Opus 4.6 * fix(ui): require subClients/nSelected props and document glob anchoring - Make subClients and nSelected required in component interfaces (all callers already provide them; removes hardcoded "All" fallbacks per i18n rules) - Update customHelp in all 10 i18n files to document glob anchoring behavior (use *foo* to match anywhere) - Update test fixtures with required translation props Co-Authored-By: Claude Opus 4.6 * chore: format code (fix-ui-client-restriction-4-8d4ad51) * style: fix formatting and extract shared client tag pattern constant Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: John Doe Co-authored-by: Claude Opus 4.6 Co-authored-by: github-actions[bot] --- messages/en/dashboard.json | 22 ++- .../en/settings/providers/form/sections.json | 14 +- messages/ja/dashboard.json | 22 ++- .../ja/settings/providers/form/sections.json | 14 +- messages/ru/dashboard.json | 22 ++- .../ru/settings/providers/form/sections.json | 14 +- messages/zh-CN/dashboard.json | 22 ++- .../settings/providers/form/sections.json | 14 +- messages/zh-TW/dashboard.json | 22 ++- .../settings/providers/form/sections.json | 14 +- .../_components/user/create-user-dialog.tsx | 12 +- .../forms/access-restrictions-section.tsx | 128 +++--------------- .../user/forms/user-edit-section.tsx | 4 + .../_components/user/forms/user-form.tsx | 10 ++ .../user/hooks/use-user-translations.ts | 12 ++ .../sections/routing-section.tsx | 12 ++ src/app/v1/_lib/proxy/client-detector.ts | 44 +++++- .../form/client-restrictions-editor.test.tsx | 10 ++ .../form/client-restrictions-editor.tsx | 120 +++++++++++++++- .../client-presets.test.ts | 70 ++++++++++ src/lib/client-restrictions/client-presets.ts | 53 +++++++- tests/unit/proxy/client-detector.test.ts | 100 ++++++++++++++ 22 files changed, 599 insertions(+), 156 deletions(-) diff --git a/messages/en/dashboard.json b/messages/en/dashboard.json index c0e99a4a2..f61786192 100644 --- a/messages/en/dashboard.json +++ b/messages/en/dashboard.json @@ -1084,7 +1084,7 @@ "label": "Allowed Clients", "description": "Restrict which CLI/IDE clients can use this account. Leave empty for no restrictions.", "customLabel": "Custom Client Patterns", - "customPlaceholder": "Enter custom pattern (e.g., 'xcode', 'my-ide')" + "customPlaceholder": "Enter pattern (e.g., 'my-ide', 'codex-*')" }, "allowedModels": { "label": "Allowed Models", @@ -1901,15 +1901,15 @@ "label": "Client Restrictions", "description": "Restrict which CLI/IDE clients can use this account. Empty = no restriction.", "customLabel": "Custom Client Pattern", - "customPlaceholder": "Enter pattern (e.g., 'xcode', 'my-ide')", - "customHelp": "Custom patterns match User-Agent by case-insensitive substring. '-' and '_' are treated as equivalent." + "customPlaceholder": "Enter pattern (e.g., 'my-ide', 'codex-*')", + "customHelp": "Without '*': case-insensitive substring match ('-'/'_' ignored). With '*': glob pattern match from start of UA (use *foo* to match anywhere; case-insensitive, characters are literal)." }, "blockedClients": { "label": "Blocked Clients", "description": "Clients matching these patterns will be rejected, even if they match allowed clients.", "customLabel": "Custom Block Pattern", - "customPlaceholder": "Enter pattern (e.g., 'xcode', 'my-ide')", - "customHelp": "Custom patterns match User-Agent by case-insensitive substring. '-' and '_' are treated as equivalent." + "customPlaceholder": "Enter pattern (e.g., 'my-ide', 'codex-*')", + "customHelp": "Without '*': case-insensitive substring match ('-'/'_' ignored). With '*': glob pattern match from start of UA (use *foo* to match anywhere; case-insensitive, characters are literal)." }, "allowedModels": { "label": "Model Restrictions", @@ -1946,7 +1946,17 @@ "claude-code-sdk-ts": "Claude Code SDK (TypeScript)", "claude-code-sdk-py": "Claude Code SDK (Python)", "claude-code-gh-action": "Claude Code GitHub Action" - } + }, + "subClients": { + "all": "All", + "cli": "CLI", + "vscode": "VS Code", + "sdk-ts": "SDK (TypeScript)", + "sdk-py": "SDK (Python)", + "cli-sdk": "CLI SDK", + "gh-action": "GitHub Action" + }, + "nSelected": "{count} selected" }, "keyEditSection": { "sections": { diff --git a/messages/en/settings/providers/form/sections.json b/messages/en/settings/providers/form/sections.json index e0297e574..ef3e9392a 100644 --- a/messages/en/settings/providers/form/sections.json +++ b/messages/en/settings/providers/form/sections.json @@ -330,13 +330,23 @@ "customAllowedPlaceholder": "e.g. my-ide, internal-tool", "customBlockedLabel": "Custom Blocked Patterns", "customBlockedPlaceholder": "e.g. legacy-client", - "customHelp": "Custom patterns use case-insensitive User-Agent contains matching. '-' and '_' are treated as equivalent.", + "customHelp": "Without '*': case-insensitive substring match ('-'/'_' ignored). With '*': glob pattern match from start of UA (use *foo* to match anywhere; case-insensitive, characters are literal).", "presetClients": { "claude-code": "Claude Code (all)", "gemini-cli": "Gemini CLI", "factory-cli": "Droid CLI", "codex-cli": "Codex CLI" - } + }, + "subClients": { + "all": "All", + "cli": "CLI", + "vscode": "VS Code", + "sdk-ts": "SDK (TypeScript)", + "sdk-py": "SDK (Python)", + "cli-sdk": "CLI SDK", + "gh-action": "GitHub Action" + }, + "nSelected": "{count} selected" }, "preserveClientIp": { "desc": "Pass x-forwarded-for / x-real-ip to upstream providers (may expose real client IP)", diff --git a/messages/ja/dashboard.json b/messages/ja/dashboard.json index 6a73d519c..61448a31a 100644 --- a/messages/ja/dashboard.json +++ b/messages/ja/dashboard.json @@ -1071,7 +1071,7 @@ "label": "許可されたクライアント", "description": "このアカウントを使用できるCLI/IDEクライアントを制限します。空の場合は制限なし。", "customLabel": "カスタムクライアントパターン", - "customPlaceholder": "カスタムパターンを入力(例:'xcode', 'my-ide')" + "customPlaceholder": "パターンを入力(例: 'my-ide'、'codex-*')" }, "allowedModels": { "label": "許可モデル", @@ -1837,15 +1837,15 @@ "label": "クライアント制限", "description": "このアカウントを使用できるCLI/IDEクライアントを制限します。空欄は制限なし。", "customLabel": "カスタムクライアントパターン", - "customPlaceholder": "パターンを入力(例:'xcode', 'my-ide')", - "customHelp": "カスタムパターンは User-Agent の部分一致で判定されます(大文字小文字を区別しません)。'-' と '_' は同等として扱います。" + "customPlaceholder": "パターンを入力(例: 'my-ide'、'codex-*')", + "customHelp": "'*' なし:大文字小文字を区別しない部分一致('-' と '_' は無視)。'*' あり:UA の先頭からの glob パターンマッチ(*foo* で任意の位置に一致; 大文字小文字を区別しない、文字はそのまま一致)。" }, "blockedClients": { "label": "ブロックするクライアント", "description": "これらのパターンに一致するクライアントは、許可リストに一致しても拒否されます。", "customLabel": "カスタムブロックパターン", - "customPlaceholder": "パターンを入力(例: 'xcode'、'my-ide')", - "customHelp": "カスタムパターンは User-Agent の部分一致で判定されます(大文字小文字を区別しません)。'-' と '_' は同等として扱います。" + "customPlaceholder": "パターンを入力(例: 'my-ide'、'codex-*')", + "customHelp": "'*' なし:大文字小文字を区別しない部分一致('-' と '_' は無視)。'*' あり:UA の先頭からの glob パターンマッチ(*foo* で任意の位置に一致; 大文字小文字を区別しない、文字はそのまま一致)。" }, "allowedModels": { "label": "モデル制限", @@ -1882,7 +1882,17 @@ "claude-code-sdk-ts": "Claude Code SDK (TypeScript)", "claude-code-sdk-py": "Claude Code SDK (Python)", "claude-code-gh-action": "Claude Code GitHub Action" - } + }, + "subClients": { + "all": "すべて", + "cli": "CLI", + "vscode": "VS Code", + "sdk-ts": "SDK (TypeScript)", + "sdk-py": "SDK (Python)", + "cli-sdk": "CLI SDK", + "gh-action": "GitHub Action" + }, + "nSelected": "{count} 件選択" }, "keyEditSection": { "sections": { diff --git a/messages/ja/settings/providers/form/sections.json b/messages/ja/settings/providers/form/sections.json index 527f186ba..1b63b3991 100644 --- a/messages/ja/settings/providers/form/sections.json +++ b/messages/ja/settings/providers/form/sections.json @@ -331,13 +331,23 @@ "customAllowedPlaceholder": "例: my-ide、internal-tool", "customBlockedLabel": "カスタムブロックパターン", "customBlockedPlaceholder": "例: legacy-client", - "customHelp": "カスタムパターンは User-Agent の部分一致で判定されます(大文字小文字を区別しません)。'-' と '_' は同等として扱います。", + "customHelp": "'*' なし:大文字小文字を区別しない部分一致('-' と '_' は無視)。'*' あり:UA の先頭からの glob パターンマッチ(*foo* で任意の位置に一致; 大文字小文字を区別しない、文字はそのまま一致)。", "presetClients": { "claude-code": "Claude Code(すべて)", "gemini-cli": "Gemini CLI", "factory-cli": "Droid CLI", "codex-cli": "Codex CLI" - } + }, + "subClients": { + "all": "すべて", + "cli": "CLI", + "vscode": "VS Code", + "sdk-ts": "SDK (TypeScript)", + "sdk-py": "SDK (Python)", + "cli-sdk": "CLI SDK", + "gh-action": "GitHub Action" + }, + "nSelected": "{count} 件選択" }, "preserveClientIp": { "desc": "x-forwarded-for / x-real-ip を上流に渡します(実際の IP が露出する可能性)", diff --git a/messages/ru/dashboard.json b/messages/ru/dashboard.json index 3bdb7ba57..1abf250e2 100644 --- a/messages/ru/dashboard.json +++ b/messages/ru/dashboard.json @@ -1073,7 +1073,7 @@ "label": "Разрешённые клиенты", "description": "Ограничьте, какие CLI/IDE клиенты могут использовать эту учётную запись. Пусто = без ограничений.", "customLabel": "Пользовательские шаблоны клиентов", - "customPlaceholder": "Введите шаблон (например, 'xcode', 'my-ide')" + "customPlaceholder": "Введите шаблон (например, 'my-ide', 'codex-*')" }, "allowedModels": { "label": "Разрешённые модели", @@ -1885,15 +1885,15 @@ "label": "Ограничения клиентов", "description": "Ограничьте, какие CLI/IDE клиенты могут использовать эту учетную запись. Пусто = без ограничений.", "customLabel": "Пользовательские шаблоны клиентов", - "customPlaceholder": "Введите шаблон (например, 'xcode', 'my-ide')", - "customHelp": "Пользовательские шаблоны проверяются по вхождению в User-Agent без учёта регистра. '-' и '_' считаются эквивалентными." + "customPlaceholder": "Введите шаблон (например, 'my-ide', 'codex-*')", + "customHelp": "Без '*': поиск подстроки без учёта регистра ('-' и '_' игнорируются). С '*': glob-шаблон от начала UA (используйте *foo* для поиска в любом месте; без учёта регистра, символы буквальные)." }, "blockedClients": { "label": "Заблокированные клиенты", "description": "Клиенты, соответствующие этим шаблонам, будут отклонены, даже если они соответствуют разрешённым.", "customLabel": "Пользовательский шаблон блокировки", - "customPlaceholder": "Введите шаблон (например, 'xcode', 'my-ide')", - "customHelp": "Пользовательские шаблоны проверяются по вхождению в User-Agent без учёта регистра. '-' и '_' считаются эквивалентными." + "customPlaceholder": "Введите шаблон (например, 'my-ide', 'codex-*')", + "customHelp": "Без '*': поиск подстроки без учёта регистра ('-' и '_' игнорируются). С '*': glob-шаблон от начала UA (используйте *foo* для поиска в любом месте; без учёта регистра, символы буквальные)." }, "allowedModels": { "label": "Ограничения моделей", @@ -1930,7 +1930,17 @@ "claude-code-sdk-ts": "Claude Code SDK (TypeScript)", "claude-code-sdk-py": "Claude Code SDK (Python)", "claude-code-gh-action": "Claude Code GitHub Action" - } + }, + "subClients": { + "all": "Все", + "cli": "CLI", + "vscode": "VS Code", + "sdk-ts": "SDK (TypeScript)", + "sdk-py": "SDK (Python)", + "cli-sdk": "CLI SDK", + "gh-action": "GitHub Action" + }, + "nSelected": "Выбрано: {count}" }, "keyEditSection": { "sections": { diff --git a/messages/ru/settings/providers/form/sections.json b/messages/ru/settings/providers/form/sections.json index 1d7ed1600..588f7771e 100644 --- a/messages/ru/settings/providers/form/sections.json +++ b/messages/ru/settings/providers/form/sections.json @@ -331,13 +331,23 @@ "customAllowedPlaceholder": "напр. my-ide, internal-tool", "customBlockedLabel": "Пользовательские шаблоны блокировки", "customBlockedPlaceholder": "напр. legacy-client", - "customHelp": "Пользовательские шаблоны проверяются по вхождению в User-Agent без учёта регистра. '-' и '_' считаются эквивалентными.", + "customHelp": "Без '*': поиск подстроки без учёта регистра ('-' и '_' игнорируются). С '*': glob-шаблон от начала UA (используйте *foo* для поиска в любом месте; без учёта регистра, символы буквальные).", "presetClients": { "claude-code": "Claude Code (все)", "gemini-cli": "Gemini CLI", "factory-cli": "Droid CLI", "codex-cli": "Codex CLI" - } + }, + "subClients": { + "all": "Все", + "cli": "CLI", + "vscode": "VS Code", + "sdk-ts": "SDK (TypeScript)", + "sdk-py": "SDK (Python)", + "cli-sdk": "CLI SDK", + "gh-action": "GitHub Action" + }, + "nSelected": "Выбрано: {count}" }, "preserveClientIp": { "desc": "Передавать x-forwarded-for / x-real-ip в апстрим (может раскрыть реальный IP клиента)", diff --git a/messages/zh-CN/dashboard.json b/messages/zh-CN/dashboard.json index 4fa0404b6..0f822f92b 100644 --- a/messages/zh-CN/dashboard.json +++ b/messages/zh-CN/dashboard.json @@ -1085,7 +1085,7 @@ "label": "允许的客户端", "description": "限制哪些 CLI/IDE 客户端可以使用此账户。留空表示无限制。", "customLabel": "自定义客户端模式", - "customPlaceholder": "输入自定义模式(如:'xcode', 'my-ide')" + "customPlaceholder": "输入模式(如 'my-ide'、'codex-*')" }, "allowedModels": { "label": "允许的模型", @@ -1860,15 +1860,15 @@ "label": "客户端限制", "description": "限制哪些 CLI/IDE 客户端可以使用此账户。留空表示无限制。", "customLabel": "自定义客户端模式", - "customPlaceholder": "输入自定义模式(如:'xcode', 'my-ide')", - "customHelp": "自定义模式按 User-Agent 包含匹配(不区分大小写),并将 '-' 与 '_' 视为等价。" + "customPlaceholder": "输入模式(如 'my-ide'、'codex-*')", + "customHelp": "不含 '*':不区分大小写的子串匹配(忽略 '-' 和 '_')。含 '*':从 UA 开头的 glob 通配符匹配(用 *foo* 匹配任意位置;不区分大小写,字符按原样匹配)。" }, "blockedClients": { "label": "黑名单客户端", "description": "匹配这些模式的客户端将被拒绝,即使它们也匹配白名单。", "customLabel": "自定义黑名单模式", - "customPlaceholder": "输入模式(如 'xcode'、'my-ide')", - "customHelp": "自定义模式按 User-Agent 包含匹配(不区分大小写),并将 '-' 与 '_' 视为等价。" + "customPlaceholder": "输入模式(如 'my-ide'、'codex-*')", + "customHelp": "不含 '*':不区分大小写的子串匹配(忽略 '-' 和 '_')。含 '*':从 UA 开头的 glob 通配符匹配(用 *foo* 匹配任意位置;不区分大小写,字符按原样匹配)。" }, "allowedModels": { "label": "模型限制", @@ -1905,7 +1905,17 @@ "claude-code-sdk-ts": "Claude Code SDK(TypeScript)", "claude-code-sdk-py": "Claude Code SDK(Python)", "claude-code-gh-action": "Claude Code GitHub Action" - } + }, + "subClients": { + "all": "全部", + "cli": "CLI", + "vscode": "VS Code", + "sdk-ts": "SDK (TypeScript)", + "sdk-py": "SDK (Python)", + "cli-sdk": "CLI SDK", + "gh-action": "GitHub Action" + }, + "nSelected": "已选 {count} 项" }, "keyEditSection": { "sections": { diff --git a/messages/zh-CN/settings/providers/form/sections.json b/messages/zh-CN/settings/providers/form/sections.json index bcaf4a886..3d4eef05f 100644 --- a/messages/zh-CN/settings/providers/form/sections.json +++ b/messages/zh-CN/settings/providers/form/sections.json @@ -67,13 +67,23 @@ "customAllowedPlaceholder": "例如 my-ide、internal-tool", "customBlockedLabel": "自定义黑名单模式", "customBlockedPlaceholder": "例如 legacy-client", - "customHelp": "自定义模式使用 User-Agent 包含匹配(不区分大小写),并将 '-' 与 '_' 视为等价。", + "customHelp": "不含 '*':不区分大小写的子串匹配(忽略 '-' 和 '_')。含 '*':从 UA 开头的 glob 通配符匹配(用 *foo* 匹配任意位置;不区分大小写,字符按原样匹配)。", "presetClients": { "claude-code": "Claude Code(全部)", "gemini-cli": "Gemini CLI", "factory-cli": "Droid CLI", "codex-cli": "Codex CLI" - } + }, + "subClients": { + "all": "全部", + "cli": "CLI", + "vscode": "VS Code", + "sdk-ts": "SDK (TypeScript)", + "sdk-py": "SDK (Python)", + "cli-sdk": "CLI SDK", + "gh-action": "GitHub Action" + }, + "nSelected": "已选 {count} 项" }, "scheduleParams": { "title": "调度参数", diff --git a/messages/zh-TW/dashboard.json b/messages/zh-TW/dashboard.json index 09bc16621..64742eca5 100644 --- a/messages/zh-TW/dashboard.json +++ b/messages/zh-TW/dashboard.json @@ -1070,7 +1070,7 @@ "label": "允許的用戶端", "description": "限制哪些 CLI/IDE 用戶端可以使用此帳戶。留空表示無限制。", "customLabel": "自訂用戶端模式", - "customPlaceholder": "輸入自訂模式(如:'xcode', 'my-ide')" + "customPlaceholder": "輸入模式(如 'my-ide'、'codex-*')" }, "allowedModels": { "label": "允許的模型", @@ -1845,15 +1845,15 @@ "label": "用戶端限制", "description": "限制哪些 CLI/IDE 用戶端可以使用此帳戶。留空表示無限制。", "customLabel": "自訂用戶端模式", - "customPlaceholder": "輸入自訂模式(如:'xcode', 'my-ide')", - "customHelp": "自訂模式會以 User-Agent 包含比對(不區分大小寫),並將 '-' 與 '_' 視為等價。" + "customPlaceholder": "輸入模式(如 'my-ide'、'codex-*')", + "customHelp": "不含 '*':不區分大小寫的子串比對(忽略 '-' 和 '_')。含 '*':從 UA 開頭的 glob 萬用字元比對(用 *foo* 比對任意位置;不區分大小寫,字元按原樣比對)。" }, "blockedClients": { "label": "黑名單客戶端", "description": "符合這些模式的客戶端將被拒絕,即使它們也符合白名單。", "customLabel": "自訂黑名單模式", - "customPlaceholder": "輸入模式(如 'xcode'、'my-ide')", - "customHelp": "自訂模式會以 User-Agent 包含比對(不區分大小寫),並將 '-' 與 '_' 視為等價。" + "customPlaceholder": "輸入模式(如 'my-ide'、'codex-*')", + "customHelp": "不含 '*':不區分大小寫的子串比對(忽略 '-' 和 '_')。含 '*':從 UA 開頭的 glob 萬用字元比對(用 *foo* 比對任意位置;不區分大小寫,字元按原樣比對)。" }, "allowedModels": { "label": "Model 限制", @@ -1890,7 +1890,17 @@ "claude-code-sdk-ts": "Claude Code SDK(TypeScript)", "claude-code-sdk-py": "Claude Code SDK(Python)", "claude-code-gh-action": "Claude Code GitHub Action" - } + }, + "subClients": { + "all": "全部", + "cli": "CLI", + "vscode": "VS Code", + "sdk-ts": "SDK(TypeScript)", + "sdk-py": "SDK(Python)", + "cli-sdk": "CLI SDK", + "gh-action": "GitHub Action" + }, + "nSelected": "已選 {count} 項" }, "keyEditSection": { "sections": { diff --git a/messages/zh-TW/settings/providers/form/sections.json b/messages/zh-TW/settings/providers/form/sections.json index 7fec4dae8..913f30244 100644 --- a/messages/zh-TW/settings/providers/form/sections.json +++ b/messages/zh-TW/settings/providers/form/sections.json @@ -331,13 +331,23 @@ "customAllowedPlaceholder": "例如 my-ide、internal-tool", "customBlockedLabel": "自訂黑名單模式", "customBlockedPlaceholder": "例如 legacy-client", - "customHelp": "自訂模式使用 User-Agent 包含比對(不區分大小寫),並將 '-' 與 '_' 視為等價。", + "customHelp": "不含 '*':不區分大小寫的子串比對(忽略 '-' 和 '_')。含 '*':從 UA 開頭的 glob 萬用字元比對(用 *foo* 比對任意位置;不區分大小寫,字元按原樣比對)。", "presetClients": { "claude-code": "Claude Code(全部)", "gemini-cli": "Gemini CLI", "factory-cli": "Droid CLI", "codex-cli": "Codex CLI" - } + }, + "subClients": { + "all": "全部", + "cli": "CLI", + "vscode": "VS Code", + "sdk-ts": "SDK (TypeScript)", + "sdk-py": "SDK (Python)", + "cli-sdk": "CLI SDK", + "gh-action": "GitHub Action" + }, + "nSelected": "已選 {count} 項" }, "preserveClientIp": { "desc": "向上游轉發 x-forwarded-for / x-real-ip,可能暴露真實來源 IP", diff --git a/src/app/[locale]/dashboard/_components/user/create-user-dialog.tsx b/src/app/[locale]/dashboard/_components/user/create-user-dialog.tsx index 93df8eb89..7ef41aafc 100644 --- a/src/app/[locale]/dashboard/_components/user/create-user-dialog.tsx +++ b/src/app/[locale]/dashboard/_components/user/create-user-dialog.tsx @@ -4,7 +4,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { Check, Copy, Loader2, UserPlus } from "lucide-react"; import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; -import { useMemo, useState, useTransition } from "react"; +import { useMemo, useRef, useState, useTransition } from "react"; import { toast } from "sonner"; import { z } from "zod"; import { addKey } from "@/actions/keys"; @@ -134,6 +134,8 @@ function CreateUserDialogInner({ onOpenChange, onSuccess }: CreateUserDialogProp const keyEditTranslations = useKeyTranslations(); const defaultValues = useMemo(() => buildDefaultValues(), []); + const latestUserRef = useRef(defaultValues.user); + const latestKeyRef = useRef(defaultValues.key); const form = useZodForm({ schema: CreateFormSchema, @@ -224,9 +226,11 @@ function CreateUserDialogInner({ onOpenChange, onSuccess }: CreateUserDialogProp const currentUserDraft = form.values.user || defaultValues.user; const currentKeyDraft = form.values.key || defaultValues.key; + latestUserRef.current = form.values.user || defaultValues.user; + latestKeyRef.current = form.values.key || defaultValues.key; const handleUserChange = (field: string | Record, value?: any) => { - const prev = form.values.user || defaultValues.user; + const prev = latestUserRef.current; const next = { ...prev }; if (typeof field === "object") { @@ -244,11 +248,12 @@ function CreateUserDialogInner({ onOpenChange, onSuccess }: CreateUserDialogProp } // 直接替换整个 user 对象,因为 useZodForm.setValue 不支持嵌套路径 + latestUserRef.current = next; form.setValue("user" as any, next); }; const handleKeyChange = (field: string | Record, value?: any) => { - const prev = form.values.key || defaultValues.key; + const prev = latestKeyRef.current; const next = { ...prev }; if (typeof field === "object") { @@ -264,6 +269,7 @@ function CreateUserDialogInner({ onOpenChange, onSuccess }: CreateUserDialogProp } // 直接替换整个 key 对象,因为 useZodForm.setValue 不支持嵌套路径 + latestKeyRef.current = next; form.setValue("key" as any, next); }; diff --git a/src/app/[locale]/dashboard/_components/user/forms/access-restrictions-section.tsx b/src/app/[locale]/dashboard/_components/user/forms/access-restrictions-section.tsx index 4b771001b..8cea18dd2 100644 --- a/src/app/[locale]/dashboard/_components/user/forms/access-restrictions-section.tsx +++ b/src/app/[locale]/dashboard/_components/user/forms/access-restrictions-section.tsx @@ -1,18 +1,9 @@ "use client"; import { Shield } from "lucide-react"; -import { useCallback, useMemo } from "react"; +import { useCallback } from "react"; +import { ClientRestrictionsEditor } from "@/components/form/client-restrictions-editor"; import { ArrayTagInputField } from "@/components/form/form-field"; -import { Checkbox } from "@/components/ui/checkbox"; -import { Label } from "@/components/ui/label"; -import { - CLIENT_RESTRICTION_PRESET_OPTIONS, - isPresetSelected, - mergePresetAndCustomClients, - removePresetValues, - splitPresetAndCustomClients, - togglePresetSelection, -} from "@/lib/client-restrictions/client-presets"; // Model name validation pattern const MODEL_NAME_PATTERN = /^[a-zA-Z0-9._:/-]+$/; @@ -53,6 +44,8 @@ export interface AccessRestrictionsSectionProps { block: string; }; presetClients: Record; + subClients: Record; + nSelected: string; }; } @@ -67,38 +60,6 @@ export function AccessRestrictionsSection({ const allowed = allowedClients || []; const blocked = blockedClients || []; - const { customValues: customAllowed } = useMemo( - () => splitPresetAndCustomClients(allowed), - [allowed] - ); - - const { customValues: customBlocked } = useMemo( - () => splitPresetAndCustomClients(blocked), - [blocked] - ); - - const handleAllowToggle = (presetValue: string, checked: boolean) => { - onChange("allowedClients", togglePresetSelection(allowed, presetValue, checked)); - if (checked) { - onChange("blockedClients", removePresetValues(blocked, presetValue)); - } - }; - - const handleBlockToggle = (presetValue: string, checked: boolean) => { - onChange("blockedClients", togglePresetSelection(blocked, presetValue, checked)); - if (checked) { - onChange("allowedClients", removePresetValues(allowed, presetValue)); - } - }; - - const handleCustomAllowedChange = (newCustom: string[]) => { - onChange("allowedClients", mergePresetAndCustomClients(allowed, newCustom)); - }; - - const handleCustomBlockedChange = (newCustom: string[]) => { - onChange("blockedClients", mergePresetAndCustomClients(blocked, newCustom)); - }; - const validateModelTag = useCallback( (tag: string): boolean => { if (!tag || tag.trim().length === 0) return false; @@ -111,46 +72,6 @@ export function AccessRestrictionsSection({ [allowedModels] ); - const renderPresetRow = (value: string) => { - const isAllowed = isPresetSelected(allowed, value); - const isBlocked = isPresetSelected(blocked, value); - const displayLabel = translations.presetClients[value] ?? value; - - return ( -
- {displayLabel} -
-
- handleAllowToggle(value, checked === true)} - /> - -
-
- handleBlockToggle(value, checked === true)} - /> - -
-
-
- ); - }; - return (
@@ -167,30 +88,23 @@ export function AccessRestrictionsSection({

-
- {CLIENT_RESTRICTION_PRESET_OPTIONS.map((client) => renderPresetRow(client.value))} -
- - {/* Custom allowed patterns */} - - - {/* Custom blocked patterns */} - onChange("allowedClients", next)} + onBlockedChange={(next) => onChange("blockedClients", next)} + translations={{ + allowAction: translations.actions.allow, + blockAction: translations.actions.block, + customAllowedLabel: translations.fields.allowedClients.customLabel, + customAllowedPlaceholder: translations.fields.allowedClients.customPlaceholder, + customBlockedLabel: translations.fields.blockedClients.customLabel, + customBlockedPlaceholder: translations.fields.blockedClients.customPlaceholder, + customHelp: translations.fields.allowedClients.customHelp, + presetClients: translations.presetClients, + subClients: translations.subClients, + nSelected: translations.nSelected, + }} />
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 20f10ba68..f42dc6102 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 @@ -120,6 +120,8 @@ export interface UserEditSectionProps { block: string; }; presetClients: Record; + subClients: Record; + nSelected: string; limitRules: { addRule: string; ruleTypes: Record; @@ -509,6 +511,8 @@ export function UserEditSection({ }, actions: translations.actions, presetClients: translations.presetClients, + subClients: translations.subClients, + nSelected: translations.nSelected, }} /> diff --git a/src/app/[locale]/dashboard/_components/user/forms/user-form.tsx b/src/app/[locale]/dashboard/_components/user/forms/user-form.tsx index d94c426d8..04c394a93 100644 --- a/src/app/[locale]/dashboard/_components/user/forms/user-form.tsx +++ b/src/app/[locale]/dashboard/_components/user/forms/user-form.tsx @@ -400,6 +400,16 @@ export function UserForm({ user, onSuccess, currentUser }: UserFormProps) { "factory-cli": tUserEdit("presetClients.factory-cli"), "codex-cli": tUserEdit("presetClients.codex-cli"), }, + subClients: { + all: tUserEdit("subClients.all"), + cli: tUserEdit("subClients.cli"), + vscode: tUserEdit("subClients.vscode"), + "sdk-ts": tUserEdit("subClients.sdk-ts"), + "sdk-py": tUserEdit("subClients.sdk-py"), + "cli-sdk": tUserEdit("subClients.cli-sdk"), + "gh-action": tUserEdit("subClients.gh-action"), + }, + nSelected: tUserEdit("nSelected", { count: "{count}" }), }} /> 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 ecd630c41..4ccb24550 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 @@ -76,6 +76,8 @@ export interface UserEditTranslations { block: string; }; presetClients: Record; + subClients: Record; + nSelected: string; limitRules: { addRule: string; ruleTypes: { @@ -203,6 +205,16 @@ export function useUserTranslations( "factory-cli": t("userEditSection.presetClients.factory-cli"), "codex-cli": t("userEditSection.presetClients.codex-cli"), }, + subClients: { + all: t("userEditSection.subClients.all"), + cli: t("userEditSection.subClients.cli"), + vscode: t("userEditSection.subClients.vscode"), + "sdk-ts": t("userEditSection.subClients.sdk-ts"), + "sdk-py": t("userEditSection.subClients.sdk-py"), + "cli-sdk": t("userEditSection.subClients.cli-sdk"), + "gh-action": t("userEditSection.subClients.gh-action"), + }, + nSelected: t("userEditSection.nSelected", { count: "{count}" }), limitRules: { addRule: t("limitRules.addRule"), ruleTypes: { diff --git a/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/routing-section.tsx b/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/routing-section.tsx index 7ec6f786b..701daa77b 100644 --- a/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/routing-section.tsx +++ b/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/routing-section.tsx @@ -298,6 +298,18 @@ export function RoutingSection({ subSectionRefs }: RoutingSectionProps) { ), "codex-cli": t("sections.routing.clientRestrictions.presetClients.codex-cli"), }, + subClients: { + all: t("sections.routing.clientRestrictions.subClients.all"), + cli: t("sections.routing.clientRestrictions.subClients.cli"), + vscode: t("sections.routing.clientRestrictions.subClients.vscode"), + "sdk-ts": t("sections.routing.clientRestrictions.subClients.sdk-ts"), + "sdk-py": t("sections.routing.clientRestrictions.subClients.sdk-py"), + "cli-sdk": t("sections.routing.clientRestrictions.subClients.cli-sdk"), + "gh-action": t("sections.routing.clientRestrictions.subClients.gh-action"), + }, + nSelected: t("sections.routing.clientRestrictions.nSelected", { + count: "{count}", + }), }} onInvalidTag={(_tag, reason) => { const messages: Record = { diff --git a/src/app/v1/_lib/proxy/client-detector.ts b/src/app/v1/_lib/proxy/client-detector.ts index d1b5705c8..391dde467 100644 --- a/src/app/v1/_lib/proxy/client-detector.ts +++ b/src/app/v1/_lib/proxy/client-detector.ts @@ -33,6 +33,33 @@ export interface ClientRestrictionResult { const normalize = (s: string) => s.toLowerCase().replace(/[-_]/g, ""); +function globMatch(pattern: string, text: string): boolean { + const lp = pattern.toLowerCase(); + const lt = text.toLowerCase(); + let pi = 0; + let ti = 0; + let starPi = -1; + let starTi = -1; + while (ti < lt.length) { + if (pi < lp.length && lp[pi] === lt[ti]) { + pi++; + ti++; + } else if (pi < lp.length && lp[pi] === "*") { + starPi = pi; + starTi = ti; + pi++; + } else if (starPi >= 0) { + pi = starPi + 1; + starTi++; + ti = starTi; + } else { + return false; + } + } + while (pi < lp.length && lp[pi] === "*") pi++; + return pi === lp.length; +} + const ENTRYPOINT_MAP: Record = { cli: "claude-code-cli", "sdk-cli": "claude-code-cli-sdk", @@ -104,6 +131,10 @@ export function matchClientPattern(session: ProxySession, pattern: string): bool return false; } + if (pattern.includes("*")) { + return globMatch(pattern, ua); + } + const normalizedPattern = normalize(pattern); if (normalizedPattern === "") { return false; @@ -138,9 +169,13 @@ export function detectClientFull(session: ProxySession, pattern: string): Client } else { const ua = session.userAgent?.trim(); if (ua) { - const normalizedPattern = normalize(pattern); - if (normalizedPattern !== "") { - matched = normalize(ua).includes(normalizedPattern); + if (pattern.includes("*")) { + matched = globMatch(pattern, ua); + } else { + const normalizedPattern = normalize(pattern); + if (normalizedPattern !== "") { + matched = normalize(ua).includes(normalizedPattern); + } } } } @@ -192,6 +227,9 @@ export function isClientAllowedDetailed( const matches = (pattern: string): boolean => { if (!isBuiltinKeyword(pattern)) { if (!ua) return false; + if (pattern.includes("*")) { + return globMatch(pattern, ua); + } const normalizedPattern = normalize(pattern); return normalizedPattern !== "" && normalizedUa.includes(normalizedPattern); } diff --git a/src/components/form/client-restrictions-editor.test.tsx b/src/components/form/client-restrictions-editor.test.tsx index 1c38dafa0..9de5191ad 100644 --- a/src/components/form/client-restrictions-editor.test.tsx +++ b/src/components/form/client-restrictions-editor.test.tsx @@ -23,6 +23,16 @@ const TEST_TRANSLATIONS = { "factory-cli": "Droid CLI", "codex-cli": "Codex CLI", }, + subClients: { + all: "All", + cli: "CLI", + vscode: "VS Code", + "sdk-ts": "SDK (TypeScript)", + "sdk-py": "SDK (Python)", + "cli-sdk": "CLI SDK", + "gh-action": "GitHub Action", + }, + nSelected: "{count} selected", }; function render(node: ReactNode) { diff --git a/src/components/form/client-restrictions-editor.tsx b/src/components/form/client-restrictions-editor.tsx index 078f7e7f1..7095b1f6d 100644 --- a/src/components/form/client-restrictions-editor.tsx +++ b/src/components/form/client-restrictions-editor.tsx @@ -1,19 +1,27 @@ "use client"; +import { ChevronDown } from "lucide-react"; import { useMemo } from "react"; import { ArrayTagInputField } from "@/components/form/form-field"; +import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { Label } from "@/components/ui/label"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { CLIENT_RESTRICTION_PRESET_OPTIONS, + getSelectedChildren, + isAllChildrenSelected, isPresetSelected, mergePresetAndCustomClients, removePresetValues, + setChildSelection, splitPresetAndCustomClients, togglePresetSelection, } from "@/lib/client-restrictions/client-presets"; import { cn } from "@/lib/utils"; +const CLIENT_TAG_PATTERN = /^[a-zA-Z0-9_./*-]+$/; + export interface ClientRestrictionsEditorProps { allowed: string[]; blocked: string[]; @@ -31,6 +39,8 @@ export interface ClientRestrictionsEditorProps { customBlockedPlaceholder: string; customHelp: string; presetClients: Record; + subClients: Record; + nSelected: string; }; } @@ -68,6 +78,41 @@ export function ClientRestrictionsEditor({ } }; + const handleChildSelectionChange = (presetValue: string, selectedChildren: string[]) => { + const preset = CLIENT_RESTRICTION_PRESET_OPTIONS.find((p) => p.value === presetValue); + if (!preset) return; + const isInAllowed = isPresetSelected(allowed, presetValue); + const isInBlocked = isPresetSelected(blocked, presetValue); + if (isInAllowed) { + onAllowedChange(setChildSelection(allowed, preset, selectedChildren)); + } else if (isInBlocked) { + onBlockedChange(setChildSelection(blocked, preset, selectedChildren)); + } + }; + + const getChildDisplayText = (preset: (typeof CLIENT_RESTRICTION_PRESET_OPTIONS)[number]) => { + if (!preset.children) return null; + const activeList = isPresetSelected(allowed, preset.value) + ? allowed + : isPresetSelected(blocked, preset.value) + ? blocked + : null; + if (!activeList) return translations.subClients.all; + const selected = getSelectedChildren(activeList, preset); + if (selected.length === 0 || selected.length === preset.children.length) { + return translations.subClients.all; + } + if (selected.length <= 2) { + return selected + .map((v) => { + const child = preset.children!.find((c) => c.value === v); + return child ? translations.subClients[child.labelKey] : v; + }) + .join(", "); + } + return translations.nSelected.replace("{count}", String(selected.length)); + }; + const handleCustomAllowedChange = (newCustom: string[]) => { onAllowedChange(mergePresetAndCustomClients(allowed, newCustom)); }; @@ -76,7 +121,8 @@ export function ClientRestrictionsEditor({ onBlockedChange(mergePresetAndCustomClients(blocked, newCustom)); }; - const renderPresetRow = (value: string) => { + const renderPresetRow = (preset: (typeof CLIENT_RESTRICTION_PRESET_OPTIONS)[number]) => { + const { value } = preset; const isAllowed = isPresetSelected(allowed, value); const isBlocked = isPresetSelected(blocked, value); const displayLabel = translations.presetClients[value] ?? value; @@ -84,6 +130,74 @@ export function ClientRestrictionsEditor({ return (
{displayLabel} + {preset.children && ( + + + + + +
+
+ { + const allChildren = preset.children!.map((c) => c.value); + handleChildSelectionChange(value, checked ? allChildren : []); + }} + disabled={disabled || (!isAllowed && !isBlocked)} + /> + +
+
+ {preset.children.map((child) => { + const activeList = isAllowed ? allowed : isBlocked ? blocked : []; + const isChildChecked = + activeList.includes(preset.value) || activeList.includes(child.value); + return ( +
+ { + const currentSelected = getSelectedChildren(activeList, preset); + const next = checked + ? [...currentSelected, child.value] + : currentSelected.filter((v) => v !== child.value); + handleChildSelectionChange(value, next); + }} + disabled={disabled || (!isAllowed && !isBlocked)} + /> + +
+ ); + })} +
+ + + )}
{/* Preset client checkbox rows */}
- {CLIENT_RESTRICTION_PRESET_OPTIONS.map((client) => renderPresetRow(client.value))} + {CLIENT_RESTRICTION_PRESET_OPTIONS.map((client) => renderPresetRow(client))}
{/* Custom allowed patterns */} @@ -132,6 +246,7 @@ export function ClientRestrictionsEditor({ maxTagLength={64} maxTags={50} placeholder={translations.customAllowedPlaceholder} + validateTag={(tag: string) => CLIENT_TAG_PATTERN.test(tag)} value={customAllowed} onChange={handleCustomAllowedChange} disabled={disabled} @@ -145,6 +260,7 @@ export function ClientRestrictionsEditor({ maxTagLength={64} maxTags={50} placeholder={translations.customBlockedPlaceholder} + validateTag={(tag: string) => CLIENT_TAG_PATTERN.test(tag)} value={customBlocked} onChange={handleCustomBlockedChange} disabled={disabled} diff --git a/src/lib/client-restrictions/client-presets.test.ts b/src/lib/client-restrictions/client-presets.test.ts index 041974ca7..55e3780ad 100644 --- a/src/lib/client-restrictions/client-presets.test.ts +++ b/src/lib/client-restrictions/client-presets.test.ts @@ -1,9 +1,13 @@ import { describe, expect, test } from "vitest"; import { + CLIENT_RESTRICTION_PRESET_OPTIONS, + getSelectedChildren, + isAllChildrenSelected, isPresetClientValue, isPresetSelected, mergePresetAndCustomClients, removePresetValues, + setChildSelection, splitPresetAndCustomClients, togglePresetSelection, } from "./client-presets"; @@ -47,4 +51,70 @@ describe("client restriction presets", () => { mergePresetAndCustomClients(["claude-code-sdk-ts", "codex-cli"], ["my-ide", "codex-cli"]) ).toEqual(["claude-code-sdk-ts", "codex-cli", "my-ide"]); }); + + describe("child selection helpers", () => { + const claudeCodePreset = CLIENT_RESTRICTION_PRESET_OPTIONS[0]; + const geminiPreset = CLIENT_RESTRICTION_PRESET_OPTIONS[1]; + const allChildValues = claudeCodePreset.children!.map((c) => c.value); + + test("getSelectedChildren returns all children when parent value is present", () => { + expect(getSelectedChildren(["claude-code", "gemini-cli"], claudeCodePreset)).toEqual( + allChildValues + ); + }); + + test("getSelectedChildren returns specific children when individual values present", () => { + expect( + getSelectedChildren(["claude-code-cli", "claude-code-vscode"], claudeCodePreset) + ).toEqual(["claude-code-cli", "claude-code-vscode"]); + }); + + test("getSelectedChildren returns empty array for preset without children", () => { + expect(getSelectedChildren(["gemini-cli"], geminiPreset)).toEqual([]); + }); + + test("isAllChildrenSelected returns true when parent value is present", () => { + expect(isAllChildrenSelected(["claude-code"], claudeCodePreset)).toBe(true); + }); + + test("isAllChildrenSelected returns true when all 6 children individually present", () => { + expect(isAllChildrenSelected(allChildValues, claudeCodePreset)).toBe(true); + }); + + test("isAllChildrenSelected returns false for partial selection", () => { + expect( + isAllChildrenSelected(["claude-code-cli", "claude-code-vscode"], claudeCodePreset) + ).toBe(false); + }); + + test("setChildSelection auto-consolidates when all 6 children selected", () => { + expect(setChildSelection(["gemini-cli"], claudeCodePreset, allChildValues)).toEqual([ + "gemini-cli", + "claude-code", + ]); + }); + + test("setChildSelection stores individual values for partial selection", () => { + expect( + setChildSelection(["gemini-cli"], claudeCodePreset, [ + "claude-code-cli", + "claude-code-vscode", + ]) + ).toEqual(["gemini-cli", "claude-code-cli", "claude-code-vscode"]); + }); + + test("setChildSelection removes all preset values when selection is empty", () => { + expect(setChildSelection(["claude-code", "gemini-cli"], claudeCodePreset, [])).toEqual([ + "gemini-cli", + ]); + }); + + test("setChildSelection replaces existing child values with new selection", () => { + expect( + setChildSelection(["claude-code-cli", "gemini-cli"], claudeCodePreset, [ + "claude-code-vscode", + ]) + ).toEqual(["gemini-cli", "claude-code-vscode"]); + }); + }); }); diff --git a/src/lib/client-restrictions/client-presets.ts b/src/lib/client-restrictions/client-presets.ts index 825186b10..3d06d5008 100644 --- a/src/lib/client-restrictions/client-presets.ts +++ b/src/lib/client-restrictions/client-presets.ts @@ -1,6 +1,12 @@ +export interface ClientRestrictionChild { + value: string; + labelKey: string; +} + export interface ClientRestrictionPresetOption { value: string; aliases: readonly string[]; + children?: readonly ClientRestrictionChild[]; } const CLAUDE_CODE_ALIAS_VALUES = [ @@ -14,7 +20,18 @@ const CLAUDE_CODE_ALIAS_VALUES = [ ] as const; export const CLIENT_RESTRICTION_PRESET_OPTIONS: readonly ClientRestrictionPresetOption[] = [ - { value: "claude-code", aliases: CLAUDE_CODE_ALIAS_VALUES }, + { + value: "claude-code", + aliases: CLAUDE_CODE_ALIAS_VALUES, + children: [ + { value: "claude-code-cli", labelKey: "cli" }, + { value: "claude-code-vscode", labelKey: "vscode" }, + { value: "claude-code-sdk-ts", labelKey: "sdk-ts" }, + { value: "claude-code-sdk-py", labelKey: "sdk-py" }, + { value: "claude-code-cli-sdk", labelKey: "cli-sdk" }, + { value: "claude-code-gh-action", labelKey: "gh-action" }, + ], + }, { value: "gemini-cli", aliases: ["gemini-cli"] }, { value: "factory-cli", aliases: ["factory-cli"] }, { value: "codex-cli", aliases: ["codex-cli"] }, @@ -87,3 +104,37 @@ export function mergePresetAndCustomClients(values: string[], customValues: stri const filteredCustomValues = customValues.filter((value) => !PRESET_ALIAS_SET.has(value)); return uniqueOrdered([...presetValues, ...filteredCustomValues]); } + +export function getSelectedChildren( + values: string[], + preset: ClientRestrictionPresetOption +): string[] { + if (!preset.children) return []; + const childValues = preset.children.map((c) => c.value); + if (values.includes(preset.value)) return childValues; + return childValues.filter((v) => values.includes(v)); +} + +export function isAllChildrenSelected( + values: string[], + preset: ClientRestrictionPresetOption +): boolean { + if (!preset.children) return false; + if (values.includes(preset.value)) return true; + return preset.children.every((c) => values.includes(c.value)); +} + +export function setChildSelection( + values: string[], + preset: ClientRestrictionPresetOption, + selectedChildren: string[] +): string[] { + if (!preset.children) return values; + const allChildValues = new Set(preset.children.map((c) => c.value)); + const filtered = values.filter((v) => v !== preset.value && !allChildValues.has(v)); + if (selectedChildren.length === 0) return filtered; + if (selectedChildren.length === preset.children.length) { + return [...filtered, preset.value]; + } + return [...filtered, ...selectedChildren]; +} diff --git a/tests/unit/proxy/client-detector.test.ts b/tests/unit/proxy/client-detector.test.ts index 131f65f74..b5327a85e 100644 --- a/tests/unit/proxy/client-detector.test.ts +++ b/tests/unit/proxy/client-detector.test.ts @@ -297,6 +297,82 @@ describe("client-detector", () => { }); }); + describe("matchClientPattern glob wildcard path", () => { + test("should match codex-* against codex-cli/2.0", () => { + const session = createMockSession({ userAgent: "codex-cli/2.0" }); + expect(matchClientPattern(session, "codex-*")).toBe(true); + }); + + test("should not match codex-* against GeminiCLI/1.0", () => { + const session = createMockSession({ userAgent: "GeminiCLI/1.0" }); + expect(matchClientPattern(session, "codex-*")).toBe(false); + }); + + test("should match *-cli* against codex-cli/2.0", () => { + const session = createMockSession({ userAgent: "codex-cli/2.0" }); + expect(matchClientPattern(session, "*-cli*")).toBe(true); + }); + + test("should match bare * against any non-empty UA", () => { + const session = createMockSession({ userAgent: "AnyClient/1.0" }); + expect(matchClientPattern(session, "*")).toBe(true); + }); + + test("should match My*App against MyCustomApp/1.0", () => { + const session = createMockSession({ userAgent: "MyCustomApp/1.0" }); + expect(matchClientPattern(session, "My*App*")).toBe(true); + }); + + test("should be case-insensitive for glob", () => { + const session = createMockSession({ userAgent: "codex-cli/1.0" }); + expect(matchClientPattern(session, "CODEX-*")).toBe(true); + }); + + test("should return false for glob when UA is empty", () => { + const session = createMockSession({ userAgent: " " }); + expect(matchClientPattern(session, "codex-*")).toBe(false); + }); + + test("should NOT normalize hyphens/underscores in glob mode", () => { + const session = createMockSession({ userAgent: "codex_cli/2.0" }); + expect(matchClientPattern(session, "codex-*")).toBe(false); + }); + + test("should match glob with underscores literally", () => { + const session = createMockSession({ userAgent: "codex_cli/2.0" }); + expect(matchClientPattern(session, "codex_*")).toBe(true); + }); + + test("consecutive wildcards ** should behave like single *", () => { + const session = createMockSession({ userAgent: "codex-cli/2.0" }); + expect(matchClientPattern(session, "codex-**")).toBe(true); + expect(matchClientPattern(session, "**codex**")).toBe(true); + }); + + test("glob should handle regex metacharacters literally", () => { + const session = createMockSession({ userAgent: "foo.bar/1.0" }); + expect(matchClientPattern(session, "foo.bar*")).toBe(true); + expect(matchClientPattern(session, "foo*bar*")).toBe(true); + const session2 = createMockSession({ userAgent: "fooXbar/1.0" }); + expect(matchClientPattern(session2, "foo.bar*")).toBe(false); + }); + + test("glob should handle brackets and parens literally", () => { + const session = createMockSession({ userAgent: "tool[v2]/1.0" }); + expect(matchClientPattern(session, "tool[v2]*")).toBe(true); + }); + + test("pathological glob pattern completes quickly without ReDoS", () => { + const session = createMockSession({ userAgent: `${"a".repeat(32)}b` }); + const pattern = "*a*a*a*a*a*a*a*a*c"; + const start = performance.now(); + const result = matchClientPattern(session, pattern); + const elapsed = performance.now() - start; + expect(result).toBe(false); + expect(elapsed).toBeLessThan(50); + }); + }); + describe("isClientAllowed", () => { test("should reject when blocked matches even if allowed also matches", () => { const session = createConfirmedClaudeCodeSession("claude-cli/1.2.3 (external, cli)"); @@ -451,6 +527,30 @@ describe("client-detector", () => { expect(result.signals).toBeUndefined(); expect(result.hubConfirmed).toBeUndefined(); }); + + test("should allow when glob pattern in allowlist matches", () => { + const session = createMockSession({ userAgent: "codex-cli/2.0" }); + const result = isClientAllowedDetailed(session, ["codex-*"], []); + expect(result.allowed).toBe(true); + expect(result.matchType).toBe("allowed"); + expect(result.matchedPattern).toBe("codex-*"); + }); + + test("should reject when glob pattern in blocklist matches", () => { + const session = createMockSession({ userAgent: "codex-cli/2.0" }); + const result = isClientAllowedDetailed(session, [], ["codex-*"]); + expect(result.allowed).toBe(false); + expect(result.matchType).toBe("blocklist_hit"); + expect(result.matchedPattern).toBe("codex-*"); + }); + + test("should work with mix of glob and substring patterns", () => { + const session = createMockSession({ userAgent: "my-custom-tool/3.0" }); + const result = isClientAllowedDetailed(session, ["codex-*", "custom"], []); + expect(result.allowed).toBe(true); + expect(result.matchType).toBe("allowed"); + expect(result.matchedPattern).toBe("custom"); + }); }); describe("detectClientFull", () => { From c45ef8ae540dfccac14d19fe33aa5d18681433d7 Mon Sep 17 00:00:00 2001 From: ding113 Date: Tue, 10 Mar 2026 14:39:07 +0800 Subject: [PATCH 29/42] perf(users): defer usage hydration after list loads Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- src/lib/dashboard/user-usage-loader.test.ts | 118 ++++++++++++++++++++ src/lib/dashboard/user-usage-loader.ts | 64 +++++++++++ 2 files changed, 182 insertions(+) create mode 100644 src/lib/dashboard/user-usage-loader.test.ts create mode 100644 src/lib/dashboard/user-usage-loader.ts diff --git a/src/lib/dashboard/user-usage-loader.test.ts b/src/lib/dashboard/user-usage-loader.test.ts new file mode 100644 index 000000000..f3ba82312 --- /dev/null +++ b/src/lib/dashboard/user-usage-loader.test.ts @@ -0,0 +1,118 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; +import { loadUserUsagePagesSequentially, USER_USAGE_IDLE_DELAY_MS } from "./user-usage-loader"; + +interface KeyUsageData { + todayUsage: number; + todayCallCount: number; + todayTokens: number; + lastUsedAt: Date | null; + lastProviderName: string | null; + modelStats: Array<{ + model: string; + callCount: number; + totalCost: number; + inputTokens: number; + outputTokens: number; + cacheCreationTokens: number; + cacheReadTokens: number; + }>; +} + +function createUsage(keyId: number): Record { + return { + [keyId]: { + todayUsage: keyId, + todayCallCount: keyId, + todayTokens: keyId * 100, + lastUsedAt: null, + lastProviderName: null, + modelStats: [], + }, + }; +} + +async function flushMicrotasks() { + await Promise.resolve(); +} + +describe("loadUserUsagePagesSequentially", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + test("waits for the idle window and fetches pages one by one", async () => { + vi.useFakeTimers(); + + const resolvers: Array<() => void> = []; + const fetchUsagePage = vi.fn( + (userIds: number[]) => + new Promise>((resolve) => { + resolvers.push(() => resolve(createUsage(userIds[0]))); + }) + ); + const onPageLoaded = vi.fn(); + const controller = new AbortController(); + + const task = loadUserUsagePagesSequentially({ + pageUserIds: [[11], [22]], + signal: controller.signal, + fetchUsagePage, + onPageLoaded, + }); + + await vi.advanceTimersByTimeAsync(USER_USAGE_IDLE_DELAY_MS - 1); + expect(fetchUsagePage).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(1); + expect(fetchUsagePage).toHaveBeenCalledTimes(1); + expect(fetchUsagePage).toHaveBeenNthCalledWith(1, [11]); + + resolvers[0]?.(); + await flushMicrotasks(); + expect(onPageLoaded).toHaveBeenCalledWith(createUsage(11)); + expect(fetchUsagePage).toHaveBeenCalledTimes(2); + expect(fetchUsagePage).toHaveBeenNthCalledWith(2, [22]); + + resolvers[1]?.(); + await flushMicrotasks(); + await task; + + expect(onPageLoaded).toHaveBeenNthCalledWith(2, createUsage(22)); + }); + + test("stops before the next page when aborted during an in-flight request", async () => { + vi.useFakeTimers(); + + let resolveFirstPage: ((value: Record) => void) | undefined; + const fetchUsagePage = vi.fn( + (userIds: number[]) => + new Promise>((resolve) => { + if (userIds[0] === 11) { + resolveFirstPage = resolve; + return; + } + resolve(createUsage(userIds[0])); + }) + ); + const onPageLoaded = vi.fn(); + const controller = new AbortController(); + + const task = loadUserUsagePagesSequentially({ + pageUserIds: [[11], [22]], + signal: controller.signal, + fetchUsagePage, + onPageLoaded, + }); + + await vi.advanceTimersByTimeAsync(USER_USAGE_IDLE_DELAY_MS); + expect(fetchUsagePage).toHaveBeenCalledTimes(1); + + controller.abort(); + resolveFirstPage?.(createUsage(11)); + await flushMicrotasks(); + await task; + + expect(onPageLoaded).not.toHaveBeenCalled(); + expect(fetchUsagePage).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/lib/dashboard/user-usage-loader.ts b/src/lib/dashboard/user-usage-loader.ts new file mode 100644 index 000000000..643eac15e --- /dev/null +++ b/src/lib/dashboard/user-usage-loader.ts @@ -0,0 +1,64 @@ +import type { KeyUsageData } from "@/actions/users"; + +export const USER_USAGE_IDLE_DELAY_MS = 250; + +interface LoadUserUsagePagesSequentiallyParams { + pageUserIds: number[][]; + signal: AbortSignal; + idleDelayMs?: number; + fetchUsagePage: (userIds: number[]) => Promise>; + onPageLoaded: (usageByKeyId: Record) => void; +} + +function waitForIdleWindow(delayMs: number, signal: AbortSignal): Promise { + return new Promise((resolve) => { + if (signal.aborted) { + resolve(false); + return; + } + + const handleAbort = () => { + clearTimeout(timeoutId); + signal.removeEventListener("abort", handleAbort); + resolve(false); + }; + + const timeoutId = setTimeout(() => { + signal.removeEventListener("abort", handleAbort); + resolve(true); + }, delayMs); + + signal.addEventListener("abort", handleAbort, { once: true }); + }); +} + +export async function loadUserUsagePagesSequentially({ + pageUserIds, + signal, + idleDelayMs = USER_USAGE_IDLE_DELAY_MS, + fetchUsagePage, + onPageLoaded, +}: LoadUserUsagePagesSequentiallyParams): Promise { + const canStart = await waitForIdleWindow(idleDelayMs, signal); + if (!canStart) { + return; + } + + for (const userIds of pageUserIds) { + if (signal.aborted || userIds.length === 0) { + if (signal.aborted) { + return; + } + continue; + } + + const usageByKeyId = await fetchUsagePage(userIds); + if (signal.aborted) { + return; + } + + if (Object.keys(usageByKeyId).length > 0) { + onPageLoaded(usageByKeyId); + } + } +} From eb06cd3c299099ab2744553b7a05ae865b0eb6a3 Mon Sep 17 00:00:00 2001 From: ding113 Date: Tue, 10 Mar 2026 14:39:18 +0800 Subject: [PATCH 30/42] perf(users): dedupe per-user limit usage requests Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- .../dashboard/user-limit-usage-cache.test.ts | 60 +++++++++++++++++ src/lib/dashboard/user-limit-usage-cache.ts | 66 +++++++++++++++++++ 2 files changed, 126 insertions(+) create mode 100644 src/lib/dashboard/user-limit-usage-cache.test.ts create mode 100644 src/lib/dashboard/user-limit-usage-cache.ts diff --git a/src/lib/dashboard/user-limit-usage-cache.test.ts b/src/lib/dashboard/user-limit-usage-cache.test.ts new file mode 100644 index 000000000..92f720d44 --- /dev/null +++ b/src/lib/dashboard/user-limit-usage-cache.test.ts @@ -0,0 +1,60 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { + clearUsageCache, + getSharedUserLimitUsage, + peekCachedUserLimitUsage, +} from "./user-limit-usage-cache"; + +const { getUserAllLimitUsageMock } = vi.hoisted(() => ({ + getUserAllLimitUsageMock: vi.fn(), +})); + +vi.mock("@/actions/users", () => ({ + getUserAllLimitUsage: getUserAllLimitUsageMock, +})); + +const usagePayload = { + limit5h: { usage: 1, limit: 10 }, + limitDaily: { usage: 2, limit: 20 }, + limitWeekly: { usage: 3, limit: 30 }, + limitMonthly: { usage: 4, limit: 40 }, + limitTotal: { usage: 5, limit: 50 }, +}; + +describe("user-limit-usage-cache", () => { + beforeEach(() => { + clearUsageCache(); + getUserAllLimitUsageMock.mockReset(); + }); + + test("deduplicates concurrent requests for the same user", async () => { + let resolveRequest: ((value: { ok: true; data: typeof usagePayload }) => void) | undefined; + + getUserAllLimitUsageMock.mockImplementation( + () => + new Promise<{ ok: true; data: typeof usagePayload }>((resolve) => { + resolveRequest = resolve; + }) + ); + + const first = getSharedUserLimitUsage(7); + const second = getSharedUserLimitUsage(7); + + expect(getUserAllLimitUsageMock).toHaveBeenCalledTimes(1); + + resolveRequest?.({ ok: true, data: usagePayload }); + + await expect(first).resolves.toEqual(usagePayload); + await expect(second).resolves.toEqual(usagePayload); + expect(peekCachedUserLimitUsage(7)).toEqual(usagePayload); + }); + + test("returns fresh cached data without hitting the action again", async () => { + getUserAllLimitUsageMock.mockResolvedValue({ ok: true, data: usagePayload }); + + await expect(getSharedUserLimitUsage(9)).resolves.toEqual(usagePayload); + await expect(getSharedUserLimitUsage(9)).resolves.toEqual(usagePayload); + + expect(getUserAllLimitUsageMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/lib/dashboard/user-limit-usage-cache.ts b/src/lib/dashboard/user-limit-usage-cache.ts new file mode 100644 index 000000000..cfee5bac6 --- /dev/null +++ b/src/lib/dashboard/user-limit-usage-cache.ts @@ -0,0 +1,66 @@ +import { getUserAllLimitUsage } from "@/actions/users"; + +export interface LimitUsageData { + limit5h: { usage: number; limit: number | null }; + limitDaily: { usage: number; limit: number | null }; + limitWeekly: { usage: number; limit: number | null }; + limitMonthly: { usage: number; limit: number | null }; + limitTotal: { usage: number; limit: number | null }; +} + +export const LIMIT_USAGE_CACHE_TTL = 60 * 1000; + +const usageCache = new Map(); +const inFlightUsageRequests = new Map>(); + +function isFresh(cached: { data: LimitUsageData; timestamp: number } | undefined): cached is { + data: LimitUsageData; + timestamp: number; +} { + return Boolean(cached && Date.now() - cached.timestamp < LIMIT_USAGE_CACHE_TTL); +} + +export function clearUsageCache(userId?: number): void { + if (userId !== undefined) { + usageCache.delete(userId); + inFlightUsageRequests.delete(userId); + return; + } + + usageCache.clear(); + inFlightUsageRequests.clear(); +} + +export function peekCachedUserLimitUsage(userId: number): LimitUsageData | null { + const cached = usageCache.get(userId); + return isFresh(cached) ? cached.data : null; +} + +export async function getSharedUserLimitUsage(userId: number): Promise { + const cached = peekCachedUserLimitUsage(userId); + if (cached) { + return cached; + } + + const inFlight = inFlightUsageRequests.get(userId); + if (inFlight) { + return inFlight; + } + + const request = getUserAllLimitUsage(userId) + .then((result) => { + if (!result.ok || !result.data) { + return null; + } + + usageCache.set(userId, { data: result.data, timestamp: Date.now() }); + return result.data; + }) + .catch(() => null) + .finally(() => { + inFlightUsageRequests.delete(userId); + }); + + inFlightUsageRequests.set(userId, request); + return request; +} From 28bb9d68e1446b9bea37c47c25b5b62037a86f5e Mon Sep 17 00:00:00 2001 From: ding113 Date: Tue, 10 Mar 2026 14:39:30 +0800 Subject: [PATCH 31/42] fix(users): reduce dashboard user query contention Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- .../_components/user/user-limit-badge.tsx | 59 +++++++-------- .../dashboard/users/users-page-client.tsx | 75 ++++++++++++------- 2 files changed, 74 insertions(+), 60 deletions(-) diff --git a/src/app/[locale]/dashboard/_components/user/user-limit-badge.tsx b/src/app/[locale]/dashboard/_components/user/user-limit-badge.tsx index a70d1836c..c803da6a9 100644 --- a/src/app/[locale]/dashboard/_components/user/user-limit-badge.tsx +++ b/src/app/[locale]/dashboard/_components/user/user-limit-badge.tsx @@ -1,9 +1,13 @@ "use client"; import { useEffect, useState } from "react"; -import { getUserAllLimitUsage } from "@/actions/users"; import { Badge } from "@/components/ui/badge"; import { Skeleton } from "@/components/ui/skeleton"; +import { + getSharedUserLimitUsage, + type LimitUsageData, + peekCachedUserLimitUsage, +} from "@/lib/dashboard/user-limit-usage-cache"; import { cn } from "@/lib/utils"; export type LimitType = "5h" | "daily" | "weekly" | "monthly" | "total"; @@ -16,26 +20,6 @@ export interface UserLimitBadgeProps { unit?: string; } -interface LimitUsageData { - limit5h: { usage: number; limit: number | null }; - limitDaily: { usage: number; limit: number | null }; - limitWeekly: { usage: number; limit: number | null }; - limitMonthly: { usage: number; limit: number | null }; - limitTotal: { usage: number; limit: number | null }; -} - -// Global cache for user limit usage data -const usageCache = new Map(); -const CACHE_TTL = 60 * 1000; // 1 minute - -export function clearUsageCache(userId?: number): void { - if (userId !== undefined) { - usageCache.delete(userId); - } else { - usageCache.clear(); - } -} - function formatPercentage(usage: number, limit: number): string { const percentage = Math.min(Math.round((usage / limit) * 100), 999); return `${percentage}%`; @@ -77,39 +61,52 @@ export function UserLimitBadge({ const [error, setError] = useState(false); useEffect(() => { + let isCancelled = false; + // If no limit is set, don't fetch usage data if (limit === null || limit === undefined) { return; } // Check cache first - const cached = usageCache.get(userId); - if (cached && Date.now() - cached.timestamp < CACHE_TTL) { + const cached = peekCachedUserLimitUsage(userId); + if (cached) { // Reset error/loading state when using cached data setError(false); setIsLoading(false); - setUsageData((prev) => (prev === cached.data ? prev : cached.data)); + setUsageData((prev) => (prev === cached ? prev : cached)); return; } setIsLoading(true); setError(false); - getUserAllLimitUsage(userId) - .then((res) => { - if (res.ok && res.data) { - usageCache.set(userId, { data: res.data, timestamp: Date.now() }); - setUsageData(res.data); + getSharedUserLimitUsage(userId) + .then((data) => { + if (isCancelled) { + return; + } + + if (data) { + setUsageData(data); } else { setError(true); } }) .catch(() => { - setError(true); + if (!isCancelled) { + setError(true); + } }) .finally(() => { - setIsLoading(false); + if (!isCancelled) { + setIsLoading(false); + } }); + + return () => { + isCancelled = true; + }; }, [userId, limit]); // No limit set - show "-" diff --git a/src/app/[locale]/dashboard/users/users-page-client.tsx b/src/app/[locale]/dashboard/users/users-page-client.tsx index e2bfc0a11..24ee3fa68 100644 --- a/src/app/[locale]/dashboard/users/users-page-client.tsx +++ b/src/app/[locale]/dashboard/users/users-page-client.tsx @@ -1,9 +1,9 @@ "use client"; -import { useInfiniteQuery, useQueries, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query"; import { Layers, Loader2, Plus, Search, ShieldCheck } from "lucide-react"; import { useTranslations } from "next-intl"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { KeyUsageData } from "@/actions/users"; import { getAllUserKeyGroups, @@ -23,13 +23,14 @@ import { } from "@/components/ui/select"; import { Skeleton } from "@/components/ui/skeleton"; import { TagInput } from "@/components/ui/tag-input"; +import { clearUsageCache } from "@/lib/dashboard/user-limit-usage-cache"; +import { loadUserUsagePagesSequentially } from "@/lib/dashboard/user-usage-loader"; import { useDebounce } from "@/lib/hooks/use-debounce"; import type { CurrencyCode } from "@/lib/utils/currency"; import type { User, UserDisplay } from "@/types/user"; import { AddKeyDialog } from "../_components/user/add-key-dialog"; import { BatchEditDialog } from "../_components/user/batch-edit/batch-edit-dialog"; import { CreateUserDialog } from "../_components/user/create-user-dialog"; -import { clearUsageCache } from "../_components/user/user-limit-badge"; import { UserManagementTable } from "../_components/user/user-management-table"; /** @@ -81,6 +82,7 @@ function UsersPageContent({ currentUser }: UsersPageClientProps) { | "createdAt" >("createdAt"); const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc"); + const [usageByKeyId, setUsageByKeyId] = useState>({}); // Debounce search term to avoid frequent API requests const debouncedSearchTerm = useDebounce(searchTerm, 300); @@ -195,39 +197,53 @@ function UsersPageContent({ currentUser }: UsersPageClientProps) { staleTime: 30_000, }); - // Per-page usage queries: fire independently for each loaded page const pageUserIds = useMemo( () => (data?.pages ?? []).map((page) => page.users.map((u) => u.id)), [data] ); + const usageDatasetKey = useMemo(() => JSON.stringify(queryKey), [queryKey]); + const latestUsageDatasetKeyRef = useRef(usageDatasetKey); - const usageQueries = useQueries({ - queries: isAdmin - ? pageUserIds.map((ids) => ({ - queryKey: ["users-usage", ids], - queryFn: () => getUsersUsageBatch(ids), - enabled: ids.length > 0, - staleTime: 60_000, - refetchOnWindowFocus: false, - })) - : [], - }); + useEffect(() => { + latestUsageDatasetKeyRef.current = usageDatasetKey; + setUsageByKeyId({}); + }, [usageDatasetKey]); - // Stable fingerprint: only changes when a query actually receives new data, - // not on every render tick (useQueries returns a new array reference each time). - const usageDataVersion = usageQueries.map((q) => q.dataUpdatedAt).join(","); - - // Build merged usageByKeyId lookup from all resolved usage queries. - // biome-ignore lint/correctness/useExhaustiveDependencies: usageDataVersion tracks actual data changes; usageQueries ref is unstable - const usageByKeyId = useMemo(() => { - const merged: Record = {}; - for (const query of usageQueries) { - if (query.data?.ok) { - Object.assign(merged, query.data.data.usageByKeyId); - } + useEffect(() => { + if (!isAdmin || isLoading || isFetching || pageUserIds.length === 0) { + return; } - return merged; - }, [usageDataVersion]); + + const abortController = new AbortController(); + const requestedDatasetKey = usageDatasetKey; + + void loadUserUsagePagesSequentially({ + pageUserIds, + signal: abortController.signal, + fetchUsagePage: async (userIds) => { + const result = await queryClient.fetchQuery({ + queryKey: ["users-usage", userIds], + queryFn: () => getUsersUsageBatch(userIds), + staleTime: 60_000, + }); + return result.ok ? result.data.usageByKeyId : {}; + }, + onPageLoaded: (pageUsageByKeyId) => { + if (latestUsageDatasetKeyRef.current !== requestedDatasetKey) { + return; + } + + setUsageByKeyId((previous) => ({ + ...previous, + ...pageUsageByKeyId, + })); + }, + }); + + return () => { + abortController.abort(); + }; + }, [isAdmin, isFetching, isLoading, pageUserIds, queryClient, usageDatasetKey]); const coreUsers = useMemo(() => data?.pages.flatMap((page) => page.users) ?? [], [data]); @@ -771,6 +787,7 @@ function UsersPageContent({ currentUser }: UsersPageClientProps) { onOpenBatchEdit={handleOpenBatchEdit} translations={tableTranslations} onRefresh={() => { + setUsageByKeyId({}); clearUsageCache(); queryClient.invalidateQueries({ queryKey: ["users-usage"] }); refetch(); From c04d50dcaf72b2fc746ecb7ba53a50a48b59c8fc Mon Sep 17 00:00:00 2001 From: ding113 Date: Tue, 10 Mar 2026 15:03:46 +0800 Subject: [PATCH 32/42] feat(pricing): add long-context priority price fields Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- src/lib/utils/price-data.ts | 6 ++++++ src/lib/utils/pricing-resolution.ts | 6 ++++++ src/types/model-price.ts | 6 ++++++ 3 files changed, 18 insertions(+) diff --git a/src/lib/utils/price-data.ts b/src/lib/utils/price-data.ts index 01a903820..8ac3c0f11 100644 --- a/src/lib/utils/price-data.ts +++ b/src/lib/utils/price-data.ts @@ -17,11 +17,17 @@ function collectNumericCosts(priceData: ModelPriceData): unknown[] { priceData.cache_creation_input_token_cost_above_200k_tokens, priceData.cache_read_input_token_cost_above_200k_tokens, priceData.cache_creation_input_token_cost_above_1hr_above_200k_tokens, + priceData.input_cost_per_token_above_200k_tokens_priority, + priceData.output_cost_per_token_above_200k_tokens_priority, + priceData.cache_read_input_token_cost_above_200k_tokens_priority, priceData.input_cost_per_token_above_272k_tokens, priceData.output_cost_per_token_above_272k_tokens, priceData.cache_creation_input_token_cost_above_272k_tokens, priceData.cache_read_input_token_cost_above_272k_tokens, priceData.cache_creation_input_token_cost_above_1hr_above_272k_tokens, + priceData.input_cost_per_token_above_272k_tokens_priority, + priceData.output_cost_per_token_above_272k_tokens_priority, + priceData.cache_read_input_token_cost_above_272k_tokens_priority, priceData.input_cost_per_token_priority, priceData.output_cost_per_token_priority, priceData.cache_read_input_token_cost_priority, diff --git a/src/lib/utils/pricing-resolution.ts b/src/lib/utils/pricing-resolution.ts index 6b440f9e9..c38fd9784 100644 --- a/src/lib/utils/pricing-resolution.ts +++ b/src/lib/utils/pricing-resolution.ts @@ -49,11 +49,17 @@ const DETAIL_FIELDS = [ "cache_creation_input_token_cost_above_200k_tokens", "cache_read_input_token_cost_above_200k_tokens", "cache_creation_input_token_cost_above_1hr_above_200k_tokens", + "input_cost_per_token_above_200k_tokens_priority", + "output_cost_per_token_above_200k_tokens_priority", + "cache_read_input_token_cost_above_200k_tokens_priority", "input_cost_per_token_above_272k_tokens", "output_cost_per_token_above_272k_tokens", "cache_creation_input_token_cost_above_272k_tokens", "cache_read_input_token_cost_above_272k_tokens", "cache_creation_input_token_cost_above_1hr_above_272k_tokens", + "input_cost_per_token_above_272k_tokens_priority", + "output_cost_per_token_above_272k_tokens_priority", + "cache_read_input_token_cost_above_272k_tokens_priority", "input_cost_per_token_priority", "output_cost_per_token_priority", "cache_read_input_token_cost_priority", diff --git a/src/types/model-price.ts b/src/types/model-price.ts index 59da132ba..9e1b86d2d 100644 --- a/src/types/model-price.ts +++ b/src/types/model-price.ts @@ -18,6 +18,9 @@ export interface ModelPriceData { cache_creation_input_token_cost_above_200k_tokens?: number; cache_read_input_token_cost_above_200k_tokens?: number; cache_creation_input_token_cost_above_1hr_above_200k_tokens?: number; + input_cost_per_token_above_200k_tokens_priority?: number; + output_cost_per_token_above_200k_tokens_priority?: number; + cache_read_input_token_cost_above_200k_tokens_priority?: number; // 272K 分层价格(GPT-5.4 等模型保留扩展) input_cost_per_token_above_272k_tokens?: number; @@ -25,6 +28,9 @@ export interface ModelPriceData { cache_creation_input_token_cost_above_272k_tokens?: number; cache_read_input_token_cost_above_272k_tokens?: number; cache_creation_input_token_cost_above_1hr_above_272k_tokens?: number; + input_cost_per_token_above_272k_tokens_priority?: number; + output_cost_per_token_above_272k_tokens_priority?: number; + cache_read_input_token_cost_above_272k_tokens_priority?: number; // 优先服务等级价格(例如 OpenAI priority tier) input_cost_per_token_priority?: number; From 61a541801d74b8e98964c24ed291332dcb6e3386 Mon Sep 17 00:00:00 2001 From: ding113 Date: Tue, 10 Mar 2026 15:04:01 +0800 Subject: [PATCH 33/42] fix(billing): apply priority long-context token pricing Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- src/lib/utils/cost-calculation.ts | 75 ++++++++++++++----- .../lib/cost-calculation-breakdown.test.ts | 60 +++++++++++++++ .../lib/cost-calculation-priority.test.ts | 72 ++++++++++++++++++ 3 files changed, 189 insertions(+), 18 deletions(-) diff --git a/src/lib/utils/cost-calculation.ts b/src/lib/utils/cost-calculation.ts index 6ffa14bac..f852cb305 100644 --- a/src/lib/utils/cost-calculation.ts +++ b/src/lib/utils/cost-calculation.ts @@ -91,9 +91,12 @@ function __calculateTieredCostWithSeparatePrices( function resolveLongContextThreshold(priceData: ModelPriceData): number { const has272kFields = typeof priceData.input_cost_per_token_above_272k_tokens === "number" || + typeof priceData.input_cost_per_token_above_272k_tokens_priority === "number" || typeof priceData.output_cost_per_token_above_272k_tokens === "number" || + typeof priceData.output_cost_per_token_above_272k_tokens_priority === "number" || typeof priceData.cache_creation_input_token_cost_above_272k_tokens === "number" || typeof priceData.cache_read_input_token_cost_above_272k_tokens === "number" || + typeof priceData.cache_read_input_token_cost_above_272k_tokens_priority === "number" || typeof priceData.cache_creation_input_token_cost_above_1hr_above_272k_tokens === "number"; const modelFamily = typeof priceData.model_family === "string" ? priceData.model_family : ""; @@ -104,6 +107,24 @@ function resolveLongContextThreshold(priceData: ModelPriceData): number { return CONTEXT_1M_TOKEN_THRESHOLD; } +function resolvePriorityAwareLongContextRate( + priorityServiceTierApplied: boolean, + fields: { + above272k?: number; + above272kPriority?: number; + above200k?: number; + above200kPriority?: number; + } +): number | undefined { + if (priorityServiceTierApplied) { + return ( + fields.above272kPriority ?? fields.above200kPriority ?? fields.above272k ?? fields.above200k + ); + } + + return fields.above272k ?? fields.above200k; +} + function getRequestInputContextTokens( usage: UsageMetrics, cache5mTokens?: number, @@ -207,12 +228,18 @@ export function calculateRequestCostBreakdown( } } - const inputAboveThreshold = - priceData.input_cost_per_token_above_272k_tokens ?? - priceData.input_cost_per_token_above_200k_tokens; - const outputAboveThreshold = - priceData.output_cost_per_token_above_272k_tokens ?? - priceData.output_cost_per_token_above_200k_tokens; + const inputAboveThreshold = resolvePriorityAwareLongContextRate(priorityServiceTierApplied, { + above272k: priceData.input_cost_per_token_above_272k_tokens, + above272kPriority: priceData.input_cost_per_token_above_272k_tokens_priority, + above200k: priceData.input_cost_per_token_above_200k_tokens, + above200kPriority: priceData.input_cost_per_token_above_200k_tokens_priority, + }); + const outputAboveThreshold = resolvePriorityAwareLongContextRate(priorityServiceTierApplied, { + above272k: priceData.output_cost_per_token_above_272k_tokens, + above272kPriority: priceData.output_cost_per_token_above_272k_tokens_priority, + above200k: priceData.output_cost_per_token_above_200k_tokens, + above200kPriority: priceData.output_cost_per_token_above_200k_tokens_priority, + }); const cacheCreationAboveThreshold = priceData.cache_creation_input_token_cost_above_272k_tokens ?? priceData.cache_creation_input_token_cost_above_200k_tokens; @@ -220,9 +247,12 @@ export function calculateRequestCostBreakdown( priceData.cache_creation_input_token_cost_above_1hr_above_272k_tokens ?? priceData.cache_creation_input_token_cost_above_1hr_above_200k_tokens ?? cacheCreationAboveThreshold; - const cacheReadAboveThreshold = - priceData.cache_read_input_token_cost_above_272k_tokens ?? - priceData.cache_read_input_token_cost_above_200k_tokens; + const cacheReadAboveThreshold = resolvePriorityAwareLongContextRate(priorityServiceTierApplied, { + above272k: priceData.cache_read_input_token_cost_above_272k_tokens, + above272kPriority: priceData.cache_read_input_token_cost_above_272k_tokens_priority, + above200k: priceData.cache_read_input_token_cost_above_200k_tokens, + above200kPriority: priceData.cache_read_input_token_cost_above_200k_tokens_priority, + }); const longContextThreshold = resolveLongContextThreshold(priceData); const longContextThresholdExceeded = getRequestInputContextTokens(usage, cache5mTokens, cache1hTokens) > longContextThreshold; @@ -434,12 +464,18 @@ export function calculateRequestCost( } } - const inputAboveThreshold = - priceData.input_cost_per_token_above_272k_tokens ?? - priceData.input_cost_per_token_above_200k_tokens; - const outputAboveThreshold = - priceData.output_cost_per_token_above_272k_tokens ?? - priceData.output_cost_per_token_above_200k_tokens; + const inputAboveThreshold = resolvePriorityAwareLongContextRate(priorityServiceTierApplied, { + above272k: priceData.input_cost_per_token_above_272k_tokens, + above272kPriority: priceData.input_cost_per_token_above_272k_tokens_priority, + above200k: priceData.input_cost_per_token_above_200k_tokens, + above200kPriority: priceData.input_cost_per_token_above_200k_tokens_priority, + }); + const outputAboveThreshold = resolvePriorityAwareLongContextRate(priorityServiceTierApplied, { + above272k: priceData.output_cost_per_token_above_272k_tokens, + above272kPriority: priceData.output_cost_per_token_above_272k_tokens_priority, + above200k: priceData.output_cost_per_token_above_200k_tokens, + above200kPriority: priceData.output_cost_per_token_above_200k_tokens_priority, + }); const cacheCreationAboveThreshold = priceData.cache_creation_input_token_cost_above_272k_tokens ?? priceData.cache_creation_input_token_cost_above_200k_tokens; @@ -447,9 +483,12 @@ export function calculateRequestCost( priceData.cache_creation_input_token_cost_above_1hr_above_272k_tokens ?? priceData.cache_creation_input_token_cost_above_1hr_above_200k_tokens ?? cacheCreationAboveThreshold; - const cacheReadAboveThreshold = - priceData.cache_read_input_token_cost_above_272k_tokens ?? - priceData.cache_read_input_token_cost_above_200k_tokens; + const cacheReadAboveThreshold = resolvePriorityAwareLongContextRate(priorityServiceTierApplied, { + above272k: priceData.cache_read_input_token_cost_above_272k_tokens, + above272kPriority: priceData.cache_read_input_token_cost_above_272k_tokens_priority, + above200k: priceData.cache_read_input_token_cost_above_200k_tokens, + above200kPriority: priceData.cache_read_input_token_cost_above_200k_tokens_priority, + }); const longContextThreshold = resolveLongContextThreshold(priceData); const longContextThresholdExceeded = getRequestInputContextTokens(usage, cache5mTokens, cache1hTokens) > longContextThreshold; diff --git a/tests/unit/lib/cost-calculation-breakdown.test.ts b/tests/unit/lib/cost-calculation-breakdown.test.ts index af49fdd3f..074efc9c3 100644 --- a/tests/unit/lib/cost-calculation-breakdown.test.ts +++ b/tests/unit/lib/cost-calculation-breakdown.test.ts @@ -101,6 +101,66 @@ describe("calculateRequestCostBreakdown", () => { expect(result.input).toBeCloseTo(1.8, 4); }); + test("uses priority long-context pricing fields in breakdown when available", () => { + const result = calculateRequestCostBreakdown( + { + input_tokens: 272001, + output_tokens: 2, + cache_read_input_tokens: 10, + }, + makePriceData({ + mode: "responses", + model_family: "gpt", + input_cost_per_token_priority: 2, + output_cost_per_token_priority: 20, + cache_read_input_token_cost_priority: 0.2, + input_cost_per_token_above_272k_tokens: 5, + output_cost_per_token_above_272k_tokens: 50, + cache_read_input_token_cost_above_272k_tokens: 0.5, + input_cost_per_token_above_272k_tokens_priority: 7, + output_cost_per_token_above_272k_tokens_priority: 70, + cache_read_input_token_cost_above_272k_tokens_priority: 0.7, + }), + false, + true + ); + + expect(result.input).toBe(1904007); + expect(result.output).toBe(140); + expect(result.cache_read).toBe(7); + expect(result.total).toBe(1904154); + }); + + test("falls back to regular long-context pricing in breakdown when priority long-context fields are absent", () => { + const result = calculateRequestCostBreakdown( + { + input_tokens: 272001, + output_tokens: 2, + cache_read_input_tokens: 10, + }, + makePriceData({ + mode: "responses", + model_family: "gpt", + input_cost_per_token_priority: 2, + output_cost_per_token_priority: 20, + cache_read_input_token_cost_priority: 0.2, + input_cost_per_token_above_272k_tokens: 5, + output_cost_per_token_above_272k_tokens: 50, + cache_read_input_token_cost_above_272k_tokens: 0.5, + input_cost_per_token_above_272k_tokens_priority: undefined, + output_cost_per_token_above_272k_tokens_priority: undefined, + cache_read_input_token_cost_above_272k_tokens_priority: undefined, + }), + false, + true + ); + + expect(result.input).toBe(1360005); + expect(result.output).toBe(100); + expect(result.cache_read).toBe(5); + expect(result.total).toBe(1360110); + }); + test("categories sum to total", () => { const result = calculateRequestCostBreakdown( { diff --git a/tests/unit/lib/cost-calculation-priority.test.ts b/tests/unit/lib/cost-calculation-priority.test.ts index 17f020ee9..0342b94be 100644 --- a/tests/unit/lib/cost-calculation-priority.test.ts +++ b/tests/unit/lib/cost-calculation-priority.test.ts @@ -43,4 +43,76 @@ describe("calculateRequestCost priority service tier", () => { expect(Number(cost.toString())).toBe(32.5); }); + + test("uses priority long-context pricing fields when available", () => { + const cost = calculateRequestCost( + { + input_tokens: 272001, + output_tokens: 2, + cache_read_input_tokens: 10, + }, + makePriceData({ + mode: "responses", + model_family: "gpt", + input_cost_per_token_above_272k_tokens: 5, + output_cost_per_token_above_272k_tokens: 50, + cache_read_input_token_cost_above_272k_tokens: 0.5, + input_cost_per_token_above_272k_tokens_priority: 7, + output_cost_per_token_above_272k_tokens_priority: 70, + cache_read_input_token_cost_above_272k_tokens_priority: 0.7, + }), + 1, + false, + true + ); + + expect(Number(cost.toString())).toBe(1904154); + }); + + test("falls back to regular long-context pricing when priority long-context fields are absent", () => { + const cost = calculateRequestCost( + { + input_tokens: 272001, + output_tokens: 2, + cache_read_input_tokens: 10, + }, + makePriceData({ + mode: "responses", + model_family: "gpt", + input_cost_per_token_above_272k_tokens: 5, + output_cost_per_token_above_272k_tokens: 50, + cache_read_input_token_cost_above_272k_tokens: 0.5, + input_cost_per_token_above_272k_tokens_priority: undefined, + output_cost_per_token_above_272k_tokens_priority: undefined, + cache_read_input_token_cost_above_272k_tokens_priority: undefined, + }), + 1, + false, + true + ); + + expect(Number(cost.toString())).toBe(1360110); + }); + + test("uses priority long-context fields by schema, not by model name", () => { + const cost = calculateRequestCost( + { + input_tokens: 272001, + output_tokens: 2, + }, + makePriceData({ + mode: "responses", + model_family: undefined, + input_cost_per_token_above_272k_tokens: undefined, + output_cost_per_token_above_272k_tokens: undefined, + input_cost_per_token_above_272k_tokens_priority: 7, + output_cost_per_token_above_272k_tokens_priority: 70, + }), + 1, + false, + true + ); + + expect(Number(cost.toString())).toBe(1904147); + }); }); From c301f6fcf7b07b66a1e12386ec76093283365aa7 Mon Sep 17 00:00:00 2001 From: ding113 Date: Tue, 10 Mar 2026 15:04:15 +0800 Subject: [PATCH 34/42] test(billing): cover priority long-context billing flow Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- .../integration/billing-model-source.test.ts | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/tests/integration/billing-model-source.test.ts b/tests/integration/billing-model-source.test.ts index 396d73b6f..54d45a74e 100644 --- a/tests/integration/billing-model-source.test.ts +++ b/tests/integration/billing-model-source.test.ts @@ -562,6 +562,74 @@ describe("Billing model source - Redis session cost vs DB cost", () => { expect(dbCosts[0]).toBe("64"); }); + it("codex fast: uses long-context priority pricing when request is priority and response omits service_tier", async () => { + vi.mocked(getSystemSettings).mockResolvedValue(makeSystemSettings("redirected")); + 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(findLatestPriceByModel).mockImplementation(async (modelName: string) => { + if (modelName === "gpt-5.4") { + return makePriceRecord(modelName, { + mode: "responses", + model_family: "gpt", + litellm_provider: "chatgpt", + pricing: { + openai: { + input_cost_per_token: 1, + output_cost_per_token: 10, + input_cost_per_token_priority: 2, + output_cost_per_token_priority: 20, + input_cost_per_token_above_272k_tokens: 5, + output_cost_per_token_above_272k_tokens: 50, + input_cost_per_token_above_272k_tokens_priority: 7, + output_cost_per_token_above_272k_tokens_priority: 70, + }, + }, + }); + } + return null; + }); + + const dbCosts: string[] = []; + vi.mocked(updateMessageRequestCost).mockImplementation( + async (_id: number, costUsd: unknown) => { + dbCosts.push(String(costUsd)); + } + ); + + const sessionCosts: string[] = []; + vi.mocked(SessionManager.updateSessionUsage).mockImplementation( + async (_sessionId: string, payload: Record) => { + if (typeof payload.costUsd === "string") { + sessionCosts.push(payload.costUsd); + } + } + ); + + const session = createSession({ + originalModel: "gpt-5.4", + redirectedModel: "gpt-5.4", + sessionId: "sess-gpt54-priority-requested-long-context", + messageId: 3203, + providerOverrides: { + name: "ChatGPT", + url: "https://chatgpt.com/backend-api/codex", + providerType: "codex", + }, + requestMessage: { service_tier: "priority" }, + }); + + const response = createNonStreamResponse({ input_tokens: 272001, output_tokens: 2 }); + await ProxyResponseHandler.dispatch(session, response); + await drainAsyncTasks(); + + expect(dbCosts[0]).toBe("1904147"); + expect(sessionCosts[0]).toBe("1904147"); + }); + it("codex fast: does not use priority pricing when response explicitly reports non-priority tier", async () => { vi.mocked(getSystemSettings).mockResolvedValue(makeSystemSettings("redirected")); vi.mocked(updateMessageRequestDetails).mockResolvedValue(undefined); From 3934d9d5b818d12a3cc3eef58e2672b490a4420d Mon Sep 17 00:00:00 2001 From: ding113 Date: Tue, 10 Mar 2026 22:51:48 +0800 Subject: [PATCH 35/42] fix(logs): pass billing model source from server to request log table UsageLogsDataSection was not forwarding billingModelSource and currencyCode to the client component, causing it to default to "original" and skip the client-side settings fetch. Now reads system settings server-side via getCachedSystemSettings() and passes both props, matching the pattern used by my-usage page. --- .../_components/usage-logs-sections.test.tsx | 64 +++++++++++++++++++ .../logs/_components/usage-logs-sections.tsx | 3 + 2 files changed, 67 insertions(+) create mode 100644 src/app/[locale]/dashboard/logs/_components/usage-logs-sections.test.tsx diff --git a/src/app/[locale]/dashboard/logs/_components/usage-logs-sections.test.tsx b/src/app/[locale]/dashboard/logs/_components/usage-logs-sections.test.tsx new file mode 100644 index 000000000..7260f09de --- /dev/null +++ b/src/app/[locale]/dashboard/logs/_components/usage-logs-sections.test.tsx @@ -0,0 +1,64 @@ +import type { ReactElement } from "react"; +import { describe, expect, it, vi } from "vitest"; + +// Mock dependencies before imports +vi.mock("@/repository/system-config", () => ({ + getSystemSettings: vi.fn(), +})); + +vi.mock("@/lib/utils/timezone", () => ({ + resolveSystemTimezone: vi.fn().mockResolvedValue("UTC"), +})); + +vi.mock("./usage-logs-view-virtualized", () => ({ + UsageLogsViewVirtualized: () => null, +})); + +vi.mock("@/components/customs/active-sessions-list", () => ({ + ActiveSessionsList: () => null, +})); + +import { getSystemSettings } from "@/repository/system-config"; +import { UsageLogsDataSection } from "./usage-logs-sections"; + +describe("UsageLogsDataSection", () => { + it("passes billingModelSource and currencyCode from system settings", async () => { + vi.mocked(getSystemSettings).mockResolvedValue({ + billingModelSource: "redirected", + currencyDisplay: "CNY", + } as Awaited>); + + const searchParams = Promise.resolve({}); + const element = (await UsageLogsDataSection({ + isAdmin: true, + userId: 1, + searchParams, + })) as ReactElement; + + expect(element.props).toMatchObject({ + billingModelSource: "redirected", + currencyCode: "CNY", + }); + }); + + it("passes billingModelSource as original when configured", async () => { + vi.mocked(getSystemSettings).mockResolvedValue({ + billingModelSource: "original", + currencyDisplay: "USD", + } as Awaited>); + + const searchParams = Promise.resolve({}); + const element = (await UsageLogsDataSection({ + isAdmin: false, + userId: 42, + searchParams, + })) as ReactElement; + + expect(element.props).toMatchObject({ + billingModelSource: "original", + currencyCode: "USD", + isAdmin: false, + userId: 42, + }); + }); +}); diff --git a/src/app/[locale]/dashboard/logs/_components/usage-logs-sections.tsx b/src/app/[locale]/dashboard/logs/_components/usage-logs-sections.tsx index 0c1ef1d5c..6bbfed1fd 100644 --- a/src/app/[locale]/dashboard/logs/_components/usage-logs-sections.tsx +++ b/src/app/[locale]/dashboard/logs/_components/usage-logs-sections.tsx @@ -30,6 +30,7 @@ export async function UsageLogsDataSection({ }: UsageLogsDataSectionProps) { const resolvedSearchParams = await searchParams; const serverTimeZone = await resolveSystemTimezone(); + const systemSettings = await getCachedSystemSettings(); return ( ); } From a626fb601590173c0f46ee9c41b683d56f5d91d1 Mon Sep 17 00:00:00 2001 From: ding113 Date: Tue, 10 Mar 2026 23:35:36 +0800 Subject: [PATCH 36/42] feat(logs): live chain display, badge dedup, hedge icon, configurable polling - Add shared shouldShowCostBadgeInCell() helper to deduplicate cost multiplier badge logic across virtualized and non-virtualized tables, preventing duplicate badges on hedge/retry requests - Unify hedge icon style: plain GitBranch icon in indigo with separate count badge, matching reuse icon visual baseline - Implement Redis-backed live chain snapshots for real-time decision chain display during in-flight requests, replacing generic spinner with provider name and phase indicators (retrying, hedge_racing, etc.) - Make polling interval configurable via DASHBOARD_LOGS_POLL_INTERVAL_MS env var (250-60000ms, default 5000ms) with function-based refetchInterval to prevent concurrent polls - Fix pre-existing bug: getPricingSourceLabel missing template literal interpolation for billing source - Fix pre-existing bug: hedge_winner missing from successful provider finder in usage-logs-table --- .env.example | 3 + messages/en/dashboard.json | 1 + messages/ja/dashboard.json | 1 + messages/ru/dashboard.json | 1 + messages/zh-CN/dashboard.json | 1 + messages/zh-TW/dashboard.json | 1 + src/actions/usage-logs.ts | 24 ++++ .../_components/provider-chain-popover.tsx | 11 +- .../_components/usage-logs-sections.test.tsx | 20 +++ .../logs/_components/usage-logs-sections.tsx | 2 + .../logs/_components/usage-logs-table.tsx | 15 ++- .../usage-logs-view-virtualized.tsx | 4 +- .../virtualized-logs-table.test.tsx | 121 ++++++++++++++++++ .../_components/virtualized-logs-table.tsx | 65 +++++++--- src/app/v1/_lib/proxy/response-handler.ts | 9 ++ src/app/v1/_lib/proxy/session.ts | 8 ++ src/lib/config/env.schema.ts | 2 + src/lib/redis/live-chain-store.test.ts | 98 ++++++++++++++ src/lib/redis/live-chain-store.ts | 92 +++++++++++++ src/lib/utils/provider-chain-display.test.ts | 89 +++++++++++++ src/lib/utils/provider-chain-display.ts | 24 ++++ src/repository/usage-logs.ts | 1 + tests/integration/usage-ledger.test.ts | 82 ++++++------ 23 files changed, 602 insertions(+), 73 deletions(-) create mode 100644 src/lib/redis/live-chain-store.test.ts create mode 100644 src/lib/redis/live-chain-store.ts create mode 100644 src/lib/utils/provider-chain-display.test.ts create mode 100644 src/lib/utils/provider-chain-display.ts diff --git a/.env.example b/.env.example index a9216eebb..9f39053e1 100644 --- a/.env.example +++ b/.env.example @@ -79,6 +79,9 @@ STORE_SESSION_RESPONSE_BODY=true # 是否在 Redis 中存储会话响应 # - false:不存储响应体(注意:不影响本次请求处理;仅影响后续查看 response body) # 说明:该开关不影响内部统计读取响应体(tokens/费用统计、SSE 假 200 检测仍会进行) +# Dashboard 配置 +DASHBOARD_LOGS_POLL_INTERVAL_MS=5000 # 日志页自动刷新轮询间隔(毫秒,默认 5000,范围 250-60000) + # 熔断器配置 # 功能说明:控制网络错误是否计入熔断器失败计数 # - false (默认):网络错误(DNS 解析失败、连接超时、代理连接失败等)不计入熔断器,仅供应商错误(4xx/5xx HTTP 响应)计入 diff --git a/messages/en/dashboard.json b/messages/en/dashboard.json index f61786192..e461aff43 100644 --- a/messages/en/dashboard.json +++ b/messages/en/dashboard.json @@ -184,6 +184,7 @@ "title": "Request Details", "statusTitle": "Status: {status}", "inProgress": "In Progress", + "retrying": "Retrying", "unknown": "Unknown", "success": "Request completed successfully", "error": "Request failed, here are the detailed error messages and provider decision chain", diff --git a/messages/ja/dashboard.json b/messages/ja/dashboard.json index 61448a31a..8622e991f 100644 --- a/messages/ja/dashboard.json +++ b/messages/ja/dashboard.json @@ -184,6 +184,7 @@ "title": "リクエスト詳細", "statusTitle": "ステータス: {status}", "inProgress": "処理中", + "retrying": "Retrying", "unknown": "不明", "success": "リクエストが正常に完了しました", "error": "リクエスト失敗、詳細なエラー情報とプロバイダー決定チェーンは以下の通りです", diff --git a/messages/ru/dashboard.json b/messages/ru/dashboard.json index 1abf250e2..606138e10 100644 --- a/messages/ru/dashboard.json +++ b/messages/ru/dashboard.json @@ -184,6 +184,7 @@ "title": "Детали запроса", "statusTitle": "Статус: {status}", "inProgress": "В процессе", + "retrying": "Retrying", "unknown": "Неизвестно", "success": "Запрос успешно выполнен", "error": "Запрос не выполнен, ниже подробная информация об ошибке и цепочке решений поставщика", diff --git a/messages/zh-CN/dashboard.json b/messages/zh-CN/dashboard.json index 0f822f92b..77e449065 100644 --- a/messages/zh-CN/dashboard.json +++ b/messages/zh-CN/dashboard.json @@ -184,6 +184,7 @@ "title": "请求详情", "statusTitle": "状态: {status}", "inProgress": "请求中", + "retrying": "Retrying", "unknown": "未知", "success": "请求成功完成", "error": "请求失败,以下是详细的错误信息和供应商决策链", diff --git a/messages/zh-TW/dashboard.json b/messages/zh-TW/dashboard.json index 64742eca5..c1399b555 100644 --- a/messages/zh-TW/dashboard.json +++ b/messages/zh-TW/dashboard.json @@ -184,6 +184,7 @@ "title": "請求詳情", "statusTitle": "狀態: {status}", "inProgress": "請求中", + "retrying": "Retrying", "unknown": "未知狀態", "success": "請求成功完成", "error": "請求失敗,以下是詳細的錯誤訊息和供應商決策鏈", diff --git a/src/actions/usage-logs.ts b/src/actions/usage-logs.ts index b0d612b0a..7a9643aab 100644 --- a/src/actions/usage-logs.ts +++ b/src/actions/usage-logs.ts @@ -7,6 +7,8 @@ import { SESSION_ID_SUGGESTION_MIN_LEN, } from "@/lib/constants/usage-logs.constants"; import { logger } from "@/lib/logger"; +import { readLiveChainBatch } from "@/lib/redis/live-chain-store"; +import { isProviderFinalized } from "@/lib/utils/provider-display"; import { findUsageLogSessionIdSuggestions, findUsageLogsBatch, @@ -385,6 +387,28 @@ export async function getUsageLogsBatch( const result = await findUsageLogsBatch(finalFilters); + // Merge Redis live chain data for unfinalised rows + const unfinalisedRows = result.logs.filter( + (row) => !isProviderFinalized(row) && row.sessionId && row.requestSequence != null + ); + + if (unfinalisedRows.length > 0) { + const liveData = await readLiveChainBatch( + unfinalisedRows.map((r) => ({ + sessionId: r.sessionId!, + requestSequence: r.requestSequence!, + })) + ); + + for (const row of unfinalisedRows) { + const key = `${row.sessionId}:${row.requestSequence}`; + const snapshot = liveData.get(key); + if (snapshot) { + row._liveChain = snapshot; + } + } + } + return { ok: true, data: result }; } catch (error) { logger.error("获取使用日志批量数据失败:", error); 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 849f56586..4940a089c 100644 --- a/src/app/[locale]/dashboard/logs/_components/provider-chain-popover.tsx +++ b/src/app/[locale]/dashboard/logs/_components/provider-chain-popover.tsx @@ -419,15 +419,10 @@ export function ProviderChainPopover({ > {/* Request count badge */} + {isHedge && } - {isHedge ? ( - - ) : ( - <> - {requestCount} - {t("logs.table.times")} - - )} + {requestCount} + {t("logs.table.times")} {/* Provider name */} diff --git a/src/app/[locale]/dashboard/logs/_components/usage-logs-sections.test.tsx b/src/app/[locale]/dashboard/logs/_components/usage-logs-sections.test.tsx index 7260f09de..bad3d3431 100644 --- a/src/app/[locale]/dashboard/logs/_components/usage-logs-sections.test.tsx +++ b/src/app/[locale]/dashboard/logs/_components/usage-logs-sections.test.tsx @@ -61,4 +61,24 @@ describe("UsageLogsDataSection", () => { userId: 42, }); }); + + it("passes logsRefreshIntervalMs from env config", async () => { + vi.mocked(getSystemSettings).mockResolvedValue({ + billingModelSource: "redirected", + currencyDisplay: "USD", + } as Awaited>); + + const searchParams = Promise.resolve({}); + const element = (await UsageLogsDataSection({ + isAdmin: true, + userId: 1, + searchParams, + })) as ReactElement; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const props = (element as any).props; + expect(props).toHaveProperty("logsRefreshIntervalMs"); + expect(typeof props.logsRefreshIntervalMs).toBe("number"); + expect(props.logsRefreshIntervalMs).toBeGreaterThanOrEqual(250); + }); }); diff --git a/src/app/[locale]/dashboard/logs/_components/usage-logs-sections.tsx b/src/app/[locale]/dashboard/logs/_components/usage-logs-sections.tsx index 6bbfed1fd..b15b4717a 100644 --- a/src/app/[locale]/dashboard/logs/_components/usage-logs-sections.tsx +++ b/src/app/[locale]/dashboard/logs/_components/usage-logs-sections.tsx @@ -1,5 +1,6 @@ import { cache } from "react"; import { ActiveSessionsList } from "@/components/customs/active-sessions-list"; +import { getEnvConfig } from "@/lib/config/env.schema"; import { resolveSystemTimezone } from "@/lib/utils/timezone"; import { getSystemSettings } from "@/repository/system-config"; import { UsageLogsViewVirtualized } from "./usage-logs-view-virtualized"; @@ -40,6 +41,7 @@ export async function UsageLogsDataSection({ serverTimeZone={serverTimeZone} billingModelSource={systemSettings.billingModelSource} currencyCode={systemSettings.currencyDisplay} + logsRefreshIntervalMs={getEnvConfig().DASHBOARD_LOGS_POLL_INTERVAL_MS} /> ); } 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 7b87a0f56..dc68c6620 100644 --- a/src/app/[locale]/dashboard/logs/_components/usage-logs-table.tsx +++ b/src/app/[locale]/dashboard/logs/_components/usage-logs-table.tsx @@ -25,6 +25,7 @@ import { NON_BILLING_ENDPOINT, shouldHideOutputRate, } from "@/lib/utils/performance-formatter"; +import { shouldShowCostBadgeInCell } from "@/lib/utils/provider-chain-display"; import { formatProviderSummary } from "@/lib/utils/provider-chain-formatter"; import { getPricingResolutionSpecialSetting, @@ -125,7 +126,9 @@ export function UsageLogsTable({ .reverse() .find( (item) => - item.reason === "request_success" || item.reason === "retry_success" + item.reason === "request_success" || + item.reason === "retry_success" || + item.reason === "hedge_winner" ) : null; @@ -243,16 +246,16 @@ export function UsageLogsTable({ )}
{/* 显示供应商倍率 Badge(不为 1.0 时) */} - {hasCostBadge && multiplier != null ? ( + {shouldShowCostBadgeInCell(log.providerChain, multiplier) ? ( 1 + 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" } > - ×{multiplier.toFixed(2)} + ×{multiplier!.toFixed(2)} ) : null}
@@ -467,11 +470,11 @@ export function UsageLogsTable({ {formatTokenAmount(log.cacheReadInputTokens)} tokens (0.1x)
)} - {hasCostBadge && multiplier != null ? ( + {hasCostBadge && multiplier != null && (
{t("logs.billingDetails.multiplier")}: {multiplier.toFixed(2)}x
- ) : null} + )} diff --git a/src/app/[locale]/dashboard/logs/_components/usage-logs-view-virtualized.tsx b/src/app/[locale]/dashboard/logs/_components/usage-logs-view-virtualized.tsx index ffaadca05..edabca40a 100644 --- a/src/app/[locale]/dashboard/logs/_components/usage-logs-view-virtualized.tsx +++ b/src/app/[locale]/dashboard/logs/_components/usage-logs-view-virtualized.tsx @@ -38,6 +38,7 @@ interface UsageLogsViewVirtualizedProps { currencyCode?: CurrencyCode; billingModelSource?: BillingModelSource; serverTimeZone?: string; + logsRefreshIntervalMs?: number; } async function fetchSystemSettings(): Promise { @@ -65,6 +66,7 @@ function UsageLogsViewContent({ currencyCode = "USD", billingModelSource = "original", serverTimeZone, + logsRefreshIntervalMs, }: UsageLogsViewVirtualizedProps) { const t = useTranslations("dashboard"); const tc = useTranslations("customs"); @@ -384,7 +386,7 @@ function UsageLogsViewContent({ currencyCode={resolvedCurrencyCode} billingModelSource={resolvedBillingModelSource} autoRefreshEnabled={!isFullscreenOpen && isAutoRefresh} - autoRefreshIntervalMs={5000} + autoRefreshIntervalMs={logsRefreshIntervalMs ?? 5000} hiddenColumns={hiddenColumns} /> 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 index 404501fa6..346f0ff6c 100644 --- a/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.test.tsx +++ b/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.test.tsx @@ -53,6 +53,9 @@ vi.mock("@/hooks/use-virtualizer", () => ({ vi.mock("@/lib/utils/provider-chain-formatter", () => ({ formatProviderSummary: () => "provider summary", + getRetryCount: () => 0, + isHedgeRace: () => false, + isActualRequest: () => true, })); vi.mock("@/actions/usage-logs", () => ({ @@ -94,6 +97,11 @@ vi.mock("./error-details-dialog", () => ({ ErrorDetailsDialog: () =>
, })); +let mockIsProviderFinalized = true; +vi.mock("@/lib/utils/provider-display", () => ({ + isProviderFinalized: () => mockIsProviderFinalized, +})); + import { VirtualizedLogsTable } from "./virtualized-logs-table"; function makeLog(overrides: Partial): UsageLogRow { @@ -375,3 +383,116 @@ describe("virtualized-logs-table multiplier badge", () => { expect(html).not.toContain("bg-amber-50"); }); }); + +describe("virtualized-logs-table live chain display", () => { + function setupLiveChainDefaults() { + mockIsLoading = false; + mockIsError = false; + mockError = null; + mockHasNextPage = false; + mockIsFetchingNextPage = false; + mockIsProviderFinalized = false; + } + + test("renders provider name from live chain when unfinalised", () => { + setupLiveChainDefaults(); + mockLogs = [ + makeLog({ + id: 1, + statusCode: null, + providerChain: null, + _liveChain: { + chain: [{ id: 1, name: "openai-east", reason: "initial_selection" }], + phase: "provider_selected", + updatedAt: Date.now(), + }, + }), + ]; + const html = renderToStaticMarkup( + + ); + expect(html).toContain("openai-east"); + expect(html).toContain("animate-spin"); + }); + + test("renders retrying badge when phase is retrying", () => { + setupLiveChainDefaults(); + mockLogs = [ + makeLog({ + id: 1, + statusCode: null, + providerChain: null, + _liveChain: { + chain: [ + { id: 1, name: "p1", reason: "initial_selection" }, + { id: 2, name: "p2", reason: "retry_failed" }, + ], + phase: "retrying", + updatedAt: Date.now(), + }, + }), + ]; + const html = renderToStaticMarkup( + + ); + expect(html).toContain("logs.details.retrying"); + expect(html).toContain("text-amber-500"); + }); + + test("renders GitBranch icon when phase is hedge_racing", () => { + setupLiveChainDefaults(); + mockLogs = [ + makeLog({ + id: 1, + statusCode: null, + providerChain: null, + _liveChain: { + chain: [{ id: 1, name: "p1", reason: "hedge_triggered" }], + phase: "hedge_racing", + updatedAt: Date.now(), + }, + }), + ]; + const html = renderToStaticMarkup( + + ); + expect(html).toContain("text-indigo-500"); + }); + + test("renders generic in-progress when live chain is empty", () => { + setupLiveChainDefaults(); + mockLogs = [ + makeLog({ + id: 1, + statusCode: null, + providerChain: null, + _liveChain: { + chain: [], + phase: "queued", + updatedAt: Date.now(), + }, + }), + ]; + const html = renderToStaticMarkup( + + ); + expect(html).toContain("logs.details.inProgress"); + }); + + test("renders generic spinner when no live chain data", () => { + setupLiveChainDefaults(); + mockLogs = [ + makeLog({ + id: 1, + statusCode: null, + providerChain: null, + _liveChain: undefined, + }), + ]; + const html = renderToStaticMarkup( + + ); + expect(html).toContain("logs.details.inProgress"); + expect(html).toContain("animate-spin"); + }); +}); 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 c34b4fc2b..2e35bff93 100644 --- a/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx +++ b/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx @@ -1,7 +1,7 @@ "use client"; import { useInfiniteQuery } from "@tanstack/react-query"; -import { ArrowUp, Loader2 } from "lucide-react"; +import { ArrowUp, GitBranch, Loader2 } from "lucide-react"; import { useTranslations } from "next-intl"; import { type MouseEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; @@ -14,8 +14,6 @@ import { useVirtualizer } from "@/hooks/use-virtualizer"; import type { LogsTableColumn } from "@/lib/column-visibility"; import { cn, formatTokenAmount } from "@/lib/utils"; import { copyTextToClipboard } from "@/lib/utils/clipboard"; -import { isProviderFinalized } from "@/lib/utils/provider-display"; -import { getRetryCount } from "@/lib/utils/provider-chain-formatter"; import type { CurrencyCode } from "@/lib/utils/currency"; import { formatCurrency } from "@/lib/utils/currency"; import { @@ -24,11 +22,12 @@ import { NON_BILLING_ENDPOINT, shouldHideOutputRate, } from "@/lib/utils/performance-formatter"; +import { shouldShowCostBadgeInCell } from "@/lib/utils/provider-chain-display"; +import { isProviderFinalized } from "@/lib/utils/provider-display"; import { getPricingResolutionSpecialSetting, hasPriorityServiceTierSpecialSetting, } from "@/lib/utils/special-settings"; -import type { ProviderChainItem } from "@/types/message"; import type { BillingModelSource } from "@/types/system-config"; import { ErrorDetailsDialog } from "./error-details-dialog"; import { ModelDisplayWithRedirect } from "./model-display-with-redirect"; @@ -77,7 +76,8 @@ export function VirtualizedLogsTable({ serverTimeZone: _serverTimeZone, }: VirtualizedLogsTableProps) { const t = useTranslations("dashboard"); - const getPricingSourceLabel = (source: string) => t(`logs.billingDetails.pricingSource.`); + const getPricingSourceLabel = (source: string) => + t(`logs.billingDetails.pricingSource.${source}`); const tChain = useTranslations("provider-chain"); const parentRef = useRef(null); const [showScrollToTop, setShowScrollToTop] = useState(false); @@ -131,7 +131,11 @@ export function VirtualizedLogsTable({ initialPageParam: undefined as { createdAt: string; id: number } | undefined, staleTime: 30000, // 30 seconds refetchOnWindowFocus: false, - refetchInterval: shouldPoll ? autoRefreshIntervalMs : false, + refetchInterval: (query) => { + if (!shouldPoll) return false; + if (query.state.fetchStatus !== "idle") return false; + return autoRefreshIntervalMs; + }, maxPages: 5, }); @@ -422,10 +426,32 @@ export function VirtualizedLogsTable({ {t("logs.table.blocked")} ) : !isProviderFinalized(log) ? ( - - - {t("logs.details.inProgress")} - + log._liveChain ? ( +
+ + + {log._liveChain.chain.length > 0 + ? log._liveChain.chain[log._liveChain.chain.length - 1].name + : t("logs.details.inProgress")} + + {log._liveChain.phase === "retrying" && ( + + {t("logs.details.retrying")} + + )} + {log._liveChain.phase === "hedge_racing" && ( + + )} +
+ ) : ( + + + {t("logs.details.inProgress")} + + ) ) : (
@@ -444,18 +470,19 @@ export function VirtualizedLogsTable({ : null; const actualCostMultiplier = successfulProvider?.costMultiplier ?? log.costMultiplier; - const multiplier = Number(actualCostMultiplier); + const multiplier = + actualCostMultiplier === "" || actualCostMultiplier == null + ? null + : Number(actualCostMultiplier); const hasCostBadge = actualCostMultiplier !== "" && actualCostMultiplier != null && Number.isFinite(multiplier) && multiplier !== 1; - - const retryCount = log.providerChain - ? getRetryCount(log.providerChain) - : 0; - // Only show badge in table when no retry (Popover shows badge when retry) - const showBadgeInTable = hasCostBadge && retryCount === 0; + const showBadgeInTable = shouldShowCostBadgeInCell( + log.providerChain, + multiplier + ); return ( <> @@ -485,12 +512,12 @@ export function VirtualizedLogsTable({ 1 + multiplier! > 1 ? "text-[10px] px-1 py-0 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-[10px] px-1 py-0 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{multiplier.toFixed(2)} + x{multiplier!.toFixed(2)} )} diff --git a/src/app/v1/_lib/proxy/response-handler.ts b/src/app/v1/_lib/proxy/response-handler.ts index d2c36e6b7..fd8ed9ba9 100644 --- a/src/app/v1/_lib/proxy/response-handler.ts +++ b/src/app/v1/_lib/proxy/response-handler.ts @@ -6,6 +6,7 @@ import { requestCloudPriceTableSync } from "@/lib/price-sync/cloud-price-updater import { ProxyStatusTracker } from "@/lib/proxy-status-tracker"; import { RateLimitService } from "@/lib/rate-limit"; import type { LeaseWindowType } from "@/lib/rate-limit/lease"; +import { deleteLiveChain } from "@/lib/redis/live-chain-store"; import { SessionManager } from "@/lib/session-manager"; import { SessionTracker } from "@/lib/session-tracker"; import type { CostBreakdown } from "@/lib/utils/cost-calculation"; @@ -3137,6 +3138,10 @@ export async function finalizeRequestStats( specialSettings: session.getSpecialSettings() ?? undefined, }); + if (session.sessionId && session.requestSequence != null) { + void deleteLiveChain(session.sessionId, session.requestSequence); + } + return normalizedUsage; } @@ -3303,6 +3308,10 @@ async function persistRequestFailure(options: { specialSettings: session.getSpecialSettings() ?? undefined, }); + if (session.sessionId && session.requestSequence != null) { + void deleteLiveChain(session.sessionId, session.requestSequence); + } + const isAsyncWrite = getEnvConfig().MESSAGE_REQUEST_WRITE_MODE !== "sync"; logger.info( isAsyncWrite diff --git a/src/app/v1/_lib/proxy/session.ts b/src/app/v1/_lib/proxy/session.ts index 7cd41bcd1..659ffc7c4 100644 --- a/src/app/v1/_lib/proxy/session.ts +++ b/src/app/v1/_lib/proxy/session.ts @@ -1,5 +1,6 @@ import type { Context } from "hono"; import { logger } from "@/lib/logger"; +import { writeLiveChain } from "@/lib/redis/live-chain-store"; import { clientRequestsContext1m as clientRequestsContext1mHelper } from "@/lib/special-attributes"; import { type ResolvedPricing, @@ -324,6 +325,7 @@ export class ProxySession { const value = Math.max(0, Date.now() - this.startTime); this.ttfbMs = value; + this.persistLiveChain(); return value; } @@ -520,9 +522,15 @@ export class ProxySession { if (shouldAdd) { this.providerChain.push(item); + this.persistLiveChain(); } } + private persistLiveChain(): void { + if (!this.sessionId || this.requestSequence == null) return; + void writeLiveChain(this.sessionId, this.requestSequence, this.providerChain); + } + /** * 获取决策链 */ diff --git a/src/lib/config/env.schema.ts b/src/lib/config/env.schema.ts index dcdf167ef..c793cc4ed 100644 --- a/src/lib/config/env.schema.ts +++ b/src/lib/config/env.schema.ts @@ -129,6 +129,8 @@ export const EnvSchema = z.object({ FETCH_HEADERS_TIMEOUT: z.coerce.number().default(600_000), // 响应头接收超时(默认 600 秒) FETCH_CONNECT_TIMEOUT: z.coerce.number().default(30000), // TCP 连接建立超时(默认 30 秒) + DASHBOARD_LOGS_POLL_INTERVAL_MS: z.coerce.number().int().min(250).max(60000).default(5000), + // Langfuse Observability (optional, auto-enabled when keys are set) LANGFUSE_PUBLIC_KEY: z.string().optional(), LANGFUSE_SECRET_KEY: z.string().optional(), diff --git a/src/lib/redis/live-chain-store.test.ts b/src/lib/redis/live-chain-store.test.ts new file mode 100644 index 000000000..79b79d1ab --- /dev/null +++ b/src/lib/redis/live-chain-store.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from "vitest"; +import { inferPhase } from "./live-chain-store"; +import type { ProviderChainItem } from "@/types/message"; + +// Note: writeLiveChain/readLiveChain/readLiveChainBatch/deleteLiveChain +// require "server-only" + Redis, so they are tested via integration tests. +// This file tests the pure logic function: inferPhase. + +function makeChainItem(overrides: Partial = {}): ProviderChainItem { + return { id: 1, name: "provider-a", timestamp: Date.now(), ...overrides }; +} + +describe("inferPhase", () => { + it('returns "queued" for empty chain', () => { + expect(inferPhase([])).toBe("queued"); + }); + + it('returns "provider_selected" for initial_selection', () => { + expect(inferPhase([makeChainItem({ reason: "initial_selection" })])).toBe("provider_selected"); + }); + + it('returns "session_reused" for session_reuse', () => { + expect(inferPhase([makeChainItem({ reason: "session_reuse" })])).toBe("session_reused"); + }); + + it('returns "retrying" for retry_failed', () => { + expect( + inferPhase([ + makeChainItem({ reason: "initial_selection" }), + makeChainItem({ reason: "retry_failed" }), + ]) + ).toBe("retrying"); + }); + + it('returns "retrying" for system_error', () => { + expect(inferPhase([makeChainItem({ reason: "system_error" })])).toBe("retrying"); + }); + + it('returns "retrying" for resource_not_found', () => { + expect(inferPhase([makeChainItem({ reason: "resource_not_found" })])).toBe("retrying"); + }); + + it('returns "hedge_racing" for hedge_triggered', () => { + expect(inferPhase([makeChainItem({ reason: "hedge_triggered" })])).toBe("hedge_racing"); + }); + + it('returns "hedge_racing" for hedge_launched', () => { + expect(inferPhase([makeChainItem({ reason: "hedge_launched" })])).toBe("hedge_racing"); + }); + + it('returns "hedge_resolved" for hedge_winner', () => { + expect( + inferPhase([ + makeChainItem({ reason: "hedge_triggered" }), + makeChainItem({ reason: "hedge_winner" }), + ]) + ).toBe("hedge_resolved"); + }); + + it('returns "hedge_resolved" for hedge_loser_cancelled', () => { + expect(inferPhase([makeChainItem({ reason: "hedge_loser_cancelled" })])).toBe("hedge_resolved"); + }); + + it('returns "streaming" for request_success', () => { + expect( + inferPhase([ + makeChainItem({ reason: "initial_selection" }), + makeChainItem({ reason: "request_success" }), + ]) + ).toBe("streaming"); + }); + + it('returns "streaming" for retry_success', () => { + expect( + inferPhase([ + makeChainItem({ reason: "retry_failed" }), + makeChainItem({ reason: "retry_success" }), + ]) + ).toBe("streaming"); + }); + + it('returns "aborted" for client_abort', () => { + expect(inferPhase([makeChainItem({ reason: "client_abort" })])).toBe("aborted"); + }); + + it('returns "forwarding" for unknown reasons', () => { + expect(inferPhase([makeChainItem({ reason: undefined })])).toBe("forwarding"); + }); + + it("uses last chain item to determine phase", () => { + const chain = [ + makeChainItem({ reason: "initial_selection" }), + makeChainItem({ reason: "retry_failed" }), + makeChainItem({ reason: "request_success" }), + ]; + expect(inferPhase(chain)).toBe("streaming"); + }); +}); diff --git a/src/lib/redis/live-chain-store.ts b/src/lib/redis/live-chain-store.ts new file mode 100644 index 000000000..937439d3f --- /dev/null +++ b/src/lib/redis/live-chain-store.ts @@ -0,0 +1,92 @@ +import "server-only"; + +import type { ProviderChainItem } from "@/types/message"; +import { RedisKVStore } from "./redis-kv-store"; + +export interface LiveChainSnapshot { + chain: ProviderChainItem[]; + phase: string; + updatedAt: number; +} + +const SESSION_TTL = Number.parseInt(process.env.SESSION_TTL || "300", 10); + +const store = new RedisKVStore({ + prefix: "cch:live-chain:", + defaultTtlSeconds: SESSION_TTL, +}); + +function buildKey(sessionId: string, requestSequence: number): string { + return `${sessionId}:${requestSequence}`; +} + +export function inferPhase(chain: ProviderChainItem[]): string { + if (chain.length === 0) return "queued"; + const last = chain[chain.length - 1]; + switch (last.reason) { + case "initial_selection": + return "provider_selected"; + case "session_reuse": + return "session_reused"; + case "retry_failed": + case "system_error": + case "resource_not_found": + return "retrying"; + case "hedge_triggered": + case "hedge_launched": + return "hedge_racing"; + case "hedge_winner": + case "hedge_loser_cancelled": + return "hedge_resolved"; + case "request_success": + case "retry_success": + return "streaming"; + case "client_abort": + return "aborted"; + default: + return "forwarding"; + } +} + +export async function writeLiveChain( + sessionId: string, + requestSequence: number, + chain: ProviderChainItem[] +): Promise { + const snapshot: LiveChainSnapshot = { + chain, + phase: inferPhase(chain), + updatedAt: Date.now(), + }; + await store.set(buildKey(sessionId, requestSequence), snapshot); +} + +export async function readLiveChain( + sessionId: string, + requestSequence: number +): Promise { + return store.get(buildKey(sessionId, requestSequence)); +} + +export async function readLiveChainBatch( + keys: Array<{ sessionId: string; requestSequence: number }> +): Promise> { + const results = new Map(); + if (keys.length === 0) return results; + + const entries = await Promise.all( + keys.map(async (k) => { + const snapshot = await store.get(buildKey(k.sessionId, k.requestSequence)); + return { key: buildKey(k.sessionId, k.requestSequence), snapshot }; + }) + ); + + for (const { key, snapshot } of entries) { + if (snapshot) results.set(key, snapshot); + } + return results; +} + +export async function deleteLiveChain(sessionId: string, requestSequence: number): Promise { + await store.delete(buildKey(sessionId, requestSequence)); +} diff --git a/src/lib/utils/provider-chain-display.test.ts b/src/lib/utils/provider-chain-display.test.ts new file mode 100644 index 000000000..19148ecb0 --- /dev/null +++ b/src/lib/utils/provider-chain-display.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "vitest"; +import { shouldShowCostBadgeInCell } from "./provider-chain-display"; +import type { ProviderChainItem } from "@/types/message"; + +function makeChainItem(overrides: Partial = {}): ProviderChainItem { + return { id: 1, name: "provider-a", ...overrides }; +} + +describe("shouldShowCostBadgeInCell", () => { + it("returns false when costMultiplier is null", () => { + expect(shouldShowCostBadgeInCell([], null)).toBe(false); + }); + + it("returns false when costMultiplier is undefined", () => { + expect(shouldShowCostBadgeInCell([], undefined)).toBe(false); + }); + + it("returns false when costMultiplier is 1", () => { + expect(shouldShowCostBadgeInCell([], 1)).toBe(false); + }); + + it("returns false when costMultiplier is NaN", () => { + expect(shouldShowCostBadgeInCell([], Number.NaN)).toBe(false); + }); + + it("returns false when costMultiplier is Infinity", () => { + expect(shouldShowCostBadgeInCell([], Number.POSITIVE_INFINITY)).toBe(false); + }); + + it("returns true for simple request (empty chain) with multiplier != 1", () => { + expect(shouldShowCostBadgeInCell([], 1.5)).toBe(true); + expect(shouldShowCostBadgeInCell(null, 0.8)).toBe(true); + expect(shouldShowCostBadgeInCell(undefined, 2.0)).toBe(true); + }); + + it("returns true for single-request chain (no retries, no hedge)", () => { + const chain = [ + makeChainItem({ reason: "initial_selection" }), + makeChainItem({ reason: "request_success", statusCode: 200 }), + ]; + expect(shouldShowCostBadgeInCell(chain, 1.5)).toBe(true); + }); + + it("returns false when chain has retries", () => { + const chain = [ + makeChainItem({ reason: "initial_selection" }), + makeChainItem({ reason: "retry_failed", statusCode: 500 }), + makeChainItem({ reason: "request_success", statusCode: 200 }), + ]; + expect(shouldShowCostBadgeInCell(chain, 1.5)).toBe(false); + }); + + it("returns false when chain has hedge race", () => { + const chain = [ + makeChainItem({ reason: "initial_selection" }), + makeChainItem({ reason: "hedge_triggered" }), + makeChainItem({ reason: "hedge_launched" }), + makeChainItem({ reason: "hedge_winner", statusCode: 200 }), + makeChainItem({ reason: "hedge_loser_cancelled" }), + ]; + expect(shouldShowCostBadgeInCell(chain, 1.5)).toBe(false); + }); + + it("returns false for mixed retry + hedge chain", () => { + const chain = [ + makeChainItem({ reason: "initial_selection" }), + makeChainItem({ reason: "retry_failed" }), + makeChainItem({ reason: "hedge_triggered" }), + makeChainItem({ reason: "hedge_winner", statusCode: 200 }), + ]; + expect(shouldShowCostBadgeInCell(chain, 2.0)).toBe(false); + }); + + it("returns true for session_reuse (no retry, no hedge)", () => { + const chain = [ + makeChainItem({ reason: "session_reuse" }), + makeChainItem({ reason: "request_success", statusCode: 200 }), + ]; + expect(shouldShowCostBadgeInCell(chain, 0.5)).toBe(true); + }); + + it("returns true for multiplier < 1 (discount)", () => { + const chain = [ + makeChainItem({ reason: "initial_selection" }), + makeChainItem({ reason: "request_success", statusCode: 200 }), + ]; + expect(shouldShowCostBadgeInCell(chain, 0.5)).toBe(true); + }); +}); diff --git a/src/lib/utils/provider-chain-display.ts b/src/lib/utils/provider-chain-display.ts new file mode 100644 index 000000000..2f02a7f5e --- /dev/null +++ b/src/lib/utils/provider-chain-display.ts @@ -0,0 +1,24 @@ +import type { ProviderChainItem } from "@/types/message"; +import { getRetryCount, isHedgeRace } from "./provider-chain-formatter"; + +/** + * Determine whether the cost multiplier badge should render + * in the TABLE CELL (outside the popover trigger). + * + * Rules: + * - Must have a cost badge (multiplier != 1) + * - Must NOT have retries (retries show badge inside popover) + * - Must NOT be a hedge race (hedge shows badge inside popover) + */ +export function shouldShowCostBadgeInCell( + providerChain: ProviderChainItem[] | null | undefined, + costMultiplier: number | null | undefined +): boolean { + if (costMultiplier == null || costMultiplier === 1) return false; + if (!Number.isFinite(costMultiplier)) return false; + const chain = providerChain ?? []; + if (chain.length === 0) return true; // no chain = simple request + if (getRetryCount(chain) > 0) return false; // retries -> badge in popover + if (isHedgeRace(chain)) return false; // hedge -> badge in popover + return true; +} diff --git a/src/repository/usage-logs.ts b/src/repository/usage-logs.ts index 1cebc0251..7bf3d48c0 100644 --- a/src/repository/usage-logs.ts +++ b/src/repository/usage-logs.ts @@ -67,6 +67,7 @@ export interface UsageLogRow { context1mApplied: boolean | null; // 是否应用了1M上下文窗口 swapCacheTtlApplied: boolean | null; // 是否启用了swap cache TTL billing specialSettings: SpecialSetting[] | null; // 特殊设置(审计/展示) + _liveChain?: { chain: ProviderChainItem[]; phase: string; updatedAt: number } | null; } export interface UsageLogSummary { diff --git a/tests/integration/usage-ledger.test.ts b/tests/integration/usage-ledger.test.ts index d64b36ee1..303d005cd 100644 --- a/tests/integration/usage-ledger.test.ts +++ b/tests/integration/usage-ledger.test.ts @@ -278,45 +278,49 @@ run("usage ledger integration", () => { }); describe("backfill", () => { - test("backfill copies non-warmup message_request rows when ledger rows are missing", { - timeout: 60_000, - }, async () => { - const userId = nextUserId(); - const providerId = nextProviderId(); - const keepA = await insertMessageRequestRow({ - key: nextKey("backfill-a"), - userId, - providerId, - costUsd: "1.100000000000000", - }); - const keepB = await insertMessageRequestRow({ - key: nextKey("backfill-b"), - userId, - providerId, - costUsd: "2.200000000000000", - }); - const warmup = await insertMessageRequestRow({ - key: nextKey("backfill-warmup"), - userId, - providerId, - blockedBy: "warmup", - }); - - await db.delete(usageLedger).where(inArray(usageLedger.requestId, [keepA, keepB, warmup])); - - const summary = await backfillUsageLedger(); - expect(summary.totalProcessed).toBeGreaterThanOrEqual(2); - - const rows = await db - .select({ requestId: usageLedger.requestId }) - .from(usageLedger) - .where(inArray(usageLedger.requestId, [keepA, keepB, warmup])); - const requestIds = rows.map((row) => row.requestId); - - expect(requestIds).toContain(keepA); - expect(requestIds).toContain(keepB); - expect(requestIds).not.toContain(warmup); - }); + test( + "backfill copies non-warmup message_request rows when ledger rows are missing", + { + timeout: 60_000, + }, + async () => { + const userId = nextUserId(); + const providerId = nextProviderId(); + const keepA = await insertMessageRequestRow({ + key: nextKey("backfill-a"), + userId, + providerId, + costUsd: "1.100000000000000", + }); + const keepB = await insertMessageRequestRow({ + key: nextKey("backfill-b"), + userId, + providerId, + costUsd: "2.200000000000000", + }); + const warmup = await insertMessageRequestRow({ + key: nextKey("backfill-warmup"), + userId, + providerId, + blockedBy: "warmup", + }); + + await db.delete(usageLedger).where(inArray(usageLedger.requestId, [keepA, keepB, warmup])); + + const summary = await backfillUsageLedger(); + expect(summary.totalProcessed).toBeGreaterThanOrEqual(2); + + const rows = await db + .select({ requestId: usageLedger.requestId }) + .from(usageLedger) + .where(inArray(usageLedger.requestId, [keepA, keepB, warmup])); + const requestIds = rows.map((row) => row.requestId); + + expect(requestIds).toContain(keepA); + expect(requestIds).toContain(keepB); + expect(requestIds).not.toContain(warmup); + } + ); test("backfill is idempotent when running twice", { timeout: 60_000 }, async () => { const requestId = await insertMessageRequestRow({ From aa4216c1bbac415504be5868e3cf0151039f28bf Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 10 Mar 2026 15:36:24 +0000 Subject: [PATCH 37/42] chore: format code (dev-a626fb6) --- tests/integration/usage-ledger.test.ts | 82 ++++++++++++-------------- 1 file changed, 39 insertions(+), 43 deletions(-) diff --git a/tests/integration/usage-ledger.test.ts b/tests/integration/usage-ledger.test.ts index 303d005cd..d64b36ee1 100644 --- a/tests/integration/usage-ledger.test.ts +++ b/tests/integration/usage-ledger.test.ts @@ -278,49 +278,45 @@ run("usage ledger integration", () => { }); describe("backfill", () => { - test( - "backfill copies non-warmup message_request rows when ledger rows are missing", - { - timeout: 60_000, - }, - async () => { - const userId = nextUserId(); - const providerId = nextProviderId(); - const keepA = await insertMessageRequestRow({ - key: nextKey("backfill-a"), - userId, - providerId, - costUsd: "1.100000000000000", - }); - const keepB = await insertMessageRequestRow({ - key: nextKey("backfill-b"), - userId, - providerId, - costUsd: "2.200000000000000", - }); - const warmup = await insertMessageRequestRow({ - key: nextKey("backfill-warmup"), - userId, - providerId, - blockedBy: "warmup", - }); - - await db.delete(usageLedger).where(inArray(usageLedger.requestId, [keepA, keepB, warmup])); - - const summary = await backfillUsageLedger(); - expect(summary.totalProcessed).toBeGreaterThanOrEqual(2); - - const rows = await db - .select({ requestId: usageLedger.requestId }) - .from(usageLedger) - .where(inArray(usageLedger.requestId, [keepA, keepB, warmup])); - const requestIds = rows.map((row) => row.requestId); - - expect(requestIds).toContain(keepA); - expect(requestIds).toContain(keepB); - expect(requestIds).not.toContain(warmup); - } - ); + test("backfill copies non-warmup message_request rows when ledger rows are missing", { + timeout: 60_000, + }, async () => { + const userId = nextUserId(); + const providerId = nextProviderId(); + const keepA = await insertMessageRequestRow({ + key: nextKey("backfill-a"), + userId, + providerId, + costUsd: "1.100000000000000", + }); + const keepB = await insertMessageRequestRow({ + key: nextKey("backfill-b"), + userId, + providerId, + costUsd: "2.200000000000000", + }); + const warmup = await insertMessageRequestRow({ + key: nextKey("backfill-warmup"), + userId, + providerId, + blockedBy: "warmup", + }); + + await db.delete(usageLedger).where(inArray(usageLedger.requestId, [keepA, keepB, warmup])); + + const summary = await backfillUsageLedger(); + expect(summary.totalProcessed).toBeGreaterThanOrEqual(2); + + const rows = await db + .select({ requestId: usageLedger.requestId }) + .from(usageLedger) + .where(inArray(usageLedger.requestId, [keepA, keepB, warmup])); + const requestIds = rows.map((row) => row.requestId); + + expect(requestIds).toContain(keepA); + expect(requestIds).toContain(keepB); + expect(requestIds).not.toContain(warmup); + }); test("backfill is idempotent when running twice", { timeout: 60_000 }, async () => { const requestId = await insertMessageRequestRow({ From 0ac9c1775226f3d05b7fd9854f7dead7dbd7fb8a Mon Sep 17 00:00:00 2001 From: ding113 Date: Wed, 11 Mar 2026 00:04:18 +0800 Subject: [PATCH 38/42] fix(logs): hedge icon replaces count badge instead of stacking --- .../logs/_components/provider-chain-popover.tsx | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) 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 4940a089c..633e617ba 100644 --- a/src/app/[locale]/dashboard/logs/_components/provider-chain-popover.tsx +++ b/src/app/[locale]/dashboard/logs/_components/provider-chain-popover.tsx @@ -419,11 +419,14 @@ export function ProviderChainPopover({ > {/* Request count badge */} - {isHedge && } - - {requestCount} - {t("logs.table.times")} - + {isHedge ? ( + + ) : ( + + {requestCount} + {t("logs.table.times")} + + )} {/* Provider name */} {displayName} From d021fc8230126fd8c8975734a2360d4b15fb07d7 Mon Sep 17 00:00:00 2001 From: ding113 Date: Wed, 11 Mar 2026 01:21:44 +0800 Subject: [PATCH 39/42] fix(proxy): prevent duplicate provider chain entry for hedge winners When a hedge winner streams, commitWinner() already logs the provider to the chain with reason "hedge_winner" and updates session binding. The deferred finalization was redundantly calling updateSessionBindingSmart, updateSessionProvider, and addProviderToChain again with reason "retry_success", causing the same provider to appear twice in dashboard logs. Add isHedgeWinner flag to DeferredStreamingFinalization so finalization skips these operations when commitWinner() has already performed them. --- src/app/v1/_lib/proxy/forwarder.ts | 1 + src/app/v1/_lib/proxy/response-handler.ts | 91 +++---- src/app/v1/_lib/proxy/stream-finalization.ts | 2 + tests/unit/proxy/hedge-winner-dedup.test.ts | 237 +++++++++++++++++++ 4 files changed, 289 insertions(+), 42 deletions(-) create mode 100644 tests/unit/proxy/hedge-winner-dedup.test.ts diff --git a/src/app/v1/_lib/proxy/forwarder.ts b/src/app/v1/_lib/proxy/forwarder.ts index fa82287f9..84c4a218a 100644 --- a/src/app/v1/_lib/proxy/forwarder.ts +++ b/src/app/v1/_lib/proxy/forwarder.ts @@ -3124,6 +3124,7 @@ export class ProxyForwarder { endpointId: attempt.endpointAudit.endpointId, endpointUrl: attempt.endpointAudit.endpointUrl, upstreamStatusCode: attempt.response.status, + isHedgeWinner: isActualHedgeWin, }); const response = new Response( diff --git a/src/app/v1/_lib/proxy/response-handler.ts b/src/app/v1/_lib/proxy/response-handler.ts index fd8ed9ba9..07fd69549 100644 --- a/src/app/v1/_lib/proxy/response-handler.ts +++ b/src/app/v1/_lib/proxy/response-handler.ts @@ -543,57 +543,64 @@ async function finalizeDeferredStreamingFinalizationIfNeeded( }); } - // 成功后绑定 session 到供应商(智能绑定策略) - if (session.sessionId) { - const result = await SessionManager.updateSessionBindingSmart( - session.sessionId, - meta.providerId, - meta.providerPriority, - meta.isFirstAttempt, - meta.isFailoverSuccess - ); + // Hedge winner: commitWinner() already performed session binding and chain logging. + // Skip duplicate operations to avoid double entries in the provider chain. + if (!meta.isHedgeWinner) { + // 成功后绑定 session 到供应商(智能绑定策略) + if (session.sessionId) { + const result = await SessionManager.updateSessionBindingSmart( + session.sessionId, + meta.providerId, + meta.providerPriority, + meta.isFirstAttempt, + meta.isFailoverSuccess + ); + + if (result.updated) { + logger.info("[ResponseHandler] Session binding updated (stream finalized)", { + sessionId: session.sessionId, + providerId: meta.providerId, + providerName: meta.providerName, + priority: meta.providerPriority, + reason: result.reason, + details: result.details, + attemptNumber: meta.attemptNumber, + totalProvidersAttempted: meta.totalProvidersAttempted, + }); + } else { + logger.debug("[ResponseHandler] Session binding not updated (stream finalized)", { + sessionId: session.sessionId, + providerId: meta.providerId, + providerName: meta.providerName, + priority: meta.providerPriority, + reason: result.reason, + details: result.details, + }); + } - if (result.updated) { - logger.info("[ResponseHandler] Session binding updated (stream finalized)", { - sessionId: session.sessionId, + // 统一更新两个数据源(确保监控数据一致) + void SessionManager.updateSessionProvider(session.sessionId, { providerId: meta.providerId, providerName: meta.providerName, - priority: meta.providerPriority, - reason: result.reason, - details: result.details, - attemptNumber: meta.attemptNumber, - totalProvidersAttempted: meta.totalProvidersAttempted, - }); - } else { - logger.debug("[ResponseHandler] Session binding not updated (stream finalized)", { - sessionId: session.sessionId, - providerId: meta.providerId, - providerName: meta.providerName, - priority: meta.providerPriority, - reason: result.reason, - details: result.details, + }).catch((err) => { + logger.error( + "[ResponseHandler] Failed to update session provider info (stream finalized)", + { + error: err, + } + ); }); } - // 统一更新两个数据源(确保监控数据一致) - void SessionManager.updateSessionProvider(session.sessionId, { - providerId: meta.providerId, - providerName: meta.providerName, - }).catch((err) => { - logger.error("[ResponseHandler] Failed to update session provider info (stream finalized)", { - error: err, - }); + session.addProviderToChain(providerForChain, { + endpointId: meta.endpointId, + endpointUrl: meta.endpointUrl, + reason: meta.isFirstAttempt ? "request_success" : "retry_success", + attemptNumber: meta.attemptNumber, + statusCode: meta.upstreamStatusCode, }); } - session.addProviderToChain(providerForChain, { - endpointId: meta.endpointId, - endpointUrl: meta.endpointUrl, - reason: meta.isFirstAttempt ? "request_success" : "retry_success", - attemptNumber: meta.attemptNumber, - statusCode: meta.upstreamStatusCode, - }); - logger.info("[ResponseHandler] Streaming request finalized as success", { providerId: meta.providerId, providerName: meta.providerName, diff --git a/src/app/v1/_lib/proxy/stream-finalization.ts b/src/app/v1/_lib/proxy/stream-finalization.ts index 9a915843e..3fd6b3da7 100644 --- a/src/app/v1/_lib/proxy/stream-finalization.ts +++ b/src/app/v1/_lib/proxy/stream-finalization.ts @@ -27,6 +27,8 @@ export type DeferredStreamingFinalization = { endpointId: number | null; endpointUrl: string; upstreamStatusCode: number; + /** When true, commitWinner() already performed session binding and chain logging; finalization should skip them. */ + isHedgeWinner?: boolean; }; const deferredMeta = new WeakMap(); diff --git a/tests/unit/proxy/hedge-winner-dedup.test.ts b/tests/unit/proxy/hedge-winner-dedup.test.ts new file mode 100644 index 000000000..ca78eebb7 --- /dev/null +++ b/tests/unit/proxy/hedge-winner-dedup.test.ts @@ -0,0 +1,237 @@ +/** + * Tests for hedge winner duplicate provider chain entry fix. + * + * Bug: When a streaming hedge request wins, commitWinner() logs the provider with + * reason "hedge_winner", then finalizeDeferredStreamingFinalizationIfNeeded() logs + * the same provider again with reason "retry_success". The dedup logic in + * addProviderToChain() doesn't catch this because "hedge_winner" !== "retry_success". + * + * Fix: Add isHedgeWinner flag to DeferredStreamingFinalization so finalization + * can skip duplicate session binding, provider update, and chain logging. + */ + +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { Provider } from "@/types/provider"; + +// ── stream-finalization round-trip ────────────────────────────────── + +describe("DeferredStreamingFinalization isHedgeWinner flag", () => { + beforeEach(() => { + vi.resetModules(); + }); + + it("should preserve isHedgeWinner=true through set/consume cycle", async () => { + const { setDeferredStreamingFinalization, consumeDeferredStreamingFinalization } = await import( + "@/app/v1/_lib/proxy/stream-finalization" + ); + + const fakeSession = {} as Parameters[0]; + + setDeferredStreamingFinalization(fakeSession, { + providerId: 1, + providerName: "test", + providerPriority: 10, + attemptNumber: 1, + totalProvidersAttempted: 2, + isFirstAttempt: false, + isFailoverSuccess: false, + endpointId: null, + endpointUrl: "https://api.example.com", + upstreamStatusCode: 200, + isHedgeWinner: true, + }); + + const meta = consumeDeferredStreamingFinalization(fakeSession); + expect(meta).not.toBeNull(); + expect(meta!.isHedgeWinner).toBe(true); + }); + + it("should preserve isHedgeWinner=false (non-hedge) through set/consume cycle", async () => { + const { setDeferredStreamingFinalization, consumeDeferredStreamingFinalization } = await import( + "@/app/v1/_lib/proxy/stream-finalization" + ); + + const fakeSession = {} as Parameters[0]; + + setDeferredStreamingFinalization(fakeSession, { + providerId: 1, + providerName: "test", + providerPriority: 10, + attemptNumber: 1, + totalProvidersAttempted: 1, + isFirstAttempt: true, + isFailoverSuccess: false, + endpointId: null, + endpointUrl: "https://api.example.com", + upstreamStatusCode: 200, + isHedgeWinner: false, + }); + + const meta = consumeDeferredStreamingFinalization(fakeSession); + expect(meta).not.toBeNull(); + expect(meta!.isHedgeWinner).toBe(false); + }); + + it("should default isHedgeWinner to undefined when not set", async () => { + const { setDeferredStreamingFinalization, consumeDeferredStreamingFinalization } = await import( + "@/app/v1/_lib/proxy/stream-finalization" + ); + + const fakeSession = {} as Parameters[0]; + + setDeferredStreamingFinalization(fakeSession, { + providerId: 1, + providerName: "test", + providerPriority: 10, + attemptNumber: 1, + totalProvidersAttempted: 1, + isFirstAttempt: true, + isFailoverSuccess: false, + endpointId: null, + endpointUrl: "https://api.example.com", + upstreamStatusCode: 200, + }); + + const meta = consumeDeferredStreamingFinalization(fakeSession); + expect(meta).not.toBeNull(); + expect(meta!.isHedgeWinner).toBeUndefined(); + }); +}); + +// ── addProviderToChain dedup gap (documents the bug) ──────────────── + +// These mocks must be declared before importing ProxySession +vi.mock("@/repository/model-price", () => ({ + findLatestPriceByModel: vi.fn(), +})); + +vi.mock("@/repository/system-config", () => ({ + getSystemSettings: vi.fn(), +})); + +vi.mock("@/repository/provider", () => ({ + findAllProviders: vi.fn(async () => []), +})); + +vi.mock("@/lib/redis/live-chain-store", () => ({ + writeLiveChain: vi.fn(), +})); + +import { ProxySession } from "@/app/v1/_lib/proxy/session"; + +const makeProvider = (id: number, name: string): Provider => + ({ + id, + name, + providerVendorId: 100, + providerType: "claude", + priority: 10, + weight: 1, + costMultiplier: 1, + groupTag: null, + isEnabled: true, + }) as unknown as Provider; + +function createSession(): ProxySession { + return 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: "test-model" }, + userAgent: null, + context: {}, + clientAbortSignal: null, + }); +} + +describe("addProviderToChain dedup behavior with hedge reasons", () => { + it("same provider with hedge_winner then retry_success produces duplicate (documents bug)", () => { + const session = createSession(); + const provider = makeProvider(1, "Provider A"); + + // commitWinner logs with hedge_winner + session.addProviderToChain(provider, { + reason: "hedge_winner", + attemptNumber: 1, + statusCode: 200, + endpointId: 10, + endpointUrl: "https://api.example.com", + }); + + // finalization would log with retry_success (the bug) + session.addProviderToChain(provider, { + reason: "retry_success", + attemptNumber: 1, + statusCode: 200, + endpointId: 10, + endpointUrl: "https://api.example.com", + }); + + const chain = session.getProviderChain(); + // Documents the current (broken) behavior: 2 entries for the same provider. + // After the fix, finalization won't call addProviderToChain for hedge winners, + // so this scenario won't arise in practice. + expect(chain).toHaveLength(2); + expect(chain[0].reason).toBe("hedge_winner"); + expect(chain[1].reason).toBe("retry_success"); + }); + + it("same provider with identical reason and attemptNumber deduplicates correctly", () => { + const session = createSession(); + const provider = makeProvider(1, "Provider A"); + + session.addProviderToChain(provider, { + reason: "request_success", + attemptNumber: 1, + statusCode: 200, + endpointId: 10, + endpointUrl: "https://api.example.com", + }); + + // Same reason + same attemptNumber -> should dedup + session.addProviderToChain(provider, { + reason: "request_success", + attemptNumber: 1, + statusCode: 200, + endpointId: 10, + endpointUrl: "https://api.example.com", + }); + + const chain = session.getProviderChain(); + expect(chain).toHaveLength(1); + expect(chain[0].reason).toBe("request_success"); + }); + + it("non-hedge finalization should add entry to chain normally", () => { + const session = createSession(); + const provider = makeProvider(1, "Provider A"); + + session.addProviderToChain(provider, { + reason: "request_success", + attemptNumber: 1, + statusCode: 200, + endpointId: 10, + endpointUrl: "https://api.example.com", + }); + + const chain = session.getProviderChain(); + expect(chain).toHaveLength(1); + expect(chain[0].reason).toBe("request_success"); + }); +}); From bff43b6e5fb2a669bdf108512b81764715aa82af Mon Sep 17 00:00:00 2001 From: ding113 Date: Wed, 11 Mar 2026 02:03:22 +0800 Subject: [PATCH 40/42] fix(proxy,dashboard): address PR #897 review comments - Move launchedProviderCount increment after endpoint resolution to prevent inflated count on resolution failure (hedge_winner misclass) - Add comment documenting intentional zero-length chunk skip - Add error logging to user-limit-usage-cache instead of silent swallow - Add React Query error states to 4 leaderboard user insight components - Add loadError i18n key across all 5 locales - Add trailing newlines to SQL migration files for POSIX compliance - Add test for endpoint resolution failure not inflating hedge count - Add test for cache error logging behavior --- drizzle/0079_easy_zeigeist.sql | 2 +- drizzle/0080_fresh_clint_barton.sql | 2 +- messages/en/dashboard.json | 3 +- messages/ja/dashboard.json | 3 +- messages/ru/dashboard.json | 3 +- messages/zh-CN/dashboard.json | 3 +- messages/zh-TW/dashboard.json | 3 +- .../_components/user-key-trend-chart.tsx | 12 +++- .../_components/user-model-breakdown.tsx | 9 ++- .../_components/user-overview-cards.tsx | 17 ++++- .../_components/user-provider-breakdown.tsx | 9 ++- src/app/v1/_lib/proxy/forwarder.ts | 6 +- .../dashboard/user-limit-usage-cache.test.ts | 15 ++++ src/lib/dashboard/user-limit-usage-cache.ts | 5 +- .../proxy-forwarder-hedge-first-byte.test.ts | 71 +++++++++++++++++++ 15 files changed, 147 insertions(+), 16 deletions(-) diff --git a/drizzle/0079_easy_zeigeist.sql b/drizzle/0079_easy_zeigeist.sql index 8862f1163..7de0318f1 100644 --- a/drizzle/0079_easy_zeigeist.sql +++ b/drizzle/0079_easy_zeigeist.sql @@ -1 +1 @@ -ALTER TABLE "system_settings" ADD COLUMN "enable_response_input_rectifier" boolean DEFAULT true NOT NULL; \ No newline at end of file +ALTER TABLE "system_settings" ADD COLUMN "enable_response_input_rectifier" boolean DEFAULT true NOT NULL; diff --git a/drizzle/0080_fresh_clint_barton.sql b/drizzle/0080_fresh_clint_barton.sql index 3d6122c9c..5cf71c93a 100644 --- a/drizzle/0080_fresh_clint_barton.sql +++ b/drizzle/0080_fresh_clint_barton.sql @@ -1 +1 @@ -ALTER TABLE "users" ADD COLUMN "cost_reset_at" timestamp with time zone; \ No newline at end of file +ALTER TABLE "users" ADD COLUMN "cost_reset_at" timestamp with time zone; diff --git a/messages/en/dashboard.json b/messages/en/dashboard.json index e461aff43..0590ae221 100644 --- a/messages/en/dashboard.json +++ b/messages/en/dashboard.json @@ -511,7 +511,8 @@ "allProviders": "All Providers", "allModels": "All Models", "dimensions": "Dimensions", - "filters": "Filters" + "filters": "Filters", + "loadError": "Failed to load data" } }, "sessions": { diff --git a/messages/ja/dashboard.json b/messages/ja/dashboard.json index 8622e991f..4e3b946cd 100644 --- a/messages/ja/dashboard.json +++ b/messages/ja/dashboard.json @@ -511,7 +511,8 @@ "allProviders": "すべてのプロバイダー", "allModels": "すべてのモデル", "dimensions": "ディメンション", - "filters": "フィルター" + "filters": "フィルター", + "loadError": "データの読み込みに失敗しました" } }, "sessions": { diff --git a/messages/ru/dashboard.json b/messages/ru/dashboard.json index 606138e10..1af0a96f2 100644 --- a/messages/ru/dashboard.json +++ b/messages/ru/dashboard.json @@ -511,7 +511,8 @@ "allProviders": "Все провайдеры", "allModels": "Все модели", "dimensions": "Измерения", - "filters": "Фильтры" + "filters": "Фильтры", + "loadError": "Не удалось загрузить данные" } }, "sessions": { diff --git a/messages/zh-CN/dashboard.json b/messages/zh-CN/dashboard.json index 77e449065..b4cea4419 100644 --- a/messages/zh-CN/dashboard.json +++ b/messages/zh-CN/dashboard.json @@ -511,7 +511,8 @@ "allProviders": "全部供应商", "allModels": "全部模型", "dimensions": "维度", - "filters": "筛选" + "filters": "筛选", + "loadError": "数据加载失败" } }, "sessions": { diff --git a/messages/zh-TW/dashboard.json b/messages/zh-TW/dashboard.json index c1399b555..cecc8daa7 100644 --- a/messages/zh-TW/dashboard.json +++ b/messages/zh-TW/dashboard.json @@ -511,7 +511,8 @@ "allProviders": "全部供應商", "allModels": "全部模型", "dimensions": "維度", - "filters": "篩選" + "filters": "篩選", + "loadError": "資料載入失敗" } }, "sessions": { diff --git a/src/app/[locale]/dashboard/leaderboard/user/[userId]/_components/user-key-trend-chart.tsx b/src/app/[locale]/dashboard/leaderboard/user/[userId]/_components/user-key-trend-chart.tsx index ac316b643..a1f61131e 100644 --- a/src/app/[locale]/dashboard/leaderboard/user/[userId]/_components/user-key-trend-chart.tsx +++ b/src/app/[locale]/dashboard/leaderboard/user/[userId]/_components/user-key-trend-chart.tsx @@ -1,6 +1,7 @@ "use client"; import { useQuery } from "@tanstack/react-query"; +import { AlertCircle } from "lucide-react"; import { useTranslations } from "next-intl"; import { useMemo } from "react"; import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts"; @@ -38,7 +39,11 @@ export function UserKeyTrendChart({ userId, timeRange, keyId }: UserKeyTrendChar const t = useTranslations("dashboard.leaderboard.userInsights"); const tStats = useTranslations("dashboard.stats"); - const { data: rawData, isLoading } = useQuery({ + const { + data: rawData, + isLoading, + isError, + } = useQuery({ queryKey: ["user-insights-key-trend", userId, timeRange], queryFn: async () => { const result = await getUserInsightsKeyTrend(userId, timeRange); @@ -115,6 +120,11 @@ export function UserKeyTrendChart({ userId, timeRange, keyId }: UserKeyTrendChar {isLoading ? ( + ) : isError ? ( +
+ + {t("loadError")} +
) : chartData.length === 0 ? (
{t("noData")} diff --git a/src/app/[locale]/dashboard/leaderboard/user/[userId]/_components/user-model-breakdown.tsx b/src/app/[locale]/dashboard/leaderboard/user/[userId]/_components/user-model-breakdown.tsx index 177184431..477d9e2c5 100644 --- a/src/app/[locale]/dashboard/leaderboard/user/[userId]/_components/user-model-breakdown.tsx +++ b/src/app/[locale]/dashboard/leaderboard/user/[userId]/_components/user-model-breakdown.tsx @@ -1,7 +1,7 @@ "use client"; import { useQuery } from "@tanstack/react-query"; -import { BarChart3 } from "lucide-react"; +import { AlertCircle, BarChart3 } from "lucide-react"; import { useTranslations } from "next-intl"; import { getUserInsightsModelBreakdown } from "@/actions/admin-user-insights"; import { @@ -33,7 +33,7 @@ export function UserModelBreakdown({ const filters = keyId || providerId ? { keyId, providerId } : undefined; - const { data, isLoading } = useQuery({ + const { data, isLoading, isError } = useQuery({ queryKey: ["user-insights-model-breakdown", userId, startDate, endDate, keyId, providerId], queryFn: async () => { const result = await getUserInsightsModelBreakdown(userId, startDate, endDate, filters); @@ -89,6 +89,11 @@ export function UserModelBreakdown({ ))}
+ ) : isError ? ( +
+ + {t("loadError")} +
) : items.length === 0 ? (
{t("noData")} diff --git a/src/app/[locale]/dashboard/leaderboard/user/[userId]/_components/user-overview-cards.tsx b/src/app/[locale]/dashboard/leaderboard/user/[userId]/_components/user-overview-cards.tsx index c0183f04c..681850de8 100644 --- a/src/app/[locale]/dashboard/leaderboard/user/[userId]/_components/user-overview-cards.tsx +++ b/src/app/[locale]/dashboard/leaderboard/user/[userId]/_components/user-overview-cards.tsx @@ -1,7 +1,7 @@ "use client"; import { useQuery } from "@tanstack/react-query"; -import { Activity, Clock, DollarSign, TrendingUp } from "lucide-react"; +import { Activity, AlertCircle, Clock, DollarSign, TrendingUp } from "lucide-react"; import { useTranslations } from "next-intl"; import { getUserInsightsOverview } from "@/actions/admin-user-insights"; import { Card, CardContent } from "@/components/ui/card"; @@ -20,7 +20,7 @@ function formatResponseTime(ms: number): string { export function UserOverviewCards({ userId }: UserOverviewCardsProps) { const t = useTranslations("dashboard.leaderboard.userInsights"); - const { data, isLoading } = useQuery({ + const { data, isLoading, isError } = useQuery({ queryKey: ["user-insights-overview", userId], queryFn: async () => { const result = await getUserInsightsOverview(userId); @@ -44,6 +44,19 @@ export function UserOverviewCards({ userId }: UserOverviewCardsProps) { ); } + if (isError) { + return ( + + +
+ + {t("loadError")} +
+
+
+ ); + } + if (!data) return null; const { overview, currencyCode } = data; diff --git a/src/app/[locale]/dashboard/leaderboard/user/[userId]/_components/user-provider-breakdown.tsx b/src/app/[locale]/dashboard/leaderboard/user/[userId]/_components/user-provider-breakdown.tsx index 99115e41e..5c046a763 100644 --- a/src/app/[locale]/dashboard/leaderboard/user/[userId]/_components/user-provider-breakdown.tsx +++ b/src/app/[locale]/dashboard/leaderboard/user/[userId]/_components/user-provider-breakdown.tsx @@ -1,7 +1,7 @@ "use client"; import { useQuery } from "@tanstack/react-query"; -import { Server } from "lucide-react"; +import { AlertCircle, Server } from "lucide-react"; import { useTranslations } from "next-intl"; import { getUserInsightsProviderBreakdown } from "@/actions/admin-user-insights"; import { @@ -33,7 +33,7 @@ export function UserProviderBreakdown({ const filters = keyId || model ? { keyId, model } : undefined; - const { data, isLoading } = useQuery({ + const { data, isLoading, isError } = useQuery({ queryKey: ["user-insights-provider-breakdown", userId, startDate, endDate, keyId, model], queryFn: async () => { const result = await getUserInsightsProviderBreakdown(userId, startDate, endDate, filters); @@ -89,6 +89,11 @@ export function UserProviderBreakdown({ ))}
+ ) : isError ? ( +
+ + {t("loadError")} +
) : items.length === 0 ? (
{t("noData")} diff --git a/src/app/v1/_lib/proxy/forwarder.ts b/src/app/v1/_lib/proxy/forwarder.ts index 84c4a218a..f93cc0558 100644 --- a/src/app/v1/_lib/proxy/forwarder.ts +++ b/src/app/v1/_lib/proxy/forwarder.ts @@ -3143,7 +3143,6 @@ export class ProxyForwarder { if (settled || winnerCommitted || launchedProviderIds.has(provider.id)) return; launchedProviderIds.add(provider.id); - launchedProviderCount += 1; let endpointSelection: { endpointId: number | null; @@ -3159,6 +3158,8 @@ export class ProxyForwarder { return; } + launchedProviderCount += 1; + const attemptSession = useOriginalSession ? session : ProxyForwarder.createStreamingShadowSession(session, provider); @@ -3509,6 +3510,9 @@ export class ProxyForwarder { if (result.done) { return result; } + // Skip zero-length chunks: some upstream providers (e.g. behind proxies/load-balancers) + // may emit empty chunks as keep-alive or framing artifacts. These carry no payload and + // must be silently skipped to avoid treating them as a valid "first byte" event. if (result.value && result.value.byteLength > 0) { return result; } diff --git a/src/lib/dashboard/user-limit-usage-cache.test.ts b/src/lib/dashboard/user-limit-usage-cache.test.ts index 92f720d44..00e3684b3 100644 --- a/src/lib/dashboard/user-limit-usage-cache.test.ts +++ b/src/lib/dashboard/user-limit-usage-cache.test.ts @@ -57,4 +57,19 @@ describe("user-limit-usage-cache", () => { expect(getUserAllLimitUsageMock).toHaveBeenCalledTimes(1); }); + + test("logs error and returns null when getUserAllLimitUsage rejects", async () => { + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const testError = new Error("network failure"); + getUserAllLimitUsageMock.mockRejectedValueOnce(testError); + + const result = await getSharedUserLimitUsage(42); + + expect(result).toBeNull(); + expect(consoleSpy).toHaveBeenCalledWith( + "[user-limit-usage-cache] getUserAllLimitUsage failed", + expect.objectContaining({ userId: 42, error: testError }) + ); + consoleSpy.mockRestore(); + }); }); diff --git a/src/lib/dashboard/user-limit-usage-cache.ts b/src/lib/dashboard/user-limit-usage-cache.ts index cfee5bac6..43502482c 100644 --- a/src/lib/dashboard/user-limit-usage-cache.ts +++ b/src/lib/dashboard/user-limit-usage-cache.ts @@ -56,7 +56,10 @@ export async function getSharedUserLimitUsage(userId: number): Promise null) + .catch((error) => { + console.error("[user-limit-usage-cache] getUserAllLimitUsage failed", { userId, error }); + return null; + }) .finally(() => { inFlightUsageRequests.delete(userId); }); diff --git a/tests/unit/proxy/proxy-forwarder-hedge-first-byte.test.ts b/tests/unit/proxy/proxy-forwarder-hedge-first-byte.test.ts index 446b5d0f9..d57a5baac 100644 --- a/tests/unit/proxy/proxy-forwarder-hedge-first-byte.test.ts +++ b/tests/unit/proxy/proxy-forwarder-hedge-first-byte.test.ts @@ -592,4 +592,75 @@ describe("ProxyForwarder - first-byte hedge scheduling", () => { vi.useRealTimers(); } }); + + test("endpoint resolution failure should not inflate launchedProviderCount, winner gets request_success not hedge_winner", async () => { + vi.useFakeTimers(); + + try { + const provider1 = createProvider({ + id: 1, + name: "p1", + providerVendorId: 123, + firstByteTimeoutStreamingMs: 100, + }); + const provider2 = createProvider({ + id: 2, + name: "p2", + providerVendorId: null, + firstByteTimeoutStreamingMs: 100, + }); + const session = createSession(); + session.requestUrl = new URL("https://example.com/v1/messages"); + session.setProvider(provider1); + + // Provider 1's strict endpoint resolution will fail + mocks.getPreferredProviderEndpoints.mockRejectedValueOnce( + new Error("Endpoint resolution failed") + ); + + // After provider 1 fails, pick provider 2 as alternative + mocks.pickRandomProviderWithExclusion.mockResolvedValueOnce(provider2); + + const doForward = vi.spyOn( + ProxyForwarder as unknown as { + doForward: (...args: unknown[]) => Promise; + }, + "doForward" + ); + + const controller2 = new AbortController(); + + // Only provider 2 reaches doForward (provider 1 fails at endpoint resolution) + doForward.mockImplementationOnce(async (attemptSession) => { + const runtime = attemptSession as ProxySession & AttemptRuntime; + runtime.responseController = controller2; + runtime.clearResponseTimeout = vi.fn(); + return createStreamingResponse({ + label: "p2", + firstChunkDelayMs: 10, + controller: controller2, + }); + }); + + const responsePromise = ProxyForwarder.send(session); + + await vi.advanceTimersByTimeAsync(200); + const response = await responsePromise; + + expect(await response.text()).toContain('"provider":"p2"'); + expect(session.provider?.id).toBe(2); + + // Key assertion: since only provider 2 actually launched (provider 1 failed at + // endpoint resolution before incrementing launchedProviderCount), the winner + // should be classified as "request_success" not "hedge_winner". + const chain = session.getProviderChain(); + const winnerEntry = chain.find( + (entry) => entry.reason === "request_success" || entry.reason === "hedge_winner" + ); + expect(winnerEntry).toBeDefined(); + expect(winnerEntry!.reason).toBe("request_success"); + } finally { + vi.useRealTimers(); + } + }); }); From 49ff9368b0b992092bce3bb54a4471285b538fe8 Mon Sep 17 00:00:00 2001 From: ding113 Date: Wed, 11 Mar 2026 10:16:05 +0800 Subject: [PATCH 41/42] fix(dashboard): resolve hedge winner provider incorrectly displayed in logs table The logs table computed finalProvider as the last entry in the provider chain, which returns the hedge loser (cancelled provider) instead of the actual winner. Add getFinalProviderName() utility with 3-tier priority: hedge_winner > last successful request > last entry fallback. Replace inline chain[length-1].name logic in both virtualized-logs-table and usage-logs-table with the new utility. --- .../logs/_components/usage-logs-table.tsx | 6 +- .../virtualized-logs-table.test.tsx | 7 +- .../_components/virtualized-logs-table.tsx | 5 +- .../utils/provider-chain-formatter.test.ts | 98 +++++++++++++++++++ src/lib/utils/provider-chain-formatter.ts | 29 ++++++ 5 files changed, 135 insertions(+), 10 deletions(-) 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 dc68c6620..dd9065476 100644 --- a/src/app/[locale]/dashboard/logs/_components/usage-logs-table.tsx +++ b/src/app/[locale]/dashboard/logs/_components/usage-logs-table.tsx @@ -26,7 +26,7 @@ import { shouldHideOutputRate, } from "@/lib/utils/performance-formatter"; import { shouldShowCostBadgeInCell } from "@/lib/utils/provider-chain-display"; -import { formatProviderSummary } from "@/lib/utils/provider-chain-formatter"; +import { formatProviderSummary, getFinalProviderName } from "@/lib/utils/provider-chain-formatter"; import { getPricingResolutionSpecialSetting, hasPriorityServiceTierSpecialSetting, @@ -202,9 +202,7 @@ export function UsageLogsTable({ 0 - ? log.providerChain[log.providerChain.length - 1].name - : null) || + getFinalProviderName(log.providerChain ?? []) || log.providerName || tChain("circuit.unknown") } 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 index 346f0ff6c..0a3f3ecb1 100644 --- a/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.test.tsx +++ b/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.test.tsx @@ -53,6 +53,7 @@ vi.mock("@/hooks/use-virtualizer", () => ({ vi.mock("@/lib/utils/provider-chain-formatter", () => ({ formatProviderSummary: () => "provider summary", + getFinalProviderName: () => "mock-provider", getRetryCount: () => 0, isHedgeRace: () => false, isActualRequest: () => true, @@ -303,9 +304,9 @@ describe("virtualized-logs-table multiplier badge", () => { const html = renderToStaticMarkup( ); - // VirtualizedLogsTable uses ProviderChainPopover which renders the provider name directly, - // not via formatProviderSummary (which is only used in other contexts) - expect(html).toContain("p1"); + // VirtualizedLogsTable uses ProviderChainPopover which renders the provider name + // via getFinalProviderName (mocked to return "mock-provider") + expect(html).toContain("mock-provider"); 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 2e35bff93..10288f03a 100644 --- a/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx +++ b/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx @@ -23,6 +23,7 @@ import { shouldHideOutputRate, } from "@/lib/utils/performance-formatter"; import { shouldShowCostBadgeInCell } from "@/lib/utils/provider-chain-display"; +import { getFinalProviderName } from "@/lib/utils/provider-chain-formatter"; import { isProviderFinalized } from "@/lib/utils/provider-display"; import { getPricingResolutionSpecialSetting, @@ -490,9 +491,7 @@ export function VirtualizedLogsTable({ 0 - ? log.providerChain[log.providerChain.length - 1].name - : null) || + getFinalProviderName(log.providerChain ?? []) || log.providerName || tChain("circuit.unknown") } diff --git a/src/lib/utils/provider-chain-formatter.test.ts b/src/lib/utils/provider-chain-formatter.test.ts index 11c96ec1a..8864f4020 100644 --- a/src/lib/utils/provider-chain-formatter.test.ts +++ b/src/lib/utils/provider-chain-formatter.test.ts @@ -6,6 +6,7 @@ import { formatProviderDescription, formatProviderSummary, formatProviderTimeline, + getFinalProviderName, getRetryCount, isActualRequest, isHedgeRace, @@ -797,3 +798,100 @@ describe("Edge cases for hedge race detection", () => { ).toBe(true); }); }); + +// ============================================================================= +// getFinalProviderName tests +// ============================================================================= + +describe("getFinalProviderName", () => { + test("returns null for empty chain", () => { + expect(getFinalProviderName([])).toBeNull(); + }); + + test("returns null for null/undefined chain", () => { + expect(getFinalProviderName(null as unknown as ProviderChainItem[])).toBeNull(); + expect(getFinalProviderName(undefined as unknown as ProviderChainItem[])).toBeNull(); + }); + + test("returns provider name for single request_success", () => { + const chain: ProviderChainItem[] = [ + { id: 1, name: "provider-a", reason: "request_success", statusCode: 200, timestamp: 1000 }, + ]; + expect(getFinalProviderName(chain)).toBe("provider-a"); + }); + + test("returns hedge_winner provider when hedge_loser_cancelled is last", () => { + const chain: ProviderChainItem[] = [ + { id: 1, name: "provider-a", reason: "initial_selection", timestamp: 0 }, + { id: 1, name: "provider-a", reason: "hedge_triggered", timestamp: 1000 }, + { id: 2, name: "provider-b", reason: "hedge_launched", timestamp: 1001 }, + { + id: 2, + name: "provider-b", + reason: "hedge_winner", + statusCode: 200, + timestamp: 2000, + }, + { id: 1, name: "provider-a", reason: "hedge_loser_cancelled", timestamp: 2001 }, + ]; + expect(getFinalProviderName(chain)).toBe("provider-b"); + }); + + test("returns retry_success provider for retry chain", () => { + const chain: ProviderChainItem[] = [ + { id: 1, name: "provider-a", reason: "retry_failed", timestamp: 1000 }, + { + id: 2, + name: "provider-b", + reason: "retry_success", + statusCode: 200, + timestamp: 2000, + }, + ]; + expect(getFinalProviderName(chain)).toBe("provider-b"); + }); + + test("returns last entry name when all entries are failures", () => { + const chain: ProviderChainItem[] = [ + { id: 1, name: "provider-a", reason: "retry_failed", timestamp: 1000 }, + { id: 2, name: "provider-b", reason: "retry_failed", timestamp: 2000 }, + ]; + expect(getFinalProviderName(chain)).toBe("provider-b"); + }); + + test("returns last entry name for intermediate-only chain", () => { + const chain: ProviderChainItem[] = [ + { id: 1, name: "provider-a", reason: "initial_selection", timestamp: 0 }, + ]; + expect(getFinalProviderName(chain)).toBe("provider-a"); + }); + + test("returns fallback for retry_success without statusCode", () => { + const chain: ProviderChainItem[] = [ + { id: 1, name: "provider-a", reason: "retry_success", timestamp: 1000 }, + ]; + // No statusCode means it's an intermediate state, falls through to last-entry fallback + expect(getFinalProviderName(chain)).toBe("provider-a"); + }); + + test("hedge_winner takes priority over request_success earlier in chain", () => { + // Edge case: both hedge_winner and request_success present + const chain: ProviderChainItem[] = [ + { + id: 1, + name: "provider-a", + reason: "request_success", + statusCode: 200, + timestamp: 500, + }, + { + id: 2, + name: "provider-b", + reason: "hedge_winner", + statusCode: 200, + timestamp: 2000, + }, + ]; + expect(getFinalProviderName(chain)).toBe("provider-b"); + }); +}); diff --git a/src/lib/utils/provider-chain-formatter.ts b/src/lib/utils/provider-chain-formatter.ts index 374036af4..3629d5f8b 100644 --- a/src/lib/utils/provider-chain-formatter.ts +++ b/src/lib/utils/provider-chain-formatter.ts @@ -151,6 +151,35 @@ export function isHedgeRace(chain: ProviderChainItem[]): boolean { ); } +/** + * Determine the final (winning) provider from a decision chain. + * + * Priority order: + * 1. hedge_winner -- the provider that won a hedge race + * 2. Last request_success / retry_success with a statusCode + * 3. Fallback to the last entry's name + * + * Returns null for empty / nullish chains. + */ +export function getFinalProviderName(chain: ProviderChainItem[] | null | undefined): string | null { + if (!chain || chain.length === 0) return null; + + // Priority 1: hedge_winner + const hedgeWinner = chain.find((item) => item.reason === "hedge_winner"); + if (hedgeWinner) return hedgeWinner.name; + + // Priority 2: last successful request (must have statusCode) + for (let i = chain.length - 1; i >= 0; i--) { + const item = chain[i]; + if ((item.reason === "request_success" || item.reason === "retry_success") && item.statusCode) { + return item.name; + } + } + + // Priority 3: fallback to last entry + return chain[chain.length - 1].name; +} + /** * Count real retries (excluding hedge race concurrent attempts). * From 14740d1925d8fcd32524f3576be074363b5c2b40 Mon Sep 17 00:00:00 2001 From: ding113 Date: Wed, 11 Mar 2026 15:00:49 +0800 Subject: [PATCH 42/42] fix(i18n): add 12 missing batchEdit translation keys for all 5 locales Add missing i18n keys referenced in batch edit UI components: - fields.isEnabled: noChange/enable/disable options for batch dropdown - affectedProviders: title and overflow text for provider summaries - batchNotes: codexOnly/claudeOnly/geminiOnly badge labels - undo: singleEditSuccess/singleEditUndone/singleDeleteSuccess/singleDeleteUndone toasts --- messages/en/settings/providers/batchEdit.json | 18 +++++++++++++++++- messages/ja/settings/providers/batchEdit.json | 18 +++++++++++++++++- messages/ru/settings/providers/batchEdit.json | 18 +++++++++++++++++- .../zh-CN/settings/providers/batchEdit.json | 18 +++++++++++++++++- .../zh-TW/settings/providers/batchEdit.json | 18 +++++++++++++++++- 5 files changed, 85 insertions(+), 5 deletions(-) diff --git a/messages/en/settings/providers/batchEdit.json b/messages/en/settings/providers/batchEdit.json index 60a462fbf..0946acf05 100644 --- a/messages/en/settings/providers/batchEdit.json +++ b/messages/en/settings/providers/batchEdit.json @@ -40,7 +40,10 @@ }, "fields": { "isEnabled": { - "label": "Enabled" + "label": "Enabled", + "noChange": "No Change", + "enable": "Enable", + "disable": "Disable" }, "priority": "Priority", "weight": "Weight", @@ -67,6 +70,10 @@ "batchDeleteSuccess": "{count} providers deleted", "button": "Undo", "batchDeleteUndone": "{count} providers restored", + "singleEditSuccess": "Provider updated", + "singleEditUndone": "Provider update reverted", + "singleDeleteSuccess": "Provider deleted", + "singleDeleteUndone": "Provider restored", "expired": "Undo expired", "failed": "Undo failed" }, @@ -76,6 +83,15 @@ "confirm": "Confirm", "processing": "Processing..." }, + "affectedProviders": { + "title": "Affected Providers", + "more": "+{count} more" + }, + "batchNotes": { + "codexOnly": "Codex only", + "claudeOnly": "Claude only", + "geminiOnly": "Gemini only" + }, "mixedValues": { "label": "(mixed values)", "tooltip": "Selected providers have different values:", diff --git a/messages/ja/settings/providers/batchEdit.json b/messages/ja/settings/providers/batchEdit.json index 3e09e3b38..be7f18739 100644 --- a/messages/ja/settings/providers/batchEdit.json +++ b/messages/ja/settings/providers/batchEdit.json @@ -40,7 +40,10 @@ }, "fields": { "isEnabled": { - "label": "有効状態" + "label": "有効状態", + "noChange": "変更なし", + "enable": "有効にする", + "disable": "無効にする" }, "priority": "優先度", "weight": "重み", @@ -67,6 +70,10 @@ "batchDeleteSuccess": "{count} 件のプロバイダーを削除しました", "button": "元に戻す", "batchDeleteUndone": "{count} 件のプロバイダーを復元しました", + "singleEditSuccess": "プロバイダーを更新しました", + "singleEditUndone": "プロバイダーの更新を元に戻しました", + "singleDeleteSuccess": "プロバイダーを削除しました", + "singleDeleteUndone": "プロバイダーを復元しました", "expired": "元に戻す期限切れ", "failed": "元に戻す操作に失敗" }, @@ -76,6 +83,15 @@ "confirm": "確認", "processing": "処理中..." }, + "affectedProviders": { + "title": "対象プロバイダー", + "more": "他 {count} 件" + }, + "batchNotes": { + "codexOnly": "Codex のみ", + "claudeOnly": "Claude のみ", + "geminiOnly": "Gemini のみ" + }, "mixedValues": { "label": "(混合値)", "tooltip": "選択されたプロバイダーには異なる値があります:", diff --git a/messages/ru/settings/providers/batchEdit.json b/messages/ru/settings/providers/batchEdit.json index 2c9db06c8..986226327 100644 --- a/messages/ru/settings/providers/batchEdit.json +++ b/messages/ru/settings/providers/batchEdit.json @@ -40,7 +40,10 @@ }, "fields": { "isEnabled": { - "label": "Включен" + "label": "Включен", + "noChange": "Без изменений", + "enable": "Включить", + "disable": "Отключить" }, "priority": "Приоритет", "weight": "Вес", @@ -67,6 +70,10 @@ "batchDeleteSuccess": "Удалено провайдеров: {count}", "button": "Отменить", "batchDeleteUndone": "Восстановлено провайдеров: {count}", + "singleEditSuccess": "Провайдер обновлен", + "singleEditUndone": "Обновление провайдера отменено", + "singleDeleteSuccess": "Провайдер удален", + "singleDeleteUndone": "Провайдер восстановлен", "expired": "Отмена истекла", "failed": "Ошибка отмены" }, @@ -76,6 +83,15 @@ "confirm": "Подтвердить", "processing": "Обработка..." }, + "affectedProviders": { + "title": "Затронутые провайдеры", + "more": "и еще {count}" + }, + "batchNotes": { + "codexOnly": "Только Codex", + "claudeOnly": "Только Claude", + "geminiOnly": "Только Gemini" + }, "mixedValues": { "label": "(смешанные значения)", "tooltip": "Выбранные провайдеры имеют разные значения:", diff --git a/messages/zh-CN/settings/providers/batchEdit.json b/messages/zh-CN/settings/providers/batchEdit.json index 28869221a..f3978ef7d 100644 --- a/messages/zh-CN/settings/providers/batchEdit.json +++ b/messages/zh-CN/settings/providers/batchEdit.json @@ -40,7 +40,10 @@ }, "fields": { "isEnabled": { - "label": "启用状态" + "label": "启用状态", + "noChange": "不修改", + "enable": "启用", + "disable": "禁用" }, "priority": "优先级", "weight": "权重", @@ -67,6 +70,10 @@ "batchDeleteSuccess": "已删除 {count} 个供应商", "button": "撤销", "batchDeleteUndone": "已恢复 {count} 个供应商", + "singleEditSuccess": "供应商已更新", + "singleEditUndone": "已撤销供应商更新", + "singleDeleteSuccess": "供应商已删除", + "singleDeleteUndone": "供应商已恢复", "expired": "撤销已过期", "failed": "撤销失败" }, @@ -76,6 +83,15 @@ "confirm": "确认", "processing": "处理中..." }, + "affectedProviders": { + "title": "受影响的供应商", + "more": "还有 {count} 个" + }, + "batchNotes": { + "codexOnly": "仅限 Codex", + "claudeOnly": "仅限 Claude", + "geminiOnly": "仅限 Gemini" + }, "mixedValues": { "label": "(混合值)", "tooltip": "选中的供应商有不同的值:", diff --git a/messages/zh-TW/settings/providers/batchEdit.json b/messages/zh-TW/settings/providers/batchEdit.json index 267234843..eede51fab 100644 --- a/messages/zh-TW/settings/providers/batchEdit.json +++ b/messages/zh-TW/settings/providers/batchEdit.json @@ -40,7 +40,10 @@ }, "fields": { "isEnabled": { - "label": "啟用狀態" + "label": "啟用狀態", + "noChange": "不修改", + "enable": "啟用", + "disable": "停用" }, "priority": "優先順序", "weight": "權重", @@ -67,6 +70,10 @@ "batchDeleteSuccess": "已刪除 {count} 個供應商", "button": "復原", "batchDeleteUndone": "已還原 {count} 個供應商", + "singleEditSuccess": "供應商已更新", + "singleEditUndone": "已復原供應商更新", + "singleDeleteSuccess": "供應商已刪除", + "singleDeleteUndone": "供應商已還原", "expired": "復原已過期", "failed": "復原失敗" }, @@ -76,6 +83,15 @@ "confirm": "確認", "processing": "處理中..." }, + "affectedProviders": { + "title": "受影響的供應商", + "more": "還有 {count} 個" + }, + "batchNotes": { + "codexOnly": "僅限 Codex", + "claudeOnly": "僅限 Claude", + "geminiOnly": "僅限 Gemini" + }, "mixedValues": { "label": "(混合值)", "tooltip": "選中的供應商有不同的值:",