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 */}
-
+
- {TAB_CONFIG.map((tab) => {
+ {filteredTabs.map((tab) => {
const Icon = tab.icon;
const isActive = activeTab === tab.id;
const status = tabStatus[tab.id];
@@ -212,12 +313,80 @@ export function FormTabNav({
);
})}
+ {(() => {
+ const activeItem = filteredNav.find((item) => item.id === activeTab);
+ if (!activeItem?.children?.length) return null;
+ return (
+
+ {activeItem.children.map((child) => {
+ const ChildIcon = child.icon;
+ const isChildActive = activeSubTab === child.id;
+ return (
+ onSubTabChange?.(child.id)}
+ disabled={disabled}
+ className={cn(
+ "flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-md transition-all whitespace-nowrap",
+ "hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
+ isChildActive ? "text-primary bg-primary/10" : "text-muted-foreground",
+ disabled && "opacity-50 cursor-not-allowed"
+ )}
+ >
+
+ {t(child.labelKey)}
+
+ );
+ })}
+
+ );
+ })()}
{/* Mobile: Bottom Navigation */}
-
+
+ {(() => {
+ const activeItem = filteredNav.find((item) => item.id === activeTab);
+ if (!activeItem?.children?.length) return null;
+ return (
+
+ {activeItem.children.map((child) => {
+ const ChildIcon = child.icon;
+ const isChildActive = activeSubTab === child.id;
+ return (
+ onSubTabChange?.(child.id)}
+ disabled={disabled}
+ className={cn(
+ "flex items-center gap-1 px-2 py-1 text-[10px] font-medium rounded-md transition-all whitespace-nowrap",
+ "hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
+ isChildActive ? "text-primary bg-primary/10" : "text-muted-foreground",
+ disabled && "opacity-50 cursor-not-allowed"
+ )}
+ >
+
+ {t(child.labelKey)}
+
+ );
+ })}
+
+ );
+ })()}
- {TAB_CONFIG.map((tab) => {
+ {filteredTabs.map((tab) => {
const Icon = tab.icon;
const isActive = activeTab === tab.id;
const status = tabStatus[tab.id];
@@ -269,7 +438,7 @@ export function FormTabNav({
role="progressbar"
aria-valuenow={stepNumber}
aria-valuemin={0}
- aria-valuemax={TAB_CONFIG.length}
+ aria-valuemax={filteredTabs.length}
aria-label={t("tabs.stepProgress")}
>
(null);
- const sectionRefs = useRef>({
- basic: null,
- routing: null,
- limits: null,
- network: null,
- testing: null,
- });
+ const sectionRefs = useRef>(
+ Object.fromEntries(NAV_ORDER.map((id) => [id, null])) as Record<
+ NavTargetId,
+ HTMLDivElement | null
+ >
+ );
const isScrollingToSection = useRef(false);
+ const rafRef = useRef(null);
+ const scrollLockTimerRef = useRef | null>(null);
+ const scrollEndListenerRef = useRef<(() => void) | null>(null);
+
+ useEffect(() => {
+ return () => {
+ if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
+ if (scrollLockTimerRef.current) clearTimeout(scrollLockTimerRef.current);
+ if (scrollEndListenerRef.current) {
+ contentRef.current?.removeEventListener("scrollend", scrollEndListenerRef.current);
+ }
+ };
+ }, []);
// Scroll to section when tab is clicked
- const scrollToSection = useCallback((tab: TabId) => {
+ const scrollToSection = useCallback((tab: NavTargetId) => {
const section = sectionRefs.current[tab];
if (section && contentRef.current) {
isScrollingToSection.current = true;
@@ -206,47 +217,70 @@ function ProviderFormContent({
const sectionTop = section.getBoundingClientRect().top;
const offset = sectionTop - containerTop + contentRef.current.scrollTop;
contentRef.current.scrollTo({ top: offset, behavior: "smooth" });
- setTimeout(() => {
+ if (scrollLockTimerRef.current) clearTimeout(scrollLockTimerRef.current);
+ if (scrollEndListenerRef.current) {
+ contentRef.current.removeEventListener("scrollend", scrollEndListenerRef.current);
+ }
+ const unlock = () => {
isScrollingToSection.current = false;
- }, 500);
+ };
+ const onScrollEnd = () => {
+ if (scrollLockTimerRef.current) clearTimeout(scrollLockTimerRef.current);
+ scrollEndListenerRef.current = null;
+ unlock();
+ };
+ scrollEndListenerRef.current = onScrollEnd;
+ contentRef.current.addEventListener("scrollend", onScrollEnd, { once: true });
+ scrollLockTimerRef.current = setTimeout(() => {
+ contentRef.current?.removeEventListener("scrollend", onScrollEnd);
+ scrollEndListenerRef.current = null;
+ unlock();
+ }, 1000);
}
}, []);
- // Detect active section based on scroll position
+ // Detect active section based on scroll position (throttled via rAF)
const handleScroll = useCallback(() => {
- if (isScrollingToSection.current || !contentRef.current) return;
-
- const container = contentRef.current;
- const containerRect = container.getBoundingClientRect();
- const _scrollTop = container.scrollTop;
-
- // Find which section is at the top of the viewport
- let activeSection: TabId = "basic";
- let minDistance = Infinity;
-
- for (const tab of TAB_ORDER) {
- const section = sectionRefs.current[tab];
- if (!section) continue;
-
- const sectionRect = section.getBoundingClientRect();
- const distanceFromTop = Math.abs(sectionRect.top - containerRect.top);
-
- if (distanceFromTop < minDistance) {
- minDistance = distanceFromTop;
- activeSection = tab;
+ if (rafRef.current !== null) return;
+ rafRef.current = requestAnimationFrame(() => {
+ rafRef.current = null;
+ if (isScrollingToSection.current || !contentRef.current) return;
+ const container = contentRef.current;
+ const containerRect = container.getBoundingClientRect();
+ let activeSection: NavTargetId = TAB_ORDER[0] ?? "basic";
+ let minDistance = Infinity;
+ for (const id of NAV_ORDER) {
+ const section = sectionRefs.current[id];
+ if (!section) continue;
+ const sectionRect = section.getBoundingClientRect();
+ const distanceFromTop = Math.abs(sectionRect.top - containerRect.top);
+ if (distanceFromTop < minDistance) {
+ minDistance = distanceFromTop;
+ activeSection = id;
+ }
}
- }
-
- if (state.ui.activeTab !== activeSection) {
- dispatch({ type: "SET_ACTIVE_TAB", payload: activeSection });
- }
- }, [dispatch, state.ui.activeTab]);
+ const parentTab =
+ activeSection in PARENT_MAP
+ ? PARENT_MAP[activeSection as SubTabId]
+ : (activeSection as TabId);
+ const subTab = activeSection in PARENT_MAP ? (activeSection as SubTabId) : null;
+ if (state.ui.activeTab !== parentTab || state.ui.activeSubTab !== subTab) {
+ dispatch({ type: "SET_ACTIVE_NAV", payload: { tab: parentTab, subTab } });
+ }
+ });
+ }, [dispatch, state.ui.activeSubTab, state.ui.activeTab]);
const handleTabChange = (tab: TabId) => {
dispatch({ type: "SET_ACTIVE_TAB", payload: tab });
scrollToSection(tab);
};
+ const handleSubTabChange = (subTab: SubTabId) => {
+ const parentTab = PARENT_MAP[subTab];
+ dispatch({ type: "SET_ACTIVE_NAV", payload: { tab: parentTab, subTab } });
+ scrollToSection(subTab);
+ };
+
// Sync isPending to context
useEffect(() => {
dispatch({ type: "SET_IS_PENDING", payload: isPending });
@@ -527,7 +561,32 @@ function ProviderFormContent({
status.routing = "configured";
}
- // Limits - configured if any limit set
+ if (
+ // Advanced options
+ state.routing.preserveClientIp ||
+ state.routing.cacheTtlPreference !== "inherit" ||
+ state.routing.swapCacheTtlBilling ||
+ state.routing.context1mPreference !== "inherit" ||
+ // Codex overrides
+ state.routing.codexReasoningEffortPreference !== "inherit" ||
+ state.routing.codexReasoningSummaryPreference !== "inherit" ||
+ state.routing.codexTextVerbosityPreference !== "inherit" ||
+ state.routing.codexParallelToolCallsPreference !== "inherit" ||
+ state.routing.codexServiceTierPreference !== "inherit" ||
+ // Anthropic overrides
+ state.routing.anthropicMaxTokensPreference !== "inherit" ||
+ state.routing.anthropicThinkingBudgetPreference !== "inherit" ||
+ state.routing.anthropicAdaptiveThinking !== null ||
+ // Gemini overrides
+ state.routing.geminiGoogleSearchPreference !== "inherit" ||
+ // Active time
+ state.routing.activeTimeStart !== null ||
+ state.routing.activeTimeEnd !== null
+ ) {
+ status.options = "configured";
+ }
+
+ // Limits - configured if any rate limit set
if (
state.rateLimit.limit5hUsd ||
state.rateLimit.limitDailyUsd ||
@@ -563,7 +622,9 @@ function ProviderFormContent({
{/* Tab Navigation */}
@@ -602,7 +663,28 @@ function ProviderFormContent({
sectionRefs.current.routing = el;
}}
>
-
+ {
+ sectionRefs.current.scheduling = el;
+ },
+ }}
+ />
+
+
+ {/* Options Section */}
+ {
+ sectionRefs.current.options = el;
+ }}
+ >
+ {
+ sectionRefs.current.activeTime = el;
+ },
+ }}
+ />
{/* Limits Section */}
@@ -611,7 +693,13 @@ function ProviderFormContent({
sectionRefs.current.limits = el;
}}
>
-
+ {
+ sectionRefs.current.circuitBreaker = el;
+ },
+ }}
+ />
{/* 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 })
+
+
-
- {/* 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,
- })}
-
- )}
+ >
+
+
+
+
+ {providerTypes.map((type) => {
+ const typeConfig = getProviderTypeConfig(type);
+ const TypeIcon = typeConfig.icon;
+ const label = renderProviderTypeLabel(type);
+ return (
+
+
+
+
+
+ {label}
+
+
+ );
+ })}
+
+
+ {!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) => (
+ onCheckedChange(!checked)}
+ />
+ ),
+}));
+vi.mock("@/components/ui/tooltip", () => ({
+ TooltipProvider: ({ children }: any) => <>{children}>,
+ Tooltip: ({ children }: any) => <>{children}>,
+ TooltipTrigger: ({ children }: any) => <>{children}>,
+ TooltipContent: ({ children }: any) => <>{children}>,
+}));
+
+import type React from "react";
+import { act } from "react";
+import { createRoot } from "react-dom/client";
+import { OptionsSection } from "@/app/[locale]/settings/providers/_components/forms/provider-form/sections/options-section";
+import type { ProviderFormState } from "@/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-types";
+
+function render(node: React.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();
+ },
+ };
+}
+
+function createMockState(
+ overrides: {
+ routing?: Partial;
+ ui?: Partial;
+ } = {}
+): ProviderFormState {
+ return {
+ basic: {
+ name: "",
+ url: "",
+ key: "",
+ websiteUrl: "",
+ },
+ routing: {
+ providerType: "claude",
+ groupTag: [],
+ preserveClientIp: false,
+ modelRedirects: {},
+ allowedModels: [],
+ allowedClients: [],
+ blockedClients: [],
+ priority: 0,
+ groupPriorities: {},
+ weight: 1,
+ costMultiplier: 1,
+ cacheTtlPreference: "inherit",
+ swapCacheTtlBilling: false,
+ context1mPreference: "inherit",
+ codexReasoningEffortPreference: "inherit",
+ codexReasoningSummaryPreference: "inherit",
+ codexTextVerbosityPreference: "inherit",
+ codexParallelToolCallsPreference: "inherit",
+ codexServiceTierPreference: "inherit",
+ anthropicMaxTokensPreference: "inherit",
+ anthropicThinkingBudgetPreference: "inherit",
+ anthropicAdaptiveThinking: null,
+ geminiGoogleSearchPreference: "inherit",
+ activeTimeStart: null,
+ activeTimeEnd: null,
+ ...overrides.routing,
+ },
+ rateLimit: {
+ limit5hUsd: null,
+ limitDailyUsd: null,
+ dailyResetMode: "fixed",
+ dailyResetTime: "00:00",
+ limitWeeklyUsd: null,
+ limitMonthlyUsd: null,
+ limitTotalUsd: null,
+ limitConcurrentSessions: null,
+ },
+ circuitBreaker: {
+ failureThreshold: undefined,
+ openDurationMinutes: undefined,
+ halfOpenSuccessThreshold: undefined,
+ maxRetryAttempts: null,
+ },
+ network: {
+ proxyUrl: "",
+ proxyFallbackToDirect: false,
+ firstByteTimeoutStreamingSeconds: undefined,
+ streamingIdleTimeoutSeconds: undefined,
+ requestTimeoutNonStreamingSeconds: undefined,
+ },
+ mcp: {
+ mcpPassthroughType: "none",
+ mcpPassthroughUrl: "",
+ },
+ batch: {
+ isEnabled: "no_change",
+ },
+ ui: {
+ activeTab: "basic",
+ activeSubTab: null,
+ isPending: false,
+ showFailureThresholdConfirm: false,
+ ...overrides.ui,
+ },
+ };
+}
+
+function setMockForm({
+ state = createMockState(),
+ mode = "create",
+}: {
+ state?: ProviderFormState;
+ mode?: "create" | "edit" | "batch";
+} = {}) {
+ mockUseProviderForm.mockReturnValue({
+ state,
+ dispatch: mockDispatch,
+ mode,
+ enableMultiProviderTypes: true,
+ hideUrl: false,
+ hideWebsiteUrl: false,
+ groupSuggestions: [],
+ dirtyFields: new Set(),
+ });
+}
+
+function renderSection({
+ state = createMockState(),
+ mode = "create",
+}: {
+ state?: ProviderFormState;
+ mode?: "create" | "edit" | "batch";
+} = {}) {
+ setMockForm({ state, mode });
+ return render();
+}
+
+function getBodyText() {
+ return document.body.textContent || "";
+}
+
+function getActiveTimeToggle(container: HTMLDivElement) {
+ return container.querySelectorAll('[data-testid="switch"]')[2] as HTMLButtonElement | null;
+}
+
+describe("OptionsSection", () => {
+ beforeEach(() => {
+ while (document.body.firstChild) {
+ document.body.removeChild(document.body.firstChild);
+ }
+ vi.clearAllMocks();
+ setMockForm();
+ });
+
+ describe("common section rendering", () => {
+ it("renders Advanced Settings section", () => {
+ const { unmount } = renderSection();
+
+ expect(getBodyText()).toContain("sections.routing.options.title");
+
+ unmount();
+ });
+
+ it("renders preserveClientIp toggle", () => {
+ const { unmount } = renderSection();
+
+ expect(document.getElementById("preserve-client-ip")).toBeTruthy();
+
+ unmount();
+ });
+
+ it("renders swapCacheTtlBilling toggle", () => {
+ const { unmount } = renderSection();
+
+ expect(document.getElementById("swap-cache-ttl-billing")).toBeTruthy();
+
+ unmount();
+ });
+
+ it("renders active time section", () => {
+ const { unmount } = renderSection();
+
+ expect(getBodyText()).toContain("sections.routing.activeTime.title");
+
+ unmount();
+ });
+ });
+
+ describe("conditional rendering - claude provider", () => {
+ it("shows context1m for claude type", () => {
+ const { unmount } = renderSection({
+ state: createMockState({ routing: { providerType: "claude" } }),
+ });
+
+ expect(getBodyText()).toContain("sections.routing.context1m.label");
+
+ unmount();
+ });
+
+ it("shows Anthropic overrides for claude type", () => {
+ const { unmount } = renderSection({
+ state: createMockState({ routing: { providerType: "claude" } }),
+ });
+
+ expect(getBodyText()).toContain("sections.routing.anthropicOverrides.maxTokens.label");
+
+ unmount();
+ });
+
+ it("hides Codex overrides for claude type", () => {
+ const { unmount } = renderSection({
+ state: createMockState({ routing: { providerType: "claude" } }),
+ });
+
+ expect(getBodyText()).not.toContain("sections.routing.codexOverrides.title");
+
+ unmount();
+ });
+
+ it("hides Gemini overrides for claude type", () => {
+ const { unmount } = renderSection({
+ state: createMockState({ routing: { providerType: "claude" } }),
+ });
+
+ expect(getBodyText()).not.toContain("sections.routing.geminiOverrides.title");
+
+ unmount();
+ });
+ });
+
+ describe("conditional rendering - codex provider", () => {
+ it("shows Codex overrides for codex type", () => {
+ const { unmount } = renderSection({
+ state: createMockState({ routing: { providerType: "codex" } }),
+ });
+
+ expect(getBodyText()).toContain("sections.routing.codexOverrides.title");
+
+ unmount();
+ });
+
+ it("hides context1m for codex type", () => {
+ const { unmount } = renderSection({
+ state: createMockState({ routing: { providerType: "codex" } }),
+ });
+
+ expect(getBodyText()).not.toContain("sections.routing.context1m.label");
+
+ unmount();
+ });
+
+ it("hides Anthropic overrides for codex type", () => {
+ const { unmount } = renderSection({
+ state: createMockState({ routing: { providerType: "codex" } }),
+ });
+
+ expect(getBodyText()).not.toContain("sections.routing.anthropicOverrides.maxTokens.label");
+
+ unmount();
+ });
+ });
+
+ describe("conditional rendering - gemini provider", () => {
+ it("shows Gemini overrides for gemini type", () => {
+ const { unmount } = renderSection({
+ state: createMockState({ routing: { providerType: "gemini" } }),
+ });
+
+ expect(getBodyText()).toContain("sections.routing.geminiOverrides.title");
+
+ unmount();
+ });
+
+ it("hides Codex overrides for gemini type", () => {
+ const { unmount } = renderSection({
+ state: createMockState({ routing: { providerType: "gemini" } }),
+ });
+
+ expect(getBodyText()).not.toContain("sections.routing.codexOverrides.title");
+
+ unmount();
+ });
+
+ it("hides Anthropic overrides for gemini type", () => {
+ const { unmount } = renderSection({
+ state: createMockState({ routing: { providerType: "gemini" } }),
+ });
+
+ expect(getBodyText()).not.toContain("sections.routing.anthropicOverrides.maxTokens.label");
+
+ unmount();
+ });
+ });
+
+ describe("conditional rendering - batch mode", () => {
+ it("shows all override sections in batch mode", () => {
+ const { unmount } = renderSection({ mode: "batch" });
+
+ expect(getBodyText()).toContain("sections.routing.codexOverrides.title");
+ expect(getBodyText()).toContain("sections.routing.anthropicOverrides.maxTokens.label");
+ expect(getBodyText()).toContain("sections.routing.geminiOverrides.title");
+
+ unmount();
+ });
+ });
+
+ describe("dispatch actions", () => {
+ it("dispatches SET_PRESERVE_CLIENT_IP on toggle", () => {
+ const { unmount } = renderSection();
+ const toggle = document.getElementById("preserve-client-ip") as HTMLButtonElement;
+
+ act(() => {
+ toggle.click();
+ });
+
+ expect(mockDispatch).toHaveBeenCalledWith({
+ type: "SET_PRESERVE_CLIENT_IP",
+ payload: true,
+ });
+
+ unmount();
+ });
+
+ it("dispatches SET_SWAP_CACHE_TTL_BILLING on toggle", () => {
+ const { unmount } = renderSection();
+ const toggle = document.getElementById("swap-cache-ttl-billing") as HTMLButtonElement;
+
+ act(() => {
+ toggle.click();
+ });
+
+ expect(mockDispatch).toHaveBeenCalledWith({
+ type: "SET_SWAP_CACHE_TTL_BILLING",
+ payload: true,
+ });
+
+ unmount();
+ });
+
+ it("dispatches active time start/end when enabling", () => {
+ const { container, unmount } = renderSection();
+ const toggle = getActiveTimeToggle(container);
+
+ act(() => {
+ toggle?.click();
+ });
+
+ expect(mockDispatch).toHaveBeenCalledWith({
+ type: "SET_ACTIVE_TIME_START",
+ payload: "09:00",
+ });
+ expect(mockDispatch).toHaveBeenCalledWith({
+ type: "SET_ACTIVE_TIME_END",
+ payload: "22:00",
+ });
+
+ unmount();
+ });
+
+ it("dispatches null when disabling active time", () => {
+ const { container, unmount } = renderSection({
+ state: createMockState({
+ routing: {
+ activeTimeStart: "09:00",
+ activeTimeEnd: "22:00",
+ },
+ }),
+ });
+ const toggle = getActiveTimeToggle(container);
+
+ act(() => {
+ toggle?.click();
+ });
+
+ expect(mockDispatch).toHaveBeenCalledWith({
+ type: "SET_ACTIVE_TIME_START",
+ payload: null,
+ });
+ expect(mockDispatch).toHaveBeenCalledWith({
+ type: "SET_ACTIVE_TIME_END",
+ payload: null,
+ });
+
+ unmount();
+ });
+ });
+
+ describe("active time UI", () => {
+ it("shows time inputs when active time enabled", () => {
+ const { container, unmount } = renderSection({
+ state: createMockState({
+ routing: {
+ activeTimeStart: "09:00",
+ activeTimeEnd: "22:00",
+ },
+ }),
+ });
+
+ expect(container.querySelectorAll('input[type="time"]')).toHaveLength(2);
+
+ unmount();
+ });
+
+ it("hides time inputs when active time disabled", () => {
+ const { container, unmount } = renderSection({
+ state: createMockState({
+ routing: {
+ activeTimeStart: null,
+ activeTimeEnd: null,
+ },
+ }),
+ });
+
+ expect(container.querySelectorAll('input[type="time"]')).toHaveLength(0);
+
+ unmount();
+ });
+
+ it("shows cross-day hint when start > end", () => {
+ const { unmount } = renderSection({
+ state: createMockState({
+ routing: {
+ activeTimeStart: "22:00",
+ activeTimeEnd: "06:00",
+ },
+ }),
+ });
+
+ expect(getBodyText()).toContain("sections.routing.activeTime.crossDayHint");
+
+ unmount();
+ });
+ });
+
+ describe("disabled state", () => {
+ it("disables switches when isPending", () => {
+ const { container, unmount } = renderSection({
+ state: createMockState({
+ ui: {
+ isPending: true,
+ },
+ }),
+ });
+ const switches = Array.from(
+ container.querySelectorAll('[data-testid="switch"]')
+ ) as HTMLButtonElement[];
+
+ expect(switches).toHaveLength(3);
+ for (const toggle of switches) {
+ expect(toggle.hasAttribute("disabled")).toBe(true);
+ }
+
+ unmount();
+ });
+ });
+
+ describe("edit mode", () => {
+ it("uses edit- prefixed IDs in edit mode", () => {
+ const { unmount } = renderSection({ mode: "edit" });
+
+ expect(document.getElementById("edit-preserve-client-ip")).toBeTruthy();
+
+ unmount();
+ });
+ });
+
+ describe("batch mode badges", () => {
+ it("shows codex-only badge in batch mode", () => {
+ const { unmount } = renderSection({
+ mode: "batch",
+ state: createMockState({ routing: { providerType: "codex" } }),
+ });
+
+ expect(getBodyText()).toContain("batchNotes.codexOnly");
+
+ unmount();
+ });
+ });
+});
From 580259f6eb58926fb1a99ce6153729ad5657d4ab Mon Sep 17 00:00:00 2001
From: Ding <44717411+ding113@users.noreply.github.com>
Date: Sun, 8 Mar 2026 23:26:37 +0800
Subject: [PATCH 02/42] fix: strip transfer-encoding from forwarded upstream
requests (#880)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* Red: 复现透传请求体回归
* fix: strip transfer-encoding from forwarded upstream requests
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)
Co-authored-by: Sisyphus
* fix: preserve original request body bytes for raw passthrough endpoints
When bypassForwarderPreprocessing=true and session.request.buffer is
available, use the original ArrayBuffer directly instead of
JSON.stringify(messageToSend). This preserves whitespace, key ordering,
and trailing newlines in the forwarded request body.
---------
Co-authored-by: Sisyphus
---
src/app/v1/_lib/proxy/forwarder.ts | 100 ++++++----
...rwarder-raw-passthrough-regression.test.ts | 178 ++++++++++++++++++
tests/unit/proxy/proxy-forwarder.test.ts | 56 ++++++
3 files changed, 293 insertions(+), 41 deletions(-)
create mode 100644 tests/unit/proxy/proxy-forwarder-raw-passthrough-regression.test.ts
diff --git a/src/app/v1/_lib/proxy/forwarder.ts b/src/app/v1/_lib/proxy/forwarder.ts
index 56eb16489..573493d6c 100644
--- a/src/app/v1/_lib/proxy/forwarder.ts
+++ b/src/app/v1/_lib/proxy/forwarder.ts
@@ -84,6 +84,8 @@ const STANDARD_ENDPOINTS = [
const STRICT_STANDARD_ENDPOINTS = ["/v1/messages", "/v1/responses", "/v1/chat/completions"];
+const OUTBOUND_TRANSPORT_HEADER_BLACKLIST = ["content-length", "connection", "transfer-encoding"];
+
const RETRY_LIMITS = PROVIDER_LIMITS.MAX_RETRY_ATTEMPTS;
const MAX_PROVIDER_SWITCHES = 20; // 保险栓:最多切换 20 次供应商(防止无限循环)
@@ -2131,51 +2133,63 @@ export class ProxyForwarder {
const hasBody = session.method !== "GET" && session.method !== "HEAD";
if (hasBody) {
- const filteredMessage = filterPrivateParameters(session.request.message) as Record<
- string,
- unknown
- >;
+ if (session.getEndpointPolicy().bypassForwarderPreprocessing && session.request.buffer) {
+ // Raw passthrough: preserve original request body bytes as-is
+ requestBody = session.request.buffer;
+ session.forwardedRequestBody = session.request.log;
- // 将 metadata.user_id 注入放在私有参数过滤之后,避免受过滤逻辑影响。
- let messageToSend: Record = filteredMessage;
- if (provider.providerType === "claude" || provider.providerType === "claude-auth") {
- const settings = await getCachedSystemSettings();
- const enabled = settings.enableClaudeMetadataUserIdInjection ?? true;
- const injection = applyClaudeMetadataUserIdInjectionWithAudit(
- filteredMessage,
- session,
- enabled
- );
+ try {
+ isStreaming = (session.request.message as Record).stream === true;
+ } catch {
+ isStreaming = false;
+ }
+ } else {
+ const filteredMessage = filterPrivateParameters(session.request.message) as Record<
+ string,
+ unknown
+ >;
+
+ // 将 metadata.user_id 注入放在私有参数过滤之后,避免受过滤逻辑影响。
+ let messageToSend: Record = filteredMessage;
+ if (provider.providerType === "claude" || provider.providerType === "claude-auth") {
+ const settings = await getCachedSystemSettings();
+ const enabled = settings.enableClaudeMetadataUserIdInjection ?? true;
+ const injection = applyClaudeMetadataUserIdInjectionWithAudit(
+ filteredMessage,
+ session,
+ enabled
+ );
- if (injection) {
- messageToSend = injection.message;
- session.addSpecialSetting(injection.audit);
- await persistSpecialSettings(session);
+ if (injection) {
+ messageToSend = injection.message;
+ session.addSpecialSetting(injection.audit);
+ await persistSpecialSettings(session);
+ }
}
- }
- const bodyString = JSON.stringify(messageToSend);
- requestBody = bodyString;
- session.forwardedRequestBody = bodyString;
+ const bodyString = JSON.stringify(messageToSend);
+ requestBody = bodyString;
+ session.forwardedRequestBody = bodyString;
- try {
- const parsed = JSON.parse(bodyString);
- isStreaming = parsed.stream === true;
- } catch {
- isStreaming = false;
- }
+ try {
+ const parsed = JSON.parse(bodyString);
+ isStreaming = parsed.stream === true;
+ } catch {
+ isStreaming = false;
+ }
- if (process.env.NODE_ENV === "development") {
- logger.trace("ProxyForwarder: Forwarding request", {
- provider: provider.name,
- providerId: provider.id,
- proxyUrl: proxyUrl,
- format: session.originalFormat,
- method: session.method,
- bodyLength: bodyString.length,
- bodyPreview: bodyString.slice(0, 1000),
- isStreaming,
- });
+ if (process.env.NODE_ENV === "development") {
+ logger.trace("ProxyForwarder: Forwarding request", {
+ provider: provider.name,
+ providerId: provider.id,
+ proxyUrl: proxyUrl,
+ format: session.originalFormat,
+ method: session.method,
+ bodyLength: bodyString.length,
+ bodyPreview: bodyString.slice(0, 1000),
+ isStreaming,
+ });
+ }
}
}
}
@@ -2915,7 +2929,7 @@ export class ProxyForwarder {
}
const headerProcessor = HeaderProcessor.createForProxy({
- blacklist: ["content-length", "connection"], // 删除 content-length(动态计算)和 connection(undici 自动管理)
+ blacklist: OUTBOUND_TRANSPORT_HEADER_BLACKLIST,
preserveClientIpHeaders: preserveClientIp,
overrides,
});
@@ -2960,7 +2974,11 @@ export class ProxyForwarder {
}
const headerProcessor = HeaderProcessor.createForProxy({
- blacklist: ["content-length", "connection", "x-api-key", GEMINI_PROTOCOL.HEADERS.API_KEY],
+ blacklist: [
+ ...OUTBOUND_TRANSPORT_HEADER_BLACKLIST,
+ "x-api-key",
+ GEMINI_PROTOCOL.HEADERS.API_KEY,
+ ],
preserveClientIpHeaders: preserveClientIp,
overrides,
});
diff --git a/tests/unit/proxy/proxy-forwarder-raw-passthrough-regression.test.ts b/tests/unit/proxy/proxy-forwarder-raw-passthrough-regression.test.ts
new file mode 100644
index 000000000..2f1ba391e
--- /dev/null
+++ b/tests/unit/proxy/proxy-forwarder-raw-passthrough-regression.test.ts
@@ -0,0 +1,178 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const mocks = vi.hoisted(() => ({
+ isHttp2Enabled: vi.fn(async () => false),
+ getCachedSystemSettings: vi.fn(async () => ({
+ enableClaudeMetadataUserIdInjection: false,
+ enableBillingHeaderRectifier: false,
+ })),
+ getProxyAgentForProvider: vi.fn(async () => null),
+ getGlobalAgentPool: vi.fn(() => ({
+ getAgent: vi.fn(),
+ markOriginUnhealthy: vi.fn(),
+ })),
+}));
+
+vi.mock("@/lib/config", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ isHttp2Enabled: mocks.isHttp2Enabled,
+ getCachedSystemSettings: mocks.getCachedSystemSettings,
+ };
+});
+
+vi.mock("@/lib/proxy-agent", () => ({
+ getProxyAgentForProvider: mocks.getProxyAgentForProvider,
+ getGlobalAgentPool: mocks.getGlobalAgentPool,
+}));
+
+import { resolveEndpointPolicy } from "@/app/v1/_lib/proxy/endpoint-policy";
+import { ProxyForwarder } from "@/app/v1/_lib/proxy/forwarder";
+import { ProxySession } from "@/app/v1/_lib/proxy/session";
+import type { Provider } from "@/types/provider";
+
+function createProvider(): Provider {
+ return {
+ id: 1,
+ name: "codex-upstream",
+ providerType: "codex",
+ url: "https://upstream.example.com/v1/responses",
+ key: "upstream-key",
+ preserveClientIp: false,
+ priority: 0,
+ maxRetryAttempts: 1,
+ mcpPassthroughType: "none",
+ mcpPassthroughUrl: null,
+ } as unknown as Provider;
+}
+
+function createRawPassthroughSession(bodyText: string, extraHeaders?: HeadersInit): ProxySession {
+ const headers = new Headers({
+ "content-type": "application/json",
+ "content-length": String(new TextEncoder().encode(bodyText).byteLength),
+ ...Object.fromEntries(new Headers(extraHeaders).entries()),
+ });
+ const originalHeaders = new Headers(headers);
+ const specialSettings: unknown[] = [];
+ const session = Object.create(ProxySession.prototype);
+
+ Object.assign(session, {
+ startTime: Date.now(),
+ method: "POST",
+ requestUrl: new URL("https://proxy.example.com/v1/responses/compact?stream=false"),
+ headers,
+ originalHeaders,
+ headerLog: JSON.stringify(Object.fromEntries(headers.entries())),
+ request: {
+ model: "gpt-5",
+ log: bodyText,
+ message: JSON.parse(bodyText) as Record,
+ buffer: new TextEncoder().encode(bodyText).buffer,
+ },
+ userAgent: "CodexTest/1.0",
+ context: null,
+ clientAbortSignal: null,
+ userName: "test-user",
+ authState: { success: true, user: null, key: null, apiKey: null },
+ provider: null,
+ messageContext: null,
+ sessionId: null,
+ requestSequence: 1,
+ originalFormat: "openai",
+ providerType: null,
+ originalModelName: null,
+ originalUrlPathname: null,
+ providerChain: [],
+ cacheTtlResolved: null,
+ context1mApplied: false,
+ cachedPriceData: undefined,
+ cachedBillingModelSource: undefined,
+ forwardedRequestBody: null,
+ endpointPolicy: resolveEndpointPolicy("/v1/responses/compact"),
+ setCacheTtlResolved: vi.fn(),
+ getCacheTtlResolved: vi.fn(() => null),
+ getCurrentModel: vi.fn(() => "gpt-5"),
+ clientRequestsContext1m: vi.fn(() => false),
+ setContext1mApplied: vi.fn(),
+ getContext1mApplied: vi.fn(() => false),
+ getEndpointPolicy: vi.fn(() => resolveEndpointPolicy("/v1/responses/compact")),
+ addSpecialSetting: vi.fn((setting: unknown) => {
+ specialSettings.push(setting);
+ }),
+ getSpecialSettings: vi.fn(() => specialSettings),
+ isHeaderModified: vi.fn((key: string) => originalHeaders.get(key) !== headers.get(key)),
+ });
+
+ return session as ProxySession;
+}
+
+function readBodyText(body: BodyInit | undefined): string | null {
+ if (body == null) return null;
+ if (typeof body === "string") return body;
+ if (body instanceof ArrayBuffer) {
+ return new TextDecoder().decode(body);
+ }
+ if (ArrayBuffer.isView(body)) {
+ return new TextDecoder().decode(body);
+ }
+ throw new Error(`Unsupported body type: ${Object.prototype.toString.call(body)}`);
+}
+
+describe("ProxyForwarder raw passthrough regression", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("raw passthrough 应优先保留原始请求体字节,而不是重新 JSON.stringify", async () => {
+ const originalBody = '{\n "model": "gpt-5",\n "input": [1, 2, 3]\n}\n';
+ const session = createRawPassthroughSession(originalBody);
+ const provider = createProvider();
+
+ let capturedInit: { body?: BodyInit; headers?: HeadersInit } | null = null;
+ const fetchWithoutAutoDecode = vi.spyOn(ProxyForwarder as any, "fetchWithoutAutoDecode");
+ fetchWithoutAutoDecode.mockImplementationOnce(async (_url: string, init: RequestInit) => {
+ capturedInit = { body: init.body ?? undefined, headers: init.headers ?? undefined };
+ return new Response("{}", {
+ status: 200,
+ headers: { "content-type": "application/json", "content-length": "2" },
+ });
+ });
+
+ const { doForward } = ProxyForwarder as unknown as {
+ doForward: (session: ProxySession, provider: Provider, baseUrl: string) => Promise;
+ };
+
+ await doForward(session, provider, provider.url);
+
+ expect(readBodyText(capturedInit?.body)).toBe(originalBody);
+ });
+
+ it("raw passthrough 出站请求不得继续携带 transfer-encoding 这类 hop-by-hop 头", async () => {
+ const body = '{"model":"gpt-5","input":[]}';
+ const session = createRawPassthroughSession(body, {
+ connection: "keep-alive",
+ "transfer-encoding": "chunked",
+ });
+ const provider = createProvider();
+
+ let capturedHeaders: Headers | null = null;
+ const fetchWithoutAutoDecode = vi.spyOn(ProxyForwarder as any, "fetchWithoutAutoDecode");
+ fetchWithoutAutoDecode.mockImplementationOnce(async (_url: string, init: RequestInit) => {
+ capturedHeaders = new Headers(init.headers);
+ return new Response("{}", {
+ status: 200,
+ headers: { "content-type": "application/json", "content-length": "2" },
+ });
+ });
+
+ const { doForward } = ProxyForwarder as unknown as {
+ doForward: (session: ProxySession, provider: Provider, baseUrl: string) => Promise;
+ };
+
+ await doForward(session, provider, provider.url);
+
+ expect(capturedHeaders?.get("connection")).toBeNull();
+ expect(capturedHeaders?.get("transfer-encoding")).toBeNull();
+ });
+});
diff --git a/tests/unit/proxy/proxy-forwarder.test.ts b/tests/unit/proxy/proxy-forwarder.test.ts
index 8eef4ffbf..00ad3ff94 100644
--- a/tests/unit/proxy/proxy-forwarder.test.ts
+++ b/tests/unit/proxy/proxy-forwarder.test.ts
@@ -153,6 +153,28 @@ describe("ProxyForwarder - buildHeaders User-Agent resolution", () => {
// 空字符串应该被保留(使用 ?? 而非 ||)
expect(resultHeaders.get("user-agent")).toBe("");
});
+
+ it("应该剥离 transfer-encoding 这类传输层 header,避免向上游继续透传", () => {
+ const session = createSession({
+ userAgent: "Original-UA/1.0",
+ headers: new Headers([
+ ["user-agent", "Original-UA/1.0"],
+ ["connection", "keep-alive"],
+ ["transfer-encoding", "chunked"],
+ ["content-length", "123"],
+ ]),
+ });
+
+ const provider = createCodexProvider();
+ const { buildHeaders } = ProxyForwarder as unknown as {
+ buildHeaders: (session: ProxySession, provider: Provider) => Headers;
+ };
+ const resultHeaders = buildHeaders(session, provider);
+
+ expect(resultHeaders.get("connection")).toBeNull();
+ expect(resultHeaders.get("transfer-encoding")).toBeNull();
+ expect(resultHeaders.get("content-length")).toBeNull();
+ });
});
describe("ProxyForwarder - buildGeminiHeaders headers passthrough", () => {
@@ -307,4 +329,38 @@ describe("ProxyForwarder - buildGeminiHeaders headers passthrough", () => {
expect(resultHeaders.get("x-goog-api-client")).toBe("GeminiCLI/1.0");
});
+
+ it("Gemini 路径也应该剥离 transfer-encoding,避免请求体透传回归污染上游", () => {
+ const session = createSession({
+ userAgent: "Original-UA/1.0",
+ headers: new Headers([
+ ["user-agent", "Original-UA/1.0"],
+ ["connection", "keep-alive"],
+ ["transfer-encoding", "chunked"],
+ ["content-length", "123"],
+ ]),
+ });
+
+ const provider = createGeminiProvider("gemini");
+ const { buildGeminiHeaders } = ProxyForwarder as unknown as {
+ buildGeminiHeaders: (
+ session: ProxySession,
+ provider: Provider,
+ baseUrl: string,
+ accessToken: string,
+ isApiKey: boolean
+ ) => Headers;
+ };
+ const resultHeaders = buildGeminiHeaders(
+ session,
+ provider,
+ "https://generativelanguage.googleapis.com/v1beta",
+ "upstream-api-key",
+ true
+ );
+
+ expect(resultHeaders.get("connection")).toBeNull();
+ expect(resultHeaders.get("transfer-encoding")).toBeNull();
+ expect(resultHeaders.get("content-length")).toBeNull();
+ });
});
From a82e99437642bd35ef8a19243c641636d85e2546 Mon Sep 17 00:00:00 2001
From: ding113
Date: Sun, 8 Mar 2026 23:54:05 +0800
Subject: [PATCH 03/42] fix: remove claude-* model prefix routing restriction
(#832)
Simplify providerSupportsModel() to treat all provider types uniformly.
Previously, claude-* models were hardcoded to only route to claude/claude-auth
providers, even when other providers explicitly declared them in allowedModels.
Now the logic is: explicit allowedModels/modelRedirects match -> accept;
empty allowedModels -> wildcard; otherwise reject. Format compatibility
remains enforced by checkFormatProviderTypeCompatibility independently.
---
src/app/v1/_lib/proxy/provider-selector.ts | 66 +----
...provider-selector-cross-type-model.test.ts | 265 ++++++++++++++++++
2 files changed, 279 insertions(+), 52 deletions(-)
create mode 100644 tests/unit/proxy/provider-selector-cross-type-model.test.ts
diff --git a/src/app/v1/_lib/proxy/provider-selector.ts b/src/app/v1/_lib/proxy/provider-selector.ts
index 788d010b8..fe6559c70 100644
--- a/src/app/v1/_lib/proxy/provider-selector.ts
+++ b/src/app/v1/_lib/proxy/provider-selector.ts
@@ -98,71 +98,33 @@ function checkProviderGroupMatch(providerGroupTag: string | null, userGroups: st
/**
* 检查供应商是否支持指定模型(用于调度器匹配)
*
- * 核心逻辑:
- * 1. Claude 模型请求 (claude-*):
- * - Anthropic 提供商:根据 allowedModels 白名单判断
- * - 非 Anthropic 提供商:不支持 claude-* 模型调度
+ * 核心逻辑(统一所有供应商类型):
+ * 1. 显式声明优先:allowedModels 包含或 modelRedirects 包含 -> 支持
+ * 2. 未设置 allowedModels(null 或空数组):接受任意模型(格式兼容性由 checkFormatProviderTypeCompatibility 保证)
+ * 3. 设置了 allowedModels 但不包含该模型 -> 不支持
*
- * 2. 非 Claude 模型请求 (gpt-*, gemini-*, 或其他任意模型):
- * - Anthropic 提供商:不支持(仅支持 Claude 模型)
- * - 非 Anthropic 提供商(codex, gemini-cli, openai-compatible):
- * a. 如果未设置 allowedModels(null 或空数组):接受任意模型
- * b. 如果设置了 allowedModels:检查模型是否在声明列表中,或有模型重定向配置
- * 注意:allowedModels 是声明性列表(用户可填写任意字符串),用于调度器匹配,不是真实模型校验
+ * 注意:allowedModels 是声明性列表(用户可填写任意字符串),用于调度器匹配,不是真实模型校验。
+ * 格式兼容性(如 claude 格式请求只路由到 claude 类型供应商)由 checkFormatProviderTypeCompatibility 独立保证。
*
* @param provider - 供应商信息
* @param requestedModel - 用户请求的模型名称
* @returns 是否支持该模型(用于调度器筛选)
*/
function providerSupportsModel(provider: Provider, requestedModel: string): boolean {
- const isClaudeModel = requestedModel.startsWith("claude-");
- const isClaudeProvider =
- provider.providerType === "claude" || provider.providerType === "claude-auth";
-
- // Case 1: Claude 模型请求
- if (isClaudeModel) {
- // 1a. Anthropic 提供商
- if (isClaudeProvider) {
- // 未设置 allowedModels 或为空数组:允许所有 claude 模型
- if (!provider.allowedModels || provider.allowedModels.length === 0) {
- return true;
- }
- // 检查白名单
- return provider.allowedModels.includes(requestedModel);
- }
-
- // 1b. 非 Anthropic 提供商不支持 Claude 模型调度
- return false;
- }
-
- // Case 2: 非 Claude 模型请求(gpt-*, gemini-*, 或其他任意模型)
- // 2a. 优先检查显式声明(支持跨类型代理)
- // 原因:允许 Claude 类型供应商通过 allowedModels/modelRedirects 声明支持非 Claude 模型
- // 场景:Claude 供应商配置模型重定向,将 gemini-* 请求转发到真实的 Gemini 上游
- const explicitlyDeclared = !!(
- provider.allowedModels?.includes(requestedModel) || provider.modelRedirects?.[requestedModel]
- );
-
- if (explicitlyDeclared) {
- return true; // 显式声明优先级最高,允许跨类型代理
- }
-
- // 2b. Anthropic 提供商不支持非声明的非 Claude 模型
- // 保护机制:防止将非 Claude 模型误路由到 Anthropic API
- if (isClaudeProvider) {
- return false;
+ // 1. 显式声明优先(allowedModels 或 modelRedirects)
+ if (
+ provider.allowedModels?.includes(requestedModel) ||
+ provider.modelRedirects?.[requestedModel]
+ ) {
+ return true;
}
- // 2c. 非 Anthropic 提供商(codex, gemini, gemini-cli, openai-compatible)
- // allowedModels 是声明列表,用于调度器匹配提供商
- // 用户可以手动填写任意模型名称(不限于真实模型),用于声明该提供商"支持"哪些模型
-
- // 未设置 allowedModels 或为空数组:接受任意模型(由上游提供商判断)
+ // 2. 未设置 allowedModels(null 或空数组):接受任意模型
if (!provider.allowedModels || provider.allowedModels.length === 0) {
return true;
}
- // 不在声明列表中且无重定向配置(前面已检查过 explicitlyDeclared)
+ // 3. 设置了 allowedModels 但不包含该模型,且无 modelRedirects
return false;
}
diff --git a/tests/unit/proxy/provider-selector-cross-type-model.test.ts b/tests/unit/proxy/provider-selector-cross-type-model.test.ts
new file mode 100644
index 000000000..c6395e05c
--- /dev/null
+++ b/tests/unit/proxy/provider-selector-cross-type-model.test.ts
@@ -0,0 +1,265 @@
+import { beforeEach, describe, expect, test, vi } from "vitest";
+import type { Provider } from "@/types/provider";
+
+const circuitBreakerMocks = vi.hoisted(() => ({
+ isCircuitOpen: vi.fn(async () => false),
+ getCircuitState: vi.fn(() => "closed"),
+}));
+
+vi.mock("@/lib/circuit-breaker", () => circuitBreakerMocks);
+
+const vendorTypeCircuitMocks = vi.hoisted(() => ({
+ isVendorTypeCircuitOpen: vi.fn(async () => false),
+}));
+
+vi.mock("@/lib/vendor-type-circuit-breaker", () => vendorTypeCircuitMocks);
+
+const sessionManagerMocks = vi.hoisted(() => ({
+ SessionManager: {
+ getSessionProvider: vi.fn(async () => null as number | null),
+ clearSessionProvider: vi.fn(async () => undefined),
+ },
+}));
+
+vi.mock("@/lib/session-manager", () => sessionManagerMocks);
+
+const providerRepositoryMocks = vi.hoisted(() => ({
+ findProviderById: vi.fn(async () => null as Provider | null),
+ findAllProviders: vi.fn(async () => [] as Provider[]),
+}));
+
+vi.mock("@/repository/provider", () => providerRepositoryMocks);
+
+const rateLimitMocks = vi.hoisted(() => ({
+ RateLimitService: {
+ checkCostLimitsWithLease: vi.fn(async () => ({ allowed: true })),
+ checkTotalCostLimit: vi.fn(async () => ({ allowed: true, current: 0 })),
+ },
+}));
+
+vi.mock("@/lib/rate-limit", () => rateLimitMocks);
+
+beforeEach(() => {
+ vi.resetAllMocks();
+});
+
+function createProvider(overrides: Partial = {}): Provider {
+ return {
+ id: 1,
+ name: "test-provider",
+ isEnabled: true,
+ providerType: "openai-compatible",
+ groupTag: null,
+ weight: 1,
+ priority: 0,
+ costMultiplier: 1,
+ allowedModels: null,
+ providerVendorId: null,
+ limit5hUsd: null,
+ limitDailyUsd: null,
+ dailyResetMode: "fixed",
+ dailyResetTime: "00:00",
+ limitWeeklyUsd: null,
+ limitMonthlyUsd: null,
+ limitTotalUsd: null,
+ totalCostResetAt: null,
+ limitConcurrentSessions: 0,
+ ...overrides,
+ } as unknown as Provider;
+}
+
+describe("providerSupportsModel - cross-type model routing (#832)", () => {
+ test("openai-compatible provider with claude model in allowedModels should match", async () => {
+ const { ProxyProviderResolver } = await import("@/app/v1/_lib/proxy/provider-selector");
+
+ const provider = createProvider({
+ id: 10,
+ providerType: "openai-compatible",
+ allowedModels: ["claude-opus-4-6"],
+ });
+
+ sessionManagerMocks.SessionManager.getSessionProvider.mockResolvedValueOnce(10);
+ providerRepositoryMocks.findProviderById.mockResolvedValueOnce(provider);
+ rateLimitMocks.RateLimitService.checkCostLimitsWithLease.mockResolvedValueOnce({
+ allowed: true,
+ });
+ rateLimitMocks.RateLimitService.checkTotalCostLimit.mockResolvedValueOnce({
+ allowed: true,
+ current: 0,
+ });
+
+ const session = {
+ sessionId: "cross-type-1",
+ shouldReuseProvider: () => true,
+ getOriginalModel: () => "claude-opus-4-6",
+ authState: null,
+ getCurrentModel: () => null,
+ } as any;
+
+ const result = await (ProxyProviderResolver as any).findReusable(session);
+
+ expect(result).not.toBeNull();
+ expect(result?.id).toBe(10);
+ });
+
+ test("openai-compatible provider with empty allowedModels should match any model (wildcard)", async () => {
+ const { ProxyProviderResolver } = await import("@/app/v1/_lib/proxy/provider-selector");
+
+ const provider = createProvider({
+ id: 11,
+ providerType: "openai-compatible",
+ allowedModels: null,
+ });
+
+ sessionManagerMocks.SessionManager.getSessionProvider.mockResolvedValueOnce(11);
+ providerRepositoryMocks.findProviderById.mockResolvedValueOnce(provider);
+ rateLimitMocks.RateLimitService.checkCostLimitsWithLease.mockResolvedValueOnce({
+ allowed: true,
+ });
+ rateLimitMocks.RateLimitService.checkTotalCostLimit.mockResolvedValueOnce({
+ allowed: true,
+ current: 0,
+ });
+
+ const session = {
+ sessionId: "cross-type-2",
+ shouldReuseProvider: () => true,
+ getOriginalModel: () => "claude-sonnet-4-5-20250929",
+ authState: null,
+ getCurrentModel: () => null,
+ } as any;
+
+ const result = await (ProxyProviderResolver as any).findReusable(session);
+
+ expect(result).not.toBeNull();
+ expect(result?.id).toBe(11);
+ });
+
+ test("openai-compatible provider with allowedModels NOT containing the model should not match", async () => {
+ const { ProxyProviderResolver } = await import("@/app/v1/_lib/proxy/provider-selector");
+
+ const provider = createProvider({
+ id: 12,
+ providerType: "openai-compatible",
+ allowedModels: ["gpt-4o", "gpt-4o-mini"],
+ });
+
+ sessionManagerMocks.SessionManager.getSessionProvider.mockResolvedValueOnce(12);
+ providerRepositoryMocks.findProviderById.mockResolvedValueOnce(provider);
+
+ const session = {
+ sessionId: "cross-type-3",
+ shouldReuseProvider: () => true,
+ getOriginalModel: () => "claude-opus-4-6",
+ authState: null,
+ getCurrentModel: () => null,
+ } as any;
+
+ const result = await (ProxyProviderResolver as any).findReusable(session);
+
+ expect(result).toBeNull();
+ expect(sessionManagerMocks.SessionManager.clearSessionProvider).toHaveBeenCalledWith(
+ "cross-type-3"
+ );
+ });
+
+ test("claude provider with empty allowedModels should match any model (wildcard)", async () => {
+ const { ProxyProviderResolver } = await import("@/app/v1/_lib/proxy/provider-selector");
+
+ const provider = createProvider({
+ id: 13,
+ providerType: "claude",
+ allowedModels: null,
+ });
+
+ sessionManagerMocks.SessionManager.getSessionProvider.mockResolvedValueOnce(13);
+ providerRepositoryMocks.findProviderById.mockResolvedValueOnce(provider);
+ rateLimitMocks.RateLimitService.checkCostLimitsWithLease.mockResolvedValueOnce({
+ allowed: true,
+ });
+ rateLimitMocks.RateLimitService.checkTotalCostLimit.mockResolvedValueOnce({
+ allowed: true,
+ current: 0,
+ });
+
+ const session = {
+ sessionId: "cross-type-4",
+ shouldReuseProvider: () => true,
+ getOriginalModel: () => "gpt-4o",
+ authState: null,
+ getCurrentModel: () => null,
+ } as any;
+
+ const result = await (ProxyProviderResolver as any).findReusable(session);
+
+ expect(result).not.toBeNull();
+ expect(result?.id).toBe(13);
+ });
+
+ test("claude provider with non-claude model in allowedModels should match (explicit declaration)", async () => {
+ const { ProxyProviderResolver } = await import("@/app/v1/_lib/proxy/provider-selector");
+
+ const provider = createProvider({
+ id: 14,
+ providerType: "claude",
+ allowedModels: ["gemini-2.5-pro"],
+ });
+
+ sessionManagerMocks.SessionManager.getSessionProvider.mockResolvedValueOnce(14);
+ providerRepositoryMocks.findProviderById.mockResolvedValueOnce(provider);
+ rateLimitMocks.RateLimitService.checkCostLimitsWithLease.mockResolvedValueOnce({
+ allowed: true,
+ });
+ rateLimitMocks.RateLimitService.checkTotalCostLimit.mockResolvedValueOnce({
+ allowed: true,
+ current: 0,
+ });
+
+ const session = {
+ sessionId: "cross-type-5",
+ shouldReuseProvider: () => true,
+ getOriginalModel: () => "gemini-2.5-pro",
+ authState: null,
+ getCurrentModel: () => null,
+ } as any;
+
+ const result = await (ProxyProviderResolver as any).findReusable(session);
+
+ expect(result).not.toBeNull();
+ expect(result?.id).toBe(14);
+ });
+
+ test("any provider with modelRedirects containing the model should match", async () => {
+ const { ProxyProviderResolver } = await import("@/app/v1/_lib/proxy/provider-selector");
+
+ const provider = createProvider({
+ id: 15,
+ providerType: "openai-compatible",
+ allowedModels: ["gpt-4o"],
+ modelRedirects: { "claude-opus-4-6": "custom-opus" },
+ });
+
+ 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",
+ authState: null,
+ getCurrentModel: () => null,
+ } as any;
+
+ const result = await (ProxyProviderResolver as any).findReusable(session);
+
+ expect(result).not.toBeNull();
+ expect(result?.id).toBe(15);
+ });
+});
From 565f2e6d49a324b395b60bf8014a2b52c0ce3237 Mon Sep 17 00:00:00 2001
From: ding113
Date: Mon, 9 Mar 2026 00:03:39 +0800
Subject: [PATCH 04/42] test: add comprehensive cross-type model routing tests
(#832)
- Export providerSupportsModel for direct unit testing
- Add table-driven tests (13 cases) covering all provider/model combinations
- Add pickRandomProvider path tests verifying format+model check interaction
- Add findReusable integration tests for session reuse scenarios
- Add invariant comment in findReusable documenting format-safety assumption
---
src/app/v1/_lib/proxy/provider-selector.ts | 6 +-
...provider-selector-cross-type-model.test.ts | 318 ++++++++++++++----
2 files changed, 260 insertions(+), 64 deletions(-)
diff --git a/src/app/v1/_lib/proxy/provider-selector.ts b/src/app/v1/_lib/proxy/provider-selector.ts
index fe6559c70..6e62f553d 100644
--- a/src/app/v1/_lib/proxy/provider-selector.ts
+++ b/src/app/v1/_lib/proxy/provider-selector.ts
@@ -540,7 +540,9 @@ export class ProxyProviderResolver {
return null;
}
- // 检查模型支持(使用新的模型匹配逻辑)
+ // 检查模型支持
+ // 注意:此处不检查格式兼容性(checkFormatProviderTypeCompatibility),
+ // 因为 session binding 仅由 pickRandomProvider 创建,该路径已保证格式兼容。
const requestedModel = session.getOriginalModel();
if (requestedModel && !providerSupportsModel(provider, requestedModel)) {
logger.debug("ProviderSelector: Session provider does not support requested model", {
@@ -1354,4 +1356,4 @@ export class ProxyProviderResolver {
}
// Export for testing
-export { checkProviderGroupMatch };
+export { checkProviderGroupMatch, providerSupportsModel };
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 c6395e05c..4c03838ab 100644
--- a/tests/unit/proxy/provider-selector-cross-type-model.test.ts
+++ b/tests/unit/proxy/provider-selector-cross-type-model.test.ts
@@ -1,6 +1,8 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import type { Provider } from "@/types/provider";
+// ── Mocks (shared by findReusable and pickRandomProvider tests) ──
+
const circuitBreakerMocks = vi.hoisted(() => ({
isCircuitOpen: vi.fn(async () => false),
getCircuitState: vi.fn(() => "closed"),
@@ -43,6 +45,8 @@ beforeEach(() => {
vi.resetAllMocks();
});
+// ── Helpers ──
+
function createProvider(overrides: Partial = {}): Provider {
return {
id: 1,
@@ -68,8 +72,144 @@ function createProvider(overrides: Partial = {}): Provider {
} as unknown as Provider;
}
-describe("providerSupportsModel - cross-type model routing (#832)", () => {
- test("openai-compatible provider with claude model in allowedModels should match", async () => {
+// ══════════════════════════════════════════════════════════════════
+// Part 1: Direct unit tests for providerSupportsModel (table-driven)
+// ══════════════════════════════════════════════════════════════════
+
+describe("providerSupportsModel - direct unit tests (#832)", () => {
+ const cases: Array<{
+ name: string;
+ providerType: string;
+ allowedModels: string[] | null;
+ modelRedirects?: Record;
+ requestedModel: string;
+ expected: boolean;
+ }> = [
+ // Core fix: openai-compatible + claude model + explicit allowedModels
+ {
+ name: "openai-compatible + allowedModels contains claude model -> true",
+ providerType: "openai-compatible",
+ allowedModels: ["claude-opus-4-6"],
+ requestedModel: "claude-opus-4-6",
+ expected: true,
+ },
+ {
+ name: "openai-compatible + null allowedModels + claude model -> true (wildcard)",
+ providerType: "openai-compatible",
+ allowedModels: null,
+ requestedModel: "claude-sonnet-4-5-20250929",
+ expected: true,
+ },
+ {
+ name: "openai-compatible + empty allowedModels + claude model -> true (wildcard)",
+ providerType: "openai-compatible",
+ allowedModels: [],
+ requestedModel: "claude-opus-4-6",
+ expected: true,
+ },
+ {
+ name: "openai-compatible + allowedModels NOT containing model -> false",
+ providerType: "openai-compatible",
+ allowedModels: ["gpt-4o", "gpt-4o-mini"],
+ requestedModel: "claude-opus-4-6",
+ expected: false,
+ },
+
+ // Claude provider behavior
+ {
+ name: "claude + null allowedModels + claude model -> true (wildcard)",
+ providerType: "claude",
+ allowedModels: null,
+ requestedModel: "claude-opus-4-6",
+ expected: true,
+ },
+ {
+ name: "claude + null allowedModels + non-claude model -> true (wildcard)",
+ providerType: "claude",
+ allowedModels: null,
+ requestedModel: "gpt-4o",
+ expected: true,
+ },
+ {
+ name: "claude + allowedModels contains non-claude model -> true (explicit)",
+ providerType: "claude",
+ allowedModels: ["gemini-2.5-pro"],
+ requestedModel: "gemini-2.5-pro",
+ expected: true,
+ },
+ {
+ name: "claude + allowedModels NOT containing model -> false",
+ providerType: "claude",
+ allowedModels: ["claude-haiku-4-5"],
+ requestedModel: "claude-opus-4-6",
+ expected: false,
+ },
+ {
+ name: "claude-auth + null allowedModels -> true (wildcard)",
+ providerType: "claude-auth",
+ allowedModels: null,
+ requestedModel: "claude-opus-4-6",
+ expected: true,
+ },
+
+ // modelRedirects
+ {
+ name: "modelRedirects contains model (allowedModels does not) -> true",
+ providerType: "openai-compatible",
+ allowedModels: ["gpt-4o"],
+ modelRedirects: { "claude-opus-4-6": "custom-opus" },
+ requestedModel: "claude-opus-4-6",
+ expected: true,
+ },
+ {
+ name: "neither allowedModels nor modelRedirects contains model -> false",
+ providerType: "openai-compatible",
+ allowedModels: ["gpt-4o"],
+ modelRedirects: { "gpt-4": "gpt-4o" },
+ requestedModel: "claude-opus-4-6",
+ expected: false,
+ },
+
+ // Other provider types
+ {
+ name: "codex + null allowedModels -> true (wildcard)",
+ providerType: "codex",
+ allowedModels: null,
+ requestedModel: "codex-mini-latest",
+ expected: true,
+ },
+ {
+ name: "gemini + allowedModels match -> true",
+ providerType: "gemini",
+ allowedModels: ["gemini-2.0-flash"],
+ requestedModel: "gemini-2.0-flash",
+ expected: true,
+ },
+ ];
+
+ test.each(cases)("$name", async ({
+ providerType,
+ allowedModels,
+ modelRedirects,
+ requestedModel,
+ expected,
+ }) => {
+ const { providerSupportsModel } = await import("@/app/v1/_lib/proxy/provider-selector");
+ const provider = createProvider({
+ providerType,
+ allowedModels,
+ ...(modelRedirects && { modelRedirects }),
+ });
+ expect(providerSupportsModel(provider, requestedModel)).toBe(expected);
+ });
+});
+
+// ══════════════════════════════════════════════════════════════════
+// Part 2: Integration tests via findReusable (session reuse path)
+// ══════════════════════════════════════════════════════════════════
+
+describe("findReusable - cross-type model routing (#832)", () => {
+ test("openai-compatible + allowedModels with claude model -> reuse succeeds", async () => {
const { ProxyProviderResolver } = await import("@/app/v1/_lib/proxy/provider-selector");
const provider = createProvider({
@@ -102,14 +242,10 @@ describe("providerSupportsModel - cross-type model routing (#832)", () => {
expect(result?.id).toBe(10);
});
- test("openai-compatible provider with empty allowedModels should match any model (wildcard)", async () => {
+ test("openai-compatible + null allowedModels + claude model -> reuse succeeds (wildcard)", async () => {
const { ProxyProviderResolver } = await import("@/app/v1/_lib/proxy/provider-selector");
- const provider = createProvider({
- id: 11,
- providerType: "openai-compatible",
- allowedModels: null,
- });
+ const provider = createProvider({ id: 11, allowedModels: null });
sessionManagerMocks.SessionManager.getSessionProvider.mockResolvedValueOnce(11);
providerRepositoryMocks.findProviderById.mockResolvedValueOnce(provider);
@@ -135,12 +271,11 @@ describe("providerSupportsModel - cross-type model routing (#832)", () => {
expect(result?.id).toBe(11);
});
- test("openai-compatible provider with allowedModels NOT containing the model should not match", async () => {
+ test("openai-compatible + allowedModels mismatch -> clears stale binding", async () => {
const { ProxyProviderResolver } = await import("@/app/v1/_lib/proxy/provider-selector");
const provider = createProvider({
id: 12,
- providerType: "openai-compatible",
allowedModels: ["gpt-4o", "gpt-4o-mini"],
});
@@ -163,16 +298,16 @@ describe("providerSupportsModel - cross-type model routing (#832)", () => {
);
});
- test("claude provider with empty allowedModels should match any model (wildcard)", async () => {
+ test("modelRedirects match overrides allowedModels mismatch -> reuse succeeds", async () => {
const { ProxyProviderResolver } = await import("@/app/v1/_lib/proxy/provider-selector");
const provider = createProvider({
- id: 13,
- providerType: "claude",
- allowedModels: null,
+ id: 15,
+ allowedModels: ["gpt-4o"],
+ modelRedirects: { "claude-opus-4-6": "custom-opus" },
});
- sessionManagerMocks.SessionManager.getSessionProvider.mockResolvedValueOnce(13);
+ sessionManagerMocks.SessionManager.getSessionProvider.mockResolvedValueOnce(15);
providerRepositoryMocks.findProviderById.mockResolvedValueOnce(provider);
rateLimitMocks.RateLimitService.checkCostLimitsWithLease.mockResolvedValueOnce({
allowed: true,
@@ -183,9 +318,9 @@ describe("providerSupportsModel - cross-type model routing (#832)", () => {
});
const session = {
- sessionId: "cross-type-4",
+ sessionId: "cross-type-6",
shouldReuseProvider: () => true,
- getOriginalModel: () => "gpt-4o",
+ getOriginalModel: () => "claude-opus-4-6",
authState: null,
getCurrentModel: () => null,
} as any;
@@ -193,73 +328,132 @@ describe("providerSupportsModel - cross-type model routing (#832)", () => {
const result = await (ProxyProviderResolver as any).findReusable(session);
expect(result).not.toBeNull();
- expect(result?.id).toBe(13);
+ expect(result?.id).toBe(15);
});
+});
+
+// ══════════════════════════════════════════════════════════════════
+// Part 3: Integration tests via pickRandomProvider (fresh selection path)
+// ══════════════════════════════════════════════════════════════════
- test("claude provider with non-claude model in allowedModels should match (explicit declaration)", async () => {
+describe("pickRandomProvider - cross-type model routing (#832)", () => {
+ function createPickSession(originalFormat: string, providers: Provider[], originalModel: string) {
+ return {
+ originalFormat,
+ authState: null,
+ getProvidersSnapshot: async () => providers,
+ getOriginalModel: () => originalModel,
+ getCurrentModel: () => originalModel,
+ clientRequestsContext1m: () => false,
+ } as any;
+ }
+
+ async function setupResolverMocks() {
const { ProxyProviderResolver } = await import("@/app/v1/_lib/proxy/provider-selector");
+ vi.spyOn(ProxyProviderResolver as any, "filterByLimits").mockImplementation(
+ async (...args: unknown[]) => args[0] as Provider[]
+ );
+ vi.spyOn(ProxyProviderResolver as any, "selectTopPriority").mockImplementation(
+ (...args: unknown[]) => args[0] as Provider[]
+ );
+ vi.spyOn(ProxyProviderResolver as any, "selectOptimal").mockImplementation(
+ (...args: unknown[]) => (args[0] as Provider[])[0] ?? null
+ );
+
+ return ProxyProviderResolver;
+ }
+
+ test("openai format + openai-compatible with allowedModels=[claude-opus-4-6] -> selected", async () => {
+ const Resolver = await setupResolverMocks();
+
const provider = createProvider({
- id: 14,
- providerType: "claude",
- allowedModels: ["gemini-2.5-pro"],
+ id: 20,
+ providerType: "openai-compatible",
+ allowedModels: ["claude-opus-4-6"],
});
+ const session = createPickSession("openai", [provider], "claude-opus-4-6");
- sessionManagerMocks.SessionManager.getSessionProvider.mockResolvedValueOnce(14);
- providerRepositoryMocks.findProviderById.mockResolvedValueOnce(provider);
- rateLimitMocks.RateLimitService.checkCostLimitsWithLease.mockResolvedValueOnce({
- allowed: true,
- });
- rateLimitMocks.RateLimitService.checkTotalCostLimit.mockResolvedValueOnce({
- allowed: true,
- current: 0,
- });
+ const { provider: picked } = await (Resolver as any).pickRandomProvider(session, []);
- const session = {
- sessionId: "cross-type-5",
- shouldReuseProvider: () => true,
- getOriginalModel: () => "gemini-2.5-pro",
- authState: null,
- getCurrentModel: () => null,
- } as any;
+ expect(picked).not.toBeNull();
+ expect(picked?.id).toBe(20);
+ });
- const result = await (ProxyProviderResolver as any).findReusable(session);
+ test("openai format + claude provider with null allowedModels -> rejected by format check", async () => {
+ const Resolver = await setupResolverMocks();
- expect(result).not.toBeNull();
- expect(result?.id).toBe(14);
+ const claudeProvider = createProvider({
+ id: 21,
+ providerType: "claude",
+ allowedModels: null,
+ });
+ const session = createPickSession("openai", [claudeProvider], "gpt-4o");
+
+ const { provider: picked, context } = await (Resolver as any).pickRandomProvider(session, []);
+
+ expect(picked).toBeNull();
+ const mismatch = context.filteredProviders.find(
+ (fp: any) => fp.id === 21 && fp.reason === "format_type_mismatch"
+ );
+ expect(mismatch).toBeDefined();
});
- test("any provider with modelRedirects containing the model should match", async () => {
- const { ProxyProviderResolver } = await import("@/app/v1/_lib/proxy/provider-selector");
+ test("openai format + openai-compatible with non-matching allowedModels -> rejected by model check", async () => {
+ const Resolver = await setupResolverMocks();
const provider = createProvider({
- id: 15,
+ id: 22,
providerType: "openai-compatible",
allowedModels: ["gpt-4o"],
- modelRedirects: { "claude-opus-4-6": "custom-opus" },
});
+ const session = createPickSession("openai", [provider], "claude-opus-4-6");
- sessionManagerMocks.SessionManager.getSessionProvider.mockResolvedValueOnce(15);
- providerRepositoryMocks.findProviderById.mockResolvedValueOnce(provider);
- rateLimitMocks.RateLimitService.checkCostLimitsWithLease.mockResolvedValueOnce({
- allowed: true,
+ const { provider: picked, context } = await (Resolver as any).pickRandomProvider(session, []);
+
+ expect(picked).toBeNull();
+ const mismatch = context.filteredProviders.find(
+ (fp: any) => fp.id === 22 && fp.reason === "model_not_allowed"
+ );
+ expect(mismatch).toBeDefined();
+ });
+
+ test("format check + model check combined: only format-and-model compatible provider selected", async () => {
+ const Resolver = await setupResolverMocks();
+
+ // claude provider (format-incompatible with openai request)
+ const p1 = createProvider({
+ id: 30,
+ providerType: "claude",
+ allowedModels: ["claude-opus-4-6"],
});
- rateLimitMocks.RateLimitService.checkTotalCostLimit.mockResolvedValueOnce({
- allowed: true,
- current: 0,
+ // openai-compatible but wrong model
+ const p2 = createProvider({
+ id: 31,
+ providerType: "openai-compatible",
+ allowedModels: ["gpt-4o"],
+ });
+ // openai-compatible with correct model
+ const p3 = createProvider({
+ id: 32,
+ providerType: "openai-compatible",
+ allowedModels: ["claude-opus-4-6"],
});
- const session = {
- sessionId: "cross-type-6",
- shouldReuseProvider: () => true,
- getOriginalModel: () => "claude-opus-4-6",
- authState: null,
- getCurrentModel: () => null,
- } as any;
+ const session = createPickSession("openai", [p1, p2, p3], "claude-opus-4-6");
- const result = await (ProxyProviderResolver as any).findReusable(session);
+ const { provider: picked, context } = await (Resolver as any).pickRandomProvider(session, []);
- expect(result).not.toBeNull();
- expect(result?.id).toBe(15);
+ expect(picked?.id).toBe(32);
+
+ const formatMismatch = context.filteredProviders.find(
+ (fp: any) => fp.id === 30 && fp.reason === "format_type_mismatch"
+ );
+ expect(formatMismatch).toBeDefined();
+
+ const modelMismatch = context.filteredProviders.find(
+ (fp: any) => fp.id === 31 && fp.reason === "model_not_allowed"
+ );
+ expect(modelMismatch).toBeDefined();
});
});
From d74d4b705b1e7ff80c09abb45c2b8bffca3fe991 Mon Sep 17 00:00:00 2001
From: Ding <44717411+ding113@users.noreply.github.com>
Date: Mon, 9 Mar 2026 02:21:35 +0800
Subject: [PATCH 05/42] fix: restore admin token auth in opaque session mode
(#884)
In opaque mode, validateAuthToken() rejected raw ADMIN_TOKEN passed via
cookie or Authorization header, breaking programmatic API access that
worked in legacy/dual mode. Add a constantTimeEqual check for the admin
token before returning null, so server-side env secret still authenticates
while regular API keys remain correctly rejected.
---
src/lib/auth.ts | 7 +
.../auth/admin-token-opaque-fallback.test.ts | 247 ++++++++++++++++++
2 files changed, 254 insertions(+)
create mode 100644 tests/unit/auth/admin-token-opaque-fallback.test.ts
diff --git a/src/lib/auth.ts b/src/lib/auth.ts
index 4f6749282..4c4631532 100644
--- a/src/lib/auth.ts
+++ b/src/lib/auth.ts
@@ -291,6 +291,13 @@ export async function validateAuthToken(
return validateKey(token, options);
}
+ // Opaque mode: allow raw ADMIN_TOKEN for backward-compatible programmatic API access.
+ // Safe because admin token is a server-side env secret, not a user-issued DB key.
+ const adminToken = config.auth.adminToken;
+ if (adminToken && constantTimeEqual(token, adminToken)) {
+ return validateKey(token, options);
+ }
+
return null;
}
diff --git a/tests/unit/auth/admin-token-opaque-fallback.test.ts b/tests/unit/auth/admin-token-opaque-fallback.test.ts
new file mode 100644
index 000000000..da91dfbf6
--- /dev/null
+++ b/tests/unit/auth/admin-token-opaque-fallback.test.ts
@@ -0,0 +1,247 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+// Hoisted mocks
+const mockCookies = vi.hoisted(() => vi.fn());
+const mockHeaders = vi.hoisted(() => vi.fn());
+const mockGetEnvConfig = vi.hoisted(() => vi.fn());
+const mockValidateApiKeyAndGetUser = vi.hoisted(() => vi.fn());
+const mockFindKeyList = vi.hoisted(() => vi.fn());
+const mockReadSession = vi.hoisted(() => vi.fn());
+const mockCookieStore = vi.hoisted(() => ({
+ get: vi.fn(),
+ set: vi.fn(),
+ delete: vi.fn(),
+}));
+const mockHeadersStore = vi.hoisted(() => ({
+ get: vi.fn(),
+}));
+const mockConfig = vi.hoisted(() => ({
+ auth: { adminToken: "test-admin-secret-token-12345" },
+}));
+
+vi.mock("next/headers", () => ({
+ cookies: mockCookies,
+ headers: mockHeaders,
+}));
+
+vi.mock("@/lib/config/env.schema", () => ({
+ getEnvConfig: mockGetEnvConfig,
+}));
+
+vi.mock("@/repository/key", () => ({
+ validateApiKeyAndGetUser: mockValidateApiKeyAndGetUser,
+ findKeyList: mockFindKeyList,
+}));
+
+vi.mock("@/lib/auth-session-store/redis-session-store", () => ({
+ RedisSessionStore: class {
+ read = mockReadSession;
+ create = vi.fn();
+ revoke = vi.fn();
+ rotate = vi.fn();
+ },
+}));
+
+vi.mock("@/lib/logger", () => ({
+ logger: { warn: vi.fn(), error: vi.fn(), info: vi.fn(), debug: vi.fn() },
+}));
+
+vi.mock("@/lib/config/config", () => ({
+ config: mockConfig,
+}));
+
+function setSessionMode(mode: "legacy" | "dual" | "opaque") {
+ mockGetEnvConfig.mockReturnValue({
+ SESSION_TOKEN_MODE: mode,
+ ENABLE_SECURE_COOKIES: false,
+ });
+}
+
+function setAuthCookie(token?: string) {
+ mockCookieStore.get.mockReturnValue(token ? { value: token } : undefined);
+}
+
+function setBearerHeader(token?: string) {
+ mockHeadersStore.get.mockReturnValue(token ? `Bearer ${token}` : null);
+}
+
+describe("admin token opaque-mode fallback", () => {
+ const ADMIN_TOKEN = "test-admin-secret-token-12345";
+
+ beforeEach(() => {
+ vi.resetModules();
+ vi.clearAllMocks();
+
+ mockCookies.mockResolvedValue(mockCookieStore);
+ mockHeaders.mockResolvedValue(mockHeadersStore);
+ mockHeadersStore.get.mockReturnValue(null);
+ mockCookieStore.get.mockReturnValue(undefined);
+
+ setSessionMode("opaque");
+ mockReadSession.mockResolvedValue(null);
+ mockFindKeyList.mockResolvedValue([]);
+ mockValidateApiKeyAndGetUser.mockResolvedValue(null);
+ mockConfig.auth.adminToken = ADMIN_TOKEN;
+ });
+
+ it("opaque mode + raw admin token via cookie -> auth succeeds", async () => {
+ setAuthCookie(ADMIN_TOKEN);
+
+ const { getSession } = await import("@/lib/auth");
+ const session = await getSession();
+
+ expect(session).not.toBeNull();
+ expect(session!.user.id).toBe(-1);
+ expect(session!.user.role).toBe("admin");
+ expect(session!.key.name).toBe("ADMIN_TOKEN");
+ });
+
+ it("opaque mode + raw non-admin API key via cookie -> auth fails", async () => {
+ setAuthCookie("sk-regular-user-key");
+ // Even if this key is valid in DB, opaque mode must reject raw keys
+ mockValidateApiKeyAndGetUser.mockResolvedValue({
+ user: { id: 1, name: "user", role: "user", isEnabled: true },
+ key: {
+ id: 1,
+ userId: 1,
+ name: "key-1",
+ key: "sk-regular-user-key",
+ isEnabled: true,
+ canLoginWebUi: true,
+ },
+ });
+
+ const { getSession } = await import("@/lib/auth");
+ const session = await getSession();
+
+ expect(session).toBeNull();
+ // Must NOT fall back to validateApiKeyAndGetUser for non-admin keys
+ expect(mockValidateApiKeyAndGetUser).not.toHaveBeenCalled();
+ });
+
+ it("opaque mode + admin token via Bearer header -> auth succeeds", async () => {
+ // No cookie set; use Authorization header instead
+ setBearerHeader(ADMIN_TOKEN);
+
+ const { getSession } = await import("@/lib/auth");
+ const session = await getSession();
+
+ expect(session).not.toBeNull();
+ expect(session!.user.id).toBe(-1);
+ expect(session!.user.role).toBe("admin");
+ expect(session!.key.name).toBe("ADMIN_TOKEN");
+ });
+
+ it("opaque mode + valid opaque session -> auth succeeds (original logic unchanged)", async () => {
+ const crypto = await import("node:crypto");
+ const keyString = "sk-opaque-source-key";
+ const fingerprint = `sha256:${crypto.createHash("sha256").update(keyString, "utf8").digest("hex")}`;
+
+ setAuthCookie("sid_valid_session");
+ mockReadSession.mockResolvedValue({
+ sessionId: "sid_valid_session",
+ keyFingerprint: fingerprint,
+ userId: 42,
+ userRole: "user",
+ createdAt: Date.now() - 1000,
+ expiresAt: Date.now() + 86400_000,
+ });
+ mockFindKeyList.mockResolvedValue([
+ {
+ id: 1,
+ userId: 42,
+ name: "key-1",
+ key: keyString,
+ isEnabled: true,
+ canLoginWebUi: true,
+ limit5hUsd: null,
+ limitDailyUsd: null,
+ dailyResetMode: "fixed",
+ dailyResetTime: "00:00",
+ limitWeeklyUsd: null,
+ limitMonthlyUsd: null,
+ limitTotalUsd: null,
+ limitConcurrentSessions: 0,
+ providerGroup: null,
+ cacheTtlPreference: null,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ },
+ ]);
+ mockValidateApiKeyAndGetUser.mockResolvedValue({
+ user: {
+ id: 42,
+ name: "user-42",
+ description: "test",
+ role: "user",
+ rpm: 100,
+ dailyQuota: 100,
+ providerGroup: null,
+ tags: [],
+ isEnabled: true,
+ expiresAt: null,
+ allowedClients: [],
+ allowedModels: [],
+ limit5hUsd: 0,
+ limitWeeklyUsd: 0,
+ limitMonthlyUsd: 0,
+ limitTotalUsd: null,
+ limitConcurrentSessions: 0,
+ dailyResetMode: "fixed",
+ dailyResetTime: "00:00",
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ },
+ key: {
+ id: 1,
+ userId: 42,
+ name: "key-1",
+ key: keyString,
+ isEnabled: true,
+ canLoginWebUi: true,
+ limit5hUsd: null,
+ limitDailyUsd: null,
+ dailyResetMode: "fixed",
+ dailyResetTime: "00:00",
+ limitWeeklyUsd: null,
+ limitMonthlyUsd: null,
+ limitTotalUsd: null,
+ limitConcurrentSessions: 0,
+ providerGroup: null,
+ cacheTtlPreference: null,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ },
+ });
+
+ const { getSession } = await import("@/lib/auth");
+ const session = await getSession({ allowReadOnlyAccess: true });
+
+ expect(session).not.toBeNull();
+ expect(session!.user.id).toBe(42);
+ });
+
+ it("legacy mode -> behavior unchanged (admin token works via validateKey)", async () => {
+ setSessionMode("legacy");
+ setAuthCookie(ADMIN_TOKEN);
+
+ const { getSession } = await import("@/lib/auth");
+ const session = await getSession();
+
+ expect(session).not.toBeNull();
+ expect(session!.user.id).toBe(-1);
+ expect(session!.user.role).toBe("admin");
+ // Legacy mode should NOT touch opaque session store
+ expect(mockReadSession).not.toHaveBeenCalled();
+ });
+
+ it("opaque mode + admin token not configured -> auth fails for raw token", async () => {
+ mockConfig.auth.adminToken = "";
+ setAuthCookie("some-random-token");
+
+ const { getSession } = await import("@/lib/auth");
+ const session = await getSession();
+
+ expect(session).toBeNull();
+ });
+});
From e863c781f2e5f0c9b45444087b2e81381e9fb32d Mon Sep 17 00:00:00 2001
From: Ding <44717411+ding113@users.noreply.github.com>
Date: Mon, 9 Mar 2026 09:27:06 +0800
Subject: [PATCH 06/42] feat: add response input rectifier for /v1/responses
(#888)
* feat: add response input rectifier for /v1/responses input normalization
OpenAI Responses API input field supports string shortcut, single object,
and array formats. This rectifier normalizes non-array input to standard
array format before the guard pipeline, with audit trail via special
settings persisted to message_requests.
- Normalize string input to [{role:"user",content:[{type:"input_text",text}]}]
- Wrap single object input (with role/type) into array
- Convert empty string to empty array
- Passthrough array input unchanged
- Default enabled, toggleable via system settings UI
- Broadened format-mapper body detection for string/object input
- Full i18n support (en, zh-CN, zh-TW, ja, ru)
- 14 unit tests covering all normalization paths
* fix: address bugbot review comments on response-input-rectifier PR
- Remove redundant ternary in rectifyResponseInput (both branches "other")
- Revert body-based detection broadening in detectClientFormat to prevent
misrouting /v1/embeddings and other endpoints with string input fields
- Add missing enableBillingHeaderRectifier to returning clause (pre-existing)
- Refine i18n descriptions to specify "single message object" (5 locales)
- Add 4 normalizeResponseInput tests covering settings gate and audit trail
---
drizzle/0079_easy_zeigeist.sql | 1 +
drizzle/meta/0079_snapshot.json | 3921 +++++++++++++++++
drizzle/meta/_journal.json | 7 +
messages/en/settings/config.json | 2 +
messages/ja/settings/config.json | 2 +
messages/ru/settings/config.json | 2 +
messages/zh-CN/settings/config.json | 2 +
messages/zh-TW/settings/config.json | 2 +
src/actions/system-config.ts | 2 +
.../_components/system-settings-form.tsx | 29 +
src/app/[locale]/settings/config/page.tsx | 1 +
src/app/v1/_lib/proxy-handler.ts | 6 +
src/app/v1/_lib/proxy/format-mapper.ts | 1 +
.../v1/_lib/proxy/response-input-rectifier.ts | 106 +
src/drizzle/schema.ts | 6 +
src/lib/config/system-settings-cache.ts | 3 +
src/lib/utils/special-settings.ts | 2 +
src/lib/validation/schemas.ts | 2 +
src/repository/_shared/transformers.ts | 1 +
src/repository/system-config.ts | 9 +
src/types/special-settings.ts | 17 +-
src/types/system-config.ts | 8 +
.../proxy/response-input-rectifier.test.ts | 242 +
23 files changed, 4373 insertions(+), 1 deletion(-)
create mode 100644 drizzle/0079_easy_zeigeist.sql
create mode 100644 drizzle/meta/0079_snapshot.json
create mode 100644 src/app/v1/_lib/proxy/response-input-rectifier.ts
create mode 100644 tests/unit/proxy/response-input-rectifier.test.ts
diff --git a/drizzle/0079_easy_zeigeist.sql b/drizzle/0079_easy_zeigeist.sql
new file mode 100644
index 000000000..8862f1163
--- /dev/null
+++ b/drizzle/0079_easy_zeigeist.sql
@@ -0,0 +1 @@
+ALTER TABLE "system_settings" ADD COLUMN "enable_response_input_rectifier" boolean DEFAULT true NOT NULL;
\ No newline at end of file
diff --git a/drizzle/meta/0079_snapshot.json b/drizzle/meta/0079_snapshot.json
new file mode 100644
index 000000000..bdea70622
--- /dev/null
+++ b/drizzle/meta/0079_snapshot.json
@@ -0,0 +1,3921 @@
+{
+ "id": "88addf3b-363e-4abc-8809-02b7f03ca6e6",
+ "prevId": "aa3f3ed9-db02-48e9-b755-e2dd39b0b77a",
+ "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
+ },
+ "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 662a127c7..defa97851 100644
--- a/drizzle/meta/_journal.json
+++ b/drizzle/meta/_journal.json
@@ -554,6 +554,13 @@
"when": 1772782546382,
"tag": "0078_remarkable_lionheart",
"breakpoints": true
+ },
+ {
+ "idx": 79,
+ "version": "7",
+ "when": 1772994859188,
+ "tag": "0079_easy_zeigeist",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/messages/en/settings/config.json b/messages/en/settings/config.json
index bb192966d..97ff8ca99 100644
--- a/messages/en/settings/config.json
+++ b/messages/en/settings/config.json
@@ -55,6 +55,8 @@
"enableThinkingBudgetRectifierDesc": "When Anthropic providers return budget_tokens < 1024 errors, automatically sets thinking budget to maximum (32000) and max_tokens to 64000 if needed, then retries once (enabled by default).",
"enableBillingHeaderRectifier": "Enable Billing Header Rectifier",
"enableBillingHeaderRectifierDesc": "Proactively removes x-anthropic-billing-header text blocks injected by Claude Code client into the system prompt, preventing Amazon Bedrock and other non-native Anthropic upstreams from returning 400 errors (enabled by default).",
+ "enableResponseInputRectifier": "Enable Response Input Rectifier",
+ "enableResponseInputRectifierDesc": "Automatically normalizes non-array input (string shortcut or single message object) in /v1/responses requests to standard array format before processing (enabled by default).",
"enableCodexSessionIdCompletion": "Enable Codex Session ID Completion",
"enableCodexSessionIdCompletionDesc": "When Codex requests provide only one of session_id (header) or prompt_cache_key (body), automatically completes the other. If both are missing, generates a UUID v7 session id and reuses it stably within the same conversation.",
"enableClaudeMetadataUserIdInjection": "Enable Claude metadata.user_id Injection",
diff --git a/messages/ja/settings/config.json b/messages/ja/settings/config.json
index abd1ad89a..24b9f7b72 100644
--- a/messages/ja/settings/config.json
+++ b/messages/ja/settings/config.json
@@ -55,6 +55,8 @@
"enableThinkingBudgetRectifierDesc": "Anthropic プロバイダーで budget_tokens < 1024 エラーが発生した場合、thinking 予算を最大値(32000)に設定し、必要に応じて max_tokens を 64000 に設定して1回だけ再試行します(既定で有効)。",
"enableBillingHeaderRectifier": "課金ヘッダー整流を有効化",
"enableBillingHeaderRectifierDesc": "Claude Code クライアントが system プロンプトに注入する x-anthropic-billing-header テキストブロックを事前に削除し、Amazon Bedrock などの非ネイティブ Anthropic 上流による 400 エラーを防止します(既定で有効)。",
+ "enableResponseInputRectifier": "Response Input 整流器を有効化",
+ "enableResponseInputRectifierDesc": "/v1/responses リクエストの非配列 input(文字列ショートカットまたは role/type 付き単一メッセージオブジェクト)を処理前に標準の配列形式に自動正規化します(既定で有効)。",
"enableCodexSessionIdCompletion": "Codex セッションID補完を有効化",
"enableCodexSessionIdCompletionDesc": "Codex リクエストで session_id(ヘッダー)または prompt_cache_key(ボディ)のどちらか一方しか提供されない場合に、欠けている方を自動補完します。両方ない場合は UUID v7 のセッションIDを生成し、同一対話内で安定して再利用します。",
"enableClaudeMetadataUserIdInjection": "Claude metadata.user_id 注入を有効化",
diff --git a/messages/ru/settings/config.json b/messages/ru/settings/config.json
index 00d6b4805..ae0f32e13 100644
--- a/messages/ru/settings/config.json
+++ b/messages/ru/settings/config.json
@@ -55,6 +55,8 @@
"enableThinkingBudgetRectifierDesc": "Если Anthropic-провайдер возвращает ошибку budget_tokens < 1024, автоматически устанавливает thinking budget на максимум (32000) и при необходимости max_tokens на 64000, затем повторяет запрос один раз (включено по умолчанию).",
"enableBillingHeaderRectifier": "Включить исправление billing-заголовка",
"enableBillingHeaderRectifierDesc": "Проактивно удаляет текстовые блоки x-anthropic-billing-header, добавленные клиентом Claude Code в системный промпт, предотвращая ошибки 400 от Amazon Bedrock и других не-Anthropic провайдеров (включено по умолчанию).",
+ "enableResponseInputRectifier": "Включить исправление Response Input",
+ "enableResponseInputRectifierDesc": "Автоматически нормализует не-массивные input (строковые сокращения или одиночные объекты сообщений с role/type) в запросах /v1/responses в стандартный формат массива перед обработкой (включено по умолчанию).",
"enableCodexSessionIdCompletion": "Включить дополнение Session ID для Codex",
"enableCodexSessionIdCompletionDesc": "Если в Codex-запросе присутствует только session_id (в заголовках) или prompt_cache_key (в теле), автоматически дополняет отсутствующее поле. Если оба отсутствуют, генерирует UUID v7 и стабильно переиспользует его в рамках одного диалога.",
"enableClaudeMetadataUserIdInjection": "Включить инъекцию Claude metadata.user_id",
diff --git a/messages/zh-CN/settings/config.json b/messages/zh-CN/settings/config.json
index 981b1fd34..53ac66081 100644
--- a/messages/zh-CN/settings/config.json
+++ b/messages/zh-CN/settings/config.json
@@ -44,6 +44,8 @@
"enableThinkingBudgetRectifierDesc": "当 Anthropic 类型供应商返回 budget_tokens < 1024 错误时,自动将 thinking 预算设为最大值(32000),并在需要时将 max_tokens 设为 64000,然后重试一次(默认开启)。",
"enableBillingHeaderRectifier": "启用计费标头整流器",
"enableBillingHeaderRectifierDesc": "主动移除 Claude Code 客户端注入到 system 提示中的 x-anthropic-billing-header 文本块,防止 Amazon Bedrock 等非原生 Anthropic 上游返回 400 错误(默认开启)。",
+ "enableResponseInputRectifier": "启用 Response Input 整流器",
+ "enableResponseInputRectifierDesc": "自动将 /v1/responses 请求中的非数组 input(字符串简写或带 role/type 的单消息对象)规范化为标准数组格式后再处理(默认开启)。",
"enableCodexSessionIdCompletion": "启用 Codex Session ID 补全",
"enableCodexSessionIdCompletionDesc": "当 Codex 请求仅提供 session_id(请求头)或 prompt_cache_key(请求体)之一时,自动补全另一个;若两者均缺失,则生成 UUID v7 会话 ID,并在同一对话内稳定复用。",
"enableClaudeMetadataUserIdInjection": "启用 Claude metadata.user_id 注入",
diff --git a/messages/zh-TW/settings/config.json b/messages/zh-TW/settings/config.json
index d2df54956..d7bdc6e6c 100644
--- a/messages/zh-TW/settings/config.json
+++ b/messages/zh-TW/settings/config.json
@@ -55,6 +55,8 @@
"enableThinkingBudgetRectifierDesc": "當 Anthropic 類型供應商返回 budget_tokens < 1024 錯誤時,自動將 thinking 預算設為最大值(32000),並在需要時將 max_tokens 設為 64000,然後重試一次(預設開啟)。",
"enableBillingHeaderRectifier": "啟用計費標頭整流器",
"enableBillingHeaderRectifierDesc": "主動移除 Claude Code 客戶端注入到 system 提示中的 x-anthropic-billing-header 文字區塊,防止 Amazon Bedrock 等非原生 Anthropic 上游回傳 400 錯誤(預設開啟)。",
+ "enableResponseInputRectifier": "啟用 Response Input 整流器",
+ "enableResponseInputRectifierDesc": "自動將 /v1/responses 請求中的非陣列 input(字串簡寫或帶 role/type 的單訊息物件)規範化為標準陣列格式後再處理(預設開啟)。",
"enableCodexSessionIdCompletion": "啟用 Codex Session ID 補全",
"enableCodexSessionIdCompletionDesc": "當 Codex 請求僅提供 session_id(請求頭)或 prompt_cache_key(請求體)之一時,自動補全另一個;若兩者皆缺失,則產生 UUID v7 會話 ID,並在同一對話內穩定複用。",
"enableClaudeMetadataUserIdInjection": "啟用 Claude metadata.user_id 注入",
diff --git a/src/actions/system-config.ts b/src/actions/system-config.ts
index e1ed4be88..4e6f8a1c6 100644
--- a/src/actions/system-config.ts
+++ b/src/actions/system-config.ts
@@ -59,6 +59,7 @@ export async function saveSystemSettings(formData: {
enableThinkingSignatureRectifier?: boolean;
enableThinkingBudgetRectifier?: boolean;
enableBillingHeaderRectifier?: boolean;
+ enableResponseInputRectifier?: boolean;
enableCodexSessionIdCompletion?: boolean;
enableClaudeMetadataUserIdInjection?: boolean;
enableResponseFixer?: boolean;
@@ -95,6 +96,7 @@ export async function saveSystemSettings(formData: {
enableThinkingSignatureRectifier: validated.enableThinkingSignatureRectifier,
enableThinkingBudgetRectifier: validated.enableThinkingBudgetRectifier,
enableBillingHeaderRectifier: validated.enableBillingHeaderRectifier,
+ enableResponseInputRectifier: validated.enableResponseInputRectifier,
enableCodexSessionIdCompletion: validated.enableCodexSessionIdCompletion,
enableClaudeMetadataUserIdInjection: validated.enableClaudeMetadataUserIdInjection,
enableResponseFixer: validated.enableResponseFixer,
diff --git a/src/app/[locale]/settings/config/_components/system-settings-form.tsx b/src/app/[locale]/settings/config/_components/system-settings-form.tsx
index de146da9d..5fdcb4057 100644
--- a/src/app/[locale]/settings/config/_components/system-settings-form.tsx
+++ b/src/app/[locale]/settings/config/_components/system-settings-form.tsx
@@ -56,6 +56,7 @@ interface SystemSettingsFormProps {
| "interceptAnthropicWarmupRequests"
| "enableThinkingSignatureRectifier"
| "enableBillingHeaderRectifier"
+ | "enableResponseInputRectifier"
| "enableThinkingBudgetRectifier"
| "enableCodexSessionIdCompletion"
| "enableClaudeMetadataUserIdInjection"
@@ -105,6 +106,9 @@ export function SystemSettingsForm({ initialSettings }: SystemSettingsFormProps)
const [enableBillingHeaderRectifier, setEnableBillingHeaderRectifier] = useState(
initialSettings.enableBillingHeaderRectifier
);
+ const [enableResponseInputRectifier, setEnableResponseInputRectifier] = useState(
+ initialSettings.enableResponseInputRectifier
+ );
const [enableThinkingBudgetRectifier, setEnableThinkingBudgetRectifier] = useState(
initialSettings.enableThinkingBudgetRectifier
);
@@ -172,6 +176,7 @@ export function SystemSettingsForm({ initialSettings }: SystemSettingsFormProps)
interceptAnthropicWarmupRequests,
enableThinkingSignatureRectifier,
enableBillingHeaderRectifier,
+ enableResponseInputRectifier,
enableThinkingBudgetRectifier,
enableCodexSessionIdCompletion,
enableClaudeMetadataUserIdInjection,
@@ -201,6 +206,7 @@ export function SystemSettingsForm({ initialSettings }: SystemSettingsFormProps)
setInterceptAnthropicWarmupRequests(result.data.interceptAnthropicWarmupRequests);
setEnableThinkingSignatureRectifier(result.data.enableThinkingSignatureRectifier);
setEnableBillingHeaderRectifier(result.data.enableBillingHeaderRectifier);
+ setEnableResponseInputRectifier(result.data.enableResponseInputRectifier);
setEnableThinkingBudgetRectifier(result.data.enableThinkingBudgetRectifier);
setEnableCodexSessionIdCompletion(result.data.enableCodexSessionIdCompletion);
setEnableClaudeMetadataUserIdInjection(result.data.enableClaudeMetadataUserIdInjection);
@@ -476,6 +482,29 @@ export function SystemSettingsForm({ initialSettings }: SystemSettingsFormProps)
/>
+ {/* 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 (
+
+
+
router.push("/dashboard/leaderboard?scope=user")}
+ >
+
+ {t("backToLeaderboard")}
+
+
+
+ {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) => (
+ setTimeRange(opt.key)}
+ data-testid={`user-insights-time-range-${opt.key}`}
+ >
+ {t(opt.labelKey)}
+
+ ))}
+
+
+
+ {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"
+ />
+
+ {tCommon("ok")}
+
+ {(appliedRange.start || appliedRange.end) && (
+
+ {t("allTime")}
+
+ )}
+
+
+
+ {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}%)
-
-
-
-
- >
- );
-}
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}%)
+
+
+
+
+ >
+ );
+}
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) => (
+ onFiltersChange({ ...filters, timeRange: opt.key })}
+ data-testid={`user-insights-time-range-${opt.key}`}
+ >
+ {t(opt.labelKey)}
+
+ ))}
+
+
+
+ {/* Dimension filters */}
+
+
+
+ {t("filters")}
+
+
+ {/* Key filter */}
+
+
+ {/* Provider filter */}
+
+
+ {/* Model filter */}
+
+
+ {/* Clear filters */}
+ {hasActiveFilters && (
+
+ onFiltersChange({
+ timeRange: filters.timeRange,
+ })
+ }
+ >
+ {t("allTime")}
+
+ )}
+
+
+ );
+}
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) => (
- setTimeRange(opt.key)}
- data-testid={`user-insights-time-range-${opt.key}`}
- >
- {t(opt.labelKey)}
-
- ))}
-
{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"
- />
-
- {tCommon("ok")}
-
- {(appliedRange.start || appliedRange.end) && (
-
- {t("allTime")}
-
- )}
-
+
+
+ {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 (