解决。
* fix(ui): remove duplicate aria-label
---
CLAUDE.md | 2 +-
messages/en/dashboard.json | 5 +
messages/en/settings.json | 16 ++
messages/ja/dashboard.json | 5 +
messages/ja/settings.json | 16 ++
messages/ru/dashboard.json | 5 +
messages/ru/settings.json | 16 ++
messages/zh-CN/dashboard.json | 5 +
messages/zh-CN/settings.json | 16 ++
messages/zh-TW/dashboard.json | 5 +
messages/zh-TW/settings.json | 16 ++
src/actions/providers.ts | 148 +++++++++++
.../session-messages-client-actions.test.tsx | 22 +-
.../session-messages-client.test.tsx | 121 ++++-----
.../_components/session-messages-client.tsx | 14 +-
.../_components/auto-sort-priority-dialog.tsx | 250 ++++++++++++++++++
.../_components/provider-manager-loader.tsx | 24 +-
src/app/[locale]/settings/providers/page.tsx | 2 +
.../ui/__tests__/code-display.test.tsx | 47 +---
src/repository/provider.ts | 37 +++
tests/unit/actions/providers.test.ts | 206 +++++++++++++++
tests/unit/repository/provider.test.ts | 127 +++++++++
tsconfig.json | 5 +-
vitest.config.ts | 2 +
vitest.integration.config.ts | 9 +-
25 files changed, 987 insertions(+), 134 deletions(-)
create mode 100644 src/app/[locale]/settings/providers/_components/auto-sort-priority-dialog.tsx
create mode 100644 tests/unit/repository/provider.test.ts
diff --git a/CLAUDE.md b/CLAUDE.md
index 8b449668c..cf1b28607 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -126,7 +126,7 @@ Key components:
- **Path alias**: `@/` maps to `./src/`
- **Formatting**: Biome (double quotes, trailing commas, 2-space indent, 100 char width)
- **Exports**: Prefer named exports over default exports
-- **i18n**: Use `next-intl` for internationalization (5 languages: zh-CN, en, ja, ko, de)
+- **i18n**: Use `next-intl` for internationalization (5 languages: zh-CN, zh-TW, en, ja, ru)
- **Testing**: Unit tests in `tests/unit/`, integration in `tests/integration/`, source-adjacent tests in `src/**/*.test.ts`
## Environment Variables
diff --git a/messages/en/dashboard.json b/messages/en/dashboard.json
index 077a63746..8cc6d5add 100644
--- a/messages/en/dashboard.json
+++ b/messages/en/dashboard.json
@@ -1,4 +1,9 @@
{
+ "actions": {
+ "copy": "Copy",
+ "download": "Download",
+ "copied": "Copied"
+ },
"title": {
"costRanking": "Cost Leaderboard",
"costRankingDescription": "View user cost rankings, data updates every 5 minutes",
diff --git a/messages/en/settings.json b/messages/en/settings.json
index 388c99495..eac7bfaeb 100644
--- a/messages/en/settings.json
+++ b/messages/en/settings.json
@@ -615,6 +615,22 @@
"addFailed": "Failed to add provider",
"addProvider": "Add Provider",
"addSuccess": "Provider added successfully",
+ "autoSort": {
+ "button": "Auto Sort Priority",
+ "dialogTitle": "Auto Sort Provider Priority",
+ "dialogDescription": "Automatically assign priority based on cost multiplier (lower cost = higher priority)",
+ "changeCount": "{count} providers will be updated",
+ "noChanges": "No changes needed (already sorted)",
+ "costMultiplierHeader": "Cost Multiplier",
+ "priorityHeader": "Priority",
+ "providersHeader": "Providers",
+ "changesTitle": "Change Details",
+ "providerHeader": "Provider",
+ "priorityChangeHeader": "Priority Change",
+ "confirm": "Apply Changes",
+ "success": "Updated priority for {count} providers",
+ "error": "Failed to update priorities"
+ },
"types": {
"claude": {
"label": "Claude",
diff --git a/messages/ja/dashboard.json b/messages/ja/dashboard.json
index 8c6340a00..a43abf9ca 100644
--- a/messages/ja/dashboard.json
+++ b/messages/ja/dashboard.json
@@ -1,4 +1,9 @@
{
+ "actions": {
+ "copy": "コピー",
+ "download": "ダウンロード",
+ "copied": "コピーしました"
+ },
"title": {
"costRanking": "コスト ランキング",
"costRankingDescription": "ユーザーコスト ランキングを表示します。データは 5 分ごとに更新されます",
diff --git a/messages/ja/settings.json b/messages/ja/settings.json
index 571f8f063..f7b2dbf6f 100644
--- a/messages/ja/settings.json
+++ b/messages/ja/settings.json
@@ -606,6 +606,22 @@
"addFailed": "プロバイダーの追加に失敗しました",
"addProvider": "プロバイダーを追加",
"addSuccess": "プロバイダーが正常に追加されました",
+ "autoSort": {
+ "button": "優先度を自動ソート",
+ "dialogTitle": "プロバイダー優先度の自動ソート",
+ "dialogDescription": "コスト倍率に基づいて優先度を自動割り当て(低コスト = 高優先度)",
+ "changeCount": "{count} 件のプロバイダーが更新されます",
+ "noChanges": "変更不要(ソート済み)",
+ "costMultiplierHeader": "コスト倍率",
+ "priorityHeader": "優先度",
+ "providersHeader": "プロバイダー",
+ "changesTitle": "変更詳細",
+ "providerHeader": "プロバイダー",
+ "priorityChangeHeader": "優先度変更",
+ "confirm": "変更を適用",
+ "success": "{count} 件のプロバイダーの優先度を更新しました",
+ "error": "優先度の更新に失敗しました"
+ },
"circuitBroken": "サーキットブレーカー作動中",
"clone": "プロバイダーを複製",
"cloneFailed": "コピーに失敗しました",
diff --git a/messages/ru/dashboard.json b/messages/ru/dashboard.json
index add9280f6..e0fd846da 100644
--- a/messages/ru/dashboard.json
+++ b/messages/ru/dashboard.json
@@ -1,4 +1,9 @@
{
+ "actions": {
+ "copy": "Копировать",
+ "download": "Скачать",
+ "copied": "Скопировано"
+ },
"title": {
"costRanking": "Таблица расходов",
"costRankingDescription": "Просмотр рейтинга расходов пользователей, данные обновляются каждые 5 минут",
diff --git a/messages/ru/settings.json b/messages/ru/settings.json
index efb1967f8..f73c3d9bf 100644
--- a/messages/ru/settings.json
+++ b/messages/ru/settings.json
@@ -606,6 +606,22 @@
"addFailed": "Ошибка добавления поставщика",
"addProvider": "Добавить провайдера",
"addSuccess": "Поставщик добавлен успешно",
+ "autoSort": {
+ "button": "Авто сортировка приоритета",
+ "dialogTitle": "Автоматическая сортировка приоритета поставщиков",
+ "dialogDescription": "Автоматически назначить приоритет на основе множителя стоимости (низкая стоимость = высокий приоритет)",
+ "changeCount": "{count} поставщиков будет обновлено",
+ "noChanges": "Изменения не требуются (уже отсортировано)",
+ "costMultiplierHeader": "Множитель стоимости",
+ "priorityHeader": "Приоритет",
+ "providersHeader": "Поставщики",
+ "changesTitle": "Детали изменений",
+ "providerHeader": "Поставщик",
+ "priorityChangeHeader": "Изменение приоритета",
+ "confirm": "Применить изменения",
+ "success": "Обновлён приоритет для {count} поставщиков",
+ "error": "Не удалось обновить приоритеты"
+ },
"circuitBroken": "Цепь разомкнута",
"clone": "Дублировать поставщика",
"cloneFailed": "Ошибка копирования",
diff --git a/messages/zh-CN/dashboard.json b/messages/zh-CN/dashboard.json
index 7e3f22f31..d027152a4 100644
--- a/messages/zh-CN/dashboard.json
+++ b/messages/zh-CN/dashboard.json
@@ -1,4 +1,9 @@
{
+ "actions": {
+ "copy": "复制",
+ "download": "下载",
+ "copied": "已复制"
+ },
"title": {
"costRanking": "消耗排行榜",
"costRankingDescription": "查看用户消耗排名,数据每 5 分钟更新一次",
diff --git a/messages/zh-CN/settings.json b/messages/zh-CN/settings.json
index 942619612..b14bdd05f 100644
--- a/messages/zh-CN/settings.json
+++ b/messages/zh-CN/settings.json
@@ -138,6 +138,22 @@
"subtitle": "服务商管理",
"subtitleDesc": "配置上游服务商的金额限流和并发限制,留空表示无限制。",
"add": "添加供应商",
+ "autoSort": {
+ "button": "自动排序优先级",
+ "dialogTitle": "自动排序供应商优先级",
+ "dialogDescription": "根据成本倍率自动分配优先级(低成本 = 高优先级)",
+ "changeCount": "{count} 个供应商将被更新",
+ "noChanges": "无需更改(已排序)",
+ "costMultiplierHeader": "成本倍率",
+ "priorityHeader": "优先级",
+ "providersHeader": "供应商",
+ "changesTitle": "变更详情",
+ "providerHeader": "供应商",
+ "priorityChangeHeader": "优先级变更",
+ "confirm": "应用变更",
+ "success": "已更新 {count} 个供应商的优先级",
+ "error": "更新优先级失败"
+ },
"types": {
"claude": {
"label": "Claude",
diff --git a/messages/zh-TW/dashboard.json b/messages/zh-TW/dashboard.json
index 77149f016..c7bc527a4 100644
--- a/messages/zh-TW/dashboard.json
+++ b/messages/zh-TW/dashboard.json
@@ -1,4 +1,9 @@
{
+ "actions": {
+ "copy": "複製",
+ "download": "下載",
+ "copied": "已複製"
+ },
"title": {
"costRanking": "消耗排行榜",
"costRankingDescription": "查看用戶消耗排名,資料每 5 分鐘更新一次",
diff --git a/messages/zh-TW/settings.json b/messages/zh-TW/settings.json
index 4ee371969..153ee73c9 100644
--- a/messages/zh-TW/settings.json
+++ b/messages/zh-TW/settings.json
@@ -606,6 +606,22 @@
"addFailed": "新增服務商失敗",
"addProvider": "新增服务商",
"addSuccess": "新增服務商成功",
+ "autoSort": {
+ "button": "自動排序優先級",
+ "dialogTitle": "自動排序供應商優先級",
+ "dialogDescription": "根據成本倍率自動分配優先級(低成本 = 高優先級)",
+ "changeCount": "{count} 個供應商將被更新",
+ "noChanges": "無需更改(已排序)",
+ "costMultiplierHeader": "成本倍率",
+ "priorityHeader": "優先級",
+ "providersHeader": "供應商",
+ "changesTitle": "變更詳情",
+ "providerHeader": "供應商",
+ "priorityChangeHeader": "優先級變更",
+ "confirm": "應用變更",
+ "success": "已更新 {count} 個供應商的優先級",
+ "error": "更新優先級失敗"
+ },
"circuitBroken": "熔断中",
"clone": "複製服務商",
"cloneFailed": "複製失敗",
diff --git a/src/actions/providers.ts b/src/actions/providers.ts
index 0a812aad7..3a9d73827 100644
--- a/src/actions/providers.ts
+++ b/src/actions/providers.ts
@@ -41,6 +41,7 @@ import {
getProviderStatistics,
resetProviderTotalCostResetAt,
updateProvider,
+ updateProviderPrioritiesBatch,
} from "@/repository/provider";
import type { CacheTtlPreference } from "@/types/cache";
import type {
@@ -54,6 +55,27 @@ import type {
} from "@/types/provider";
import type { ActionResult } from "./types";
+type AutoSortResult = {
+ groups: Array<{
+ costMultiplier: number;
+ priority: number;
+ providers: Array<{ id: number; name: string }>;
+ }>;
+ changes: Array<{
+ providerId: number;
+ name: string;
+ oldPriority: number;
+ newPriority: number;
+ costMultiplier: number;
+ }>;
+ summary: {
+ totalProviders: number;
+ changedCount: number;
+ groupCount: number;
+ };
+ applied: boolean;
+};
+
const API_TEST_TIMEOUT_LIMITS = {
DEFAULT: 15000,
MIN: 5000,
@@ -740,6 +762,132 @@ export async function removeProvider(providerId: number): Promise
}
}
+export async function autoSortProviderPriority(args: {
+ confirm: boolean;
+}): Promise> {
+ try {
+ const session = await getSession();
+ if (!session || session.user.role !== "admin") {
+ return { ok: false, error: "无权限执行此操作" };
+ }
+
+ const providers = await findAllProvidersFresh();
+ if (providers.length === 0) {
+ return {
+ ok: true,
+ data: {
+ groups: [],
+ changes: [],
+ summary: {
+ totalProviders: 0,
+ changedCount: 0,
+ groupCount: 0,
+ },
+ applied: args.confirm,
+ },
+ };
+ }
+
+ const groupsByCostMultiplier = new Map();
+ for (const provider of providers) {
+ const rawCostMultiplier = Number(provider.costMultiplier);
+ const costMultiplier = Number.isFinite(rawCostMultiplier) ? rawCostMultiplier : 0;
+
+ if (!Number.isFinite(rawCostMultiplier)) {
+ logger.warn("autoSortProviderPriority:invalid_cost_multiplier", {
+ providerId: provider.id,
+ providerName: provider.name,
+ costMultiplier: provider.costMultiplier,
+ fallback: costMultiplier,
+ });
+ }
+
+ const bucket = groupsByCostMultiplier.get(costMultiplier);
+ if (bucket) {
+ bucket.push(provider);
+ } else {
+ groupsByCostMultiplier.set(costMultiplier, [provider]);
+ }
+ }
+
+ const sortedCostMultipliers = Array.from(groupsByCostMultiplier.keys()).sort((a, b) => a - b);
+ const groups: AutoSortResult["groups"] = [];
+ const changes: AutoSortResult["changes"] = [];
+
+ for (const [priority, costMultiplier] of sortedCostMultipliers.entries()) {
+ const groupProviders = groupsByCostMultiplier.get(costMultiplier) ?? [];
+ groups.push({
+ costMultiplier,
+ priority,
+ providers: groupProviders
+ .slice()
+ .sort((a, b) => a.id - b.id)
+ .map((provider) => ({ id: provider.id, name: provider.name })),
+ });
+
+ for (const provider of groupProviders) {
+ const oldPriority = provider.priority ?? 0;
+ const newPriority = priority;
+ if (oldPriority !== newPriority) {
+ changes.push({
+ providerId: provider.id,
+ name: provider.name,
+ oldPriority,
+ newPriority,
+ costMultiplier,
+ });
+ }
+ }
+ }
+
+ const summary: AutoSortResult["summary"] = {
+ totalProviders: providers.length,
+ changedCount: changes.length,
+ groupCount: groups.length,
+ };
+
+ if (!args.confirm) {
+ return {
+ ok: true,
+ data: {
+ groups,
+ changes,
+ summary,
+ applied: false,
+ },
+ };
+ }
+
+ if (changes.length > 0) {
+ await updateProviderPrioritiesBatch(
+ changes.map((change) => ({ id: change.providerId, priority: change.newPriority }))
+ );
+ try {
+ await publishProviderCacheInvalidation();
+ } catch (error) {
+ logger.warn("autoSortProviderPriority:cache_invalidation_failed", {
+ changedCount: changes.length,
+ error: error instanceof Error ? error.message : String(error),
+ });
+ }
+ }
+
+ return {
+ ok: true,
+ data: {
+ groups,
+ changes,
+ summary,
+ applied: true,
+ },
+ };
+ } catch (error) {
+ logger.error("autoSortProviderPriority:error", error);
+ const message = error instanceof Error ? error.message : "自动排序供应商优先级失败";
+ return { ok: false, error: message };
+ }
+}
+
/**
* 获取所有供应商的熔断器健康状态
* 返回格式:{ providerId: { circuitState, failureCount, circuitOpenUntil, ... } }
diff --git a/src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/session-messages-client-actions.test.tsx b/src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/session-messages-client-actions.test.tsx
index 50b64654c..bb5993295 100644
--- a/src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/session-messages-client-actions.test.tsx
+++ b/src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/session-messages-client-actions.test.tsx
@@ -194,9 +194,8 @@ describe("SessionMessagesClient (request export actions)", () => {
const { container, unmount } = renderClient();
await flushEffects();
- const buttons = Array.from(container.querySelectorAll("button"));
- const downloadBtn = buttons.find((b) => b.textContent?.includes("actions.downloadMessages"));
- expect(downloadBtn).not.toBeUndefined();
+ const downloadBtn = container.querySelector('button[aria-label="actions.downloadMessages"]');
+ expect(downloadBtn).not.toBeNull();
click(downloadBtn as HTMLButtonElement);
expect(createObjectURLSpy).toHaveBeenCalledTimes(1);
@@ -282,9 +281,8 @@ describe("SessionMessagesClient (request export actions)", () => {
2
);
- const buttons = Array.from(container.querySelectorAll("button"));
- const copyBtn = buttons.find((b) => b.textContent?.includes("actions.copyMessages"));
- expect(copyBtn).not.toBeUndefined();
+ const copyBtn = container.querySelector('button[aria-label="actions.copyMessages"]');
+ expect(copyBtn).not.toBeNull();
await clickAsync(copyBtn as HTMLButtonElement);
expect(clipboardWriteText).toHaveBeenCalledWith(expectedJson);
act(() => {
@@ -292,8 +290,8 @@ describe("SessionMessagesClient (request export actions)", () => {
});
vi.useRealTimers();
- const downloadBtn = buttons.find((b) => b.textContent?.includes("actions.downloadMessages"));
- expect(downloadBtn).not.toBeUndefined();
+ const downloadBtn = container.querySelector('button[aria-label="actions.downloadMessages"]');
+ expect(downloadBtn).not.toBeNull();
click(downloadBtn as HTMLButtonElement);
expect(createObjectURLSpy).toHaveBeenCalledTimes(1);
@@ -335,8 +333,8 @@ describe("SessionMessagesClient (request export actions)", () => {
const { container, unmount } = renderClient();
await flushEffects();
- expect(container.textContent).not.toContain("actions.copyMessages");
- expect(container.textContent).not.toContain("actions.downloadMessages");
+ expect(container.querySelector('button[aria-label="actions.copyMessages"]')).toBeNull();
+ expect(container.querySelector('button[aria-label="actions.downloadMessages"]')).toBeNull();
unmount();
});
@@ -366,8 +364,8 @@ describe("SessionMessagesClient (request export actions)", () => {
const { container, unmount } = renderClient();
await flushEffects();
- expect(container.textContent).not.toContain("actions.copyMessages");
- expect(container.textContent).not.toContain("actions.downloadMessages");
+ expect(container.querySelector('button[aria-label="actions.copyMessages"]')).toBeNull();
+ expect(container.querySelector('button[aria-label="actions.downloadMessages"]')).toBeNull();
unmount();
});
diff --git a/src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/session-messages-client.test.tsx b/src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/session-messages-client.test.tsx
index 1c3465276..f2c92da01 100644
--- a/src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/session-messages-client.test.tsx
+++ b/src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/session-messages-client.test.tsx
@@ -7,49 +7,12 @@ import { act } from "react";
import { createRoot } from "react-dom/client";
import { NextIntlClientProvider } from "next-intl";
import { describe, expect, test, vi } from "vitest";
+import dashboardMessages from "@messages/en/dashboard.json";
import { SessionMessagesDetailsTabs } from "./session-details-tabs";
+// Use real locale messages to ensure test stays in sync with actual translations
const messages = {
- dashboard: {
- sessions: {
- details: {
- requestHeaders: "Request Headers",
- requestBody: "Request Body",
- requestMessages: "Request Messages",
- specialSettings: "Special",
- responseHeaders: "Response Headers",
- responseBody: "Response Body",
- noHeaders: "No data",
- noData: "No Data",
- storageTip: "Storage Tip",
- },
- codeDisplay: {
- raw: "Raw",
- pretty: "Pretty",
- searchPlaceholder: "Search",
- expand: "Expand",
- collapse: "Collapse",
- themeAuto: "Auto",
- themeLight: "Light",
- themeDark: "Dark",
- noMatches: "No matches",
- onlyMatches: "Only matches",
- showAll: "Show all",
- prevPage: "Prev",
- nextPage: "Next",
- pageInfo: "Page {page} / {total}",
- sseEvent: "Event",
- sseData: "Data",
- hardLimit: {
- title: "Content too large",
- size: "Size: {sizeMB} MB ({sizeBytes} bytes)",
- maximum: "Maximum allowed: {maxSizeMB} MB or {maxLines} lines",
- hint: "Please download the file to view the full content.",
- download: "Download",
- },
- },
- },
- },
+ dashboard: dashboardMessages,
} as const;
function renderWithIntl(node: ReactNode) {
@@ -108,39 +71,61 @@ describe("SessionMessagesDetailsTabs", () => {
container.querySelector("[data-testid='session-tab-trigger-request-messages']")
).not.toBeNull();
- const requestBody = container.querySelector(
- "[data-testid='session-tab-request-body'] [data-testid='code-display']"
+ // Check request body tab content within its scope
+ const requestBodyTab = container.querySelector(
+ "[data-testid='session-tab-request-body']"
) as HTMLElement;
- expect(requestBody.getAttribute("data-language")).toBe("json");
- expect(container.textContent).toContain('"model": "gpt-5.2"');
+ const requestBodyCodeDisplay = requestBodyTab.querySelector(
+ "[data-testid='code-display']"
+ ) as HTMLElement;
+ expect(requestBodyCodeDisplay.getAttribute("data-language")).toBe("json");
+ expect(requestBodyTab.textContent).toContain('"model": "gpt-5.2"');
+ // Switch to request headers tab and check within its scope
const requestHeadersTrigger = container.querySelector(
"[data-testid='session-tab-trigger-request-headers']"
) as HTMLElement;
click(requestHeadersTrigger);
- expect(container.textContent).toContain("CLIENT: POST https://example.com/v1/responses");
+ const requestHeadersTab = container.querySelector(
+ "[data-testid='session-tab-request-headers']"
+ ) as HTMLElement;
+ expect(requestHeadersTab.textContent).toContain(
+ "CLIENT: POST https://example.com/v1/responses"
+ );
+ // Switch to request messages tab and check within its scope
const requestMessagesTrigger = container.querySelector(
"[data-testid='session-tab-trigger-request-messages']"
) as HTMLElement;
click(requestMessagesTrigger);
- expect(container.textContent).toContain('"content": "hi"');
+ const requestMessagesTab = container.querySelector(
+ "[data-testid='session-tab-request-messages']"
+ ) as HTMLElement;
+ expect(requestMessagesTab.textContent).toContain('"content": "hi"');
+ // Switch to response body tab and check SSE detection
const responseBodyTrigger = container.querySelector(
"[data-testid='session-tab-trigger-response-body']"
) as HTMLElement;
click(responseBodyTrigger);
- const responseBody = container.querySelector(
- "[data-testid='session-tab-response-body'] [data-testid='code-display']"
+ const responseBodyTab = container.querySelector(
+ "[data-testid='session-tab-response-body']"
) as HTMLElement;
- expect(responseBody.getAttribute("data-language")).toBe("sse");
+ const responseBodyCodeDisplay = responseBodyTab.querySelector(
+ "[data-testid='code-display']"
+ ) as HTMLElement;
+ expect(responseBodyCodeDisplay.getAttribute("data-language")).toBe("sse");
+ // Switch to response headers tab and check within its scope
const responseHeadersTrigger = container.querySelector(
"[data-testid='session-tab-trigger-response-headers']"
) as HTMLElement;
click(responseHeadersTrigger);
- expect(container.textContent).toContain(
+ const responseHeadersTab = container.querySelector(
+ "[data-testid='session-tab-response-headers']"
+ ) as HTMLElement;
+ expect(responseHeadersTab.textContent).toContain(
"UPSTREAM: HTTP 200 https://api.example.com/v1/responses"
);
@@ -166,10 +151,13 @@ describe("SessionMessagesDetailsTabs", () => {
) as HTMLElement;
click(responseBodyTrigger);
- const responseBody = container.querySelector(
- "[data-testid='session-tab-response-body'] [data-testid='code-display']"
+ const responseBodyTab = container.querySelector(
+ "[data-testid='session-tab-response-body']"
) as HTMLElement;
- expect(responseBody.getAttribute("data-language")).toBe("json");
+ const responseBodyCodeDisplay = responseBodyTab.querySelector(
+ "[data-testid='code-display']"
+ ) as HTMLElement;
+ expect(responseBodyCodeDisplay.getAttribute("data-language")).toBe("json");
unmount();
});
@@ -188,19 +176,31 @@ describe("SessionMessagesDetailsTabs", () => {
/>
);
- expect(container.textContent).toContain("Storage Tip");
+ // Check default tab (request body) shows storageTip when null - scoped to tab
+ const requestBodyTab = container.querySelector(
+ "[data-testid='session-tab-request-body']"
+ ) as HTMLElement;
+ expect(requestBodyTab.textContent).toContain(dashboardMessages.sessions.details.storageTip);
+ // Switch to request headers tab and check storageTip - scoped to tab
const requestHeadersTrigger = container.querySelector(
"[data-testid='session-tab-trigger-request-headers']"
) as HTMLElement;
click(requestHeadersTrigger);
- expect(container.textContent).toContain("Storage Tip");
+ const requestHeadersTab = container.querySelector(
+ "[data-testid='session-tab-request-headers']"
+ ) as HTMLElement;
+ expect(requestHeadersTab.textContent).toContain(dashboardMessages.sessions.details.storageTip);
+ // Switch to special settings tab and check noData - scoped to tab
const specialSettingsTrigger = container.querySelector(
"[data-testid='session-tab-trigger-special-settings']"
) as HTMLElement;
click(specialSettingsTrigger);
- expect(container.textContent).toContain("No Data");
+ const specialSettingsTab = container.querySelector(
+ "[data-testid='session-tab-special-settings']"
+ ) as HTMLElement;
+ expect(specialSettingsTab.textContent).toContain(dashboardMessages.sessions.details.noData);
unmount();
});
@@ -231,7 +231,9 @@ describe("SessionMessagesDetailsTabs", () => {
const requestHeadersTab = container.querySelector(
"[data-testid='session-tab-request-headers']"
) as HTMLElement;
- expect(requestHeadersTab.textContent).not.toContain("Content too large");
+ expect(requestHeadersTab.textContent).not.toContain(
+ dashboardMessages.sessions.codeDisplay.hardLimit.title
+ );
const search = requestHeadersTab.querySelector(
"[data-testid='code-display-search']"
@@ -278,8 +280,9 @@ describe("SessionMessagesDetailsTabs", () => {
const requestBodyTab = container.querySelector(
"[data-testid='session-tab-request-body']"
) as HTMLElement;
- expect(requestBodyTab.textContent).toContain("Content too large");
- expect(requestBodyTab.textContent).toContain("30,000 lines");
+ expect(requestBodyTab.textContent).toContain(
+ dashboardMessages.sessions.codeDisplay.hardLimit.title
+ );
const downloadBtn = requestBodyTab.querySelector(
"[data-testid='code-display-hard-limit-download']"
diff --git a/src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/session-messages-client.tsx b/src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/session-messages-client.tsx
index aba97ea85..032ae1f3d 100644
--- a/src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/session-messages-client.tsx
+++ b/src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/session-messages-client.tsx
@@ -372,8 +372,8 @@ export function SessionMessagesClient() {
variant="outline"
size="icon"
className="h-8 w-8"
- onClick={handleCopyRequest}
aria-label={t("actions.copyMessages")}
+ onClick={handleCopyRequest}
>
{copiedRequest ? (
@@ -394,8 +394,8 @@ export function SessionMessagesClient() {
variant="outline"
size="icon"
className="h-8 w-8"
- onClick={handleDownloadRequest}
aria-label={t("actions.downloadMessages")}
+ onClick={handleDownloadRequest}
>
{t("actions.downloadMessages")}
@@ -562,10 +562,12 @@ export function SessionMessagesClient() {
{t("actions.terminateSessionTitle")}
-
- {t("actions.terminateSessionDescription")}
-
- {sessionId}
+
+
+ {t("actions.terminateSessionDescription")}
+
+ {sessionId}
+
diff --git a/src/app/[locale]/settings/providers/_components/auto-sort-priority-dialog.tsx b/src/app/[locale]/settings/providers/_components/auto-sort-priority-dialog.tsx
new file mode 100644
index 000000000..eaebd4992
--- /dev/null
+++ b/src/app/[locale]/settings/providers/_components/auto-sort-priority-dialog.tsx
@@ -0,0 +1,250 @@
+"use client";
+
+import { useQueryClient } from "@tanstack/react-query";
+import { ArrowRight, ListOrdered, Loader2 } from "lucide-react";
+import { useTranslations } from "next-intl";
+import { useState, useTransition } from "react";
+import { toast } from "sonner";
+import { autoSortProviderPriority } from "@/actions/providers";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+
+type AutoSortResult = {
+ groups: Array<{
+ costMultiplier: number;
+ priority: number;
+ providers: Array<{ id: number; name: string }>;
+ }>;
+ changes: Array<{
+ providerId: number;
+ name: string;
+ oldPriority: number;
+ newPriority: number;
+ costMultiplier: number;
+ }>;
+ summary: {
+ totalProviders: number;
+ changedCount: number;
+ groupCount: number;
+ };
+ applied: boolean;
+};
+
+export function AutoSortPriorityDialog() {
+ const queryClient = useQueryClient();
+ const t = useTranslations("settings.providers.autoSort");
+ const tCommon = useTranslations("settings.common");
+ const tErrors = useTranslations("errors");
+
+ const [open, setOpen] = useState(false);
+ const [previewData, setPreviewData] = useState
(null);
+ const [isPending, startTransition] = useTransition();
+ const [isApplying, setIsApplying] = useState(false);
+
+ const getActionErrorMessage = (result: {
+ errorCode?: string;
+ errorParams?: Record;
+ error?: string | null;
+ }): string => {
+ if (result.errorCode) {
+ try {
+ return tErrors(result.errorCode, result.errorParams);
+ } catch {
+ return t("error");
+ }
+ }
+
+ if (result.error) {
+ try {
+ return tErrors(result.error);
+ } catch {
+ return t("error");
+ }
+ }
+
+ return t("error");
+ };
+
+ const handleOpenChange = (isOpen: boolean) => {
+ setOpen(isOpen);
+ if (isOpen) {
+ // Load preview when dialog opens
+ startTransition(async () => {
+ try {
+ const result = await autoSortProviderPriority({ confirm: false });
+ if (result.ok) {
+ setPreviewData(result.data);
+ } else {
+ toast.error(getActionErrorMessage(result));
+ setOpen(false);
+ }
+ } catch (error) {
+ console.error("autoSortProviderPriority preview failed", error);
+ toast.error(t("error"));
+ setOpen(false);
+ }
+ });
+ } else {
+ // Clear preview when dialog closes
+ setPreviewData(null);
+ }
+ };
+
+ const handleApply = async () => {
+ setIsApplying(true);
+ try {
+ const result = await autoSortProviderPriority({ confirm: true });
+ if (result.ok) {
+ toast.success(t("success", { count: result.data.summary.changedCount }));
+ queryClient.invalidateQueries({ queryKey: ["providers"] });
+ setOpen(false);
+ } else {
+ toast.error(getActionErrorMessage(result));
+ }
+ } catch (error) {
+ console.error("autoSortProviderPriority apply failed", error);
+ toast.error(t("error"));
+ } finally {
+ setIsApplying(false);
+ }
+ };
+
+ const hasChanges = previewData && previewData.summary.changedCount > 0;
+
+ return (
+
+ );
+}
diff --git a/src/app/[locale]/settings/providers/_components/provider-manager-loader.tsx b/src/app/[locale]/settings/providers/_components/provider-manager-loader.tsx
index f857e0fa5..b07fb3e0b 100644
--- a/src/app/[locale]/settings/providers/_components/provider-manager-loader.tsx
+++ b/src/app/[locale]/settings/providers/_components/provider-manager-loader.tsx
@@ -1,6 +1,6 @@
"use client";
-import { QueryClient, QueryClientProvider, useQuery } from "@tanstack/react-query";
+import { useQuery } from "@tanstack/react-query";
import {
getProviderStatisticsAsync,
getProviders,
@@ -23,15 +23,6 @@ type ProviderHealthStatus = Record<
}
>;
-const queryClient = new QueryClient({
- defaultOptions: {
- queries: {
- refetchOnWindowFocus: false,
- staleTime: 30000,
- },
- },
-});
-
async function fetchSystemSettings(): Promise<{ currencyDisplay: CurrencyCode }> {
const response = await fetch("/api/system-settings");
if (!response.ok) {
@@ -56,6 +47,8 @@ function ProviderManagerLoaderContent({
} = useQuery({
queryKey: ["providers"],
queryFn: getProviders,
+ refetchOnWindowFocus: false,
+ staleTime: 30_000,
});
const {
@@ -65,6 +58,8 @@ function ProviderManagerLoaderContent({
} = useQuery({
queryKey: ["providers-health"],
queryFn: getProvidersHealthStatus,
+ refetchOnWindowFocus: false,
+ staleTime: 30_000,
});
// Statistics loaded independently with longer cache
@@ -72,6 +67,7 @@ function ProviderManagerLoaderContent({
useQuery({
queryKey: ["providers-statistics"],
queryFn: getProviderStatisticsAsync,
+ refetchOnWindowFocus: false,
staleTime: 30_000,
refetchInterval: 60_000,
});
@@ -83,6 +79,8 @@ function ProviderManagerLoaderContent({
} = useQuery<{ currencyDisplay: CurrencyCode }>({
queryKey: ["system-settings"],
queryFn: fetchSystemSettings,
+ refetchOnWindowFocus: false,
+ staleTime: 30_000,
});
const loading = isProvidersLoading || isHealthLoading || isSettingsLoading;
@@ -106,9 +104,5 @@ function ProviderManagerLoaderContent({
}
export function ProviderManagerLoader(props: ProviderManagerLoaderProps) {
- return (
-
-
-
- );
+ return ;
}
diff --git a/src/app/[locale]/settings/providers/page.tsx b/src/app/[locale]/settings/providers/page.tsx
index f8a7dd1ae..9f06333ae 100644
--- a/src/app/[locale]/settings/providers/page.tsx
+++ b/src/app/[locale]/settings/providers/page.tsx
@@ -6,6 +6,7 @@ import { Link } from "@/i18n/routing";
import { getSession } from "@/lib/auth";
import { getEnvConfig } from "@/lib/config/env.schema";
import { SettingsPageHeader } from "../_components/settings-page-header";
+import { AutoSortPriorityDialog } from "./_components/auto-sort-priority-dialog";
import { ProviderManagerLoader } from "./_components/provider-manager-loader";
import { SchedulingRulesDialog } from "./_components/scheduling-rules-dialog";
@@ -33,6 +34,7 @@ export default async function SettingsProvidersPage() {
{t("providers.section.leaderboard")}
+
>
}
diff --git a/src/components/ui/__tests__/code-display.test.tsx b/src/components/ui/__tests__/code-display.test.tsx
index 49f0bb77f..836bf1793 100644
--- a/src/components/ui/__tests__/code-display.test.tsx
+++ b/src/components/ui/__tests__/code-display.test.tsx
@@ -7,38 +7,12 @@ import { act } from "react";
import { createRoot } from "react-dom/client";
import { NextIntlClientProvider } from "next-intl";
import { describe, expect, test, vi } from "vitest";
+import dashboardMessages from "@messages/en/dashboard.json";
import { CodeDisplay } from "@/components/ui/code-display";
+// Use real locale messages to ensure test stays in sync with actual translations
const messages = {
- dashboard: {
- sessions: {
- codeDisplay: {
- raw: "Raw",
- pretty: "Pretty",
- searchPlaceholder: "Search",
- expand: "Expand",
- collapse: "Collapse",
- themeAuto: "Auto",
- themeLight: "Light",
- themeDark: "Dark",
- noMatches: "No matches",
- onlyMatches: "Only matches",
- showAll: "Show all",
- prevPage: "Prev",
- nextPage: "Next",
- pageInfo: "Page {page} / {total}",
- sseEvent: "Event",
- sseData: "Data",
- hardLimit: {
- title: "Content too large",
- size: "Size: {sizeMB} MB ({sizeBytes} bytes)",
- maximum: "Maximum allowed: {maxSizeMB} MB or {maxLines} lines",
- hint: "Please download the file to view the full content.",
- download: "Download",
- },
- },
- },
- },
+ dashboard: dashboardMessages,
} as const;
function renderWithIntl(node: ReactNode) {
@@ -208,7 +182,7 @@ describe("CodeDisplay", () => {
"[data-testid='code-display-only-matches-toggle']"
) as HTMLElement;
click(toggle);
- expect(container.textContent).toContain("No matches");
+ expect(container.textContent).toContain(dashboardMessages.sessions.codeDisplay.noMatches);
act(() => {
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
@@ -245,7 +219,7 @@ describe("CodeDisplay", () => {
setter?.call(input, "does-not-exist");
input.dispatchEvent(new Event("input", { bubbles: true }));
});
- expect(container.textContent).toContain("No matches");
+ expect(container.textContent).toContain(dashboardMessages.sessions.codeDisplay.noMatches);
unmount();
});
@@ -295,8 +269,7 @@ describe("CodeDisplay", () => {
);
- expect(container.textContent).toContain("Content too large");
- expect(container.textContent).toContain("1.00 MB");
+ expect(container.textContent).toContain(dashboardMessages.sessions.codeDisplay.hardLimit.title);
unmount();
});
@@ -331,8 +304,9 @@ describe("CodeDisplay", () => {
click(downloadBtn);
expect(createObjectURLSpy).toHaveBeenCalledTimes(1);
- expect(lastAnchor?.download).toBe("huge.txt");
- expect(lastAnchor?.href).toBe("blob:mock");
+ expect(lastAnchor).not.toBeNull();
+ expect(lastAnchor!.download).toBe("huge.txt");
+ expect(lastAnchor!.href).toBe("blob:mock");
const blob = createObjectURLSpy.mock.calls[0]?.[0] as Blob;
expect(await blob.text()).toBe(hugeContent);
@@ -352,8 +326,7 @@ describe("CodeDisplay", () => {
);
- expect(container.textContent).toContain("Content too large");
- expect(container.textContent).toContain("10,000 lines");
+ expect(container.textContent).toContain(dashboardMessages.sessions.codeDisplay.hardLimit.title);
unmount();
});
});
diff --git a/src/repository/provider.ts b/src/repository/provider.ts
index 4e1eed68d..357998331 100644
--- a/src/repository/provider.ts
+++ b/src/repository/provider.ts
@@ -495,6 +495,43 @@ export async function updateProvider(
return toProvider(provider);
}
+export async function updateProviderPrioritiesBatch(
+ updates: Array<{ id: number; priority: number }>
+): Promise {
+ if (updates.length === 0) {
+ return 0;
+ }
+
+ // Deduplicate ids: last one wins
+ const updateMap = new Map();
+ for (const update of updates) {
+ updateMap.set(update.id, update.priority);
+ }
+
+ const ids = Array.from(updateMap.keys());
+ const priorityCol = sql.identifier("priority");
+ const updatedAtCol = sql.identifier("updated_at");
+ const cases = ids.map((id) => sql`WHEN ${id} THEN ${updateMap.get(id)!}`);
+
+ const idList = sql.join(
+ ids.map((id) => sql`${id}`),
+ sql`, `
+ );
+
+ const query = sql`
+ UPDATE providers
+ SET
+ ${priorityCol} = CASE id ${sql.join(cases, sql` `)} ELSE ${priorityCol} END,
+ ${updatedAtCol} = NOW()
+ WHERE id IN (${idList}) AND deleted_at IS NULL
+ `;
+
+ const result = await db.execute(query);
+
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ return (result as any).rowCount || 0;
+}
+
export async function deleteProvider(id: number): Promise {
const result = await db
.update(providers)
diff --git a/tests/unit/actions/providers.test.ts b/tests/unit/actions/providers.test.ts
index c6cec156b..53d783920 100644
--- a/tests/unit/actions/providers.test.ts
+++ b/tests/unit/actions/providers.test.ts
@@ -7,6 +7,7 @@ const getProviderStatisticsMock = vi.fn();
const createProviderMock = vi.fn();
const updateProviderMock = vi.fn();
const deleteProviderMock = vi.fn();
+const updateProviderPrioritiesBatchMock = vi.fn();
const publishProviderCacheInvalidationMock = vi.fn();
const saveProviderCircuitConfigMock = vi.fn();
@@ -29,6 +30,7 @@ vi.mock("@/repository/provider", () => ({
getProviderStatistics: getProviderStatisticsMock,
resetProviderTotalCostResetAt: vi.fn(async () => {}),
updateProvider: updateProviderMock,
+ updateProviderPrioritiesBatch: updateProviderPrioritiesBatchMock,
}));
vi.mock("@/lib/cache/provider-cache", () => ({
@@ -161,6 +163,7 @@ describe("Provider Actions - Async Optimization", () => {
saveProviderCircuitConfigMock.mockResolvedValue(undefined);
deleteProviderCircuitConfigMock.mockResolvedValue(undefined);
clearProviderStateMock.mockResolvedValue(undefined);
+ updateProviderPrioritiesBatchMock.mockResolvedValue(0);
});
describe("getProviders", () => {
@@ -188,6 +191,209 @@ describe("Provider Actions - Async Optimization", () => {
});
});
+ describe("autoSortProviderPriority", () => {
+ it("should return preview only when confirm is false", async () => {
+ findAllProvidersFreshMock.mockResolvedValue([
+ { id: 1, name: "a", costMultiplier: "2.0", priority: 0 } as any,
+ { id: 2, name: "b", costMultiplier: "1.0", priority: 1 } as any,
+ { id: 3, name: "c", costMultiplier: "1.0", priority: 9 } as any,
+ ]);
+
+ const { autoSortProviderPriority } = await import("@/actions/providers");
+ const result = await autoSortProviderPriority({ confirm: false });
+
+ expect(result.ok).toBe(true);
+ if (!result.ok) return;
+
+ expect(result.data.applied).toBe(false);
+ expect(result.data.summary.groupCount).toBe(2);
+ expect(result.data.summary.totalProviders).toBe(3);
+ expect(result.data.summary.changedCount).toBe(3);
+ expect(result.data.groups).toEqual([
+ {
+ costMultiplier: 1,
+ priority: 0,
+ providers: [
+ { id: 2, name: "b" },
+ { id: 3, name: "c" },
+ ],
+ },
+ {
+ costMultiplier: 2,
+ priority: 1,
+ providers: [{ id: 1, name: "a" }],
+ },
+ ]);
+
+ expect(updateProviderPrioritiesBatchMock).not.toHaveBeenCalled();
+ expect(publishProviderCacheInvalidationMock).not.toHaveBeenCalled();
+ });
+
+ it("should handle invalid costMultiplier values gracefully", async () => {
+ findAllProvidersFreshMock.mockResolvedValue([
+ { id: 1, name: "bad", costMultiplier: undefined, priority: 5 } as any,
+ { id: 2, name: "good", costMultiplier: "1.0", priority: 0 } as any,
+ ]);
+
+ const { autoSortProviderPriority } = await import("@/actions/providers");
+ const result = await autoSortProviderPriority({ confirm: false });
+
+ expect(result.ok).toBe(true);
+ if (!result.ok) return;
+
+ expect(result.data.summary.groupCount).toBe(2);
+ expect(result.data.groups).toEqual([
+ {
+ costMultiplier: 0,
+ priority: 0,
+ providers: [{ id: 1, name: "bad" }],
+ },
+ {
+ costMultiplier: 1,
+ priority: 1,
+ providers: [{ id: 2, name: "good" }],
+ },
+ ]);
+ });
+
+ it("should apply changes when confirm is true", async () => {
+ findAllProvidersFreshMock.mockResolvedValue([
+ { id: 10, name: "x", costMultiplier: "2.0", priority: 0 } as any,
+ { id: 20, name: "y", costMultiplier: "1.0", priority: 0 } as any,
+ ]);
+
+ const { autoSortProviderPriority } = await import("@/actions/providers");
+ const result = await autoSortProviderPriority({ confirm: true });
+
+ expect(result.ok).toBe(true);
+ if (!result.ok) return;
+
+ expect(result.data.applied).toBe(true);
+ expect(result.data.summary.changedCount).toBe(1);
+
+ expect(updateProviderPrioritiesBatchMock).toHaveBeenCalledTimes(1);
+ expect(updateProviderPrioritiesBatchMock).toHaveBeenCalledWith([{ id: 10, priority: 1 }]);
+ expect(publishProviderCacheInvalidationMock).toHaveBeenCalledTimes(1);
+ });
+
+ it("should work with a single provider", async () => {
+ findAllProvidersFreshMock.mockResolvedValue([
+ { id: 1, name: "only", costMultiplier: "1.0", priority: 9 } as any,
+ ]);
+
+ const { autoSortProviderPriority } = await import("@/actions/providers");
+ const result = await autoSortProviderPriority({ confirm: true });
+
+ expect(result.ok).toBe(true);
+ if (!result.ok) return;
+
+ expect(result.data.applied).toBe(true);
+ expect(result.data.summary.groupCount).toBe(1);
+ expect(result.data.summary.changedCount).toBe(1);
+ expect(updateProviderPrioritiesBatchMock).toHaveBeenCalledWith([{ id: 1, priority: 0 }]);
+ expect(publishProviderCacheInvalidationMock).toHaveBeenCalledTimes(1);
+ });
+
+ it("should set priority 0 for all providers when costMultiplier is the same", async () => {
+ findAllProvidersFreshMock.mockResolvedValue([
+ { id: 1, name: "a", costMultiplier: "1.0", priority: 5 } as any,
+ { id: 2, name: "b", costMultiplier: "1.0", priority: 6 } as any,
+ { id: 3, name: "c", costMultiplier: "1.0", priority: 7 } as any,
+ ]);
+
+ const { autoSortProviderPriority } = await import("@/actions/providers");
+ const result = await autoSortProviderPriority({ confirm: true });
+
+ expect(result.ok).toBe(true);
+ if (!result.ok) return;
+
+ expect(result.data.groups).toEqual([
+ {
+ costMultiplier: 1,
+ priority: 0,
+ providers: [
+ { id: 1, name: "a" },
+ { id: 2, name: "b" },
+ { id: 3, name: "c" },
+ ],
+ },
+ ]);
+ expect(updateProviderPrioritiesBatchMock).toHaveBeenCalledWith([
+ { id: 1, priority: 0 },
+ { id: 2, priority: 0 },
+ { id: 3, priority: 0 },
+ ]);
+ expect(publishProviderCacheInvalidationMock).toHaveBeenCalledTimes(1);
+ });
+
+ it("should reject non-admin users", async () => {
+ getSessionMock.mockResolvedValueOnce({ user: { id: 2, role: "user" } });
+
+ const { autoSortProviderPriority } = await import("@/actions/providers");
+ const result = await autoSortProviderPriority({ confirm: true });
+
+ expect(result.ok).toBe(false);
+ if (result.ok) return;
+
+ expect(result.error).toBe("无权限执行此操作");
+ expect(updateProviderPrioritiesBatchMock).not.toHaveBeenCalled();
+ expect(publishProviderCacheInvalidationMock).not.toHaveBeenCalled();
+ });
+
+ it("should not fail when cache invalidation publish throws", async () => {
+ publishProviderCacheInvalidationMock.mockRejectedValueOnce(new Error("boom"));
+ findAllProvidersFreshMock.mockResolvedValue([
+ { id: 10, name: "x", costMultiplier: "2.0", priority: 0 } as any,
+ { id: 20, name: "y", costMultiplier: "1.0", priority: 0 } as any,
+ ]);
+
+ const { autoSortProviderPriority } = await import("@/actions/providers");
+ const result = await autoSortProviderPriority({ confirm: true });
+
+ expect(result.ok).toBe(true);
+ expect(updateProviderPrioritiesBatchMock).toHaveBeenCalledTimes(1);
+ expect(publishProviderCacheInvalidationMock).toHaveBeenCalledTimes(1);
+ });
+
+ it("should not write or invalidate cache when already sorted", async () => {
+ findAllProvidersFreshMock.mockResolvedValue([
+ { id: 10, name: "x", costMultiplier: "1.0", priority: 0 } as any,
+ { id: 20, name: "y", costMultiplier: "2.0", priority: 1 } as any,
+ ]);
+
+ const { autoSortProviderPriority } = await import("@/actions/providers");
+ const result = await autoSortProviderPriority({ confirm: true });
+
+ expect(result.ok).toBe(true);
+ if (!result.ok) return;
+
+ expect(result.data.applied).toBe(true);
+ expect(result.data.changes).toEqual([]);
+ expect(updateProviderPrioritiesBatchMock).not.toHaveBeenCalled();
+ expect(publishProviderCacheInvalidationMock).not.toHaveBeenCalled();
+ });
+
+ it("should handle empty providers list", async () => {
+ findAllProvidersFreshMock.mockResolvedValue([]);
+
+ const { autoSortProviderPriority } = await import("@/actions/providers");
+ const preview = await autoSortProviderPriority({ confirm: false });
+ const applied = await autoSortProviderPriority({ confirm: true });
+
+ expect(preview.ok).toBe(true);
+ if (preview.ok) {
+ expect(preview.data.summary.totalProviders).toBe(0);
+ expect(preview.data.applied).toBe(false);
+ }
+
+ expect(applied.ok).toBe(true);
+ if (applied.ok) {
+ expect(applied.data.summary.totalProviders).toBe(0);
+ expect(applied.data.applied).toBe(true);
+ }
+ });
+ });
+
describe("getProviderStatisticsAsync", () => {
it("should return statistics map by provider id", async () => {
getProviderStatisticsMock.mockResolvedValue([
diff --git a/tests/unit/repository/provider.test.ts b/tests/unit/repository/provider.test.ts
new file mode 100644
index 000000000..694c29e98
--- /dev/null
+++ b/tests/unit/repository/provider.test.ts
@@ -0,0 +1,127 @@
+import { describe, expect, test, vi } from "vitest";
+
+function sqlToString(sqlObj: unknown): string {
+ const stack = new Set
+
+
+
+
+ {t("enableThinkingSignatureRectifierDesc")}
+
+
+
setEnableThinkingSignatureRectifier(checked)}
+ disabled={isPending}
+ />
+
+
diff --git a/src/app/[locale]/settings/config/page.tsx b/src/app/[locale]/settings/config/page.tsx
index 9fee99375..6b00d4669 100644
--- a/src/app/[locale]/settings/config/page.tsx
+++ b/src/app/[locale]/settings/config/page.tsx
@@ -41,6 +41,7 @@ async function SettingsConfigContent() {
verboseProviderError: settings.verboseProviderError,
enableHttp2: settings.enableHttp2,
interceptAnthropicWarmupRequests: settings.interceptAnthropicWarmupRequests,
+ enableThinkingSignatureRectifier: settings.enableThinkingSignatureRectifier,
enableResponseFixer: settings.enableResponseFixer,
responseFixerConfig: settings.responseFixerConfig,
}}
diff --git a/src/app/v1/_lib/proxy/forwarder.ts b/src/app/v1/_lib/proxy/forwarder.ts
index ec604301a..fc18c9be8 100644
--- a/src/app/v1/_lib/proxy/forwarder.ts
+++ b/src/app/v1/_lib/proxy/forwarder.ts
@@ -10,7 +10,7 @@ import {
recordSuccess,
} from "@/lib/circuit-breaker";
import { applyCodexProviderOverridesWithAudit } from "@/lib/codex/provider-overrides";
-import { isHttp2Enabled } from "@/lib/config";
+import { getCachedSystemSettings, isHttp2Enabled } from "@/lib/config";
import { getEnvConfig } from "@/lib/config/env.schema";
import { PROVIDER_DEFAULTS, PROVIDER_LIMITS } from "@/lib/constants/provider.constants";
import { logger } from "@/lib/logger";
@@ -41,6 +41,10 @@ import { mapClientFormatToTransformer, mapProviderTypeToTransformer } from "./fo
import { ModelRedirector } from "./model-redirector";
import { ProxyProviderResolver } from "./provider-selector";
import type { ProxySession } from "./session";
+import {
+ detectThinkingSignatureRectifierTrigger,
+ rectifyAnthropicRequestMessage,
+} from "./thinking-signature-rectifier";
const STANDARD_ENDPOINTS = [
"/v1/messages",
@@ -201,10 +205,11 @@ export class ProxyForwarder {
totalProvidersAttempted++;
let attemptCount = 0; // 当前供应商的尝试次数
- const maxAttemptsPerProvider = resolveMaxAttemptsForProvider(
+ let maxAttemptsPerProvider = resolveMaxAttemptsForProvider(
currentProvider,
envDefaultMaxAttempts
);
+ let thinkingSignatureRectifierRetried = false;
logger.info("ProxyForwarder: Trying provider", {
providerId: currentProvider.id,
@@ -374,7 +379,7 @@ export class ProxyForwarder {
// ⭐ 1. 分类错误(供应商错误 vs 系统错误 vs 客户端中断)
// 使用异步版本确保错误规则已加载
- const errorCategory = await categorizeErrorAsync(lastError);
+ let errorCategory = await categorizeErrorAsync(lastError);
const errorMessage =
lastError instanceof ProxyError
? lastError.getDetailedErrorMessage()
@@ -411,6 +416,148 @@ export class ProxyForwarder {
throw lastError;
}
+ // 2.5 Thinking signature 整流器:命中后对同供应商“整流 + 重试一次”
+ // 目标:解决 Anthropic 与非 Anthropic 渠道切换导致的 thinking 签名不兼容问题
+ // 约束:
+ // - 仅对 Anthropic 类型供应商生效
+ // - 不依赖 error rules 开关(用户可能关闭规则,但仍希望整流生效)
+ // - 不计入熔断器、不触发供应商切换
+ const isAnthropicProvider =
+ currentProvider.providerType === "claude" ||
+ currentProvider.providerType === "claude-auth";
+ const rectifierTrigger = isAnthropicProvider
+ ? detectThinkingSignatureRectifierTrigger(errorMessage)
+ : null;
+
+ if (rectifierTrigger) {
+ const settings = await getCachedSystemSettings();
+ const enabled = settings.enableThinkingSignatureRectifier ?? true;
+
+ if (enabled) {
+ // 已重试过仍失败:强制按“不可重试的客户端错误”处理,避免污染熔断器/触发供应商切换
+ if (thinkingSignatureRectifierRetried) {
+ errorCategory = ErrorCategory.NON_RETRYABLE_CLIENT_ERROR;
+ } else {
+ const requestDetailsBeforeRectify = buildRequestDetails(session);
+
+ // 整流请求体(原地修改 session.request.message)
+ const rectified = rectifyAnthropicRequestMessage(
+ session.request.message as Record
+ );
+
+ // 写入审计字段(specialSettings)
+ session.addSpecialSetting({
+ type: "thinking_signature_rectifier",
+ scope: "request",
+ hit: rectified.applied,
+ providerId: currentProvider.id,
+ providerName: currentProvider.name,
+ trigger: rectifierTrigger,
+ attemptNumber: attemptCount,
+ retryAttemptNumber: attemptCount + 1,
+ removedThinkingBlocks: rectified.removedThinkingBlocks,
+ removedRedactedThinkingBlocks: rectified.removedRedactedThinkingBlocks,
+ removedSignatureFields: rectified.removedSignatureFields,
+ });
+
+ const specialSettings = session.getSpecialSettings();
+ if (specialSettings && session.sessionId) {
+ try {
+ await SessionManager.storeSessionSpecialSettings(
+ session.sessionId,
+ specialSettings,
+ session.requestSequence
+ );
+ } catch (persistError) {
+ logger.error("[ProxyForwarder] Failed to store special settings", {
+ error: persistError,
+ sessionId: session.sessionId,
+ });
+ }
+ }
+
+ if (specialSettings && session.messageContext?.id) {
+ try {
+ await updateMessageRequestDetails(session.messageContext.id, {
+ specialSettings,
+ });
+ } catch (persistError) {
+ logger.error("[ProxyForwarder] Failed to persist special settings", {
+ error: persistError,
+ messageRequestId: session.messageContext.id,
+ });
+ }
+ }
+
+ // 无任何可整流内容:不做无意义重试,直接走既有“不可重试客户端错误”分支
+ if (!rectified.applied) {
+ logger.info(
+ "ProxyForwarder: Thinking signature rectifier not applicable, skipping retry",
+ {
+ providerId: currentProvider.id,
+ providerName: currentProvider.name,
+ trigger: rectifierTrigger,
+ attemptNumber: attemptCount,
+ }
+ );
+ errorCategory = ErrorCategory.NON_RETRYABLE_CLIENT_ERROR;
+ } else {
+ logger.info("ProxyForwarder: Thinking signature rectifier applied, retrying", {
+ providerId: currentProvider.id,
+ providerName: currentProvider.name,
+ trigger: rectifierTrigger,
+ attemptNumber: attemptCount,
+ willRetryAttemptNumber: attemptCount + 1,
+ });
+
+ thinkingSignatureRectifierRetried = true;
+
+ // 记录失败的第一次请求(以 retry_failed 体现“发生过一次重试”)
+ if (lastError instanceof ProxyError) {
+ session.addProviderToChain(currentProvider, {
+ reason: "retry_failed",
+ circuitState: getCircuitState(currentProvider.id),
+ attemptNumber: attemptCount,
+ errorMessage,
+ statusCode: lastError.statusCode,
+ errorDetails: {
+ provider: {
+ id: currentProvider.id,
+ name: currentProvider.name,
+ statusCode: lastError.statusCode,
+ statusText: lastError.message,
+ upstreamBody: lastError.upstreamError?.body,
+ upstreamParsed: lastError.upstreamError?.parsed,
+ },
+ request: requestDetailsBeforeRectify,
+ },
+ });
+ } else {
+ session.addProviderToChain(currentProvider, {
+ reason: "retry_failed",
+ circuitState: getCircuitState(currentProvider.id),
+ attemptNumber: attemptCount,
+ errorMessage,
+ errorDetails: {
+ system: {
+ errorType: lastError.constructor.name,
+ errorName: lastError.name,
+ errorMessage: lastError.message || lastError.name || "Unknown error",
+ errorStack: lastError.stack?.split("\n").slice(0, 3).join("\n"),
+ },
+ request: requestDetailsBeforeRectify,
+ },
+ });
+ }
+
+ // 确保即使 maxAttemptsPerProvider=1 也能完成一次额外重试
+ maxAttemptsPerProvider = Math.max(maxAttemptsPerProvider, attemptCount + 1);
+ continue;
+ }
+ }
+ }
+ }
+
// ⭐ 3. 不可重试的客户端输入错误处理(不计入熔断器,不重试,立即返回)
if (errorCategory === ErrorCategory.NON_RETRYABLE_CLIENT_ERROR) {
const proxyError = lastError as ProxyError;
diff --git a/src/app/v1/_lib/proxy/thinking-signature-rectifier.test.ts b/src/app/v1/_lib/proxy/thinking-signature-rectifier.test.ts
new file mode 100644
index 000000000..ae25421e2
--- /dev/null
+++ b/src/app/v1/_lib/proxy/thinking-signature-rectifier.test.ts
@@ -0,0 +1,88 @@
+import { describe, expect, test } from "vitest";
+
+import {
+ detectThinkingSignatureRectifierTrigger,
+ rectifyAnthropicRequestMessage,
+} from "./thinking-signature-rectifier";
+
+describe("thinking-signature-rectifier", () => {
+ describe("detectThinkingSignatureRectifierTrigger", () => {
+ test("应命中:Invalid `signature` in `thinking` block(含反引号)", () => {
+ const trigger = detectThinkingSignatureRectifierTrigger(
+ "messages.1.content.0: Invalid `signature` in `thinking` block"
+ );
+ expect(trigger).toBe("invalid_signature_in_thinking_block");
+ });
+
+ test("应命中:Invalid signature in thinking block(无反引号/大小写混用)", () => {
+ const trigger = detectThinkingSignatureRectifierTrigger(
+ "Messages.1.Content.0: invalid signature in thinking block"
+ );
+ expect(trigger).toBe("invalid_signature_in_thinking_block");
+ });
+
+ test("应命中:非法请求/illegal request/invalid request", () => {
+ expect(detectThinkingSignatureRectifierTrigger("非法请求")).toBe("invalid_request");
+ expect(detectThinkingSignatureRectifierTrigger("illegal request format")).toBe(
+ "invalid_request"
+ );
+ expect(detectThinkingSignatureRectifierTrigger("invalid request: malformed JSON")).toBe(
+ "invalid_request"
+ );
+ });
+
+ test("不应命中:无关错误", () => {
+ expect(detectThinkingSignatureRectifierTrigger("Request timeout")).toBeNull();
+ });
+ });
+
+ describe("rectifyAnthropicRequestMessage", () => {
+ test("应移除 thinking/redacted_thinking block,并移除非 thinking block 的 signature 字段", () => {
+ const message: Record = {
+ model: "claude-test",
+ messages: [
+ {
+ role: "assistant",
+ content: [
+ { type: "thinking", thinking: "t", signature: "sig_thinking" },
+ { type: "text", text: "hello", signature: "sig_text_should_remove" },
+ {
+ type: "tool_use",
+ id: "toolu_1",
+ name: "WebSearch",
+ input: { query: "q" },
+ signature: "sig_tool_should_remove",
+ },
+ { type: "redacted_thinking", data: "r", signature: "sig_redacted" },
+ ],
+ },
+ {
+ role: "user",
+ content: [{ type: "text", text: "hi" }],
+ },
+ ],
+ };
+
+ const result = rectifyAnthropicRequestMessage(message);
+ expect(result.applied).toBe(true);
+ expect(result.removedThinkingBlocks).toBe(1);
+ expect(result.removedRedactedThinkingBlocks).toBe(1);
+ expect(result.removedSignatureFields).toBe(2);
+
+ const messages = message.messages as any[];
+ const content = messages[0].content as any[];
+ expect(content.map((b) => b.type)).toEqual(["text", "tool_use"]);
+ expect(content[0].signature).toBeUndefined();
+ expect(content[1].signature).toBeUndefined();
+ });
+
+ test("无 messages 或 messages 不为数组时,应不修改", () => {
+ const message: Record = { model: "claude-test" };
+ const result = rectifyAnthropicRequestMessage(message);
+ expect(result.applied).toBe(false);
+ expect(result.removedThinkingBlocks).toBe(0);
+ expect(result.removedRedactedThinkingBlocks).toBe(0);
+ expect(result.removedSignatureFields).toBe(0);
+ });
+ });
+});
diff --git a/src/app/v1/_lib/proxy/thinking-signature-rectifier.ts b/src/app/v1/_lib/proxy/thinking-signature-rectifier.ts
new file mode 100644
index 000000000..22b114978
--- /dev/null
+++ b/src/app/v1/_lib/proxy/thinking-signature-rectifier.ts
@@ -0,0 +1,123 @@
+export type ThinkingSignatureRectifierTrigger =
+ | "invalid_signature_in_thinking_block"
+ | "invalid_request";
+
+export type ThinkingSignatureRectifierResult = {
+ applied: boolean;
+ removedThinkingBlocks: number;
+ removedRedactedThinkingBlocks: number;
+ removedSignatureFields: number;
+};
+
+/**
+ * 检测是否需要触发「thinking signature 整流器」
+ *
+ * 注意:这里不依赖错误规则开关(error rules 可能被用户关闭),仅做字符串/正则判断。
+ */
+export function detectThinkingSignatureRectifierTrigger(
+ errorMessage: string | null | undefined
+): ThinkingSignatureRectifierTrigger | null {
+ if (!errorMessage) return null;
+
+ const lower = errorMessage.toLowerCase();
+
+ // 兼容带/不带反引号、不同大小写的变体
+ const looksLikeInvalidSignatureInThinkingBlock =
+ lower.includes("invalid") &&
+ lower.includes("signature") &&
+ lower.includes("thinking") &&
+ lower.includes("block");
+
+ if (looksLikeInvalidSignatureInThinkingBlock) {
+ return "invalid_signature_in_thinking_block";
+ }
+
+ // 与默认错误规则保持一致(Issue #432 / Rule 6)
+ if (/非法请求|illegal request|invalid request/i.test(errorMessage)) {
+ return "invalid_request";
+ }
+
+ return null;
+}
+
+/**
+ * 对 Anthropic 请求体做最小侵入整流:
+ * - 移除 messages[*].content 中的 thinking/redacted_thinking block(避免签名不兼容触发 400)
+ * - 移除非 thinking block 上遗留的 signature 字段(兼容跨渠道历史)
+ *
+ * 说明:
+ * - 仅在上游报错后、同供应商重试前调用,避免影响正常请求。
+ * - 该函数会原地修改 message 对象(更适合代理链路的性能要求)。
+ */
+export function rectifyAnthropicRequestMessage(
+ message: Record
+): ThinkingSignatureRectifierResult {
+ const messages = message.messages;
+ if (!Array.isArray(messages)) {
+ return {
+ applied: false,
+ removedThinkingBlocks: 0,
+ removedRedactedThinkingBlocks: 0,
+ removedSignatureFields: 0,
+ };
+ }
+
+ let removedThinkingBlocks = 0;
+ let removedRedactedThinkingBlocks = 0;
+ let removedSignatureFields = 0;
+ let applied = false;
+
+ for (const msg of messages) {
+ if (!msg || typeof msg !== "object") continue;
+ const msgObj = msg as Record;
+ const content = msgObj.content;
+ if (!Array.isArray(content)) continue;
+
+ const newContent: unknown[] = [];
+ let contentWasModified = false;
+
+ for (const block of content) {
+ if (!block || typeof block !== "object") {
+ newContent.push(block);
+ continue;
+ }
+
+ const blockObj = block as Record;
+ const type = blockObj.type;
+
+ if (type === "thinking") {
+ removedThinkingBlocks += 1;
+ contentWasModified = true;
+ continue;
+ }
+
+ if (type === "redacted_thinking") {
+ removedRedactedThinkingBlocks += 1;
+ contentWasModified = true;
+ continue;
+ }
+
+ if ("signature" in blockObj) {
+ const { signature: _signature, ...rest } = blockObj as any;
+ removedSignatureFields += 1;
+ contentWasModified = true;
+ newContent.push(rest);
+ continue;
+ }
+
+ newContent.push(blockObj);
+ }
+
+ if (contentWasModified) {
+ applied = true;
+ msgObj.content = newContent;
+ }
+ }
+
+ return {
+ applied,
+ removedThinkingBlocks,
+ removedRedactedThinkingBlocks,
+ removedSignatureFields,
+ };
+}
diff --git a/src/drizzle/schema.ts b/src/drizzle/schema.ts
index e437f4bfc..f162fef90 100644
--- a/src/drizzle/schema.ts
+++ b/src/drizzle/schema.ts
@@ -485,6 +485,12 @@ export const systemSettings = pgTable('system_settings', {
.notNull()
.default(false),
+ // thinking signature 整流器(默认开启)
+ // 开启后:当 Anthropic 类型供应商出现 thinking 签名不兼容/非法请求等 400 错误时,自动整流并重试一次
+ enableThinkingSignatureRectifier: boolean('enable_thinking_signature_rectifier')
+ .notNull()
+ .default(true),
+
// 响应整流(默认开启)
enableResponseFixer: boolean('enable_response_fixer').notNull().default(true),
responseFixerConfig: jsonb('response_fixer_config')
diff --git a/src/lib/config/system-settings-cache.ts b/src/lib/config/system-settings-cache.ts
index 89fba2576..8c0071627 100644
--- a/src/lib/config/system-settings-cache.ts
+++ b/src/lib/config/system-settings-cache.ts
@@ -26,10 +26,15 @@ let cachedAt: number = 0;
/** Default settings used when cache fetch fails */
const DEFAULT_SETTINGS: Pick<
SystemSettings,
- "enableHttp2" | "interceptAnthropicWarmupRequests" | "enableResponseFixer" | "responseFixerConfig"
+ | "enableHttp2"
+ | "interceptAnthropicWarmupRequests"
+ | "enableThinkingSignatureRectifier"
+ | "enableResponseFixer"
+ | "responseFixerConfig"
> = {
enableHttp2: false,
interceptAnthropicWarmupRequests: false,
+ enableThinkingSignatureRectifier: true,
enableResponseFixer: true,
responseFixerConfig: {
fixTruncatedJson: true,
@@ -97,6 +102,7 @@ export async function getCachedSystemSettings(): Promise {
enableClientVersionCheck: false,
enableHttp2: DEFAULT_SETTINGS.enableHttp2,
interceptAnthropicWarmupRequests: DEFAULT_SETTINGS.interceptAnthropicWarmupRequests,
+ enableThinkingSignatureRectifier: DEFAULT_SETTINGS.enableThinkingSignatureRectifier,
enableResponseFixer: DEFAULT_SETTINGS.enableResponseFixer,
responseFixerConfig: DEFAULT_SETTINGS.responseFixerConfig,
createdAt: new Date(),
diff --git a/src/lib/utils/special-settings.ts b/src/lib/utils/special-settings.ts
index b66cb07a7..91047eb83 100644
--- a/src/lib/utils/special-settings.ts
+++ b/src/lib/utils/special-settings.ts
@@ -55,6 +55,18 @@ function buildSettingKey(setting: SpecialSetting): string {
return JSON.stringify([setting.type, setting.ttl]);
case "anthropic_context_1m_header_override":
return JSON.stringify([setting.type, setting.header, setting.flag]);
+ case "thinking_signature_rectifier":
+ return JSON.stringify([
+ setting.type,
+ setting.hit,
+ setting.providerId ?? null,
+ setting.trigger,
+ setting.attemptNumber,
+ setting.retryAttemptNumber,
+ setting.removedThinkingBlocks,
+ setting.removedRedactedThinkingBlocks,
+ setting.removedSignatureFields,
+ ]);
default: {
// 兜底:保证即使未来扩展类型也不会导致运行时崩溃
const _exhaustive: never = setting;
diff --git a/src/lib/validation/schemas.ts b/src/lib/validation/schemas.ts
index ef7b010b3..38f26ce54 100644
--- a/src/lib/validation/schemas.ts
+++ b/src/lib/validation/schemas.ts
@@ -741,6 +741,8 @@ export const UpdateSystemSettingsSchema = z.object({
enableHttp2: z.boolean().optional(),
// 可选拦截 Anthropic Warmup 请求(可选)
interceptAnthropicWarmupRequests: z.boolean().optional(),
+ // thinking signature 整流器(可选)
+ enableThinkingSignatureRectifier: z.boolean().optional(),
// 响应整流(可选)
enableResponseFixer: z.boolean().optional(),
responseFixerConfig: z
diff --git a/src/repository/_shared/transformers.ts b/src/repository/_shared/transformers.ts
index e0f0f4b1d..1b144a705 100644
--- a/src/repository/_shared/transformers.ts
+++ b/src/repository/_shared/transformers.ts
@@ -170,6 +170,7 @@ export function toSystemSettings(dbSettings: any): SystemSettings {
verboseProviderError: dbSettings?.verboseProviderError ?? false,
enableHttp2: dbSettings?.enableHttp2 ?? false,
interceptAnthropicWarmupRequests: dbSettings?.interceptAnthropicWarmupRequests ?? false,
+ enableThinkingSignatureRectifier: dbSettings?.enableThinkingSignatureRectifier ?? true,
enableResponseFixer: dbSettings?.enableResponseFixer ?? true,
responseFixerConfig: {
...defaultResponseFixerConfig,
diff --git a/src/repository/system-config.ts b/src/repository/system-config.ts
index f64a79a5a..b5f2ee944 100644
--- a/src/repository/system-config.ts
+++ b/src/repository/system-config.ts
@@ -148,6 +148,7 @@ function createFallbackSettings(): SystemSettings {
verboseProviderError: false,
enableHttp2: false,
interceptAnthropicWarmupRequests: false,
+ enableThinkingSignatureRectifier: true,
enableResponseFixer: true,
responseFixerConfig: {
fixTruncatedJson: true,
@@ -180,6 +181,7 @@ export async function getSystemSettings(): Promise {
verboseProviderError: systemSettings.verboseProviderError,
enableHttp2: systemSettings.enableHttp2,
interceptAnthropicWarmupRequests: systemSettings.interceptAnthropicWarmupRequests,
+ enableThinkingSignatureRectifier: systemSettings.enableThinkingSignatureRectifier,
enableResponseFixer: systemSettings.enableResponseFixer,
responseFixerConfig: systemSettings.responseFixerConfig,
createdAt: systemSettings.createdAt,
@@ -312,6 +314,11 @@ export async function updateSystemSettings(
updates.interceptAnthropicWarmupRequests = payload.interceptAnthropicWarmupRequests;
}
+ // thinking signature 整流器开关(如果提供)
+ if (payload.enableThinkingSignatureRectifier !== undefined) {
+ updates.enableThinkingSignatureRectifier = payload.enableThinkingSignatureRectifier;
+ }
+
// 响应整流开关(如果提供)
if (payload.enableResponseFixer !== undefined) {
updates.enableResponseFixer = payload.enableResponseFixer;
@@ -342,6 +349,7 @@ export async function updateSystemSettings(
verboseProviderError: systemSettings.verboseProviderError,
enableHttp2: systemSettings.enableHttp2,
interceptAnthropicWarmupRequests: systemSettings.interceptAnthropicWarmupRequests,
+ enableThinkingSignatureRectifier: systemSettings.enableThinkingSignatureRectifier,
enableResponseFixer: systemSettings.enableResponseFixer,
responseFixerConfig: systemSettings.responseFixerConfig,
createdAt: systemSettings.createdAt,
diff --git a/src/types/special-settings.ts b/src/types/special-settings.ts
index 7ef0d2cca..a989e9bde 100644
--- a/src/types/special-settings.ts
+++ b/src/types/special-settings.ts
@@ -9,6 +9,7 @@ export type SpecialSetting =
| ProviderParameterOverrideSpecialSetting
| ResponseFixerSpecialSetting
| GuardInterceptSpecialSetting
+ | ThinkingSignatureRectifierSpecialSetting
| AnthropicCacheTtlHeaderOverrideSpecialSetting
| AnthropicContext1mHeaderOverrideSpecialSetting;
@@ -85,3 +86,24 @@ export type AnthropicContext1mHeaderOverrideSpecialSetting = {
header: "anthropic-beta";
flag: string;
};
+
+/**
+ * Thinking signature 整流器审计
+ *
+ * 用于记录:当 Anthropic 类型供应商遇到 thinking 签名不兼容/非法请求等 400 错误时,
+ * 代理对请求体进行最小整流(移除 thinking/redacted_thinking 与遗留 signature 字段)
+ * 并对同供应商自动重试一次的行为,便于在请求日志中审计与回溯。
+ */
+export type ThinkingSignatureRectifierSpecialSetting = {
+ type: "thinking_signature_rectifier";
+ scope: "request";
+ hit: boolean;
+ providerId: number | null;
+ providerName: string | null;
+ trigger: "invalid_signature_in_thinking_block" | "invalid_request";
+ attemptNumber: number;
+ retryAttemptNumber: number;
+ removedThinkingBlocks: number;
+ removedRedactedThinkingBlocks: number;
+ removedSignatureFields: number;
+};
diff --git a/src/types/system-config.ts b/src/types/system-config.ts
index d74466de4..02bf316b6 100644
--- a/src/types/system-config.ts
+++ b/src/types/system-config.ts
@@ -40,6 +40,10 @@ export interface SystemSettings {
// 可选拦截 Anthropic Warmup 请求(默认关闭)
interceptAnthropicWarmupRequests: boolean;
+ // thinking signature 整流器(默认开启)
+ // 目标:当 Anthropic 类型供应商出现 thinking 签名不兼容导致的 400 错误时,自动整流并重试一次
+ enableThinkingSignatureRectifier: boolean;
+
// 响应整流(默认开启)
enableResponseFixer: boolean;
responseFixerConfig: ResponseFixerConfig;
@@ -77,6 +81,9 @@ export interface UpdateSystemSettingsInput {
// 可选拦截 Anthropic Warmup 请求(可选)
interceptAnthropicWarmupRequests?: boolean;
+ // thinking signature 整流器(可选)
+ enableThinkingSignatureRectifier?: boolean;
+
// 响应整流(可选)
enableResponseFixer?: boolean;
responseFixerConfig?: Partial;
diff --git a/tests/unit/proxy/proxy-forwarder-thinking-signature-rectifier.test.ts b/tests/unit/proxy/proxy-forwarder-thinking-signature-rectifier.test.ts
new file mode 100644
index 000000000..2c8505ea6
--- /dev/null
+++ b/tests/unit/proxy/proxy-forwarder-thinking-signature-rectifier.test.ts
@@ -0,0 +1,269 @@
+import { beforeEach, describe, expect, test, vi } from "vitest";
+
+const mocks = vi.hoisted(() => {
+ return {
+ getCachedSystemSettings: vi.fn(async () => ({
+ enableThinkingSignatureRectifier: true,
+ })),
+ recordSuccess: vi.fn(),
+ recordFailure: vi.fn(async () => {}),
+ getCircuitState: vi.fn(() => "closed"),
+ getProviderHealthInfo: vi.fn(async () => ({
+ health: { failureCount: 0 },
+ config: { failureThreshold: 3 },
+ })),
+ updateMessageRequestDetails: vi.fn(async () => {}),
+ };
+});
+
+vi.mock("@/lib/config", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ isHttp2Enabled: vi.fn(async () => false),
+ getCachedSystemSettings: mocks.getCachedSystemSettings,
+ };
+});
+
+vi.mock("@/lib/circuit-breaker", () => ({
+ getCircuitState: mocks.getCircuitState,
+ getProviderHealthInfo: mocks.getProviderHealthInfo,
+ recordFailure: mocks.recordFailure,
+ recordSuccess: mocks.recordSuccess,
+}));
+
+vi.mock("@/repository/message", () => ({
+ updateMessageRequestDetails: mocks.updateMessageRequestDetails,
+}));
+
+import { ProxyForwarder } from "@/app/v1/_lib/proxy/forwarder";
+import { ProxyError } from "@/app/v1/_lib/proxy/errors";
+import { ProxySession } from "@/app/v1/_lib/proxy/session";
+import type { Provider } from "@/types/provider";
+
+function createSession(): ProxySession {
+ const headers = new Headers();
+ const session = Object.create(ProxySession.prototype);
+
+ Object.assign(session, {
+ startTime: Date.now(),
+ method: "POST",
+ requestUrl: new URL("https://example.com/v1/messages"),
+ headers,
+ originalHeaders: new Headers(headers),
+ headerLog: JSON.stringify(Object.fromEntries(headers.entries())),
+ request: {
+ model: "claude-test",
+ log: "",
+ message: {
+ model: "claude-test",
+ messages: [
+ {
+ role: "assistant",
+ content: [
+ { type: "thinking", thinking: "t", signature: "sig_thinking" },
+ { type: "text", text: "hello", signature: "sig_text_should_remove" },
+ { type: "redacted_thinking", data: "r", signature: "sig_redacted" },
+ ],
+ },
+ ],
+ },
+ },
+ userAgent: null,
+ context: null,
+ clientAbortSignal: null,
+ userName: "test-user",
+ authState: { success: true, user: null, key: null, apiKey: null },
+ provider: null,
+ messageContext: { id: 123, createdAt: new Date(), user: { id: 1 }, key: {}, apiKey: "k" },
+ sessionId: null,
+ requestSequence: 1,
+ originalFormat: "claude",
+ providerType: null,
+ originalModelName: null,
+ originalUrlPathname: null,
+ providerChain: [],
+ cacheTtlResolved: null,
+ context1mApplied: false,
+ specialSettings: [],
+ cachedPriceData: undefined,
+ cachedBillingModelSource: undefined,
+ isHeaderModified: () => false,
+ });
+
+ return session as any;
+}
+
+function createAnthropicProvider(): Provider {
+ return {
+ id: 1,
+ name: "anthropic-1",
+ providerType: "claude",
+ url: "https://example.com/v1/messages",
+ key: "k",
+ preserveClientIp: false,
+ priority: 0,
+ } as unknown as Provider;
+}
+
+describe("ProxyForwarder - thinking signature rectifier", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ test("首次命中特定 400 错误时应整流并对同供应商重试一次(成功后不抛错)", async () => {
+ const session = createSession();
+ session.setProvider(createAnthropicProvider());
+
+ const doForward = vi.spyOn(ProxyForwarder as any, "doForward");
+
+ doForward.mockImplementationOnce(async () => {
+ throw new ProxyError("Invalid `signature` in `thinking` block", 400, {
+ body: "",
+ providerId: 1,
+ providerName: "anthropic-1",
+ });
+ });
+
+ doForward.mockImplementationOnce(async (s: ProxySession) => {
+ const msg = s.request.message as any;
+ const blocks = msg.messages[0].content as any[];
+ expect(blocks.some((b) => b.type === "thinking")).toBe(false);
+ expect(blocks.some((b) => b.type === "redacted_thinking")).toBe(false);
+ expect(blocks.some((b) => "signature" in b)).toBe(false);
+
+ const body = JSON.stringify({
+ type: "message",
+ content: [{ type: "text", text: "ok" }],
+ });
+
+ return new Response(body, {
+ status: 200,
+ headers: {
+ "content-type": "application/json",
+ "content-length": String(body.length),
+ },
+ });
+ });
+
+ const response = await ProxyForwarder.send(session);
+
+ expect(response.status).toBe(200);
+ expect(doForward).toHaveBeenCalledTimes(2);
+ expect(session.getProviderChain()?.length).toBeGreaterThanOrEqual(2);
+
+ const special = session.getSpecialSettings();
+ expect(special).not.toBeNull();
+ expect(JSON.stringify(special)).toContain("thinking_signature_rectifier");
+ expect(mocks.updateMessageRequestDetails).toHaveBeenCalledTimes(1);
+ });
+
+ test("命中 invalid request 相关 400 错误时也应整流并对同供应商重试一次", async () => {
+ const session = createSession();
+ session.setProvider(createAnthropicProvider());
+
+ const doForward = vi.spyOn(ProxyForwarder as any, "doForward");
+
+ doForward.mockImplementationOnce(async () => {
+ throw new ProxyError("invalid request: malformed content", 400, {
+ body: "",
+ providerId: 1,
+ providerName: "anthropic-1",
+ });
+ });
+
+ doForward.mockImplementationOnce(async (s: ProxySession) => {
+ const msg = s.request.message as any;
+ const blocks = msg.messages[0].content as any[];
+ expect(blocks.some((b) => b.type === "thinking")).toBe(false);
+ expect(blocks.some((b) => b.type === "redacted_thinking")).toBe(false);
+ expect(blocks.some((b) => "signature" in b)).toBe(false);
+
+ const body = JSON.stringify({
+ type: "message",
+ content: [{ type: "text", text: "ok" }],
+ });
+
+ return new Response(body, {
+ status: 200,
+ headers: {
+ "content-type": "application/json",
+ "content-length": String(body.length),
+ },
+ });
+ });
+
+ const response = await ProxyForwarder.send(session);
+
+ expect(response.status).toBe(200);
+ expect(doForward).toHaveBeenCalledTimes(2);
+ expect(session.getProviderChain()?.length).toBeGreaterThanOrEqual(2);
+
+ const special = session.getSpecialSettings();
+ expect(special).not.toBeNull();
+ expect(JSON.stringify(special)).toContain("thinking_signature_rectifier");
+ expect(mocks.updateMessageRequestDetails).toHaveBeenCalledTimes(1);
+ });
+
+ test("匹配触发但无可整流内容时不应做无意义重试", async () => {
+ const session = createSession();
+ session.setProvider(createAnthropicProvider());
+
+ const msg = session.request.message as any;
+ msg.messages[0].content = [{ type: "text", text: "hello" }];
+
+ const doForward = vi.spyOn(ProxyForwarder as any, "doForward");
+
+ doForward.mockImplementationOnce(async () => {
+ throw new ProxyError("Invalid `signature` in `thinking` block", 400, {
+ body: "",
+ providerId: 1,
+ providerName: "anthropic-1",
+ });
+ });
+
+ await expect(ProxyForwarder.send(session)).rejects.toBeInstanceOf(ProxyError);
+ expect(doForward).toHaveBeenCalledTimes(1);
+
+ // 仍应写入一次审计字段,但不应触发第二次 doForward 调用
+ expect(mocks.updateMessageRequestDetails).toHaveBeenCalledTimes(1);
+
+ const special = (session.getSpecialSettings() ?? []) as any[];
+ const rectifier = special.find((s) => s.type === "thinking_signature_rectifier");
+ expect(rectifier).toBeTruthy();
+ expect(rectifier.hit).toBe(false);
+ });
+
+ test("重试后仍失败时应停止继续重试/切换,并按最终错误抛出", async () => {
+ const session = createSession();
+ session.setProvider(createAnthropicProvider());
+
+ const doForward = vi.spyOn(ProxyForwarder as any, "doForward");
+
+ doForward.mockImplementationOnce(async () => {
+ throw new ProxyError("Invalid `signature` in `thinking` block", 400, {
+ body: "",
+ providerId: 1,
+ providerName: "anthropic-1",
+ });
+ });
+
+ doForward.mockImplementationOnce(async () => {
+ throw new ProxyError("Invalid `signature` in `thinking` block", 400, {
+ body: "",
+ providerId: 1,
+ providerName: "anthropic-1",
+ });
+ });
+
+ await expect(ProxyForwarder.send(session)).rejects.toBeInstanceOf(ProxyError);
+ expect(doForward).toHaveBeenCalledTimes(2);
+
+ // 第一次失败会写入审计字段,且只需要写一次(同一条 message_request 记录)
+ expect(mocks.updateMessageRequestDetails).toHaveBeenCalledTimes(1);
+
+ const special = session.getSpecialSettings();
+ expect(special).not.toBeNull();
+ expect(JSON.stringify(special)).toContain("thinking_signature_rectifier");
+ });
+});
diff --git a/vitest.thinking-signature-rectifier.config.ts b/vitest.thinking-signature-rectifier.config.ts
new file mode 100644
index 000000000..63f7d0079
--- /dev/null
+++ b/vitest.thinking-signature-rectifier.config.ts
@@ -0,0 +1,52 @@
+import path from "node:path";
+import { defineConfig } from "vitest/config";
+
+/**
+ * thinking signature 整流器专项覆盖率配置
+ *
+ * 目的:
+ * - 仅统计本次新增的整流器模块,避免把 Next/DB/Redis 等重模块纳入阈值
+ * - 对“错误整流 + 重试一次”这类稳定性修复设置覆盖率门槛(>= 80%)
+ */
+export default defineConfig({
+ test: {
+ globals: true,
+ environment: "node",
+ setupFiles: ["./tests/setup.ts"],
+
+ include: [
+ "src/app/v1/_lib/proxy/thinking-signature-rectifier.test.ts",
+ "tests/unit/proxy/proxy-forwarder-thinking-signature-rectifier.test.ts",
+ ],
+ exclude: ["node_modules", ".next", "dist", "build", "coverage", "tests/integration/**"],
+
+ coverage: {
+ provider: "v8",
+ reporter: ["text", "html", "json"],
+ reportsDirectory: "./coverage-thinking-signature-rectifier",
+
+ include: ["src/app/v1/_lib/proxy/thinking-signature-rectifier.ts"],
+ exclude: ["node_modules/", "tests/", "**/*.d.ts", ".next/"],
+
+ thresholds: {
+ lines: 80,
+ functions: 80,
+ branches: 70,
+ statements: 80,
+ },
+ },
+
+ reporters: ["verbose"],
+ isolate: true,
+ mockReset: true,
+ restoreMocks: true,
+ clearMocks: true,
+ },
+
+ resolve: {
+ alias: {
+ "@": path.resolve(__dirname, "./src"),
+ "server-only": path.resolve(__dirname, "./tests/server-only.mock.ts"),
+ },
+ },
+});
From ecce1f528e5dbee6d4f334c31e19b0dff63322e5 Mon Sep 17 00:00:00 2001
From: NieiR <31194814+NieiR@users.noreply.github.com>
Date: Sat, 10 Jan 2026 17:26:37 +0800
Subject: [PATCH 08/17] =?UTF-8?q?feat(prices):=20=E6=B7=BB=E5=8A=A0?=
=?UTF-8?q?=E6=89=8B=E5=8A=A8=E6=A8=A1=E5=9E=8B=E4=BB=B7=E6=A0=BC=E7=AE=A1?=
=?UTF-8?q?=E7=90=86=E5=8A=9F=E8=83=BD=20(#573)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* fix: enable provider group editing in edit user dialog
- Always show providerGroup field in edit mode (was hidden when user had no providerGroup)
- Replace read-only Badge display with editable ProviderGroupSelect component
- Move modelSuggestions hook after form declaration to support dynamic updates
Regression from #539
* fix: add complete translations for ProviderGroupSelect in edit user dialog
Pass full translations object to ProviderGroupSelect including:
- tagInputErrors for validation messages (empty, duplicate, too_long, etc.)
- errors.loadFailed for API error handling
- providersSuffix for provider count display
This fixes untranslated error messages when users input invalid provider group tags.
* feat(prices): 添加手动模型价格管理功能
- 新增 source 字段区分 litellm/manual 来源
- 支持手动添加、编辑、删除模型价格
- LiteLLM 同步时自动跳过手动价格,避免覆盖
- 添加冲突检测和解决 UI,支持批量处理
- 完整的单元测试覆盖
closes #405
* fix: 修复 CI 检查问题
- 移除未使用的 ModelPriceSource 导入
- 修复 useEffect 依赖数组 (fetchPrices)
- 修复 fetchPrices 声明前使用问题
- 添加价格非负数验证
- 格式化代码
* fix: wrap upsertModelPrice in transaction for data integrity
---
drizzle/0052_model_price_source.sql | 2 +
drizzle/meta/0052_snapshot.json | 2374 +++++++++++++++++
drizzle/meta/_journal.json | 9 +-
messages/en/dashboard.json | 4 +
messages/en/settings.json | 64 +-
messages/ja/dashboard.json | 4 +
messages/ja/settings.json | 64 +-
messages/ru/dashboard.json | 4 +
messages/ru/settings.json | 64 +-
messages/zh-CN/dashboard.json | 4 +
messages/zh-CN/settings.json | 64 +-
messages/zh-TW/dashboard.json | 4 +
messages/zh-TW/settings.json | 64 +-
src/actions/model-prices.ts | 217 +-
.../_components/user/edit-user-dialog.tsx | 11 +-
.../user/forms/user-edit-section.tsx | 33 +-
.../user/hooks/use-user-translations.ts | 25 +-
.../_components/delete-model-dialog.tsx | 92 +
.../prices/_components/model-price-dialog.tsx | 272 ++
.../prices/_components/price-list.tsx | 69 +-
.../_components/sync-conflict-dialog.tsx | 391 +++
.../_components/sync-litellm-button.tsx | 100 +-
.../_components/upload-price-dialog.tsx | 1 +
src/app/[locale]/settings/prices/page.tsx | 2 +
src/drizzle/schema.ts | 4 +
src/lib/price-sync.ts | 2 +-
src/repository/_shared/transformers.ts | 1 +
src/repository/model-price.ts | 118 +-
src/types/model-price.ts | 24 +
tests/unit/actions/model-prices.test.ts | 448 ++++
30 files changed, 4475 insertions(+), 60 deletions(-)
create mode 100644 drizzle/0052_model_price_source.sql
create mode 100644 drizzle/meta/0052_snapshot.json
create mode 100644 src/app/[locale]/settings/prices/_components/delete-model-dialog.tsx
create mode 100644 src/app/[locale]/settings/prices/_components/model-price-dialog.tsx
create mode 100644 src/app/[locale]/settings/prices/_components/sync-conflict-dialog.tsx
create mode 100644 tests/unit/actions/model-prices.test.ts
diff --git a/drizzle/0052_model_price_source.sql b/drizzle/0052_model_price_source.sql
new file mode 100644
index 000000000..5075e48f4
--- /dev/null
+++ b/drizzle/0052_model_price_source.sql
@@ -0,0 +1,2 @@
+ALTER TABLE "model_prices" ADD COLUMN "source" varchar(20) DEFAULT 'litellm' NOT NULL;--> statement-breakpoint
+CREATE INDEX IF NOT EXISTS "idx_model_prices_source" ON "model_prices" USING btree ("source");
\ No newline at end of file
diff --git a/drizzle/meta/0052_snapshot.json b/drizzle/meta/0052_snapshot.json
new file mode 100644
index 000000000..62239cbb4
--- /dev/null
+++ b/drizzle/meta/0052_snapshot.json
@@ -0,0 +1,2374 @@
+{
+ "id": "e7a58fbf-6e7a-4c5f-a0ac-255fcf6439d7",
+ "prevId": "c7b01fc8-2ed8-4359-a233-9fa3a2f7e8ec",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.error_rules": {
+ "name": "error_rules",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "pattern": {
+ "name": "pattern",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "match_type": {
+ "name": "match_type",
+ "type": "varchar(20)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'regex'"
+ },
+ "category": {
+ "name": "category",
+ "type": "varchar(50)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "override_response": {
+ "name": "override_response",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "override_status_code": {
+ "name": "override_status_code",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "is_enabled": {
+ "name": "is_enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "is_default": {
+ "name": "is_default",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "priority": {
+ "name": "priority",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "idx_error_rules_enabled": {
+ "name": "idx_error_rules_enabled",
+ "columns": [
+ {
+ "expression": "is_enabled",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "priority",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "unique_pattern": {
+ "name": "unique_pattern",
+ "columns": [
+ {
+ "expression": "pattern",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_category": {
+ "name": "idx_category",
+ "columns": [
+ {
+ "expression": "category",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_match_type": {
+ "name": "idx_match_type",
+ "columns": [
+ {
+ "expression": "match_type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.keys": {
+ "name": "keys",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "key": {
+ "name": "key",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "is_enabled": {
+ "name": "is_enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false,
+ "default": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "can_login_web_ui": {
+ "name": "can_login_web_ui",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false,
+ "default": false
+ },
+ "limit_5h_usd": {
+ "name": "limit_5h_usd",
+ "type": "numeric(10, 2)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "limit_daily_usd": {
+ "name": "limit_daily_usd",
+ "type": "numeric(10, 2)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "daily_reset_mode": {
+ "name": "daily_reset_mode",
+ "type": "daily_reset_mode",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'fixed'"
+ },
+ "daily_reset_time": {
+ "name": "daily_reset_time",
+ "type": "varchar(5)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'00:00'"
+ },
+ "limit_weekly_usd": {
+ "name": "limit_weekly_usd",
+ "type": "numeric(10, 2)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "limit_monthly_usd": {
+ "name": "limit_monthly_usd",
+ "type": "numeric(10, 2)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "limit_total_usd": {
+ "name": "limit_total_usd",
+ "type": "numeric(10, 2)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "limit_concurrent_sessions": {
+ "name": "limit_concurrent_sessions",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "default": 0
+ },
+ "provider_group": {
+ "name": "provider_group",
+ "type": "varchar(50)",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'default'"
+ },
+ "cache_ttl_preference": {
+ "name": "cache_ttl_preference",
+ "type": "varchar(10)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ },
+ "deleted_at": {
+ "name": "deleted_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "idx_keys_user_id": {
+ "name": "idx_keys_user_id",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_keys_created_at": {
+ "name": "idx_keys_created_at",
+ "columns": [
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_keys_deleted_at": {
+ "name": "idx_keys_deleted_at",
+ "columns": [
+ {
+ "expression": "deleted_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.message_request": {
+ "name": "message_request",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "key": {
+ "name": "key",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "model": {
+ "name": "model",
+ "type": "varchar(128)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "duration_ms": {
+ "name": "duration_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "cost_usd": {
+ "name": "cost_usd",
+ "type": "numeric(21, 15)",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'0'"
+ },
+ "cost_multiplier": {
+ "name": "cost_multiplier",
+ "type": "numeric(10, 4)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "session_id": {
+ "name": "session_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "request_sequence": {
+ "name": "request_sequence",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "default": 1
+ },
+ "provider_chain": {
+ "name": "provider_chain",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status_code": {
+ "name": "status_code",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "api_type": {
+ "name": "api_type",
+ "type": "varchar(20)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "endpoint": {
+ "name": "endpoint",
+ "type": "varchar(256)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "original_model": {
+ "name": "original_model",
+ "type": "varchar(128)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "input_tokens": {
+ "name": "input_tokens",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "output_tokens": {
+ "name": "output_tokens",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ttfb_ms": {
+ "name": "ttfb_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "cache_creation_input_tokens": {
+ "name": "cache_creation_input_tokens",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "cache_read_input_tokens": {
+ "name": "cache_read_input_tokens",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "cache_creation_5m_input_tokens": {
+ "name": "cache_creation_5m_input_tokens",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "cache_creation_1h_input_tokens": {
+ "name": "cache_creation_1h_input_tokens",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "cache_ttl_applied": {
+ "name": "cache_ttl_applied",
+ "type": "varchar(10)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "context_1m_applied": {
+ "name": "context_1m_applied",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false,
+ "default": false
+ },
+ "special_settings": {
+ "name": "special_settings",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "error_message": {
+ "name": "error_message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "error_stack": {
+ "name": "error_stack",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "error_cause": {
+ "name": "error_cause",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "blocked_by": {
+ "name": "blocked_by",
+ "type": "varchar(50)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "blocked_reason": {
+ "name": "blocked_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_agent": {
+ "name": "user_agent",
+ "type": "varchar(512)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "messages_count": {
+ "name": "messages_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ },
+ "deleted_at": {
+ "name": "deleted_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "idx_message_request_user_date_cost": {
+ "name": "idx_message_request_user_date_cost",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "cost_usd",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "where": "\"message_request\".\"deleted_at\" IS NULL",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_message_request_user_query": {
+ "name": "idx_message_request_user_query",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "where": "\"message_request\".\"deleted_at\" IS NULL",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_message_request_session_id": {
+ "name": "idx_message_request_session_id",
+ "columns": [
+ {
+ "expression": "session_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "where": "\"message_request\".\"deleted_at\" IS NULL",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_message_request_session_seq": {
+ "name": "idx_message_request_session_seq",
+ "columns": [
+ {
+ "expression": "session_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "request_sequence",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "where": "\"message_request\".\"deleted_at\" IS NULL",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_message_request_endpoint": {
+ "name": "idx_message_request_endpoint",
+ "columns": [
+ {
+ "expression": "endpoint",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "where": "\"message_request\".\"deleted_at\" IS NULL",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_message_request_blocked_by": {
+ "name": "idx_message_request_blocked_by",
+ "columns": [
+ {
+ "expression": "blocked_by",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "where": "\"message_request\".\"deleted_at\" IS NULL",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_message_request_provider_id": {
+ "name": "idx_message_request_provider_id",
+ "columns": [
+ {
+ "expression": "provider_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_message_request_user_id": {
+ "name": "idx_message_request_user_id",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_message_request_key": {
+ "name": "idx_message_request_key",
+ "columns": [
+ {
+ "expression": "key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_message_request_created_at": {
+ "name": "idx_message_request_created_at",
+ "columns": [
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_message_request_deleted_at": {
+ "name": "idx_message_request_deleted_at",
+ "columns": [
+ {
+ "expression": "deleted_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.model_prices": {
+ "name": "model_prices",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "model_name": {
+ "name": "model_name",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "price_data": {
+ "name": "price_data",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ },
+ "source": {
+ "name": "source",
+ "type": "varchar(20)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'litellm'"
+ }
+ },
+ "indexes": {
+ "idx_model_prices_latest": {
+ "name": "idx_model_prices_latest",
+ "columns": [
+ {
+ "expression": "model_name",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": false,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_model_prices_model_name": {
+ "name": "idx_model_prices_model_name",
+ "columns": [
+ {
+ "expression": "model_name",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_model_prices_created_at": {
+ "name": "idx_model_prices_created_at",
+ "columns": [
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": false,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_model_prices_source": {
+ "name": "idx_model_prices_source",
+ "columns": [
+ {
+ "expression": "source",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.notification_settings": {
+ "name": "notification_settings",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "use_legacy_mode": {
+ "name": "use_legacy_mode",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "circuit_breaker_enabled": {
+ "name": "circuit_breaker_enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "circuit_breaker_webhook": {
+ "name": "circuit_breaker_webhook",
+ "type": "varchar(512)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "daily_leaderboard_enabled": {
+ "name": "daily_leaderboard_enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "daily_leaderboard_webhook": {
+ "name": "daily_leaderboard_webhook",
+ "type": "varchar(512)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "daily_leaderboard_time": {
+ "name": "daily_leaderboard_time",
+ "type": "varchar(10)",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'09:00'"
+ },
+ "daily_leaderboard_top_n": {
+ "name": "daily_leaderboard_top_n",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "default": 5
+ },
+ "cost_alert_enabled": {
+ "name": "cost_alert_enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "cost_alert_webhook": {
+ "name": "cost_alert_webhook",
+ "type": "varchar(512)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "cost_alert_threshold": {
+ "name": "cost_alert_threshold",
+ "type": "numeric(5, 2)",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'0.80'"
+ },
+ "cost_alert_check_interval": {
+ "name": "cost_alert_check_interval",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "default": 60
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.notification_target_bindings": {
+ "name": "notification_target_bindings",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "notification_type": {
+ "name": "notification_type",
+ "type": "notification_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "target_id": {
+ "name": "target_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "is_enabled": {
+ "name": "is_enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "schedule_cron": {
+ "name": "schedule_cron",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "schedule_timezone": {
+ "name": "schedule_timezone",
+ "type": "varchar(50)",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'Asia/Shanghai'"
+ },
+ "template_override": {
+ "name": "template_override",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "unique_notification_target_binding": {
+ "name": "unique_notification_target_binding",
+ "columns": [
+ {
+ "expression": "notification_type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "target_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_notification_bindings_type": {
+ "name": "idx_notification_bindings_type",
+ "columns": [
+ {
+ "expression": "notification_type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "is_enabled",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_notification_bindings_target": {
+ "name": "idx_notification_bindings_target",
+ "columns": [
+ {
+ "expression": "target_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "is_enabled",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "notification_target_bindings_target_id_webhook_targets_id_fk": {
+ "name": "notification_target_bindings_target_id_webhook_targets_id_fk",
+ "tableFrom": "notification_target_bindings",
+ "tableTo": "webhook_targets",
+ "columnsFrom": [
+ "target_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.providers": {
+ "name": "providers",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "url": {
+ "name": "url",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "key": {
+ "name": "key",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "is_enabled": {
+ "name": "is_enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "weight": {
+ "name": "weight",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 1
+ },
+ "priority": {
+ "name": "priority",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "cost_multiplier": {
+ "name": "cost_multiplier",
+ "type": "numeric(10, 4)",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'1.0'"
+ },
+ "group_tag": {
+ "name": "group_tag",
+ "type": "varchar(50)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "provider_type": {
+ "name": "provider_type",
+ "type": "varchar(20)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'claude'"
+ },
+ "preserve_client_ip": {
+ "name": "preserve_client_ip",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "model_redirects": {
+ "name": "model_redirects",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "allowed_models": {
+ "name": "allowed_models",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'null'::jsonb"
+ },
+ "join_claude_pool": {
+ "name": "join_claude_pool",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false,
+ "default": false
+ },
+ "codex_instructions_strategy": {
+ "name": "codex_instructions_strategy",
+ "type": "varchar(20)",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'auto'"
+ },
+ "mcp_passthrough_type": {
+ "name": "mcp_passthrough_type",
+ "type": "varchar(20)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'none'"
+ },
+ "mcp_passthrough_url": {
+ "name": "mcp_passthrough_url",
+ "type": "varchar(512)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "limit_5h_usd": {
+ "name": "limit_5h_usd",
+ "type": "numeric(10, 2)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "limit_daily_usd": {
+ "name": "limit_daily_usd",
+ "type": "numeric(10, 2)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "daily_reset_mode": {
+ "name": "daily_reset_mode",
+ "type": "daily_reset_mode",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'fixed'"
+ },
+ "daily_reset_time": {
+ "name": "daily_reset_time",
+ "type": "varchar(5)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'00:00'"
+ },
+ "limit_weekly_usd": {
+ "name": "limit_weekly_usd",
+ "type": "numeric(10, 2)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "limit_monthly_usd": {
+ "name": "limit_monthly_usd",
+ "type": "numeric(10, 2)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "limit_total_usd": {
+ "name": "limit_total_usd",
+ "type": "numeric(10, 2)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "total_cost_reset_at": {
+ "name": "total_cost_reset_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "limit_concurrent_sessions": {
+ "name": "limit_concurrent_sessions",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "default": 0
+ },
+ "max_retry_attempts": {
+ "name": "max_retry_attempts",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "circuit_breaker_failure_threshold": {
+ "name": "circuit_breaker_failure_threshold",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "default": 5
+ },
+ "circuit_breaker_open_duration": {
+ "name": "circuit_breaker_open_duration",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "default": 1800000
+ },
+ "circuit_breaker_half_open_success_threshold": {
+ "name": "circuit_breaker_half_open_success_threshold",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "default": 2
+ },
+ "proxy_url": {
+ "name": "proxy_url",
+ "type": "varchar(512)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "proxy_fallback_to_direct": {
+ "name": "proxy_fallback_to_direct",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false,
+ "default": false
+ },
+ "first_byte_timeout_streaming_ms": {
+ "name": "first_byte_timeout_streaming_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "streaming_idle_timeout_ms": {
+ "name": "streaming_idle_timeout_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "request_timeout_non_streaming_ms": {
+ "name": "request_timeout_non_streaming_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "website_url": {
+ "name": "website_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "favicon_url": {
+ "name": "favicon_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "cache_ttl_preference": {
+ "name": "cache_ttl_preference",
+ "type": "varchar(10)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "context_1m_preference": {
+ "name": "context_1m_preference",
+ "type": "varchar(20)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "codex_reasoning_effort_preference": {
+ "name": "codex_reasoning_effort_preference",
+ "type": "varchar(20)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "codex_reasoning_summary_preference": {
+ "name": "codex_reasoning_summary_preference",
+ "type": "varchar(20)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "codex_text_verbosity_preference": {
+ "name": "codex_text_verbosity_preference",
+ "type": "varchar(10)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "codex_parallel_tool_calls_preference": {
+ "name": "codex_parallel_tool_calls_preference",
+ "type": "varchar(10)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "tpm": {
+ "name": "tpm",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "default": 0
+ },
+ "rpm": {
+ "name": "rpm",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "default": 0
+ },
+ "rpd": {
+ "name": "rpd",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "default": 0
+ },
+ "cc": {
+ "name": "cc",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ },
+ "deleted_at": {
+ "name": "deleted_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "idx_providers_enabled_priority": {
+ "name": "idx_providers_enabled_priority",
+ "columns": [
+ {
+ "expression": "is_enabled",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "priority",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "weight",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "where": "\"providers\".\"deleted_at\" IS NULL",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_providers_group": {
+ "name": "idx_providers_group",
+ "columns": [
+ {
+ "expression": "group_tag",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "where": "\"providers\".\"deleted_at\" IS NULL",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_providers_created_at": {
+ "name": "idx_providers_created_at",
+ "columns": [
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_providers_deleted_at": {
+ "name": "idx_providers_deleted_at",
+ "columns": [
+ {
+ "expression": "deleted_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.request_filters": {
+ "name": "request_filters",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "scope": {
+ "name": "scope",
+ "type": "varchar(20)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "action": {
+ "name": "action",
+ "type": "varchar(30)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "match_type": {
+ "name": "match_type",
+ "type": "varchar(20)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "target": {
+ "name": "target",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "replacement": {
+ "name": "replacement",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "priority": {
+ "name": "priority",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "is_enabled": {
+ "name": "is_enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "binding_type": {
+ "name": "binding_type",
+ "type": "varchar(20)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'global'"
+ },
+ "provider_ids": {
+ "name": "provider_ids",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "group_tags": {
+ "name": "group_tags",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "idx_request_filters_enabled": {
+ "name": "idx_request_filters_enabled",
+ "columns": [
+ {
+ "expression": "is_enabled",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "priority",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_request_filters_scope": {
+ "name": "idx_request_filters_scope",
+ "columns": [
+ {
+ "expression": "scope",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_request_filters_action": {
+ "name": "idx_request_filters_action",
+ "columns": [
+ {
+ "expression": "action",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_request_filters_binding": {
+ "name": "idx_request_filters_binding",
+ "columns": [
+ {
+ "expression": "is_enabled",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "binding_type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.sensitive_words": {
+ "name": "sensitive_words",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "word": {
+ "name": "word",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "match_type": {
+ "name": "match_type",
+ "type": "varchar(20)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'contains'"
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "is_enabled": {
+ "name": "is_enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "idx_sensitive_words_enabled": {
+ "name": "idx_sensitive_words_enabled",
+ "columns": [
+ {
+ "expression": "is_enabled",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "match_type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_sensitive_words_created_at": {
+ "name": "idx_sensitive_words_created_at",
+ "columns": [
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.system_settings": {
+ "name": "system_settings",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "site_title": {
+ "name": "site_title",
+ "type": "varchar(128)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'Claude Code Hub'"
+ },
+ "allow_global_usage_view": {
+ "name": "allow_global_usage_view",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "currency_display": {
+ "name": "currency_display",
+ "type": "varchar(10)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'USD'"
+ },
+ "billing_model_source": {
+ "name": "billing_model_source",
+ "type": "varchar(20)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'original'"
+ },
+ "enable_auto_cleanup": {
+ "name": "enable_auto_cleanup",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false,
+ "default": false
+ },
+ "cleanup_retention_days": {
+ "name": "cleanup_retention_days",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "default": 30
+ },
+ "cleanup_schedule": {
+ "name": "cleanup_schedule",
+ "type": "varchar(50)",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'0 2 * * *'"
+ },
+ "cleanup_batch_size": {
+ "name": "cleanup_batch_size",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "default": 10000
+ },
+ "enable_client_version_check": {
+ "name": "enable_client_version_check",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "verbose_provider_error": {
+ "name": "verbose_provider_error",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "enable_http2": {
+ "name": "enable_http2",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "intercept_anthropic_warmup_requests": {
+ "name": "intercept_anthropic_warmup_requests",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "enable_response_fixer": {
+ "name": "enable_response_fixer",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "response_fixer_config": {
+ "name": "response_fixer_config",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'{\"fixTruncatedJson\":true,\"fixSseFormat\":true,\"fixEncoding\":true,\"maxJsonDepth\":200,\"maxFixSize\":1048576}'::jsonb"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.users": {
+ "name": "users",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "role": {
+ "name": "role",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'user'"
+ },
+ "rpm_limit": {
+ "name": "rpm_limit",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "daily_limit_usd": {
+ "name": "daily_limit_usd",
+ "type": "numeric(10, 2)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "provider_group": {
+ "name": "provider_group",
+ "type": "varchar(50)",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'default'"
+ },
+ "tags": {
+ "name": "tags",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'[]'::jsonb"
+ },
+ "limit_5h_usd": {
+ "name": "limit_5h_usd",
+ "type": "numeric(10, 2)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "limit_weekly_usd": {
+ "name": "limit_weekly_usd",
+ "type": "numeric(10, 2)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "limit_monthly_usd": {
+ "name": "limit_monthly_usd",
+ "type": "numeric(10, 2)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "limit_total_usd": {
+ "name": "limit_total_usd",
+ "type": "numeric(10, 2)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "limit_concurrent_sessions": {
+ "name": "limit_concurrent_sessions",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "daily_reset_mode": {
+ "name": "daily_reset_mode",
+ "type": "daily_reset_mode",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'fixed'"
+ },
+ "daily_reset_time": {
+ "name": "daily_reset_time",
+ "type": "varchar(5)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'00:00'"
+ },
+ "is_enabled": {
+ "name": "is_enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "allowed_clients": {
+ "name": "allowed_clients",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'[]'::jsonb"
+ },
+ "allowed_models": {
+ "name": "allowed_models",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'[]'::jsonb"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ },
+ "deleted_at": {
+ "name": "deleted_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "idx_users_active_role_sort": {
+ "name": "idx_users_active_role_sort",
+ "columns": [
+ {
+ "expression": "deleted_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "role",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "where": "\"users\".\"deleted_at\" IS NULL",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_users_enabled_expires_at": {
+ "name": "idx_users_enabled_expires_at",
+ "columns": [
+ {
+ "expression": "is_enabled",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "expires_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "where": "\"users\".\"deleted_at\" IS NULL",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_users_created_at": {
+ "name": "idx_users_created_at",
+ "columns": [
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_users_deleted_at": {
+ "name": "idx_users_deleted_at",
+ "columns": [
+ {
+ "expression": "deleted_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.webhook_targets": {
+ "name": "webhook_targets",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider_type": {
+ "name": "provider_type",
+ "type": "webhook_provider_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "webhook_url": {
+ "name": "webhook_url",
+ "type": "varchar(1024)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "telegram_bot_token": {
+ "name": "telegram_bot_token",
+ "type": "varchar(256)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "telegram_chat_id": {
+ "name": "telegram_chat_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "dingtalk_secret": {
+ "name": "dingtalk_secret",
+ "type": "varchar(256)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "custom_template": {
+ "name": "custom_template",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "custom_headers": {
+ "name": "custom_headers",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "proxy_url": {
+ "name": "proxy_url",
+ "type": "varchar(512)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "proxy_fallback_to_direct": {
+ "name": "proxy_fallback_to_direct",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false,
+ "default": false
+ },
+ "is_enabled": {
+ "name": "is_enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "last_test_at": {
+ "name": "last_test_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_test_result": {
+ "name": "last_test_result",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {
+ "public.daily_reset_mode": {
+ "name": "daily_reset_mode",
+ "schema": "public",
+ "values": [
+ "fixed",
+ "rolling"
+ ]
+ },
+ "public.notification_type": {
+ "name": "notification_type",
+ "schema": "public",
+ "values": [
+ "circuit_breaker",
+ "daily_leaderboard",
+ "cost_alert"
+ ]
+ },
+ "public.webhook_provider_type": {
+ "name": "webhook_provider_type",
+ "schema": "public",
+ "values": [
+ "wechat",
+ "feishu",
+ "dingtalk",
+ "telegram",
+ "custom"
+ ]
+ }
+ },
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json
index 2da6ac2a7..9473c9cce 100644
--- a/drizzle/meta/_journal.json
+++ b/drizzle/meta/_journal.json
@@ -365,6 +365,13 @@
"when": 1767976327237,
"tag": "0051_silent_maelstrom",
"breakpoints": true
+ },
+ {
+ "idx": 52,
+ "version": "7",
+ "when": 1767924921400,
+ "tag": "0052_model_price_source",
+ "breakpoints": true
}
]
-}
\ No newline at end of file
+}
diff --git a/messages/en/dashboard.json b/messages/en/dashboard.json
index 8cc6d5add..91ef7163a 100644
--- a/messages/en/dashboard.json
+++ b/messages/en/dashboard.json
@@ -1377,6 +1377,10 @@
"threeMonths": "In 3 months",
"oneYear": "In 1 year"
},
+ "providerGroupSelect": {
+ "providersSuffix": "providers",
+ "loadFailed": "Failed to load provider groups"
+ },
"providerGroup": {
"label": "Provider group",
"placeholder": "Select provider group",
diff --git a/messages/en/settings.json b/messages/en/settings.json
index cddd6971c..0572ae511 100644
--- a/messages/en/settings.json
+++ b/messages/en/settings.json
@@ -546,13 +546,46 @@
"sync": {
"button": "Sync LiteLLM Prices",
"syncing": "Syncing...",
+ "checking": "Checking conflicts...",
"successWithChanges": "Price table updated: {added} added, {updated} updated, {unchanged} unchanged",
"successNoChanges": "Price table is up to date, no updates needed",
"failed": "Sync failed",
"failedError": "Sync failed: {error}",
"failedNoResult": "Price table updated but no result returned",
"noModels": "No model prices found",
- "partialFailure": "Partial update succeeded, but {failed} models failed"
+ "partialFailure": "Partial update succeeded, but {failed} models failed",
+ "skippedConflicts": "Skipped {count} manual models"
+ },
+ "conflict": {
+ "title": "Select Items to Overwrite",
+ "description": "The following models have manual prices. Check the ones to overwrite with LiteLLM prices, unchecked ones will be kept unchanged",
+ "searchPlaceholder": "Search models...",
+ "table": {
+ "modelName": "Model",
+ "manualPrice": "Manual Price",
+ "litellmPrice": "LiteLLM Price",
+ "action": "Action"
+ },
+ "viewDiff": "View Diff",
+ "diffTitle": "Price Difference",
+ "diff": {
+ "field": "Field",
+ "manual": "Manual",
+ "litellm": "LiteLLM",
+ "inputPrice": "Input Price",
+ "outputPrice": "Output Price",
+ "imagePrice": "Image Price",
+ "provider": "Provider",
+ "mode": "Type"
+ },
+ "pagination": {
+ "showing": "Showing {from}-{to} of {total}"
+ },
+ "selectedCount": "Selected {count}/{total} models",
+ "noMatch": "No matching models found",
+ "noConflicts": "No conflicts",
+ "applyOverwrite": "Apply Overwrite",
+ "applying": "Applying..."
},
"table": {
"modelName": "Model Name",
@@ -561,6 +594,7 @@
"inputPrice": "Input Price ($/M)",
"outputPrice": "Output Price ($/M)",
"updatedAt": "Updated At",
+ "actions": "Actions",
"typeChat": "Chat",
"typeImage": "Image",
"typeCompletion": "Completion",
@@ -610,6 +644,34 @@
"details": "Details",
"viewDetails": "View detailed logs"
}
+ },
+ "addModel": "Add Model",
+ "editModel": "Edit Model",
+ "deleteModel": "Delete Model",
+ "addModelDescription": "Manually add a new model price configuration",
+ "editModelDescription": "Edit the model price configuration",
+ "deleteConfirm": "Are you sure you want to delete model {name}? This action cannot be undone.",
+ "form": {
+ "modelName": "Model Name",
+ "modelNamePlaceholder": "e.g., gpt-5.2-codex",
+ "modelNameRequired": "Model name is required",
+ "type": "Type",
+ "provider": "Provider",
+ "providerPlaceholder": "e.g., openai",
+ "inputPrice": "Input Price ($/M tokens)",
+ "outputPrice": "Output Price ($/M tokens)",
+ "outputPriceImage": "Output Price ($/image)"
+ },
+ "actions": {
+ "edit": "Edit",
+ "delete": "Delete"
+ },
+ "toast": {
+ "createSuccess": "Model added",
+ "updateSuccess": "Model updated",
+ "deleteSuccess": "Model deleted",
+ "saveFailed": "Failed to save",
+ "deleteFailed": "Failed to delete"
}
},
"providers": {
diff --git a/messages/ja/dashboard.json b/messages/ja/dashboard.json
index a43abf9ca..b8ece37ac 100644
--- a/messages/ja/dashboard.json
+++ b/messages/ja/dashboard.json
@@ -1339,6 +1339,10 @@
"threeMonths": "3か月後",
"oneYear": "1年後"
},
+ "providerGroupSelect": {
+ "providersSuffix": "件のプロバイダー",
+ "loadFailed": "プロバイダーグループの読み込みに失敗しました"
+ },
"providerGroup": {
"label": "プロバイダーグループ",
"placeholder": "プロバイダーグループを選択",
diff --git a/messages/ja/settings.json b/messages/ja/settings.json
index d4c391415..c02d1d290 100644
--- a/messages/ja/settings.json
+++ b/messages/ja/settings.json
@@ -537,13 +537,46 @@
"sync": {
"button": "LiteLLM価格を同期",
"syncing": "同期中...",
+ "checking": "競合を確認中...",
"successWithChanges": "価格表を更新: {added}件追加、{updated}件更新、{unchanged}件変化なし",
"successNoChanges": "価格表は最新です。更新の必要はありません",
"failed": "同期に失敗しました",
"failedError": "同期に失敗しました: {error}",
"failedNoResult": "価格表は更新されましたが結果が返されていません",
"noModels": "モデル価格が見つかりません",
- "partialFailure": "一部更新が成功しましたが、{failed}件のモデルが失敗しました"
+ "partialFailure": "一部更新が成功しましたが、{failed}件のモデルが失敗しました",
+ "skippedConflicts": "{count}件の手動モデルをスキップしました"
+ },
+ "conflict": {
+ "title": "上書きする項目を選択",
+ "description": "以下のモデルには手動で設定された価格があります。チェックした項目はLiteLLM価格で上書きされ、チェックしない項目は現在のままです",
+ "searchPlaceholder": "モデルを検索...",
+ "table": {
+ "modelName": "モデル",
+ "manualPrice": "手動価格",
+ "litellmPrice": "LiteLLM価格",
+ "action": "操作"
+ },
+ "viewDiff": "差異を表示",
+ "diffTitle": "価格差異",
+ "diff": {
+ "field": "フィールド",
+ "manual": "手動",
+ "litellm": "LiteLLM",
+ "inputPrice": "入力価格",
+ "outputPrice": "出力価格",
+ "imagePrice": "画像価格",
+ "provider": "プロバイダー",
+ "mode": "タイプ"
+ },
+ "pagination": {
+ "showing": "{from}〜{to}件を表示(全{total}件)"
+ },
+ "selectedCount": "{count}/{total}件のモデルを選択",
+ "noMatch": "一致するモデルが見つかりません",
+ "noConflicts": "競合なし",
+ "applyOverwrite": "上書きを適用",
+ "applying": "適用中..."
},
"table": {
"modelName": "モデル名",
@@ -552,6 +585,7 @@
"inputPrice": "入力価格 ($/M)",
"outputPrice": "出力価格 ($/M)",
"updatedAt": "更新日時",
+ "actions": "操作",
"typeChat": "チャット",
"typeImage": "画像生成",
"typeCompletion": "補完",
@@ -601,6 +635,34 @@
"details": "詳細",
"viewDetails": "詳細ログを表示"
}
+ },
+ "addModel": "モデルを追加",
+ "editModel": "モデルを編集",
+ "deleteModel": "モデルを削除",
+ "addModelDescription": "新しいモデル価格設定を手動で追加します",
+ "editModelDescription": "モデルの価格設定を編集します",
+ "deleteConfirm": "モデル {name} を削除してもよろしいですか?この操作は元に戻せません。",
+ "form": {
+ "modelName": "モデル名",
+ "modelNamePlaceholder": "例: gpt-5.2-codex",
+ "modelNameRequired": "モデル名は必須です",
+ "type": "タイプ",
+ "provider": "プロバイダー",
+ "providerPlaceholder": "例: openai",
+ "inputPrice": "入力価格 ($/M tokens)",
+ "outputPrice": "出力価格 ($/M tokens)",
+ "outputPriceImage": "出力価格 ($/image)"
+ },
+ "actions": {
+ "edit": "編集",
+ "delete": "削除"
+ },
+ "toast": {
+ "createSuccess": "モデルを追加しました",
+ "updateSuccess": "モデルを更新しました",
+ "deleteSuccess": "モデルを削除しました",
+ "saveFailed": "保存に失敗しました",
+ "deleteFailed": "削除に失敗しました"
}
},
"providers": {
diff --git a/messages/ru/dashboard.json b/messages/ru/dashboard.json
index e0fd846da..1b332bc7e 100644
--- a/messages/ru/dashboard.json
+++ b/messages/ru/dashboard.json
@@ -1350,6 +1350,10 @@
"threeMonths": "Через 3 месяца",
"oneYear": "Через год"
},
+ "providerGroupSelect": {
+ "providersSuffix": "провайдеров",
+ "loadFailed": "Не удалось загрузить группы провайдеров"
+ },
"providerGroup": {
"label": "Группа провайдеров",
"placeholder": "Выберите группу провайдеров",
diff --git a/messages/ru/settings.json b/messages/ru/settings.json
index 8f7730d3b..4fe8730ae 100644
--- a/messages/ru/settings.json
+++ b/messages/ru/settings.json
@@ -537,13 +537,46 @@
"sync": {
"button": "Синхронизировать цены LiteLLM",
"syncing": "Синхронизация...",
+ "checking": "Проверка конфликтов...",
"successWithChanges": "Обновление прайс-листа: добавлено {added}, обновлено {updated}, без изменений {unchanged}",
"successNoChanges": "Прайс-лист актуален, обновление не требуется",
"failed": "Ошибка синхронизации",
"failedError": "Ошибка синхронизации: {error}",
"failedNoResult": "Прайс-лист обновлен но результат не возвращен",
"noModels": "Цены моделей не найдены",
- "partialFailure": "Частичное обновление выполнено, но {failed} моделей не удалось обновить"
+ "partialFailure": "Частичное обновление выполнено, но {failed} моделей не удалось обновить",
+ "skippedConflicts": "Пропущено {count} ручных моделей"
+ },
+ "conflict": {
+ "title": "Выберите элементы для перезаписи",
+ "description": "Следующие модели имеют ручные цены. Отмеченные будут перезаписаны ценами LiteLLM, неотмеченные останутся без изменений",
+ "searchPlaceholder": "Поиск моделей...",
+ "table": {
+ "modelName": "Модель",
+ "manualPrice": "Ручная цена",
+ "litellmPrice": "Цена LiteLLM",
+ "action": "Действие"
+ },
+ "viewDiff": "Показать различия",
+ "diffTitle": "Различия цен",
+ "diff": {
+ "field": "Поле",
+ "manual": "Ручное",
+ "litellm": "LiteLLM",
+ "inputPrice": "Цена ввода",
+ "outputPrice": "Цена вывода",
+ "imagePrice": "Цена изображения",
+ "provider": "Поставщик",
+ "mode": "Тип"
+ },
+ "pagination": {
+ "showing": "Показано {from}-{to} из {total}"
+ },
+ "selectedCount": "Выбрано {count}/{total} моделей",
+ "noMatch": "Модели не найдены",
+ "noConflicts": "Конфликтов нет",
+ "applyOverwrite": "Применить перезапись",
+ "applying": "Применение..."
},
"table": {
"modelName": "Название модели",
@@ -552,6 +585,7 @@
"inputPrice": "Цена ввода ($/M)",
"outputPrice": "Цена вывода ($/M)",
"updatedAt": "Обновлено",
+ "actions": "Действия",
"typeChat": "Чат",
"typeImage": "Генерация изображений",
"typeCompletion": "Дополнение",
@@ -601,6 +635,34 @@
"details": "Подробности",
"viewDetails": "Просмотреть подробный журнал"
}
+ },
+ "addModel": "Добавить модель",
+ "editModel": "Редактировать модель",
+ "deleteModel": "Удалить модель",
+ "addModelDescription": "Вручную добавить новую цену модели",
+ "editModelDescription": "Редактировать цену модели",
+ "deleteConfirm": "Удалить модель {name}? Это действие необратимо.",
+ "form": {
+ "modelName": "Название модели",
+ "modelNamePlaceholder": "например: gpt-5.2-codex",
+ "modelNameRequired": "Название модели обязательно",
+ "type": "Тип",
+ "provider": "Поставщик",
+ "providerPlaceholder": "например: openai",
+ "inputPrice": "Цена ввода ($/M токенов)",
+ "outputPrice": "Цена вывода ($/M токенов)",
+ "outputPriceImage": "Цена вывода ($/изображение)"
+ },
+ "actions": {
+ "edit": "Редактировать",
+ "delete": "Удалить"
+ },
+ "toast": {
+ "createSuccess": "Модель добавлена",
+ "updateSuccess": "Модель обновлена",
+ "deleteSuccess": "Модель удалена",
+ "saveFailed": "Ошибка сохранения",
+ "deleteFailed": "Ошибка удаления"
}
},
"providers": {
diff --git a/messages/zh-CN/dashboard.json b/messages/zh-CN/dashboard.json
index d027152a4..40b1be77a 100644
--- a/messages/zh-CN/dashboard.json
+++ b/messages/zh-CN/dashboard.json
@@ -1378,6 +1378,10 @@
"threeMonths": "三月后",
"oneYear": "一年后"
},
+ "providerGroupSelect": {
+ "providersSuffix": "个供应商",
+ "loadFailed": "加载供应商分组失败"
+ },
"providerGroup": {
"label": "供应商分组",
"placeholder": "选择供应商分组",
diff --git a/messages/zh-CN/settings.json b/messages/zh-CN/settings.json
index 147b1b227..a63c08b93 100644
--- a/messages/zh-CN/settings.json
+++ b/messages/zh-CN/settings.json
@@ -1265,13 +1265,46 @@
"sync": {
"button": "同步 LiteLLM 价格",
"syncing": "同步中...",
+ "checking": "检查冲突...",
"successWithChanges": "价格表更新: 新增 {added} 个,更新 {updated} 个,未变化 {unchanged} 个",
"successNoChanges": "价格表已是最新,无需更新",
"failed": "同步失败",
"failedError": "同步失败: {error}",
"failedNoResult": "价格表更新成功但未返回处理结果",
"noModels": "未找到支持的模型价格",
- "partialFailure": "部分更新成功,但有 {failed} 个模型失败"
+ "partialFailure": "部分更新成功,但有 {failed} 个模型失败",
+ "skippedConflicts": "跳过 {count} 个手动模型"
+ },
+ "conflict": {
+ "title": "选择要覆盖的冲突项",
+ "description": "以下模型存在手动维护的价格,勾选后将用 LiteLLM 价格覆盖,未勾选的保持本地不变",
+ "searchPlaceholder": "搜索模型...",
+ "table": {
+ "modelName": "模型",
+ "manualPrice": "手动价格",
+ "litellmPrice": "LiteLLM 价格",
+ "action": "操作"
+ },
+ "viewDiff": "查看差异",
+ "diffTitle": "价格差异对比",
+ "diff": {
+ "field": "字段",
+ "manual": "手动",
+ "litellm": "LiteLLM",
+ "inputPrice": "输入价格",
+ "outputPrice": "输出价格",
+ "imagePrice": "图片价格",
+ "provider": "供应商",
+ "mode": "类型"
+ },
+ "pagination": {
+ "showing": "显示 {from}-{to} 条,共 {total} 条"
+ },
+ "selectedCount": "已选择 {count}/{total} 个模型",
+ "noMatch": "未找到匹配的模型",
+ "noConflicts": "无冲突项",
+ "applyOverwrite": "应用覆盖",
+ "applying": "应用中..."
},
"table": {
"modelName": "模型名称",
@@ -1280,6 +1313,7 @@
"inputPrice": "输入价格 ($/M)",
"outputPrice": "输出价格 ($/M)",
"updatedAt": "更新时间",
+ "actions": "操作",
"typeChat": "对话",
"typeImage": "图像生成",
"typeCompletion": "补全",
@@ -1329,6 +1363,34 @@
"details": "详细信息",
"viewDetails": "查看详细日志"
}
+ },
+ "addModel": "添加模型",
+ "editModel": "编辑模型",
+ "deleteModel": "删除模型",
+ "addModelDescription": "手动添加新的模型价格配置",
+ "editModelDescription": "编辑模型的价格配置",
+ "deleteConfirm": "确定要删除模型 {name} 吗?此操作不可撤销。",
+ "form": {
+ "modelName": "模型名称",
+ "modelNamePlaceholder": "例如: gpt-5.2-codex",
+ "modelNameRequired": "模型名称不能为空",
+ "type": "类型",
+ "provider": "供应商",
+ "providerPlaceholder": "例如: openai",
+ "inputPrice": "输入价格 ($/M tokens)",
+ "outputPrice": "输出价格 ($/M tokens)",
+ "outputPriceImage": "输出价格 ($/image)"
+ },
+ "actions": {
+ "edit": "编辑",
+ "delete": "删除"
+ },
+ "toast": {
+ "createSuccess": "模型已添加",
+ "updateSuccess": "模型已更新",
+ "deleteSuccess": "模型已删除",
+ "saveFailed": "保存失败",
+ "deleteFailed": "删除失败"
}
},
"sensitiveWords": {
diff --git a/messages/zh-TW/dashboard.json b/messages/zh-TW/dashboard.json
index c7bc527a4..5751f6d32 100644
--- a/messages/zh-TW/dashboard.json
+++ b/messages/zh-TW/dashboard.json
@@ -1348,6 +1348,10 @@
"threeMonths": "三個月後",
"oneYear": "一年後"
},
+ "providerGroupSelect": {
+ "providersSuffix": "個供應商",
+ "loadFailed": "載入供應商分組失敗"
+ },
"providerGroup": {
"label": "供應商分組",
"placeholder": "選擇供應商分組",
diff --git a/messages/zh-TW/settings.json b/messages/zh-TW/settings.json
index fb33cf1b5..2cf3fe91b 100644
--- a/messages/zh-TW/settings.json
+++ b/messages/zh-TW/settings.json
@@ -537,13 +537,46 @@
"sync": {
"button": "同步 LiteLLM 價格",
"syncing": "同步中...",
+ "checking": "檢查衝突...",
"successWithChanges": "價格表更新: 新增 {added} 個,更新 {updated} 個,未變化 {unchanged} 個",
"successNoChanges": "價格表已是最新,無需更新",
"failed": "同步失敗",
"failedError": "同步失敗: {error}",
"failedNoResult": "價格表更新成功但未返回處理結果",
"noModels": "未找到支援的模型價格",
- "partialFailure": "部分更新成功,但有 {failed} 個模型失敗"
+ "partialFailure": "部分更新成功,但有 {failed} 個模型失敗",
+ "skippedConflicts": "跳過 {count} 個手動模型"
+ },
+ "conflict": {
+ "title": "選擇要覆蓋的衝突項",
+ "description": "以下模型存在手動維護的價格,勾選後將用 LiteLLM 價格覆蓋,未勾選的保持本地不變",
+ "searchPlaceholder": "搜尋模型...",
+ "table": {
+ "modelName": "模型",
+ "manualPrice": "手動價格",
+ "litellmPrice": "LiteLLM 價格",
+ "action": "操作"
+ },
+ "viewDiff": "查看差異",
+ "diffTitle": "價格差異對比",
+ "diff": {
+ "field": "欄位",
+ "manual": "手動",
+ "litellm": "LiteLLM",
+ "inputPrice": "輸入價格",
+ "outputPrice": "輸出價格",
+ "imagePrice": "圖片價格",
+ "provider": "供應商",
+ "mode": "類型"
+ },
+ "pagination": {
+ "showing": "顯示 {from}-{to} 條,共 {total} 條"
+ },
+ "selectedCount": "已選擇 {count}/{total} 個模型",
+ "noMatch": "未找到符合的模型",
+ "noConflicts": "無衝突項",
+ "applyOverwrite": "套用覆蓋",
+ "applying": "套用中..."
},
"table": {
"modelName": "模型名稱",
@@ -552,6 +585,7 @@
"inputPrice": "輸入價格 ($/M)",
"outputPrice": "輸出價格 ($/M)",
"updatedAt": "更新時間",
+ "actions": "操作",
"typeChat": "對話",
"typeImage": "圖像生成",
"typeCompletion": "補全",
@@ -601,6 +635,34 @@
"details": "詳細資訊",
"viewDetails": "檢視詳細記錄"
}
+ },
+ "addModel": "新增模型",
+ "editModel": "編輯模型",
+ "deleteModel": "刪除模型",
+ "addModelDescription": "手動新增模型價格設定",
+ "editModelDescription": "編輯模型的價格設定",
+ "deleteConfirm": "確定要刪除模型 {name} 嗎?此操作無法復原。",
+ "form": {
+ "modelName": "模型名稱",
+ "modelNamePlaceholder": "例如: gpt-5.2-codex",
+ "modelNameRequired": "模型名稱為必填",
+ "type": "類型",
+ "provider": "提供商",
+ "providerPlaceholder": "例如: openai",
+ "inputPrice": "輸入價格 ($/M tokens)",
+ "outputPrice": "輸出價格 ($/M tokens)",
+ "outputPriceImage": "輸出價格 ($/張圖)"
+ },
+ "actions": {
+ "edit": "編輯",
+ "delete": "刪除"
+ },
+ "toast": {
+ "createSuccess": "模型已新增",
+ "updateSuccess": "模型已更新",
+ "deleteSuccess": "模型已刪除",
+ "saveFailed": "儲存失敗",
+ "deleteFailed": "刪除失敗"
}
},
"providers": {
diff --git a/src/actions/model-prices.ts b/src/actions/model-prices.ts
index c082ebfb7..9d25caef7 100644
--- a/src/actions/model-prices.ts
+++ b/src/actions/model-prices.ts
@@ -6,18 +6,23 @@ import { logger } from "@/lib/logger";
import { getPriceTableJson } from "@/lib/price-sync";
import {
createModelPrice,
+ deleteModelPriceByName,
findAllLatestPrices,
findAllLatestPricesPaginated,
+ findAllManualPrices,
findLatestPriceByModel,
hasAnyPriceRecords,
type PaginatedResult,
type PaginationParams,
+ upsertModelPrice,
} from "@/repository/model-price";
import type {
ModelPrice,
ModelPriceData,
PriceTableJson,
PriceUpdateResult,
+ SyncConflict,
+ SyncConflictCheckResult,
} from "@/types/model-price";
import type { ActionResult } from "./types";
@@ -32,9 +37,12 @@ function isPriceDataEqual(data1: ModelPriceData, data2: ModelPriceData): boolean
/**
* 价格表处理核心逻辑(内部函数,无权限检查)
* 用于系统初始化和 Web UI 上传
+ * @param jsonContent - 价格表 JSON 内容
+ * @param overwriteManual - 可选,要覆盖的手动添加模型名称列表
*/
export async function processPriceTableInternal(
- jsonContent: string
+ jsonContent: string,
+ overwriteManual?: string[]
): Promise> {
try {
// 解析JSON内容
@@ -63,12 +71,19 @@ export async function processPriceTableInternal(
return typeof modelName === "string" && modelName.trim().length > 0;
});
+ // 创建覆盖列表的 Set 用于快速查找
+ const overwriteSet = new Set(overwriteManual ?? []);
+
+ // 获取所有手动添加的模型(用于冲突检测)
+ const manualPrices = await findAllManualPrices();
+
const result: PriceUpdateResult = {
added: [],
updated: [],
unchanged: [],
failed: [],
total: entries.length,
+ skippedConflicts: [],
};
// 处理每个模型的价格
@@ -88,23 +103,37 @@ export async function processPriceTableInternal(
continue;
}
+ // 检查是否存在手动添加的价格且不在覆盖列表中
+ const isManualPrice = manualPrices.has(modelName);
+ if (isManualPrice && !overwriteSet.has(modelName)) {
+ // 跳过手动添加的模型,记录到 skippedConflicts
+ result.skippedConflicts?.push(modelName);
+ result.unchanged.push(modelName);
+ logger.debug(`跳过手动添加的模型: ${modelName}`);
+ continue;
+ }
+
// 查找该模型的最新价格
const existingPrice = await findLatestPriceByModel(modelName);
if (!existingPrice) {
// 模型不存在,新增记录
- await createModelPrice(modelName, priceData);
+ await createModelPrice(modelName, priceData, "litellm");
result.added.push(modelName);
} else if (!isPriceDataEqual(existingPrice.priceData, priceData)) {
- // 模型存在但价格发生变化,新增记录
- await createModelPrice(modelName, priceData);
+ // 模型存在但价格发生变化
+ // 如果是手动模型且在覆盖列表中,先删除旧记录
+ if (isManualPrice && overwriteSet.has(modelName)) {
+ await deleteModelPriceByName(modelName);
+ }
+ await createModelPrice(modelName, priceData, "litellm");
result.updated.push(modelName);
} else {
// 价格未发生变化,不需要更新
result.unchanged.push(modelName);
}
} catch (error) {
- logger.error("处理模型 ${modelName} 失败:", error);
+ logger.error(`处理模型 ${modelName} 失败:`, error);
result.failed.push(modelName);
}
}
@@ -122,9 +151,11 @@ export async function processPriceTableInternal(
/**
* 上传并更新模型价格表(Web UI 入口,包含权限检查)
+ * @param overwriteManual - 可选,要覆盖的手动添加模型名称列表
*/
export async function uploadPriceTable(
- jsonContent: string
+ jsonContent: string,
+ overwriteManual?: string[]
): Promise> {
// 权限检查:只有管理员可以上传价格表
const session = await getSession();
@@ -133,7 +164,7 @@ export async function uploadPriceTable(
}
// 调用核心逻辑
- return processPriceTableInternal(jsonContent);
+ return processPriceTableInternal(jsonContent, overwriteManual);
}
/**
@@ -241,11 +272,76 @@ export async function getAvailableModelsByProviderType(): Promise {
* 获取指定模型的最新价格
*/
+/**
+ * 检查 LiteLLM 同步是否会产生冲突
+ * @returns 冲突检查结果,包含是否有冲突以及冲突列表
+ */
+export async function checkLiteLLMSyncConflicts(): Promise> {
+ try {
+ // 权限检查:只有管理员可以检查冲突
+ const session = await getSession();
+ if (!session || session.user.role !== "admin") {
+ return { ok: false, error: "无权限执行此操作" };
+ }
+
+ // 获取价格表 JSON
+ const jsonContent = await getPriceTableJson();
+ if (!jsonContent) {
+ return {
+ ok: false,
+ error: "无法从 CDN 或缓存获取价格表,请检查网络连接或稍后重试",
+ };
+ }
+
+ // 解析 JSON
+ let priceTable: PriceTableJson;
+ try {
+ priceTable = JSON.parse(jsonContent);
+ } catch {
+ return { ok: false, error: "JSON格式不正确" };
+ }
+
+ // 获取数据库中所有 manual 价格
+ const manualPrices = await findAllManualPrices();
+ logger.info(`[Conflict Check] Found ${manualPrices.size} manual prices in database`);
+
+ // 构建冲突列表:检查哪些 manual 模型会被 LiteLLM 同步覆盖
+ const conflicts: SyncConflict[] = [];
+ for (const [modelName, manualPrice] of manualPrices) {
+ const litellmPrice = priceTable[modelName];
+ if (litellmPrice && typeof litellmPrice === "object" && "mode" in litellmPrice) {
+ conflicts.push({
+ modelName,
+ manualPrice: manualPrice.priceData,
+ litellmPrice: litellmPrice as ModelPriceData,
+ });
+ }
+ }
+
+ logger.info(`[Conflict Check] Found ${conflicts.length} conflicts`);
+
+ return {
+ ok: true,
+ data: {
+ hasConflicts: conflicts.length > 0,
+ conflicts,
+ },
+ };
+ } catch (error) {
+ logger.error("检查同步冲突失败:", error);
+ const message = error instanceof Error ? error.message : "检查失败,请稍后重试";
+ return { ok: false, error: message };
+ }
+}
+
/**
* 从 LiteLLM CDN 同步价格表到数据库
+ * @param overwriteManual - 可选,要覆盖的手动添加模型名称列表
* @returns 同步结果
*/
-export async function syncLiteLLMPrices(): Promise> {
+export async function syncLiteLLMPrices(
+ overwriteManual?: string[]
+): Promise> {
try {
// 权限检查:只有管理员可以同步价格表
const session = await getSession();
@@ -267,7 +363,7 @@ export async function syncLiteLLMPrices(): Promise> {
+ try {
+ // 权限检查:只有管理员可以操作
+ const session = await getSession();
+ if (!session || session.user.role !== "admin") {
+ return { ok: false, error: "无权限执行此操作" };
+ }
+
+ // 验证输入
+ if (!input.modelName?.trim()) {
+ return { ok: false, error: "模型名称不能为空" };
+ }
+
+ // 验证价格非负
+ if (
+ input.inputCostPerToken !== undefined &&
+ (input.inputCostPerToken < 0 || !Number.isFinite(input.inputCostPerToken))
+ ) {
+ return { ok: false, error: "输入价格必须为非负数" };
+ }
+ if (
+ input.outputCostPerToken !== undefined &&
+ (input.outputCostPerToken < 0 || !Number.isFinite(input.outputCostPerToken))
+ ) {
+ return { ok: false, error: "输出价格必须为非负数" };
+ }
+ if (
+ input.outputCostPerImage !== undefined &&
+ (input.outputCostPerImage < 0 || !Number.isFinite(input.outputCostPerImage))
+ ) {
+ return { ok: false, error: "图片价格必须为非负数" };
+ }
+
+ // 构建价格数据
+ const priceData: ModelPriceData = {
+ mode: input.mode,
+ litellm_provider: input.litellmProvider || undefined,
+ input_cost_per_token: input.inputCostPerToken,
+ output_cost_per_token: input.outputCostPerToken,
+ output_cost_per_image: input.outputCostPerImage,
+ };
+
+ // 执行更新
+ const result = await upsertModelPrice(input.modelName.trim(), priceData);
+
+ // 刷新页面数据
+ revalidatePath("/settings/prices");
+
+ return { ok: true, data: result };
+ } catch (error) {
+ logger.error("更新模型价格失败:", error);
+ const message = error instanceof Error ? error.message : "操作失败,请稍后重试";
+ return { ok: false, error: message };
+ }
+}
+
+/**
+ * 删除单个模型价格(硬删除)
+ */
+export async function deleteSingleModelPrice(modelName: string): Promise> {
+ try {
+ // 权限检查:只有管理员可以操作
+ const session = await getSession();
+ if (!session || session.user.role !== "admin") {
+ return { ok: false, error: "无权限执行此操作" };
+ }
+
+ // 验证输入
+ if (!modelName?.trim()) {
+ return { ok: false, error: "模型名称不能为空" };
+ }
+
+ // 执行删除
+ await deleteModelPriceByName(modelName.trim());
+
+ // 刷新页面数据
+ revalidatePath("/settings/prices");
+
+ return { ok: true, data: undefined };
+ } catch (error) {
+ logger.error("删除模型价格失败:", error);
+ const message = error instanceof Error ? error.message : "删除失败,请稍后重试";
+ return { ok: false, error: message };
+ }
+}
diff --git a/src/app/[locale]/dashboard/_components/user/edit-user-dialog.tsx b/src/app/[locale]/dashboard/_components/user/edit-user-dialog.tsx
index 5d6b3a97f..e808276f9 100644
--- a/src/app/[locale]/dashboard/_components/user/edit-user-dialog.tsx
+++ b/src/app/[locale]/dashboard/_components/user/edit-user-dialog.tsx
@@ -72,10 +72,8 @@ function EditUserDialogInner({ onOpenChange, user, onSuccess }: EditUserDialogPr
const tCommon = useTranslations("common");
const [isPending, startTransition] = useTransition();
- // Use shared hooks
- const modelSuggestions = useModelSuggestions(user.providerGroup);
- const showUserProviderGroup = Boolean(user.providerGroup?.trim());
- const userEditTranslations = useUserTranslations({ showProviderGroup: showUserProviderGroup });
+ // Always show providerGroup field in edit mode
+ const userEditTranslations = useUserTranslations({ showProviderGroup: true });
const defaultValues = useMemo(() => buildDefaultValues(user), [user]);
@@ -125,6 +123,9 @@ function EditUserDialogInner({ onOpenChange, user, onSuccess }: EditUserDialogPr
const currentUserDraft = form.values || defaultValues;
+ // Model suggestions based on current providerGroup value
+ const modelSuggestions = useModelSuggestions(currentUserDraft.providerGroup);
+
const handleUserChange = (field: string | Record, value?: any) => {
const prev = form.values || defaultValues;
const next = { ...prev } as EditUserValues;
@@ -236,7 +237,7 @@ function EditUserDialogInner({ onOpenChange, user, onSuccess }: EditUserDialogPr
await handleEnableUser();
}
}}
- showProviderGroup={showUserProviderGroup}
+ showProviderGroup
onChange={handleUserChange}
translations={userEditTranslations}
modelSuggestions={modelSuggestions}
diff --git a/src/app/[locale]/dashboard/_components/user/forms/user-edit-section.tsx b/src/app/[locale]/dashboard/_components/user/forms/user-edit-section.tsx
index e8a691d60..1f8b30cdf 100644
--- a/src/app/[locale]/dashboard/_components/user/forms/user-edit-section.tsx
+++ b/src/app/[locale]/dashboard/_components/user/forms/user-edit-section.tsx
@@ -14,7 +14,6 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
-import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
@@ -23,6 +22,7 @@ import { cn } from "@/lib/utils";
import { AccessRestrictionsSection } from "./access-restrictions-section";
import { type DailyResetMode, LimitRulePicker, type LimitType } from "./limit-rule-picker";
import { type LimitRuleDisplayItem, LimitRulesDisplay } from "./limit-rules-display";
+import { ProviderGroupSelect } from "./provider-group-select";
import { QuickExpirePicker } from "./quick-expire-picker";
export interface UserEditSectionProps {
@@ -69,6 +69,17 @@ export interface UserEditSectionProps {
providerGroup?: {
label: string;
placeholder: string;
+ providersSuffix?: string;
+ tagInputErrors?: {
+ empty?: string;
+ duplicate?: string;
+ too_long?: string;
+ invalid_format?: string;
+ max_tags?: string;
+ };
+ errors?: {
+ loadFailed?: string;
+ };
};
enableStatus?: {
label: string;
@@ -411,20 +422,12 @@ export function UserEditSection({
/>
{showProviderGroup && translations.fields.providerGroup && (
-
-
-
- {(user.providerGroup || PROVIDER_GROUP.DEFAULT)
- .split(",")
- .map((g) => g.trim())
- .filter(Boolean)
- .map((group) => (
-
- {group}
-
- ))}
-
-
+ emitChange("providerGroup", val)}
+ disabled={false}
+ translations={translations.fields.providerGroup}
+ />
)}
diff --git a/src/app/[locale]/dashboard/_components/user/hooks/use-user-translations.ts b/src/app/[locale]/dashboard/_components/user/hooks/use-user-translations.ts
index 1360c99f1..96cb4e8de 100644
--- a/src/app/[locale]/dashboard/_components/user/hooks/use-user-translations.ts
+++ b/src/app/[locale]/dashboard/_components/user/hooks/use-user-translations.ts
@@ -26,6 +26,17 @@ export interface UserEditTranslations {
providerGroup?: {
label: string;
placeholder: string;
+ providersSuffix?: string;
+ tagInputErrors?: {
+ empty?: string;
+ duplicate?: string;
+ too_long?: string;
+ invalid_format?: string;
+ max_tags?: string;
+ };
+ errors?: {
+ loadFailed?: string;
+ };
};
enableStatus: {
label: string;
@@ -98,6 +109,7 @@ export function useUserTranslations(
): UserEditTranslations {
const { showProviderGroup = false } = options;
const t = useTranslations("dashboard.userManagement");
+ const tUi = useTranslations("ui.tagInput");
return useMemo(() => {
return {
@@ -124,6 +136,17 @@ export function useUserTranslations(
? {
label: t("userEditSection.fields.providerGroup.label"),
placeholder: t("userEditSection.fields.providerGroup.placeholder"),
+ providersSuffix: t("providerGroupSelect.providersSuffix"),
+ tagInputErrors: {
+ empty: tUi("emptyTag"),
+ duplicate: tUi("duplicateTag"),
+ too_long: tUi("tooLong", { max: 50 }),
+ invalid_format: tUi("invalidFormat"),
+ max_tags: tUi("maxTags"),
+ },
+ errors: {
+ loadFailed: t("providerGroupSelect.loadFailed"),
+ },
}
: undefined,
enableStatus: {
@@ -187,5 +210,5 @@ export function useUserTranslations(
year: t("quickExpire.oneYear"),
},
};
- }, [t, showProviderGroup]);
+ }, [t, tUi, showProviderGroup]);
}
diff --git a/src/app/[locale]/settings/prices/_components/delete-model-dialog.tsx b/src/app/[locale]/settings/prices/_components/delete-model-dialog.tsx
new file mode 100644
index 000000000..e11781ccc
--- /dev/null
+++ b/src/app/[locale]/settings/prices/_components/delete-model-dialog.tsx
@@ -0,0 +1,92 @@
+"use client";
+
+import { Loader2, Trash2 } from "lucide-react";
+import { useTranslations } from "next-intl";
+import { useState } from "react";
+import { toast } from "sonner";
+import { deleteSingleModelPrice } from "@/actions/model-prices";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+} from "@/components/ui/alert-dialog";
+import { Button } from "@/components/ui/button";
+
+interface DeleteModelDialogProps {
+ modelName: string;
+ trigger?: React.ReactNode;
+ onSuccess?: () => void;
+}
+
+/**
+ * 删除模型价格确认对话框
+ */
+export function DeleteModelDialog({ modelName, trigger, onSuccess }: DeleteModelDialogProps) {
+ const t = useTranslations("settings.prices");
+ const tCommon = useTranslations("settings.common");
+
+ const [open, setOpen] = useState(false);
+ const [loading, setLoading] = useState(false);
+
+ const handleDelete = async () => {
+ setLoading(true);
+
+ try {
+ const result = await deleteSingleModelPrice(modelName);
+
+ if (!result.ok) {
+ toast.error(result.error);
+ return;
+ }
+
+ toast.success(t("toast.deleteSuccess"));
+ setOpen(false);
+ onSuccess?.();
+ window.dispatchEvent(new Event("price-data-updated"));
+ } catch (error) {
+ console.error("删除失败:", error);
+ toast.error(t("toast.deleteFailed"));
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const defaultTrigger = (
+
+ );
+
+ return (
+
+ {trigger || defaultTrigger}
+
+
+ {t("deleteModel")}
+ {t("deleteConfirm", { name: modelName })}
+
+
+ {tCommon("cancel")}
+ {
+ e.preventDefault();
+ handleDelete();
+ }}
+ disabled={loading}
+ className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
+ >
+ {loading && }
+ {tCommon("delete")}
+
+
+
+
+ );
+}
diff --git a/src/app/[locale]/settings/prices/_components/model-price-dialog.tsx b/src/app/[locale]/settings/prices/_components/model-price-dialog.tsx
new file mode 100644
index 000000000..dba1c1c34
--- /dev/null
+++ b/src/app/[locale]/settings/prices/_components/model-price-dialog.tsx
@@ -0,0 +1,272 @@
+"use client";
+
+import { Loader2, Pencil, Plus } from "lucide-react";
+import { useTranslations } from "next-intl";
+import { useEffect, useState } from "react";
+import { toast } from "sonner";
+import { upsertSingleModelPrice } from "@/actions/model-prices";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import type { ModelPrice } from "@/types/model-price";
+
+interface ModelPriceDialogProps {
+ mode: "create" | "edit";
+ initialData?: ModelPrice;
+ trigger?: React.ReactNode;
+ onSuccess?: () => void;
+}
+
+type ModelMode = "chat" | "image_generation" | "completion";
+
+/**
+ * 模型价格添加/编辑对话框
+ */
+export function ModelPriceDialog({ mode, initialData, trigger, onSuccess }: ModelPriceDialogProps) {
+ const t = useTranslations("settings.prices");
+ const tCommon = useTranslations("settings.common");
+
+ const [open, setOpen] = useState(false);
+ const [loading, setLoading] = useState(false);
+
+ // 表单状态
+ const [modelName, setModelName] = useState("");
+ const [modelMode, setModelMode] = useState
("chat");
+ const [provider, setProvider] = useState("");
+ const [inputPrice, setInputPrice] = useState("");
+ const [outputPrice, setOutputPrice] = useState("");
+
+ // 当对话框打开或初始数据变化时,重置表单
+ useEffect(() => {
+ if (open) {
+ if (mode === "edit" && initialData) {
+ setModelName(initialData.modelName);
+ setModelMode((initialData.priceData.mode as ModelMode) || "chat");
+ setProvider(initialData.priceData.litellm_provider || "");
+ // 将每 token 价格转换为每百万 token 价格显示
+ setInputPrice(
+ initialData.priceData.input_cost_per_token
+ ? (initialData.priceData.input_cost_per_token * 1000000).toString()
+ : ""
+ );
+ if (initialData.priceData.mode === "image_generation") {
+ setOutputPrice(
+ initialData.priceData.output_cost_per_image
+ ? initialData.priceData.output_cost_per_image.toString()
+ : ""
+ );
+ } else {
+ setOutputPrice(
+ initialData.priceData.output_cost_per_token
+ ? (initialData.priceData.output_cost_per_token * 1000000).toString()
+ : ""
+ );
+ }
+ } else {
+ // 创建模式,清空表单
+ setModelName("");
+ setModelMode("chat");
+ setProvider("");
+ setInputPrice("");
+ setOutputPrice("");
+ }
+ }
+ }, [open, mode, initialData]);
+
+ const handleSubmit = async () => {
+ // 验证
+ if (!modelName.trim()) {
+ toast.error(t("form.modelNameRequired"));
+ return;
+ }
+
+ setLoading(true);
+
+ try {
+ // 将每百万 token 价格转换回每 token 价格
+ const inputCostPerToken = inputPrice ? parseFloat(inputPrice) / 1000000 : undefined;
+ const outputCostPerToken =
+ modelMode !== "image_generation" && outputPrice
+ ? parseFloat(outputPrice) / 1000000
+ : undefined;
+ const outputCostPerImage =
+ modelMode === "image_generation" && outputPrice ? parseFloat(outputPrice) : undefined;
+
+ const result = await upsertSingleModelPrice({
+ modelName: modelName.trim(),
+ mode: modelMode,
+ litellmProvider: provider.trim() || undefined,
+ inputCostPerToken,
+ outputCostPerToken,
+ outputCostPerImage,
+ });
+
+ if (!result.ok) {
+ toast.error(result.error);
+ return;
+ }
+
+ toast.success(mode === "create" ? t("toast.createSuccess") : t("toast.updateSuccess"));
+ setOpen(false);
+ onSuccess?.();
+ window.dispatchEvent(new Event("price-data-updated"));
+ } catch (error) {
+ console.error("保存失败:", error);
+ toast.error(t("toast.saveFailed"));
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const defaultTrigger =
+ mode === "create" ? (
+
+ ) : (
+
+ );
+
+ return (
+
+ );
+}
diff --git a/src/app/[locale]/settings/prices/_components/price-list.tsx b/src/app/[locale]/settings/prices/_components/price-list.tsx
index 17bb114d6..f471c957f 100644
--- a/src/app/[locale]/settings/prices/_components/price-list.tsx
+++ b/src/app/[locale]/settings/prices/_components/price-list.tsx
@@ -1,10 +1,25 @@
"use client";
-import { ChevronLeft, ChevronRight, DollarSign, Package, Search } from "lucide-react";
+import {
+ ChevronLeft,
+ ChevronRight,
+ DollarSign,
+ MoreHorizontal,
+ Package,
+ Pencil,
+ Search,
+ Trash2,
+} from "lucide-react";
import { useTranslations } from "next-intl";
import { useCallback, useEffect, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import {
Select,
@@ -23,6 +38,8 @@ import {
} from "@/components/ui/table";
import { useDebounce } from "@/lib/hooks/use-debounce";
import type { ModelPrice } from "@/types/model-price";
+import { DeleteModelDialog } from "./delete-model-dialog";
+import { ModelPriceDialog } from "./model-price-dialog";
interface PriceListProps {
initialPrices: ModelPrice[];
@@ -112,6 +129,16 @@ export function PriceList({
[]
);
+ // 监听价格数据变化事件(由其他组件触发)
+ useEffect(() => {
+ const handlePriceUpdate = () => {
+ fetchPrices(page, pageSize, debouncedSearchTerm);
+ };
+
+ window.addEventListener("price-data-updated", handlePriceUpdate);
+ return () => window.removeEventListener("price-data-updated", handlePriceUpdate);
+ }, [page, pageSize, debouncedSearchTerm, fetchPrices]);
+
// 当防抖后的搜索词变化时,触发搜索(重置到第一页)
useEffect(() => {
// 跳过初始渲染(当 debouncedSearchTerm 等于初始 searchTerm 时)
@@ -229,12 +256,13 @@ export function PriceList({
{t("table.inputPrice")}
{t("table.outputPrice")}
{t("table.updatedAt")}
+ {t("table.actions")}
{isLoading ? (
-
+
{t("table.loading")}
@@ -274,11 +302,46 @@ export function PriceList({
{new Date(price.createdAt).toLocaleDateString("zh-CN")}
+
+
+
+
+
+
+ fetchPrices(page, pageSize, debouncedSearchTerm)}
+ trigger={
+ e.preventDefault()}>
+
+ {t("actions.edit")}
+
+ }
+ />
+ fetchPrices(page, pageSize, debouncedSearchTerm)}
+ trigger={
+ e.preventDefault()}
+ className="text-destructive focus:text-destructive"
+ >
+
+ {t("actions.delete")}
+
+ }
+ />
+
+
+
))
) : (
-
+
{searchTerm ? (
<>
diff --git a/src/app/[locale]/settings/prices/_components/sync-conflict-dialog.tsx b/src/app/[locale]/settings/prices/_components/sync-conflict-dialog.tsx
new file mode 100644
index 000000000..2940ba240
--- /dev/null
+++ b/src/app/[locale]/settings/prices/_components/sync-conflict-dialog.tsx
@@ -0,0 +1,391 @@
+"use client";
+
+import { AlertTriangle, ChevronLeft, ChevronRight, Eye, Search } from "lucide-react";
+import { useTranslations } from "next-intl";
+import { useMemo, useState } from "react";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Checkbox } from "@/components/ui/checkbox";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import type { ModelPriceData, SyncConflict } from "@/types/model-price";
+
+interface SyncConflictDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ conflicts: SyncConflict[];
+ onConfirm: (selectedModels: string[]) => void;
+ isLoading?: boolean;
+}
+
+const PAGE_SIZE = 10;
+
+/**
+ * 格式化价格显示为每百万token的价格
+ */
+function formatPrice(value?: number): string {
+ if (value === undefined || value === null) return "-";
+ const pricePerMillion = value * 1000000;
+ if (pricePerMillion < 0.01) {
+ return `$${pricePerMillion.toFixed(4)}/M`;
+ } else if (pricePerMillion < 1) {
+ return `$${pricePerMillion.toFixed(3)}/M`;
+ } else if (pricePerMillion < 100) {
+ return `$${pricePerMillion.toFixed(2)}/M`;
+ }
+ return `$${pricePerMillion.toFixed(0)}/M`;
+}
+
+/**
+ * 价格差异对比 Popover
+ */
+function PriceDiffPopover({
+ manualPrice,
+ litellmPrice,
+}: {
+ manualPrice: ModelPriceData;
+ litellmPrice: ModelPriceData;
+}) {
+ const t = useTranslations("settings.prices.conflict");
+
+ const diffs = useMemo(() => {
+ const items: Array<{
+ field: string;
+ manual: string;
+ litellm: string;
+ changed: boolean;
+ }> = [];
+
+ // 输入价格
+ const manualInput = formatPrice(manualPrice.input_cost_per_token);
+ const litellmInput = formatPrice(litellmPrice.input_cost_per_token);
+ items.push({
+ field: t("diff.inputPrice"),
+ manual: manualInput,
+ litellm: litellmInput,
+ changed: manualInput !== litellmInput,
+ });
+
+ // 输出价格
+ const manualOutput = formatPrice(manualPrice.output_cost_per_token);
+ const litellmOutput = formatPrice(litellmPrice.output_cost_per_token);
+ items.push({
+ field: t("diff.outputPrice"),
+ manual: manualOutput,
+ litellm: litellmOutput,
+ changed: manualOutput !== litellmOutput,
+ });
+
+ // 图片价格(如果有)
+ if (manualPrice.output_cost_per_image || litellmPrice.output_cost_per_image) {
+ const manualImg = manualPrice.output_cost_per_image
+ ? `$${manualPrice.output_cost_per_image}/img`
+ : "-";
+ const litellmImg = litellmPrice.output_cost_per_image
+ ? `$${litellmPrice.output_cost_per_image}/img`
+ : "-";
+ items.push({
+ field: t("diff.imagePrice"),
+ manual: manualImg,
+ litellm: litellmImg,
+ changed: manualImg !== litellmImg,
+ });
+ }
+
+ // 供应商
+ const manualProvider = manualPrice.litellm_provider || "-";
+ const litellmProvider = litellmPrice.litellm_provider || "-";
+ items.push({
+ field: t("diff.provider"),
+ manual: manualProvider,
+ litellm: litellmProvider,
+ changed: manualProvider !== litellmProvider,
+ });
+
+ // 类型
+ const manualMode = manualPrice.mode || "-";
+ const litellmMode = litellmPrice.mode || "-";
+ items.push({
+ field: t("diff.mode"),
+ manual: manualMode,
+ litellm: litellmMode,
+ changed: manualMode !== litellmMode,
+ });
+
+ return items;
+ }, [manualPrice, litellmPrice, t]);
+
+ return (
+
+
+
+
+
+
+
{t("diffTitle")}
+
+
+
+ {t("diff.field")}
+ {t("diff.manual")}
+ {t("diff.litellm")}
+
+
+
+ {diffs.map((diff) => (
+
+ {diff.field}
+
+ {diff.changed ? (
+ {diff.manual}
+ ) : (
+ diff.manual
+ )}
+
+
+ {diff.changed ? (
+ {diff.litellm}
+ ) : (
+ diff.litellm
+ )}
+
+
+ ))}
+
+
+
+
+
+ );
+}
+
+/**
+ * 同步冲突对比弹窗
+ */
+export function SyncConflictDialog({
+ open,
+ onOpenChange,
+ conflicts,
+ onConfirm,
+ isLoading = false,
+}: SyncConflictDialogProps) {
+ const t = useTranslations("settings.prices.conflict");
+ const tCommon = useTranslations("settings.common");
+
+ const [selectedModels, setSelectedModels] = useState
>(new Set());
+ const [searchTerm, setSearchTerm] = useState("");
+ const [page, setPage] = useState(1);
+
+ // 过滤冲突列表
+ const filteredConflicts = useMemo(() => {
+ if (!searchTerm.trim()) return conflicts;
+ const term = searchTerm.toLowerCase();
+ return conflicts.filter((c) => c.modelName.toLowerCase().includes(term));
+ }, [conflicts, searchTerm]);
+
+ // 分页
+ const totalPages = Math.ceil(filteredConflicts.length / PAGE_SIZE);
+ const paginatedConflicts = useMemo(() => {
+ const start = (page - 1) * PAGE_SIZE;
+ return filteredConflicts.slice(start, start + PAGE_SIZE);
+ }, [filteredConflicts, page]);
+
+ // 全选/取消全选(仅当前页)
+ const allCurrentPageSelected = paginatedConflicts.every((c) => selectedModels.has(c.modelName));
+ const someCurrentPageSelected =
+ paginatedConflicts.some((c) => selectedModels.has(c.modelName)) && !allCurrentPageSelected;
+
+ const handleSelectAll = (checked: boolean) => {
+ const newSelected = new Set(selectedModels);
+ if (checked) {
+ paginatedConflicts.forEach((c) => newSelected.add(c.modelName));
+ } else {
+ paginatedConflicts.forEach((c) => newSelected.delete(c.modelName));
+ }
+ setSelectedModels(newSelected);
+ };
+
+ const handleSelectModel = (modelName: string, checked: boolean) => {
+ const newSelected = new Set(selectedModels);
+ if (checked) {
+ newSelected.add(modelName);
+ } else {
+ newSelected.delete(modelName);
+ }
+ setSelectedModels(newSelected);
+ };
+
+ const handleConfirm = () => {
+ onConfirm(Array.from(selectedModels));
+ };
+
+ const handleCancel = () => {
+ // 取消时不覆盖任何手动模型
+ onConfirm([]);
+ };
+
+ // 搜索时重置页码
+ const handleSearchChange = (value: string) => {
+ setSearchTerm(value);
+ setPage(1);
+ };
+
+ return (
+
+ );
+}
diff --git a/src/app/[locale]/settings/prices/_components/sync-litellm-button.tsx b/src/app/[locale]/settings/prices/_components/sync-litellm-button.tsx
index 78edbeeee..ed0f77cb0 100644
--- a/src/app/[locale]/settings/prices/_components/sync-litellm-button.tsx
+++ b/src/app/[locale]/settings/prices/_components/sync-litellm-button.tsx
@@ -5,8 +5,10 @@ import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useState } from "react";
import { toast } from "sonner";
-import { syncLiteLLMPrices } from "@/actions/model-prices";
+import { checkLiteLLMSyncConflicts, syncLiteLLMPrices } from "@/actions/model-prices";
import { Button } from "@/components/ui/button";
+import type { SyncConflict } from "@/types/model-price";
+import { SyncConflictDialog } from "./sync-conflict-dialog";
/**
* LiteLLM 价格同步按钮组件
@@ -15,12 +17,20 @@ export function SyncLiteLLMButton() {
const t = useTranslations("settings");
const router = useRouter();
const [syncing, setSyncing] = useState(false);
+ const [checking, setChecking] = useState(false);
- const handleSync = async () => {
+ // 冲突弹窗状态
+ const [conflictDialogOpen, setConflictDialogOpen] = useState(false);
+ const [conflicts, setConflicts] = useState([]);
+
+ /**
+ * 执行同步(可选覆盖列表)
+ */
+ const doSync = async (overwriteManual?: string[]) => {
setSyncing(true);
try {
- const response = await syncLiteLLMPrices();
+ const response = await syncLiteLLMPrices(overwriteManual);
if (!response.ok) {
toast.error(response.error || t("prices.sync.failed"));
@@ -32,7 +42,7 @@ export function SyncLiteLLMButton() {
return;
}
- const { added, updated, unchanged, failed } = response.data;
+ const { added, updated, unchanged, failed, skippedConflicts } = response.data;
// 优先显示失败信息(更明显)
if (failed.length > 0) {
@@ -47,13 +57,16 @@ export function SyncLiteLLMButton() {
// 显示成功信息
if (added.length > 0 || updated.length > 0) {
- toast.success(
- t("prices.sync.successWithChanges", {
- added: added.length,
- updated: updated.length,
- unchanged: unchanged.length,
- })
- );
+ let message = t("prices.sync.successWithChanges", {
+ added: added.length,
+ updated: updated.length,
+ unchanged: unchanged.length,
+ });
+ // 如果有跳过的冲突,追加提示
+ if (skippedConflicts && skippedConflicts.length > 0) {
+ message += ` (${t("prices.sync.skippedConflicts", { count: skippedConflicts.length })})`;
+ }
+ toast.success(message);
} else if (unchanged.length > 0) {
toast.info(t("prices.sync.successNoChanges", { unchanged: unchanged.length }));
} else if (failed.length === 0) {
@@ -62,6 +75,7 @@ export function SyncLiteLLMButton() {
// 刷新页面数据
router.refresh();
+ window.dispatchEvent(new Event("price-data-updated"));
} catch (error) {
console.error("同步失败:", error);
toast.error(t("prices.sync.failedError"));
@@ -70,10 +84,66 @@ export function SyncLiteLLMButton() {
}
};
+ /**
+ * 处理同步按钮点击 - 先检查冲突
+ */
+ const handleSync = async () => {
+ setChecking(true);
+
+ try {
+ // 先检查是否有冲突
+ const checkResult = await checkLiteLLMSyncConflicts();
+
+ if (!checkResult.ok) {
+ toast.error(checkResult.error || t("prices.sync.failed"));
+ return;
+ }
+
+ if (checkResult.data?.hasConflicts && checkResult.data.conflicts.length > 0) {
+ // 有冲突,显示弹窗
+ setConflicts(checkResult.data.conflicts);
+ setConflictDialogOpen(true);
+ } else {
+ // 无冲突,直接同步
+ await doSync();
+ }
+ } catch (error) {
+ console.error("检查冲突失败:", error);
+ toast.error(t("prices.sync.failedError"));
+ } finally {
+ setChecking(false);
+ }
+ };
+
+ /**
+ * 处理冲突弹窗确认
+ */
+ const handleConflictConfirm = async (selectedModels: string[]) => {
+ setConflictDialogOpen(false);
+ // 执行同步,传入要覆盖的模型列表
+ await doSync(selectedModels);
+ };
+
+ const isLoading = syncing || checking;
+
return (
-
+ <>
+
+
+
+ >
);
}
diff --git a/src/app/[locale]/settings/prices/_components/upload-price-dialog.tsx b/src/app/[locale]/settings/prices/_components/upload-price-dialog.tsx
index cf92d9ba5..358ec76ba 100644
--- a/src/app/[locale]/settings/prices/_components/upload-price-dialog.tsx
+++ b/src/app/[locale]/settings/prices/_components/upload-price-dialog.tsx
@@ -118,6 +118,7 @@ export function UploadPriceDialog({
setResult(response.data);
const totalUpdates = response.data.added.length + response.data.updated.length;
toast.success(t("dialog.updateSuccess", { count: totalUpdates }));
+ window.dispatchEvent(new Event("price-data-updated"));
} catch (error) {
console.error("更新失败:", error);
toast.error(t("dialog.updateFailed"));
diff --git a/src/app/[locale]/settings/prices/page.tsx b/src/app/[locale]/settings/prices/page.tsx
index 3f4238bf8..f3b21cc51 100644
--- a/src/app/[locale]/settings/prices/page.tsx
+++ b/src/app/[locale]/settings/prices/page.tsx
@@ -3,6 +3,7 @@ import { Suspense } from "react";
import { getModelPrices, getModelPricesPaginated } from "@/actions/model-prices";
import { Section } from "@/components/section";
import { SettingsPageHeader } from "../_components/settings-page-header";
+import { ModelPriceDialog } from "./_components/model-price-dialog";
import { PriceList } from "./_components/price-list";
import { PricesSkeleton } from "./_components/prices-skeleton";
import { SyncLiteLLMButton } from "./_components/sync-litellm-button";
@@ -73,6 +74,7 @@ async function SettingsPricesContent({ searchParams }: SettingsPricesPageProps)
description={t("prices.section.description")}
actions={
+
diff --git a/src/drizzle/schema.ts b/src/drizzle/schema.ts
index f162fef90..17154d356 100644
--- a/src/drizzle/schema.ts
+++ b/src/drizzle/schema.ts
@@ -366,6 +366,8 @@ export const modelPrices = pgTable('model_prices', {
id: serial('id').primaryKey(),
modelName: varchar('model_name').notNull(),
priceData: jsonb('price_data').notNull(),
+ // 价格来源: 'litellm' = 从 LiteLLM 同步, 'manual' = 手动添加
+ source: varchar('source', { length: 20 }).notNull().default('litellm').$type<'litellm' | 'manual'>(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(),
}, (table) => ({
@@ -374,6 +376,8 @@ export const modelPrices = pgTable('model_prices', {
// 基础索引
modelPricesModelNameIdx: index('idx_model_prices_model_name').on(table.modelName),
modelPricesCreatedAtIdx: index('idx_model_prices_created_at').on(table.createdAt.desc()),
+ // 按来源过滤的索引
+ modelPricesSourceIdx: index('idx_model_prices_source').on(table.source),
}));
// Error Rules table
diff --git a/src/lib/price-sync.ts b/src/lib/price-sync.ts
index bb3006c27..002933ab7 100644
--- a/src/lib/price-sync.ts
+++ b/src/lib/price-sync.ts
@@ -13,7 +13,7 @@ import { isClientAbortError } from "@/app/v1/_lib/proxy/errors";
import { logger } from "@/lib/logger";
const LITELLM_PRICE_URL =
- "https://jsd-proxy.ygxz.in/gh/BerriAI/litellm/model_prices_and_context_window.json";
+ "https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json";
const CACHE_FILE_PATH = path.join(process.cwd(), "public", "cache", "litellm-prices.json");
const FETCH_TIMEOUT_MS = 10000; // 10 秒超时
diff --git a/src/repository/_shared/transformers.ts b/src/repository/_shared/transformers.ts
index 1b144a705..218048ceb 100644
--- a/src/repository/_shared/transformers.ts
+++ b/src/repository/_shared/transformers.ts
@@ -141,6 +141,7 @@ export function toMessageRequest(dbMessage: any): MessageRequest {
export function toModelPrice(dbPrice: any): ModelPrice {
return {
...dbPrice,
+ source: dbPrice?.source ?? "litellm", // 默认为 litellm(向后兼容)
createdAt: dbPrice?.createdAt ? new Date(dbPrice.createdAt) : new Date(),
updatedAt: dbPrice?.updatedAt ? new Date(dbPrice.updatedAt) : new Date(),
};
diff --git a/src/repository/model-price.ts b/src/repository/model-price.ts
index ad7f98407..0d5ab8e0e 100644
--- a/src/repository/model-price.ts
+++ b/src/repository/model-price.ts
@@ -3,7 +3,7 @@
import { desc, eq, sql } from "drizzle-orm";
import { db } from "@/drizzle/db";
import { modelPrices } from "@/drizzle/schema";
-import type { ModelPrice, ModelPriceData } from "@/types/model-price";
+import type { ModelPrice, ModelPriceData, ModelPriceSource } from "@/types/model-price";
import { toModelPrice } from "./_shared/transformers";
/**
@@ -13,6 +13,7 @@ export interface PaginationParams {
page: number;
pageSize: number;
search?: string; // 可选的搜索关键词
+ source?: ModelPriceSource; // 可选的来源过滤
}
/**
@@ -35,6 +36,7 @@ export async function findLatestPriceByModel(modelName: string): Promise {
mp.id,
mp.model_name,
mp.price_data,
+ mp.source,
mp.created_at,
mp.updated_at,
ROW_NUMBER() OVER (PARTITION BY mp.model_name ORDER BY mp.id DESC) as rn
@@ -77,6 +80,7 @@ export async function findAllLatestPrices(): Promise {
id,
model_name as "modelName",
price_data as "priceData",
+ source,
created_at as "createdAt",
updated_at as "updatedAt"
FROM latest_records
@@ -95,9 +99,25 @@ export async function findAllLatestPrices(): Promise {
export async function findAllLatestPricesPaginated(
params: PaginationParams
): Promise> {
- const { page, pageSize, search } = params;
+ const { page, pageSize, search, source } = params;
const offset = (page - 1) * pageSize;
+ // 构建 WHERE 条件
+ const buildWhereCondition = () => {
+ const conditions: ReturnType[] = [];
+ if (search?.trim()) {
+ conditions.push(sql`model_name ILIKE ${`%${search.trim()}%`}`);
+ }
+ if (source) {
+ conditions.push(sql`source = ${source}`);
+ }
+ if (conditions.length === 0) return sql``;
+ if (conditions.length === 1) return sql`WHERE ${conditions[0]}`;
+ return sql`WHERE ${sql.join(conditions, sql` AND `)}`;
+ };
+
+ const whereCondition = buildWhereCondition();
+
// 先获取总数
const countQuery = sql`
WITH latest_prices AS (
@@ -105,7 +125,7 @@ export async function findAllLatestPricesPaginated(
model_name,
MAX(created_at) as max_created_at
FROM model_prices
- ${search?.trim() ? sql`WHERE model_name ILIKE ${`%${search.trim()}%`}` : sql``}
+ ${whereCondition}
GROUP BY model_name
),
latest_records AS (
@@ -132,7 +152,7 @@ export async function findAllLatestPricesPaginated(
model_name,
MAX(created_at) as max_created_at
FROM model_prices
- ${search?.trim() ? sql`WHERE model_name ILIKE ${`%${search.trim()}%`}` : sql``}
+ ${whereCondition}
GROUP BY model_name
),
latest_records AS (
@@ -140,6 +160,7 @@ export async function findAllLatestPricesPaginated(
mp.id,
mp.model_name,
mp.price_data,
+ mp.source,
mp.created_at,
mp.updated_at,
ROW_NUMBER() OVER (PARTITION BY mp.model_name ORDER BY mp.id DESC) as rn
@@ -152,6 +173,7 @@ export async function findAllLatestPricesPaginated(
id,
model_name as "modelName",
price_data as "priceData",
+ source,
created_at as "createdAt",
updated_at as "updatedAt"
FROM latest_records
@@ -183,21 +205,25 @@ export async function hasAnyPriceRecords(): Promise {
/**
* 创建新的价格记录
+ * @param source - 价格来源,默认为 'litellm'(同步时使用),手动添加时传入 'manual'
*/
export async function createModelPrice(
modelName: string,
- priceData: ModelPriceData
+ priceData: ModelPriceData,
+ source: ModelPriceSource = "litellm"
): Promise {
const [price] = await db
.insert(modelPrices)
.values({
modelName: modelName,
priceData: priceData,
+ source: source,
})
.returning({
id: modelPrices.id,
modelName: modelPrices.modelName,
priceData: modelPrices.priceData,
+ source: modelPrices.source,
createdAt: modelPrices.createdAt,
updatedAt: modelPrices.updatedAt,
});
@@ -205,6 +231,88 @@ export async function createModelPrice(
return toModelPrice(price);
}
+/**
+ * 更新或插入模型价格(先删除旧记录,再插入新记录)
+ * 用于手动维护单个模型价格,source 固定为 'manual'
+ */
+export async function upsertModelPrice(
+ modelName: string,
+ priceData: ModelPriceData
+): Promise {
+ // 使用事务确保删除和插入的原子性
+ return await db.transaction(async (tx) => {
+ // 先删除该模型的所有旧记录
+ await tx.delete(modelPrices).where(eq(modelPrices.modelName, modelName));
+
+ // 插入新记录,source 固定为 'manual'
+ const [price] = await tx
+ .insert(modelPrices)
+ .values({
+ modelName: modelName,
+ priceData: priceData,
+ source: "manual",
+ })
+ .returning();
+ return toModelPrice(price);
+ });
+}
+
+/**
+ * 删除指定模型的所有价格记录(硬删除)
+ */
+export async function deleteModelPriceByName(modelName: string): Promise {
+ await db.delete(modelPrices).where(eq(modelPrices.modelName, modelName));
+}
+
+/**
+ * 获取数据库中所有 source='manual' 的最新价格记录
+ * 返回 Map
+ */
+export async function findAllManualPrices(): Promise