From 499d925d9809d94d8442d71a0cdeb22399265b2a Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Thu, 11 Jun 2026 12:15:26 +0800 Subject: [PATCH 01/24] Merge pull request #1233 from tesgth032/issue/1232-doc-entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为登录后用户补充使用文档入口 [未完成] --- messages/en/myUsage.json | 1 + messages/ja/myUsage.json | 1 + messages/ru/myUsage.json | 1 + messages/zh-CN/myUsage.json | 1 + messages/zh-TW/myUsage.json | 1 + .../_components/dashboard-header.test.tsx | 111 ++++++++++++++++++ .../_components/dashboard-header.tsx | 9 +- .../dashboard/_components/user-menu.test.tsx | 67 +++++++++++ .../dashboard/_components/user-menu.tsx | 16 ++- .../_components/my-usage-header.test.tsx | 76 ++++++++++++ .../my-usage/_components/my-usage-header.tsx | 14 ++- src/app/[locale]/usage-doc/layout.tsx | 5 +- tests/unit/i18n/my-usage-keys.test.ts | 63 ++++++++++ .../usage-doc/usage-doc-auth-state.test.tsx | 1 + 14 files changed, 358 insertions(+), 9 deletions(-) create mode 100644 src/app/[locale]/dashboard/_components/dashboard-header.test.tsx create mode 100644 src/app/[locale]/dashboard/_components/user-menu.test.tsx create mode 100644 src/app/[locale]/my-usage/_components/my-usage-header.test.tsx create mode 100644 tests/unit/i18n/my-usage-keys.test.ts diff --git a/messages/en/myUsage.json b/messages/en/myUsage.json index 851da6c1d..58a82954b 100644 --- a/messages/en/myUsage.json +++ b/messages/en/myUsage.json @@ -2,6 +2,7 @@ "header": { "title": "My Usage", "welcome": "Welcome, {name}", + "documentation": "Usage Docs", "logout": "Logout", "keyLabel": "Key", "userLabel": "User", diff --git a/messages/ja/myUsage.json b/messages/ja/myUsage.json index 80761d0a8..823b78b21 100644 --- a/messages/ja/myUsage.json +++ b/messages/ja/myUsage.json @@ -2,6 +2,7 @@ "header": { "title": "マイ利用状況", "welcome": "ようこそ、{name}さん", + "documentation": "利用ドキュメント", "logout": "ログアウト", "keyLabel": "キー", "userLabel": "ユーザー", diff --git a/messages/ru/myUsage.json b/messages/ru/myUsage.json index c85dd01ea..7d58f7262 100644 --- a/messages/ru/myUsage.json +++ b/messages/ru/myUsage.json @@ -2,6 +2,7 @@ "header": { "title": "Мои расходы", "welcome": "Добро пожаловать, {name}", + "documentation": "Документация", "logout": "Выйти", "keyLabel": "Ключ", "userLabel": "Пользователь", diff --git a/messages/zh-CN/myUsage.json b/messages/zh-CN/myUsage.json index 5b230d9df..472c2318f 100644 --- a/messages/zh-CN/myUsage.json +++ b/messages/zh-CN/myUsage.json @@ -2,6 +2,7 @@ "header": { "title": "我的用量", "welcome": "欢迎,{name}", + "documentation": "使用文档", "logout": "退出登录", "keyLabel": "密钥", "userLabel": "用户", diff --git a/messages/zh-TW/myUsage.json b/messages/zh-TW/myUsage.json index 41be1b6e8..772f88b47 100644 --- a/messages/zh-TW/myUsage.json +++ b/messages/zh-TW/myUsage.json @@ -2,6 +2,7 @@ "header": { "title": "我的使用量", "welcome": "歡迎,{name}", + "documentation": "使用文件", "logout": "登出", "keyLabel": "金鑰", "userLabel": "使用者", diff --git a/src/app/[locale]/dashboard/_components/dashboard-header.test.tsx b/src/app/[locale]/dashboard/_components/dashboard-header.test.tsx new file mode 100644 index 000000000..b0cc108f7 --- /dev/null +++ b/src/app/[locale]/dashboard/_components/dashboard-header.test.tsx @@ -0,0 +1,111 @@ +import type { ReactNode } from "react"; +import { renderToString } from "react-dom/server"; +import { describe, expect, it, vi } from "vitest"; +import type { AuthSession } from "@/lib/auth"; +import { DashboardHeader } from "./dashboard-header"; + +vi.mock("next-intl/server", () => ({ + getTranslations: () => (key: string) => + ({ + availability: "Availability", + dashboard: "Dashboard", + documentation: "Docs", + leaderboard: "Leaderboard", + login: "Login", + myQuota: "My Quota", + providers: "Providers", + quotasManagement: "Quotas", + systemSettings: "Settings", + usageLogs: "Usage Logs", + userManagement: "Users", + })[key] ?? key, +})); + +vi.mock("@/i18n/routing", () => ({ + Link: ({ href, children, ...rest }: { href: string; children: ReactNode }) => ( + + {children} + + ), +})); + +vi.mock("@/components/customs/version-update-notifier", () => ({ + VersionUpdateNotifier: () => , +})); + +vi.mock("@/components/ui/language-switcher", () => ({ + LanguageSwitcher: () => , +})); + +vi.mock("@/components/ui/theme-switcher", () => ({ + ThemeSwitcher: () => , +})); + +vi.mock("./dashboard-nav", () => ({ + DashboardNav: ({ items }: { items: { href: string; label: string }[] }) => ( + + ), +})); + +vi.mock("./mobile-nav", () => ({ + MobileNav: ({ items }: { items: { href: string; label: string }[] }) => ( + + ), +})); + +vi.mock("./user-menu", () => ({ + UserMenu: ({ user }: { user: { name: string } }) => ( + {user.name} + ), +})); + +function buildSession(canLoginWebUi: boolean): AuthSession { + return { + user: { + id: 1, + name: "Ada Lovelace", + role: "user", + }, + key: { + id: 10, + name: "readonly-key", + key: "sk-readonly", + userId: 1, + canLoginWebUi, + }, + } as AuthSession; +} + +describe("DashboardHeader", () => { + it("hides dashboard-only navigation for readonly usage sessions", async () => { + const html = renderToString( + await DashboardHeader({ session: buildSession(false), locale: "en" }) + ); + + expect(html).toContain('href="/usage-doc"'); + expect(html).not.toContain('href="/dashboard/logs"'); + expect(html).not.toContain('href="/dashboard/users"'); + }); + + it("keeps normal dashboard navigation for full web UI sessions", async () => { + const html = renderToString( + await DashboardHeader({ session: buildSession(true), locale: "en" }) + ); + + expect(html).toContain('href="/dashboard/logs"'); + expect(html).toContain('href="/dashboard/my-quota"'); + expect(html).toContain('href="/usage-doc"'); + }); +}); diff --git a/src/app/[locale]/dashboard/_components/dashboard-header.tsx b/src/app/[locale]/dashboard/_components/dashboard-header.tsx index b9caf6e16..637859206 100644 --- a/src/app/[locale]/dashboard/_components/dashboard-header.tsx +++ b/src/app/[locale]/dashboard/_components/dashboard-header.tsx @@ -17,6 +17,8 @@ interface DashboardHeaderProps { export async function DashboardHeader({ session, locale }: DashboardHeaderProps) { const t = await getTranslations({ locale, namespace: "dashboard.nav" }); const isAdmin = session?.user.role === "admin"; + const canUseDashboard = !!session && (isAdmin || session.key.canLoginWebUi); + const documentationItem = { href: "/usage-doc", label: t("documentation") }; const NAV_ITEMS: (DashboardNavItem & { adminOnly?: boolean })[] = [ { href: "/dashboard", label: t("dashboard") }, @@ -28,11 +30,14 @@ export async function DashboardHeader({ session, locale }: DashboardHeaderProps) ? [{ href: "/dashboard/quotas", label: t("quotasManagement") }] : [{ href: "/dashboard/my-quota", label: t("myQuota") }]), { href: "/dashboard/users", label: t("userManagement") }, - { href: "/usage-doc", label: t("documentation") }, + documentationItem, { href: "/settings", label: t("systemSettings"), adminOnly: true }, ]; - const items = NAV_ITEMS.filter((item) => !item.adminOnly || isAdmin); + const items = + session && !canUseDashboard + ? [documentationItem] + : NAV_ITEMS.filter((item) => !item.adminOnly || isAdmin); return (
diff --git a/src/app/[locale]/dashboard/_components/user-menu.test.tsx b/src/app/[locale]/dashboard/_components/user-menu.test.tsx new file mode 100644 index 000000000..e2b1260e5 --- /dev/null +++ b/src/app/[locale]/dashboard/_components/user-menu.test.tsx @@ -0,0 +1,67 @@ +/** + * @vitest-environment happy-dom + */ + +import type { ReactNode } from "react"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { NextIntlClientProvider } from "next-intl"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { UserMenu } from "./user-menu"; + +const mockPush = vi.hoisted(() => vi.fn()); +const mockRefresh = vi.hoisted(() => vi.fn()); + +vi.mock("@/i18n/routing", () => ({ + Link: ({ href, children, ...rest }: { href: string; children: ReactNode }) => ( + + {children} + + ), + useRouter: () => ({ + push: mockPush, + refresh: mockRefresh, + }), +})); + +const messages = { + dashboard: { + nav: { + documentation: "Docs", + logout: "Logout", + }, + }, +}; + +describe("UserMenu", () => { + let container: HTMLDivElement; + let root: ReturnType; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => { + root.unmount(); + }); + container.remove(); + }); + + it("keeps a usage documentation entry in the user control area", () => { + act(() => { + root.render( + + + + ); + }); + + const docsLink = container.querySelector('a[href="/usage-doc"][aria-label="Docs"]'); + + expect(docsLink).not.toBeNull(); + expect(docsLink?.getAttribute("title")).toBe("Docs"); + }); +}); diff --git a/src/app/[locale]/dashboard/_components/user-menu.tsx b/src/app/[locale]/dashboard/_components/user-menu.tsx index 5cbec755e..613e8cee5 100644 --- a/src/app/[locale]/dashboard/_components/user-menu.tsx +++ b/src/app/[locale]/dashboard/_components/user-menu.tsx @@ -1,10 +1,10 @@ "use client"; -import { LogOut } from "lucide-react"; +import { BookOpen, LogOut } from "lucide-react"; import { useTranslations } from "next-intl"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { Button } from "@/components/ui/button"; -import { useRouter } from "@/i18n/routing"; +import { Link, useRouter } from "@/i18n/routing"; interface UserMenuProps { user: { @@ -46,6 +46,18 @@ export function UserMenu({ user }: UserMenuProps) { {user.name} + + ), +})); + +vi.mock("@/components/ui/dialog", () => ({ + DialogClose: ({ children }: any) => <>{children}, + DialogDescription: ({ children }: any) =>
{children}
, + DialogFooter: ({ children }: any) =>
{children}
, + DialogHeader: ({ children }: any) =>
{children}
, + DialogTitle: ({ children }: any) =>
{children}
, +})); + +describe("DeleteKeyConfirm error toast", () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + vi.clearAllMocks(); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + async function renderAndConfirm() { + const { DeleteKeyConfirm } = await import( + "@/app/[locale]/dashboard/_components/user/forms/delete-key-confirm" + ); + await act(async () => { + root.render(); + }); + const confirmButton = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent === "confirm" + ); + expect(confirmButton).toBeDefined(); + await act(async () => { + confirmButton?.click(); + }); + } + + test("translates errorCode instead of showing the generic problem detail", async () => { + removeKeyMock.mockResolvedValueOnce({ + ok: false, + error: "Bad request", + errorCode: "CANNOT_DELETE_LAST_KEY", + }); + + await renderAndConfirm(); + + expect(toastErrorMock).toHaveBeenCalledWith("CANNOT_DELETE_LAST_KEY"); + expect(toastErrorMock).not.toHaveBeenCalledWith("Bad request"); + }); + + test("falls back to the raw error when no errorCode is present", async () => { + removeKeyMock.mockResolvedValueOnce({ + ok: false, + error: "network down", + }); + + await renderAndConfirm(); + + expect(toastErrorMock).toHaveBeenCalledWith("network down"); + }); +}); diff --git a/tests/unit/i18n/key-created-copy.test.ts b/tests/unit/i18n/key-created-copy.test.ts new file mode 100644 index 000000000..5bcdaf7c0 --- /dev/null +++ b/tests/unit/i18n/key-created-copy.test.ts @@ -0,0 +1,95 @@ +import fs from "node:fs"; +import path from "node:path"; +import { describe, expect, test } from "vitest"; + +/** + * Regression guards for two related issues: + * + * 1. The key-creation dialogs used to claim the full key is "only shown once" / + * "cannot be viewed again", but the dashboard allows owners and admins to + * reveal and copy the full key from the key list at any time + * (getUnmaskedKey / GET /api/v1/keys/{id}:reveal). The copy must describe + * the real behavior. + * + * 2. removeKey now reports failures through dedicated error codes; the errors + * namespace must carry translations for them in every locale so REST + * clients can render the real reason instead of a generic "Bad request". + */ + +const LOCALES = ["zh-CN", "zh-TW", "en", "ja", "ru"] as const; + +const COPY_PATHS: ReadonlyArray = [ + ["keyListHeader", "keyCreatedDialog", "description"], + ["keyListHeader", "keyCreatedDialog", "warningText"], + ["addKeyForm", "generatedKey", "hint"], + ["userManagement", "createDialog", "keyHint"], +]; + +const ONE_TIME_CLAIM_PATTERNS: RegExp[] = [ + /仅显示一次/, + /无法再次查看/, + /僅顯示一次/, + /無法再次檢視/, + /only (?:be )?(?:displayed|shown) once/i, + /not be able to view this key again/i, + /一度だけ表示/, + /一度しか表示されません/, + /再度表示することはできません/, + /только один раз/i, + /не сможете снова просмотреть/i, +]; + +const REVIEWABLE_MARKERS: Record<(typeof LOCALES)[number], RegExp> = { + "zh-CN": /重新查看/, + "zh-TW": /重新檢視/, + en: /view the full key again/i, + ja: /再表示できます/, + ru: /снова просмотреть/i, +}; + +function loadMessages(locale: string, file: string): Record { + const filePath = path.join(process.cwd(), "messages", locale, file); + return JSON.parse(fs.readFileSync(filePath, "utf8")); +} + +function getString(messages: Record, keyPath: readonly string[]): string { + let value: unknown = messages; + for (const segment of keyPath) { + expect(value, `missing segment "${segment}" in ${keyPath.join(".")}`).toBeTypeOf("object"); + value = (value as Record)[segment]; + } + expect(value, `value at ${keyPath.join(".")} must be a string`).toBeTypeOf("string"); + return value as string; +} + +describe.each(LOCALES)("key creation copy (%s)", (locale) => { + const dashboard = loadMessages(locale, "dashboard.json"); + + test.each( + COPY_PATHS.map((p) => [p.join("."), p] as const) + )("%s matches the actual reveal behavior", (_label, keyPath) => { + const copy = getString(dashboard, keyPath); + + expect(copy.trim().length).toBeGreaterThan(0); + for (const pattern of ONE_TIME_CLAIM_PATTERNS) { + expect(copy).not.toMatch(pattern); + } + expect(copy).toMatch(REVIEWABLE_MARKERS[locale]); + }); +}); + +describe.each(LOCALES)("removeKey error code translations (%s)", (locale) => { + const errors = loadMessages(locale, "errors.json"); + + test.each([ + "CANNOT_DELETE_LAST_KEY", + "CANNOT_DELETE_LAST_GROUP_KEY", + ])("errors namespace translates %s", (code) => { + const value = errors[code]; + expect(value, `${locale}/errors.json must define ${code}`).toBeTypeOf("string"); + expect((value as string).trim().length).toBeGreaterThan(0); + // Must be a distinct, specific message rather than a copy of a generic one. + expect(value).not.toBe(errors.OPERATION_FAILED); + expect(value).not.toBe(errors.DELETE_FAILED); + }); +}); From 8ec16aa6c2f589af650e3c2fa71314b5ac32cb99 Mon Sep 17 00:00:00 2001 From: ding113 Date: Thu, 11 Jun 2026 13:21:02 +0800 Subject: [PATCH 03/24] feat(api): add self-service key creation for non-admin users Non-admin dashboard users previously could not create API keys because the per-user key creation route was admin-only. This adds a new read-tier POST /api/v1/users:self/keys endpoint that derives the target user from the authenticated session, rejecting read-only sessions to prevent privilege escalation. The API client now falls back to this self-service endpoint when the admin route returns 403 Forbidden. Additionally, raw REST error codes are now mapped to translation keys via getApiErrorMessageKey so frontend forms display localized messages instead of literal error code strings. --- src/app/api/v1/resources/keys/handlers.ts | 39 +++ src/app/api/v1/resources/keys/router.ts | 26 ++ src/lib/api-client/v1/actions/_compat.ts | 9 +- src/lib/api-client/v1/actions/keys.ts | 13 +- src/lib/api-client/v1/actions/users.ts | 6 +- src/lib/api-client/v1/errors.ts | 10 + src/lib/api-client/v1/openapi-types.gen.ts | 237 +++++++++++++++++++ tests/api/v1/keys/keys.self.test.ts | 153 ++++++++++++ tests/unit/api/v1/api-client-actions.test.ts | 78 +++++- tests/unit/frontend/api-error-i18n.test.ts | 26 ++ 10 files changed, 587 insertions(+), 10 deletions(-) create mode 100644 tests/api/v1/keys/keys.self.test.ts diff --git a/src/app/api/v1/resources/keys/handlers.ts b/src/app/api/v1/resources/keys/handlers.ts index b6c3b12dd..d4373c276 100644 --- a/src/app/api/v1/resources/keys/handlers.ts +++ b/src/app/api/v1/resources/keys/handlers.ts @@ -66,6 +66,45 @@ export async function createUserKey(c: Context): Promise { return createdResponse(result.data, location, { headers: withNoStoreHeaders() }); } +// NOTE(#1259): self-service write endpoint — the per-user route above is +// admin-only, so non-admin dashboard users create keys through this route. +// The target user id always comes from the authenticated session. +export async function createSelfKey(c: Context): Promise { + const auth = c.get("auth"); + const sessionUserId = auth?.session?.user?.id; + if (!sessionUserId) { + return createProblemResponse({ + status: 401, + instance: new URL(c.req.url).pathname, + errorCode: "auth.missing", + detail: "Authentication is required.", + }); + } + // Read-tier auth admits canLoginWebUi=false keys as read-only sessions; a + // key write here would let them mint a Web-UI-capable key, so reject them. + if (auth?.session?.key?.canLoginWebUi === false) { + return createProblemResponse({ + status: 403, + instance: new URL(c.req.url).pathname, + errorCode: "auth.forbidden", + detail: "Read-only sessions cannot create keys.", + }); + } + const body = await parseHonoJsonBody(c, KeyCreateSchema); + if (!body.ok) return body.response; + const actions = await import("@/actions/keys"); + const result = await callAction( + c, + actions.addKey, + [{ userId: sessionUserId, ...body.data }] as never[], + c.get("auth") + ); + if (!result.ok) return actionError(c, result); + const createdKeyId = (result.data as { id?: number }).id; + const location = createdKeyId ? `/api/v1/keys/${createdKeyId}` : "/api/v1/users:self/keys"; + return createdResponse(result.data, location, { headers: withNoStoreHeaders() }); +} + export async function getKey(c: Context): Promise { const params = parseKeyParams(c); if (params instanceof Response) return params; diff --git a/src/app/api/v1/resources/keys/router.ts b/src/app/api/v1/resources/keys/router.ts index a2166d3e9..6ad705991 100644 --- a/src/app/api/v1/resources/keys/router.ts +++ b/src/app/api/v1/resources/keys/router.ts @@ -19,6 +19,7 @@ import { } from "@/lib/api/v1/schemas/keys"; import { batchUpdateKeys, + createSelfKey, createUserKey, deleteKey, enableKey, @@ -111,6 +112,31 @@ keysRouter.openapi( createUserKey as never ); +keysRouter.openapi( + createRoute({ + method: "post", + path: "/users:self/keys", + middleware: requireAuth("read"), + tags: ["Keys"], + summary: "Create own key", + description: + "Creates a key for the current session user. The target user id always comes from the authenticated session; read-only sessions (keys without Web UI access) are rejected.", + "x-required-access": "read", + security, + request: { + body: { required: true, content: { "application/json": { schema: KeyCreateSchema } } }, + }, + responses: { + 201: { + description: "Created key.", + content: { "application/json": { schema: GenericKeyResponseSchema } }, + }, + ...problemResponses, + }, + }), + createSelfKey as never +); + // Custom-method routes (`/keys/{id}:reveal` etc.) must register before the // generic `/keys/{keyId}` CRUD routes — Hono's RegExpRouter resolves // overlapping matches in registration order, and the generic GET would diff --git a/src/lib/api-client/v1/actions/_compat.ts b/src/lib/api-client/v1/actions/_compat.ts index b5442d0d0..9ef821e00 100644 --- a/src/lib/api-client/v1/actions/_compat.ts +++ b/src/lib/api-client/v1/actions/_compat.ts @@ -1,9 +1,12 @@ "use client"; import { apiClient } from "@/lib/api-client/v1/client"; -import { ApiError } from "@/lib/api-client/v1/errors"; +import { ApiError, getApiErrorMessageKey } from "@/lib/api-client/v1/errors"; import type { ActionResult } from "./types"; +// NOTE(#1259): errorCode is pre-mapped to an errors-namespace key so forms can +// pass it straight to getErrorMessage(); raw REST codes like "auth.forbidden" +// have no translation and would render as the literal key path. export function toActionResult(promise: Promise): Promise> { return promise .then((data) => ({ ok: true as const, data }) as ActionResult) @@ -11,7 +14,7 @@ export function toActionResult(promise: Promise): Promise => ({ ok: false as const, error: error instanceof Error ? error.message : "Request failed", - errorCode: error instanceof ApiError ? error.errorCode : undefined, + errorCode: error instanceof ApiError ? getApiErrorMessageKey(error) : undefined, errorParams: error instanceof ApiError ? toActionErrorParams(error.errorParams) : undefined, }) ); @@ -24,7 +27,7 @@ export function toVoidActionResult(promise: Promise): Promise ({ ok: false as const, error: error instanceof Error ? error.message : "Request failed", - errorCode: error instanceof ApiError ? error.errorCode : undefined, + errorCode: error instanceof ApiError ? getApiErrorMessageKey(error) : undefined, errorParams: error instanceof ApiError ? toActionErrorParams(error.errorParams) : undefined, }) ); diff --git a/src/lib/api-client/v1/actions/keys.ts b/src/lib/api-client/v1/actions/keys.ts index 05b2299ff..6d7babca8 100644 --- a/src/lib/api-client/v1/actions/keys.ts +++ b/src/lib/api-client/v1/actions/keys.ts @@ -1,4 +1,5 @@ import type { BatchUpdateKeysParams, PatchKeyLimitField } from "@/actions/keys"; +import { isAdminForbidden } from "@/lib/api-client/v1/errors"; import type { Key } from "@/types/key"; import { apiDelete, @@ -15,7 +16,17 @@ export type { Key } from "@/types/key"; export function addKey(data: { userId: number } & Record) { const { userId, ...body } = data; - return toActionResult(apiPost(`/api/v1/users/${userId}/keys`, body)); + // NOTE(#1259): the per-user route is admin-only. Non-admin self-service runs + // into auth.forbidden there, so retry via the read-tier self endpoint, which + // derives the target user from the session (mirrors getUsers()'s fallback). + return toActionResult( + apiPost(`/api/v1/users/${userId}/keys`, body).catch((error: unknown) => { + if (isAdminForbidden(error)) { + return apiPost("/api/v1/users:self/keys", body); + } + throw error; + }) + ); } export function editKey(keyId: number, data: unknown) { diff --git a/src/lib/api-client/v1/actions/users.ts b/src/lib/api-client/v1/actions/users.ts index dee997699..e4da0b95b 100644 --- a/src/lib/api-client/v1/actions/users.ts +++ b/src/lib/api-client/v1/actions/users.ts @@ -1,6 +1,6 @@ import type { BatchUpdateUsersParams, GetUsersBatchParams } from "@/actions/users"; import { DASHBOARD_COMPAT_HEADER } from "@/lib/api/v1/_shared/constants"; -import { ApiError } from "@/lib/api-client/v1/errors"; +import { isAdminForbidden } from "@/lib/api-client/v1/errors"; import type { UserDisplay } from "@/types/user"; import { apiDelete, @@ -183,7 +183,3 @@ function toUserListQuery(params?: GetUsersBatchParams): string { sortOrder: params?.sortOrder, }); } - -function isAdminForbidden(error: unknown): boolean { - return error instanceof ApiError && error.status === 403 && error.errorCode === "auth.forbidden"; -} diff --git a/src/lib/api-client/v1/errors.ts b/src/lib/api-client/v1/errors.ts index 45769d3f9..de9a69ce8 100644 --- a/src/lib/api-client/v1/errors.ts +++ b/src/lib/api-client/v1/errors.ts @@ -23,6 +23,12 @@ export function isApiError(error: unknown): error is ApiError { return error instanceof ApiError; } +// Admin-only routes reject non-admin sessions with this exact pair; clients +// use it to decide whether to retry through a read-tier self endpoint. +export function isAdminForbidden(error: unknown): boolean { + return isApiError(error) && error.status === 403 && error.errorCode === "auth.forbidden"; +} + const API_ERROR_MESSAGE_KEYS: Record = { "api.error": "INTERNAL_ERROR", "api.malformed_error_body": "INTERNAL_ERROR", @@ -39,6 +45,10 @@ const API_ERROR_MESSAGE_KEYS: Record = { "provider_endpoint.action_failed": "OPERATION_FAILED", "provider_vendor.not_found": "NOT_FOUND", "provider_vendor.action_failed": "OPERATION_FAILED", + "key.not_found": "KEY_NOT_FOUND", + "key.action_failed": "OPERATION_FAILED", + "user.not_found": "USER_NOT_FOUND", + "user.action_failed": "OPERATION_FAILED", }; export function getApiErrorMessageKey(error: ApiError): string { diff --git a/src/lib/api-client/v1/openapi-types.gen.ts b/src/lib/api-client/v1/openapi-types.gen.ts index 3b3aba480..1f6ff3165 100644 --- a/src/lib/api-client/v1/openapi-types.gen.ts +++ b/src/lib/api-client/v1/openapi-types.gen.ts @@ -2468,6 +2468,26 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/users:self/keys": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Create own key + * @description Creates a key for the current session user. The target user id always comes from the authenticated session; read-only sessions (keys without Web UI access) are rejected. + */ + post: operations["postUsersSelfKeys"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/keys/{keyId}:enable": { parameters: { query?: never; @@ -32814,6 +32834,223 @@ export interface operations { }; }; }; + postUsersSelfKeys: { + parameters: { + query?: never; + header?: { + /** @description Required only when authenticating with the auth-token cookie on mutation requests. */ + "X-CCH-CSRF"?: string; + }; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** @description Key name. */ + name: string; + /** @description Expiration date or null. */ + expiresAt?: string | null; + /** @description Whether the key is enabled. */ + isEnabled?: boolean; + /** @description Whether this key can login to the Web UI. */ + canLoginWebUi?: boolean; + /** @description Five-hour USD quota. */ + limit5hUsd?: number | null; + /** + * @description Five-hour reset mode. + * @enum {string} + */ + limit5hResetMode?: "fixed" | "rolling"; + /** @description Daily USD quota. */ + limitDailyUsd?: number | null; + /** + * @description Daily reset mode. + * @enum {string} + */ + dailyResetMode?: "fixed" | "rolling"; + /** @description Daily reset time in HH:mm. */ + dailyResetTime?: string; + /** @description Weekly USD quota. */ + limitWeeklyUsd?: number | null; + /** @description Monthly USD quota. */ + limitMonthlyUsd?: number | null; + /** @description Total USD quota. */ + limitTotalUsd?: number | null; + /** @description Concurrent session limit. */ + limitConcurrentSessions?: number; + /** @description Provider group expression. */ + providerGroup?: string | null; + /** + * @description Cache TTL preference. + * @enum {string} + */ + cacheTtlPreference?: "inherit" | "5m" | "1h"; + }; + }; + }; + responses: { + /** @description Created key. */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + [key: string]: unknown; + }; + }; + }; + /** @description Invalid request. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": { + /** @description Stable problem type URI or URN. */ + type: string; + /** @description Short problem title. */ + title: string; + /** @description HTTP status code. */ + status: number; + /** @description Human-readable error detail. */ + detail: string; + /** @description Request path that produced the problem. */ + instance: string; + /** @description Application error code for frontend i18n. */ + errorCode: string; + /** @description Optional i18n parameters. */ + errorParams?: { + [key: string]: unknown; + }; + /** @description Optional request trace identifier. */ + traceId?: string; + /** @description Validation failure details. */ + invalidParams?: { + /** @description Path to the invalid input field. */ + path: (string | number)[]; + /** @description Machine-readable validation error code. */ + code: string; + /** @description Validation error message. */ + message: string; + }[]; + }; + }; + }; + /** @description Authentication required. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": { + /** @description Stable problem type URI or URN. */ + type: string; + /** @description Short problem title. */ + title: string; + /** @description HTTP status code. */ + status: number; + /** @description Human-readable error detail. */ + detail: string; + /** @description Request path that produced the problem. */ + instance: string; + /** @description Application error code for frontend i18n. */ + errorCode: string; + /** @description Optional i18n parameters. */ + errorParams?: { + [key: string]: unknown; + }; + /** @description Optional request trace identifier. */ + traceId?: string; + /** @description Validation failure details. */ + invalidParams?: { + /** @description Path to the invalid input field. */ + path: (string | number)[]; + /** @description Machine-readable validation error code. */ + code: string; + /** @description Validation error message. */ + message: string; + }[]; + }; + }; + }; + /** @description Access denied. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": { + /** @description Stable problem type URI or URN. */ + type: string; + /** @description Short problem title. */ + title: string; + /** @description HTTP status code. */ + status: number; + /** @description Human-readable error detail. */ + detail: string; + /** @description Request path that produced the problem. */ + instance: string; + /** @description Application error code for frontend i18n. */ + errorCode: string; + /** @description Optional i18n parameters. */ + errorParams?: { + [key: string]: unknown; + }; + /** @description Optional request trace identifier. */ + traceId?: string; + /** @description Validation failure details. */ + invalidParams?: { + /** @description Path to the invalid input field. */ + path: (string | number)[]; + /** @description Machine-readable validation error code. */ + code: string; + /** @description Validation error message. */ + message: string; + }[]; + }; + }; + }; + /** @description Key not found. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": { + /** @description Stable problem type URI or URN. */ + type: string; + /** @description Short problem title. */ + title: string; + /** @description HTTP status code. */ + status: number; + /** @description Human-readable error detail. */ + detail: string; + /** @description Request path that produced the problem. */ + instance: string; + /** @description Application error code for frontend i18n. */ + errorCode: string; + /** @description Optional i18n parameters. */ + errorParams?: { + [key: string]: unknown; + }; + /** @description Optional request trace identifier. */ + traceId?: string; + /** @description Validation failure details. */ + invalidParams?: { + /** @description Path to the invalid input field. */ + path: (string | number)[]; + /** @description Machine-readable validation error code. */ + code: string; + /** @description Validation error message. */ + message: string; + }[]; + }; + }; + }; + }; + }; postKeysByKeyidEnable: { parameters: { query?: never; diff --git a/tests/api/v1/keys/keys.self.test.ts b/tests/api/v1/keys/keys.self.test.ts new file mode 100644 index 000000000..000b8d3a1 --- /dev/null +++ b/tests/api/v1/keys/keys.self.test.ts @@ -0,0 +1,153 @@ +import type { AuthSession } from "@/lib/auth"; +import { beforeEach, describe, expect, test, vi } from "vitest"; + +const validateAuthTokenMock = vi.hoisted(() => vi.fn()); +const addKeyMock = vi.hoisted(() => vi.fn()); + +vi.mock("@/lib/auth", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, validateAuthToken: validateAuthTokenMock }; +}); + +vi.mock("@/actions/keys", () => ({ + addKey: addKeyMock, +})); + +const { callV1Route } = await import("../test-utils"); + +const adminSession = { + user: { id: 1, role: "admin", isEnabled: true }, + key: { id: 1, userId: 1, key: "admin-token", canLoginWebUi: true }, +} as AuthSession; + +const userSession = { + user: { id: 2, role: "user", isEnabled: true }, + key: { id: 2, userId: 2, key: "user-token", canLoginWebUi: true }, +} as AuthSession; + +// Read-tier auth admits canLoginWebUi=false keys as read-only sessions; the +// self key-creation endpoint must reject them (issue #1259 / privilege fence). +const readOnlySession = { + user: { id: 3, role: "user", isEnabled: true }, + key: { id: 3, userId: 3, key: "readonly-token", canLoginWebUi: false }, +} as AuthSession; + +describe("POST /api/v1/users:self/keys", () => { + beforeEach(() => { + vi.clearAllMocks(); + addKeyMock.mockResolvedValue({ + ok: true, + data: { id: 77, generatedKey: "sk-new", name: "self-key" }, + }); + }); + + test("creates a key for the session user without admin access", async () => { + validateAuthTokenMock.mockResolvedValue(userSession); + + const created = await callV1Route({ + method: "POST", + pathname: "/api/v1/users:self/keys", + headers: { Authorization: "Bearer user-token" }, + body: { name: "self-key", providerGroup: "default" }, + }); + + expect(created.response.status).toBe(201); + expect(created.response.headers.get("Location")).toBe("/api/v1/keys/77"); + expect(created.response.headers.get("Cache-Control")).toContain("no-store"); + expect(addKeyMock).toHaveBeenCalledWith({ + userId: 2, + name: "self-key", + providerGroup: "default", + }); + }); + + test("derives the target user from the session for admins too", async () => { + validateAuthTokenMock.mockResolvedValue(adminSession); + + const created = await callV1Route({ + method: "POST", + pathname: "/api/v1/users:self/keys", + headers: { Authorization: "Bearer admin-token" }, + body: { name: "self-key" }, + }); + + expect(created.response.status).toBe(201); + expect(addKeyMock).toHaveBeenCalledWith({ userId: 1, name: "self-key" }); + }); + + test("rejects body attempts to choose another user id via the strict schema", async () => { + validateAuthTokenMock.mockResolvedValue(userSession); + + const response = await callV1Route({ + method: "POST", + pathname: "/api/v1/users:self/keys", + headers: { Authorization: "Bearer user-token" }, + body: { name: "self-key", userId: 999 }, + }); + + expect(response.response.status).toBe(400); + expect(response.json).toMatchObject({ errorCode: "request.validation_failed" }); + expect(addKeyMock).not.toHaveBeenCalled(); + }); + + test("rejects read-only sessions that cannot log into the Web UI", async () => { + validateAuthTokenMock.mockResolvedValue(readOnlySession); + + const response = await callV1Route({ + method: "POST", + pathname: "/api/v1/users:self/keys", + headers: { Authorization: "Bearer readonly-token" }, + body: { name: "escalation-attempt" }, + }); + + expect(response.response.status).toBe(403); + expect(response.json).toMatchObject({ errorCode: "auth.forbidden" }); + expect(addKeyMock).not.toHaveBeenCalled(); + }); + + test("requires authentication", async () => { + const response = await callV1Route({ + method: "POST", + pathname: "/api/v1/users:self/keys", + body: { name: "anonymous" }, + }); + + expect(response.response.status).toBe(401); + expect(response.json).toMatchObject({ errorCode: "auth.missing" }); + expect(addKeyMock).not.toHaveBeenCalled(); + }); + + test("surfaces action failures through the problem envelope", async () => { + validateAuthTokenMock.mockResolvedValue(userSession); + addKeyMock.mockResolvedValue({ + ok: false, + error: "duplicate name", + errorCode: "DUPLICATE_NAME", + }); + + const response = await callV1Route({ + method: "POST", + pathname: "/api/v1/users:self/keys", + headers: { Authorization: "Bearer user-token" }, + body: { name: "self-key" }, + }); + + expect(response.response.status).toBe(400); + expect(response.json).toMatchObject({ errorCode: "DUPLICATE_NAME" }); + }); + + test("keeps the per-user admin route admin-only", async () => { + validateAuthTokenMock.mockResolvedValue(userSession); + + const response = await callV1Route({ + method: "POST", + pathname: "/api/v1/users/2/keys", + headers: { Authorization: "Bearer user-token" }, + body: { name: "self-key" }, + }); + + expect(response.response.status).toBe(403); + expect(response.json).toMatchObject({ errorCode: "auth.forbidden" }); + expect(addKeyMock).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/api/v1/api-client-actions.test.ts b/tests/unit/api/v1/api-client-actions.test.ts index 74c285d9f..9650c350e 100644 --- a/tests/unit/api/v1/api-client-actions.test.ts +++ b/tests/unit/api/v1/api-client-actions.test.ts @@ -33,6 +33,9 @@ const providerEndpoints = await vi.importActual< const usageLogs = await vi.importActual( "@/lib/api-client/v1/actions/usage-logs" ); +const keys = await vi.importActual( + "@/lib/api-client/v1/actions/keys" +); describe("v1 action compatibility client", () => { beforeEach(() => { @@ -408,10 +411,83 @@ describe("v1 action compatibility client", () => { const result = await providers.getProviderGroupsWithCount(); + // errorCode must arrive pre-mapped to an errors-namespace key so forms can + // translate it directly (issue #1259: raw "auth.forbidden" rendered as the + // literal fallback string "errors.auth.forbidden"). expect(result).toEqual({ ok: false, error: "Admin access is required.", - errorCode: "auth.forbidden", + errorCode: "PERMISSION_DENIED", + errorParams: undefined, + }); + }); + + test("falls back to the self key-creation endpoint for non-admin key creation", async () => { + postMock + .mockRejectedValueOnce( + new ApiError({ + status: 403, + errorCode: "auth.forbidden", + detail: "Admin access is required.", + }) + ) + .mockResolvedValueOnce({ id: 77, generatedKey: "sk-new", name: "self-key" }); + + const result = await keys.addKey({ userId: 2, name: "self-key", providerGroup: "default" }); + + expect(postMock).toHaveBeenNthCalledWith( + 1, + "/api/v1/users/2/keys", + { name: "self-key", providerGroup: "default" }, + undefined + ); + expect(postMock).toHaveBeenNthCalledWith( + 2, + "/api/v1/users:self/keys", + { name: "self-key", providerGroup: "default" }, + undefined + ); + expect(result).toEqual({ + ok: true, + data: { id: 77, generatedKey: "sk-new", name: "self-key" }, + }); + }); + + test("does not retry key creation for non-authorization failures", async () => { + postMock.mockRejectedValue( + new ApiError({ + status: 400, + errorCode: "DUPLICATE_NAME", + detail: "Key name already exists.", + }) + ); + + const result = await keys.addKey({ userId: 2, name: "self-key" }); + + expect(postMock).toHaveBeenCalledTimes(1); + expect(result).toEqual({ + ok: false, + error: "Key name already exists.", + errorCode: "DUPLICATE_NAME", + errorParams: undefined, + }); + }); + + test("maps resource action_failed codes to translatable error codes", async () => { + getMock.mockRejectedValue( + new ApiError({ + status: 400, + errorCode: "key.action_failed", + detail: "Bad request", + }) + ); + + const result = await keys.getKeys(2); + + expect(result).toEqual({ + ok: false, + error: "Bad request", + errorCode: "OPERATION_FAILED", errorParams: undefined, }); }); diff --git a/tests/unit/frontend/api-error-i18n.test.ts b/tests/unit/frontend/api-error-i18n.test.ts index 731f5ed04..f77a20c1a 100644 --- a/tests/unit/frontend/api-error-i18n.test.ts +++ b/tests/unit/frontend/api-error-i18n.test.ts @@ -58,4 +58,30 @@ describe("v1 API error i18n mapping", () => { ) ).toBe("OPERATION_FAILED"); }); + + test("maps key and user REST codes to existing translation keys", () => { + expect( + getApiErrorMessageKey( + new ApiError({ status: 404, errorCode: "key.not_found", detail: "Not found" }) + ) + ).toBe("KEY_NOT_FOUND"); + + expect( + getApiErrorMessageKey( + new ApiError({ status: 400, errorCode: "key.action_failed", detail: "Bad request" }) + ) + ).toBe("OPERATION_FAILED"); + + expect( + getApiErrorMessageKey( + new ApiError({ status: 404, errorCode: "user.not_found", detail: "Not found" }) + ) + ).toBe("USER_NOT_FOUND"); + + expect( + getApiErrorMessageKey( + new ApiError({ status: 400, errorCode: "user.action_failed", detail: "Bad request" }) + ) + ).toBe("OPERATION_FAILED"); + }); }); From f70b7832c9b4310e53911c6200f23c2411491a29 Mon Sep 17 00:00:00 2001 From: ding113 Date: Thu, 11 Jun 2026 13:49:09 +0800 Subject: [PATCH 04/24] feat(proxy): add thinking effort conflict rectifier When Anthropic-compatible providers like DeepSeek or MiMo receive a request with thinking disabled but reasoning_effort or output_config.effort set, they return a 400 error. This rectifier detects the specific error message, strips the conflicting effort fields while preserving the disabled thinking state, and retries the request once against the same provider. Adds a new system setting enableThinkingEffortConflictRectifier (enabled by default) with full UI, API, and database migration support to control this behavior. --- drizzle/0105_chief_rocket_racer.sql | 1 + drizzle/meta/0105_snapshot.json | 4535 +++++++++++++++++ drizzle/meta/_journal.json | 7 + messages/en/settings/config.json | 2 + messages/ja/settings/config.json | 2 + messages/ru/settings/config.json | 2 + messages/zh-CN/settings/config.json | 2 + messages/zh-TW/settings/config.json | 2 + package.json | 1 + src/actions/system-config.ts | 2 + .../_components/system-settings-form.tsx | 28 + src/app/[locale]/settings/config/page.tsx | 1 + src/app/v1/_lib/proxy/forwarder.ts | 93 +- ...thinking-effort-conflict-rectifier.test.ts | 158 + .../thinking-effort-conflict-rectifier.ts | 113 + src/drizzle/schema.ts | 7 + src/lib/api-client/v1/openapi-types.gen.ts | 6 + src/lib/api/v1/schemas/system-config.ts | 3 + src/lib/config/system-settings-cache.ts | 3 + src/lib/utils/special-settings.ts | 13 + src/lib/validation/schemas.ts | 2 + src/repository/_shared/transformers.ts | 2 + src/repository/system-config.ts | 10 + src/types/special-settings.ts | 23 + src/types/system-config.ts | 5 + ...inking-effort-conflict-rectifier.config.ts | 12 + ...g-thinking-effort-conflict-setting.test.ts | 39 + ...thinking-effort-conflict-rectifier.test.ts | 253 + 28 files changed, 5319 insertions(+), 8 deletions(-) create mode 100644 drizzle/0105_chief_rocket_racer.sql create mode 100644 drizzle/meta/0105_snapshot.json create mode 100644 src/app/v1/_lib/proxy/thinking-effort-conflict-rectifier.test.ts create mode 100644 src/app/v1/_lib/proxy/thinking-effort-conflict-rectifier.ts create mode 100644 tests/configs/thinking-effort-conflict-rectifier.config.ts create mode 100644 tests/unit/actions/system-config-thinking-effort-conflict-setting.test.ts create mode 100644 tests/unit/proxy/proxy-forwarder-thinking-effort-conflict-rectifier.test.ts diff --git a/drizzle/0105_chief_rocket_racer.sql b/drizzle/0105_chief_rocket_racer.sql new file mode 100644 index 000000000..14aa3e620 --- /dev/null +++ b/drizzle/0105_chief_rocket_racer.sql @@ -0,0 +1 @@ +ALTER TABLE "system_settings" ADD COLUMN "enable_thinking_effort_conflict_rectifier" boolean DEFAULT true NOT NULL; \ No newline at end of file diff --git a/drizzle/meta/0105_snapshot.json b/drizzle/meta/0105_snapshot.json new file mode 100644 index 000000000..41d3c35e7 --- /dev/null +++ b/drizzle/meta/0105_snapshot.json @@ -0,0 +1,4535 @@ +{ + "id": "e61c4e85-4edf-4d73-b2e3-5326af94eba0", + "prevId": "e287317d-0fc7-4491-960d-b22636dc9471", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "action_category": { + "name": "action_category", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "action_type": { + "name": "action_type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false + }, + "target_id": { + "name": "target_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "target_name": { + "name": "target_name", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "before_value": { + "name": "before_value", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "after_value": { + "name": "after_value", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "operator_user_id": { + "name": "operator_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "operator_user_name": { + "name": "operator_user_name", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "operator_key_id": { + "name": "operator_key_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "operator_key_name": { + "name": "operator_key_name", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "operator_ip": { + "name": "operator_ip", + "type": "varchar(45)", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "success": { + "name": "success", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_audit_log_category_created_at": { + "name": "idx_audit_log_category_created_at", + "columns": [ + { + "expression": "action_category", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_audit_log_operator_user_created_at": { + "name": "idx_audit_log_operator_user_created_at", + "columns": [ + { + "expression": "operator_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"audit_log\".\"operator_user_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_audit_log_operator_ip_created_at": { + "name": "idx_audit_log_operator_ip_created_at", + "columns": [ + { + "expression": "operator_ip", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"audit_log\".\"operator_ip\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_audit_log_target": { + "name": "idx_audit_log_target", + "columns": [ + { + "expression": "target_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"audit_log\".\"target_type\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_audit_log_created_at_id": { + "name": "idx_audit_log_created_at_id", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.error_rules": { + "name": "error_rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "pattern": { + "name": "pattern", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "match_type": { + "name": "match_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'regex'" + }, + "category": { + "name": "category", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "override_response": { + "name": "override_response", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "override_status_code": { + "name": "override_status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_error_rules_enabled": { + "name": "idx_error_rules_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "unique_pattern": { + "name": "unique_pattern", + "columns": [ + { + "expression": "pattern", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_category": { + "name": "idx_category", + "columns": [ + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_match_type": { + "name": "idx_match_type", + "columns": [ + { + "expression": "match_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.keys": { + "name": "keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "can_login_web_ui": { + "name": "can_login_web_ui", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "limit_5h_usd": { + "name": "limit_5h_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_5h_reset_mode": { + "name": "limit_5h_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'rolling'" + }, + "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 + }, + "cost_reset_at": { + "name": "cost_reset_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "limit_concurrent_sessions": { + "name": "limit_concurrent_sessions", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "provider_group": { + "name": "provider_group", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false, + "default": "'default'" + }, + "cache_ttl_preference": { + "name": "cache_ttl_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_keys_user_id": { + "name": "idx_keys_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_keys_key": { + "name": "idx_keys_key", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_keys_created_at": { + "name": "idx_keys_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_keys_deleted_at": { + "name": "idx_keys_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.message_request": { + "name": "message_request", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cost_usd": { + "name": "cost_usd", + "type": "numeric(21, 15)", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "cost_multiplier": { + "name": "cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false + }, + "group_cost_multiplier": { + "name": "group_cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false + }, + "cost_breakdown": { + "name": "cost_breakdown", + "type": "jsonb", + "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 + }, + "actual_response_model": { + "name": "actual_response_model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "input_tokens": { + "name": "input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "output_tokens": { + "name": "output_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "ttfb_ms": { + "name": "ttfb_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cache_creation_input_tokens": { + "name": "cache_creation_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_read_input_tokens": { + "name": "cache_read_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_creation_5m_input_tokens": { + "name": "cache_creation_5m_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_creation_1h_input_tokens": { + "name": "cache_creation_1h_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_ttl_applied": { + "name": "cache_ttl_applied", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "context_1m_applied": { + "name": "context_1m_applied", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "swap_cache_ttl_applied": { + "name": "swap_cache_ttl_applied", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "special_settings": { + "name": "special_settings", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "hedge_losers": { + "name": "hedge_losers", + "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 + }, + "client_ip": { + "name": "client_ip", + "type": "varchar(45)", + "primaryKey": false, + "notNull": false + }, + "messages_count": { + "name": "messages_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_message_request_user_date_cost": { + "name": "idx_message_request_user_date_cost", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_usd", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_user_created_at_cost_stats": { + "name": "idx_message_request_user_created_at_cost_stats", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_usd", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_user_query": { + "name": "idx_message_request_user_query", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_provider_created_at_active": { + "name": "idx_message_request_provider_created_at_active", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_provider_created_at_finalized_active": { + "name": "idx_message_request_provider_created_at_finalized_active", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"status_code\" IS NOT 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_id_prefix": { + "name": "idx_message_request_session_id_prefix", + "columns": [ + { + "expression": "\"session_id\" varchar_pattern_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_session_seq": { + "name": "idx_message_request_session_seq", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "request_sequence", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_endpoint": { + "name": "idx_message_request_endpoint", + "columns": [ + { + "expression": "endpoint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_blocked_by": { + "name": "idx_message_request_blocked_by", + "columns": [ + { + "expression": "blocked_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_provider_id": { + "name": "idx_message_request_provider_id", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_user_id": { + "name": "idx_message_request_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key": { + "name": "idx_message_request_key", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key_created_at_id": { + "name": "idx_message_request_key_created_at_id", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key_model_active": { + "name": "idx_message_request_key_model_active", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"model\" IS NOT NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key_endpoint_active": { + "name": "idx_message_request_key_endpoint_active", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "endpoint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"endpoint\" IS NOT NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_created_at_id_active": { + "name": "idx_message_request_created_at_id_active", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_model_active": { + "name": "idx_message_request_model_active", + "columns": [ + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"model\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_status_code_active": { + "name": "idx_message_request_status_code_active", + "columns": [ + { + "expression": "status_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"status_code\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_created_at": { + "name": "idx_message_request_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_deleted_at": { + "name": "idx_message_request_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key_last_active": { + "name": "idx_message_request_key_last_active", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key_cost_active": { + "name": "idx_message_request_key_cost_active", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_usd", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_session_user_info": { + "name": "idx_message_request_session_user_info", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_client_ip_created_at": { + "name": "idx_message_request_client_ip_created_at", + "columns": [ + { + "expression": "client_ip", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"client_ip\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.model_prices": { + "name": "model_prices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "model_name": { + "name": "model_name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "price_data": { + "name": "price_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'litellm'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_model_prices_latest": { + "name": "idx_model_prices_latest", + "columns": [ + { + "expression": "model_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_model_prices_model_name": { + "name": "idx_model_prices_model_name", + "columns": [ + { + "expression": "model_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_model_prices_created_at": { + "name": "idx_model_prices_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_model_prices_source": { + "name": "idx_model_prices_source", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_settings": { + "name": "notification_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "use_legacy_mode": { + "name": "use_legacy_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "circuit_breaker_enabled": { + "name": "circuit_breaker_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "circuit_breaker_webhook": { + "name": "circuit_breaker_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "daily_leaderboard_enabled": { + "name": "daily_leaderboard_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "daily_leaderboard_webhook": { + "name": "daily_leaderboard_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "daily_leaderboard_time": { + "name": "daily_leaderboard_time", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false, + "default": "'09:00'" + }, + "daily_leaderboard_top_n": { + "name": "daily_leaderboard_top_n", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 5 + }, + "cost_alert_enabled": { + "name": "cost_alert_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cost_alert_webhook": { + "name": "cost_alert_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "cost_alert_threshold": { + "name": "cost_alert_threshold", + "type": "numeric(5, 2)", + "primaryKey": false, + "notNull": false, + "default": "'0.80'" + }, + "cost_alert_check_interval": { + "name": "cost_alert_check_interval", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 60 + }, + "cache_hit_rate_alert_enabled": { + "name": "cache_hit_rate_alert_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cache_hit_rate_alert_webhook": { + "name": "cache_hit_rate_alert_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "cache_hit_rate_alert_window_mode": { + "name": "cache_hit_rate_alert_window_mode", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false, + "default": "'auto'" + }, + "cache_hit_rate_alert_check_interval": { + "name": "cache_hit_rate_alert_check_interval", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 5 + }, + "cache_hit_rate_alert_historical_lookback_days": { + "name": "cache_hit_rate_alert_historical_lookback_days", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 7 + }, + "cache_hit_rate_alert_min_eligible_requests": { + "name": "cache_hit_rate_alert_min_eligible_requests", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 20 + }, + "cache_hit_rate_alert_min_eligible_tokens": { + "name": "cache_hit_rate_alert_min_eligible_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "cache_hit_rate_alert_abs_min": { + "name": "cache_hit_rate_alert_abs_min", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.05'" + }, + "cache_hit_rate_alert_drop_rel": { + "name": "cache_hit_rate_alert_drop_rel", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.3'" + }, + "cache_hit_rate_alert_drop_abs": { + "name": "cache_hit_rate_alert_drop_abs", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.1'" + }, + "cache_hit_rate_alert_cooldown_minutes": { + "name": "cache_hit_rate_alert_cooldown_minutes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30 + }, + "cache_hit_rate_alert_top_n": { + "name": "cache_hit_rate_alert_top_n", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 10 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_target_bindings": { + "name": "notification_target_bindings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "notification_type": { + "name": "notification_type", + "type": "notification_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "schedule_cron": { + "name": "schedule_cron", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "schedule_timezone": { + "name": "schedule_timezone", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "template_override": { + "name": "template_override", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "unique_notification_target_binding": { + "name": "unique_notification_target_binding", + "columns": [ + { + "expression": "notification_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_notification_bindings_type": { + "name": "idx_notification_bindings_type", + "columns": [ + { + "expression": "notification_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_notification_bindings_target": { + "name": "idx_notification_bindings_target", + "columns": [ + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notification_target_bindings_target_id_webhook_targets_id_fk": { + "name": "notification_target_bindings_target_id_webhook_targets_id_fk", + "tableFrom": "notification_target_bindings", + "tableTo": "webhook_targets", + "columnsFrom": [ + "target_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_endpoint_probe_logs": { + "name": "provider_endpoint_probe_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "endpoint_id": { + "name": "endpoint_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'scheduled'" + }, + "ok": { + "name": "ok", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "latency_ms": { + "name": "latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "error_type": { + "name": "error_type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_provider_endpoint_probe_logs_endpoint_created_at": { + "name": "idx_provider_endpoint_probe_logs_endpoint_created_at", + "columns": [ + { + "expression": "endpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoint_probe_logs_created_at": { + "name": "idx_provider_endpoint_probe_logs_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "provider_endpoint_probe_logs_endpoint_id_provider_endpoints_id_fk": { + "name": "provider_endpoint_probe_logs_endpoint_id_provider_endpoints_id_fk", + "tableFrom": "provider_endpoint_probe_logs", + "tableTo": "provider_endpoints", + "columnsFrom": [ + "endpoint_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_endpoints": { + "name": "provider_endpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "vendor_id": { + "name": "vendor_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "provider_type": { + "name": "provider_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'claude'" + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_probed_at": { + "name": "last_probed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_probe_ok": { + "name": "last_probe_ok", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "last_probe_status_code": { + "name": "last_probe_status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_probe_latency_ms": { + "name": "last_probe_latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_probe_error_type": { + "name": "last_probe_error_type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "last_probe_error_message": { + "name": "last_probe_error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "uniq_provider_endpoints_vendor_type_url": { + "name": "uniq_provider_endpoints_vendor_type_url", + "columns": [ + { + "expression": "vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"provider_endpoints\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_vendor_type": { + "name": "idx_provider_endpoints_vendor_type", + "columns": [ + { + "expression": "vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"provider_endpoints\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_enabled": { + "name": "idx_provider_endpoints_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"provider_endpoints\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_pick_enabled": { + "name": "idx_provider_endpoints_pick_enabled", + "columns": [ + { + "expression": "vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"provider_endpoints\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_created_at": { + "name": "idx_provider_endpoints_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_deleted_at": { + "name": "idx_provider_endpoints_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "provider_endpoints_vendor_id_provider_vendors_id_fk": { + "name": "provider_endpoints_vendor_id_provider_vendors_id_fk", + "tableFrom": "provider_endpoints", + "tableTo": "provider_vendors", + "columnsFrom": [ + "vendor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_groups": { + "name": "provider_groups", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "cost_multiplier": { + "name": "cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": true, + "default": "'1.0'" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "provider_groups_name_unique": { + "name": "provider_groups_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_vendors": { + "name": "provider_vendors", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "website_domain": { + "name": "website_domain", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "website_url": { + "name": "website_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "favicon_url": { + "name": "favicon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "uniq_provider_vendors_website_domain": { + "name": "uniq_provider_vendors_website_domain", + "columns": [ + { + "expression": "website_domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_vendors_created_at": { + "name": "idx_provider_vendors_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.providers": { + "name": "providers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "provider_vendor_id": { + "name": "provider_vendor_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "weight": { + "name": "weight", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "group_priorities": { + "name": "group_priorities", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "cost_multiplier": { + "name": "cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false, + "default": "'1.0'" + }, + "group_tag": { + "name": "group_tag", + "type": "varchar(255)", + "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 + }, + "disable_session_reuse": { + "name": "disable_session_reuse", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "model_redirects": { + "name": "model_redirects", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "allowed_models": { + "name": "allowed_models", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "allowed_clients": { + "name": "allowed_clients", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "blocked_clients": { + "name": "blocked_clients", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "active_time_start": { + "name": "active_time_start", + "type": "varchar(5)", + "primaryKey": false, + "notNull": false + }, + "active_time_end": { + "name": "active_time_end", + "type": "varchar(5)", + "primaryKey": false, + "notNull": false + }, + "codex_instructions_strategy": { + "name": "codex_instructions_strategy", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "default": "'auto'" + }, + "mcp_passthrough_type": { + "name": "mcp_passthrough_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "mcp_passthrough_url": { + "name": "mcp_passthrough_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "limit_5h_usd": { + "name": "limit_5h_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_5h_reset_mode": { + "name": "limit_5h_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'rolling'" + }, + "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 + }, + "custom_headers": { + "name": "custom_headers", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "first_byte_timeout_streaming_ms": { + "name": "first_byte_timeout_streaming_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "streaming_idle_timeout_ms": { + "name": "streaming_idle_timeout_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "request_timeout_non_streaming_ms": { + "name": "request_timeout_non_streaming_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "website_url": { + "name": "website_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "favicon_url": { + "name": "favicon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cache_ttl_preference": { + "name": "cache_ttl_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "swap_cache_ttl_billing": { + "name": "swap_cache_ttl_billing", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "context_1m_preference": { + "name": "context_1m_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "codex_reasoning_effort_preference": { + "name": "codex_reasoning_effort_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "codex_reasoning_summary_preference": { + "name": "codex_reasoning_summary_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "codex_text_verbosity_preference": { + "name": "codex_text_verbosity_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "codex_parallel_tool_calls_preference": { + "name": "codex_parallel_tool_calls_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "codex_service_tier_preference": { + "name": "codex_service_tier_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "anthropic_max_tokens_preference": { + "name": "anthropic_max_tokens_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "anthropic_thinking_budget_preference": { + "name": "anthropic_thinking_budget_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "anthropic_adaptive_thinking": { + "name": "anthropic_adaptive_thinking", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "gemini_google_search_preference": { + "name": "gemini_google_search_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "tpm": { + "name": "tpm", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "rpm": { + "name": "rpm", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "rpd": { + "name": "rpd", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "cc": { + "name": "cc", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_providers_enabled_priority": { + "name": "idx_providers_enabled_priority", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "weight", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_group": { + "name": "idx_providers_group", + "columns": [ + { + "expression": "group_tag", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_vendor_type_url_active": { + "name": "idx_providers_vendor_type_url_active", + "columns": [ + { + "expression": "provider_vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_created_at": { + "name": "idx_providers_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_deleted_at": { + "name": "idx_providers_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_vendor_type": { + "name": "idx_providers_vendor_type", + "columns": [ + { + "expression": "provider_vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_enabled_vendor_type": { + "name": "idx_providers_enabled_vendor_type", + "columns": [ + { + "expression": "provider_vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL AND \"providers\".\"is_enabled\" = true AND \"providers\".\"provider_vendor_id\" IS NOT NULL AND \"providers\".\"provider_vendor_id\" > 0", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "providers_provider_vendor_id_provider_vendors_id_fk": { + "name": "providers_provider_vendor_id_provider_vendors_id_fk", + "tableFrom": "providers", + "tableTo": "provider_vendors", + "columnsFrom": [ + "provider_vendor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.request_filters": { + "name": "request_filters", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true + }, + "match_type": { + "name": "match_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "target": { + "name": "target", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "replacement": { + "name": "replacement", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "binding_type": { + "name": "binding_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'global'" + }, + "provider_ids": { + "name": "provider_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "group_tags": { + "name": "group_tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "rule_mode": { + "name": "rule_mode", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'simple'" + }, + "execution_phase": { + "name": "execution_phase", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'guard'" + }, + "operations": { + "name": "operations", + "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": {} + }, + "idx_request_filters_phase": { + "name": "idx_request_filters_phase", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_phase", + "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'" + }, + "codex_priority_billing_source": { + "name": "codex_priority_billing_source", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'requested'" + }, + "bill_non_successful_requests": { + "name": "bill_non_successful_requests", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "bill_hedge_losers": { + "name": "bill_hedge_losers", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "timezone": { + "name": "timezone", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "enable_auto_cleanup": { + "name": "enable_auto_cleanup", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "cleanup_retention_days": { + "name": "cleanup_retention_days", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30 + }, + "cleanup_schedule": { + "name": "cleanup_schedule", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "default": "'0 2 * * *'" + }, + "cleanup_batch_size": { + "name": "cleanup_batch_size", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 10000 + }, + "enable_client_version_check": { + "name": "enable_client_version_check", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "verbose_provider_error": { + "name": "verbose_provider_error", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "pass_through_upstream_error_message": { + "name": "pass_through_upstream_error_message", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_http2": { + "name": "enable_http2", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enable_openai_responses_websocket": { + "name": "enable_openai_responses_websocket", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_high_concurrency_mode": { + "name": "enable_high_concurrency_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "intercept_anthropic_warmup_requests": { + "name": "intercept_anthropic_warmup_requests", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enable_thinking_signature_rectifier": { + "name": "enable_thinking_signature_rectifier", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_thinking_budget_rectifier": { + "name": "enable_thinking_budget_rectifier", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_thinking_effort_conflict_rectifier": { + "name": "enable_thinking_effort_conflict_rectifier", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_billing_header_rectifier": { + "name": "enable_billing_header_rectifier", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_response_input_rectifier": { + "name": "enable_response_input_rectifier", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "allow_non_conversation_endpoint_provider_fallback": { + "name": "allow_non_conversation_endpoint_provider_fallback", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "fake_streaming_whitelist": { + "name": "fake_streaming_whitelist", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "enable_codex_session_id_completion": { + "name": "enable_codex_session_id_completion", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_claude_metadata_user_id_injection": { + "name": "enable_claude_metadata_user_id_injection", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_response_fixer": { + "name": "enable_response_fixer", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "response_fixer_config": { + "name": "response_fixer_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{\"fixTruncatedJson\":true,\"fixSseFormat\":true,\"fixEncoding\":true,\"maxJsonDepth\":200,\"maxFixSize\":1048576}'::jsonb" + }, + "quota_db_refresh_interval_seconds": { + "name": "quota_db_refresh_interval_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 10 + }, + "quota_lease_percent_5h": { + "name": "quota_lease_percent_5h", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.05'" + }, + "quota_lease_percent_daily": { + "name": "quota_lease_percent_daily", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.05'" + }, + "quota_lease_percent_weekly": { + "name": "quota_lease_percent_weekly", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.05'" + }, + "quota_lease_percent_monthly": { + "name": "quota_lease_percent_monthly", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.05'" + }, + "quota_lease_cap_usd": { + "name": "quota_lease_cap_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "ip_extraction_config": { + "name": "ip_extraction_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ip_geo_lookup_enabled": { + "name": "ip_geo_lookup_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "public_status_window_hours": { + "name": "public_status_window_hours", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 24 + }, + "public_status_aggregation_interval_minutes": { + "name": "public_status_aggregation_interval_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 5 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.usage_ledger": { + "name": "usage_ledger", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "final_provider_id": { + "name": "final_provider_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "original_model": { + "name": "original_model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "actual_response_model": { + "name": "actual_response_model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "endpoint": { + "name": "endpoint", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "api_type": { + "name": "api_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_success": { + "name": "is_success", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "success_rate_outcome": { + "name": "success_rate_outcome", + "type": "varchar(16)", + "primaryKey": false, + "notNull": false + }, + "blocked_by": { + "name": "blocked_by", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "cost_usd": { + "name": "cost_usd", + "type": "numeric(21, 15)", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "cost_multiplier": { + "name": "cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false + }, + "group_cost_multiplier": { + "name": "group_cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false + }, + "input_tokens": { + "name": "input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "output_tokens": { + "name": "output_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_creation_input_tokens": { + "name": "cache_creation_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_read_input_tokens": { + "name": "cache_read_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_creation_5m_input_tokens": { + "name": "cache_creation_5m_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_creation_1h_input_tokens": { + "name": "cache_creation_1h_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_ttl_applied": { + "name": "cache_ttl_applied", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "context_1m_applied": { + "name": "context_1m_applied", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "swap_cache_ttl_applied": { + "name": "swap_cache_ttl_applied", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "ttfb_ms": { + "name": "ttfb_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "client_ip": { + "name": "client_ip", + "type": "varchar(45)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_usage_ledger_request_id": { + "name": "idx_usage_ledger_request_id", + "columns": [ + { + "expression": "request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_user_created_at": { + "name": "idx_usage_ledger_user_created_at", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"blocked_by\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_key_created_at": { + "name": "idx_usage_ledger_key_created_at", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"blocked_by\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_provider_created_at": { + "name": "idx_usage_ledger_provider_created_at", + "columns": [ + { + "expression": "final_provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"blocked_by\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_created_at_minute": { + "name": "idx_usage_ledger_created_at_minute", + "columns": [ + { + "expression": "date_trunc('minute', \"created_at\" AT TIME ZONE 'UTC')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_created_at_desc_id": { + "name": "idx_usage_ledger_created_at_desc_id", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_session_id": { + "name": "idx_usage_ledger_session_id", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"session_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_model": { + "name": "idx_usage_ledger_model", + "columns": [ + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"model\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_key_cost": { + "name": "idx_usage_ledger_key_cost", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_usd", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "endpoint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"blocked_by\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_user_cost_cover": { + "name": "idx_usage_ledger_user_cost_cover", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_usd", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "endpoint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"blocked_by\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_provider_cost_cover": { + "name": "idx_usage_ledger_provider_cost_cover", + "columns": [ + { + "expression": "final_provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_usd", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "endpoint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"blocked_by\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_key_created_at_desc_cover": { + "name": "idx_usage_ledger_key_created_at_desc_cover", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"created_at\" DESC NULLS LAST", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "final_provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"blocked_by\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "varchar", + "primaryKey": false, + "notNull": false, + "default": "'user'" + }, + "rpm_limit": { + "name": "rpm_limit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "daily_limit_usd": { + "name": "daily_limit_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "provider_group": { + "name": "provider_group", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false, + "default": "'default'" + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "limit_5h_usd": { + "name": "limit_5h_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_5h_reset_mode": { + "name": "limit_5h_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'rolling'" + }, + "limit_weekly_usd": { + "name": "limit_weekly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_monthly_usd": { + "name": "limit_monthly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_total_usd": { + "name": "limit_total_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "cost_reset_at": { + "name": "cost_reset_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "limit_5h_cost_reset_at": { + "name": "limit_5h_cost_reset_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "limit_concurrent_sessions": { + "name": "limit_concurrent_sessions", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "daily_reset_mode": { + "name": "daily_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "daily_reset_time": { + "name": "daily_reset_time", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'00:00'" + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "allowed_clients": { + "name": "allowed_clients", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "allowed_models": { + "name": "allowed_models", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "blocked_clients": { + "name": "blocked_clients", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_users_active_role_sort": { + "name": "idx_users_active_role_sort", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"users\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_users_enabled_expires_at": { + "name": "idx_users_enabled_expires_at", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"users\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_users_tags_gin": { + "name": "idx_users_tags_gin", + "columns": [ + { + "expression": "tags", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"users\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_users_created_at": { + "name": "idx_users_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_users_deleted_at": { + "name": "idx_users_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook_targets": { + "name": "webhook_targets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "provider_type": { + "name": "provider_type", + "type": "webhook_provider_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "webhook_url": { + "name": "webhook_url", + "type": "varchar(1024)", + "primaryKey": false, + "notNull": false + }, + "telegram_bot_token": { + "name": "telegram_bot_token", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "telegram_chat_id": { + "name": "telegram_chat_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "dingtalk_secret": { + "name": "dingtalk_secret", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "custom_template": { + "name": "custom_template", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "custom_headers": { + "name": "custom_headers", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "proxy_url": { + "name": "proxy_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "proxy_fallback_to_direct": { + "name": "proxy_fallback_to_direct", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_test_at": { + "name": "last_test_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_test_result": { + "name": "last_test_result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.daily_reset_mode": { + "name": "daily_reset_mode", + "schema": "public", + "values": [ + "fixed", + "rolling" + ] + }, + "public.notification_type": { + "name": "notification_type", + "schema": "public", + "values": [ + "circuit_breaker", + "daily_leaderboard", + "cost_alert", + "cache_hit_rate_alert" + ] + }, + "public.webhook_provider_type": { + "name": "webhook_provider_type", + "schema": "public", + "values": [ + "wechat", + "feishu", + "dingtalk", + "telegram", + "custom" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 7d08f0bbd..4d57a874a 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -736,6 +736,13 @@ "when": 1780575810214, "tag": "0104_watery_thunderbird", "breakpoints": true + }, + { + "idx": 105, + "version": "7", + "when": 1781156586163, + "tag": "0105_chief_rocket_racer", + "breakpoints": true } ] } \ No newline at end of file diff --git a/messages/en/settings/config.json b/messages/en/settings/config.json index 6d1447d2a..a61789449 100644 --- a/messages/en/settings/config.json +++ b/messages/en/settings/config.json @@ -66,6 +66,8 @@ "enableThinkingSignatureRectifierDesc": "When Anthropic providers return thinking signature incompatibility or invalid request errors, automatically removes incompatible thinking blocks and retries once against the same provider (enabled by default).", "enableThinkingBudgetRectifier": "Enable Thinking Budget Rectifier", "enableThinkingBudgetRectifierDesc": "When Anthropic providers return budget_tokens < 1024 errors, automatically sets thinking budget to maximum (32000) and max_tokens to 64000 if needed, then retries once (enabled by default).", + "enableThinkingEffortConflictRectifier": "Enable Thinking Effort Conflict Rectifier", + "enableThinkingEffortConflictRectifierDesc": "When Anthropic-compatible providers (such as DeepSeek or MiMo) return 400 errors because disabled thinking conflicts with reasoning_effort, automatically strips the effort fields (output_config/reasoning_effort) and retries once against the same provider (enabled by default).", "enableBillingHeaderRectifier": "Enable Billing Header Rectifier", "enableBillingHeaderRectifierDesc": "Proactively removes x-anthropic-billing-header text blocks injected by Claude Code client into the system prompt, preventing Amazon Bedrock and other non-native Anthropic upstreams from returning 400 errors (enabled by default).", "enableResponseInputRectifier": "Enable Response Input Rectifier", diff --git a/messages/ja/settings/config.json b/messages/ja/settings/config.json index 68a55077c..6623f9504 100644 --- a/messages/ja/settings/config.json +++ b/messages/ja/settings/config.json @@ -66,6 +66,8 @@ "enableThinkingSignatureRectifierDesc": "Anthropic プロバイダーで thinking 署名の不整合や不正なリクエストエラーが発生した場合、thinking 関連ブロックを削除して同一プロバイダーへ1回だけ再試行します(既定で有効)。", "enableThinkingBudgetRectifier": "thinking 予算整流を有効化", "enableThinkingBudgetRectifierDesc": "Anthropic プロバイダーで budget_tokens < 1024 エラーが発生した場合、thinking 予算を最大値(32000)に設定し、必要に応じて max_tokens を 64000 に設定して1回だけ再試行します(既定で有効)。", + "enableThinkingEffortConflictRectifier": "thinking effort 競合整流を有効化", + "enableThinkingEffortConflictRectifierDesc": "Anthropic 互換プロバイダー(DeepSeek や MiMo など)が thinking 無効と reasoning_effort の併存により 400 エラーを返した場合、effort フィールド(output_config/reasoning_effort)を自動的に取り除き、同じプロバイダーに対して1回だけ再試行します(既定で有効)。", "enableBillingHeaderRectifier": "課金ヘッダー整流を有効化", "enableBillingHeaderRectifierDesc": "Claude Code クライアントが system プロンプトに注入する x-anthropic-billing-header テキストブロックを事前に削除し、Amazon Bedrock などの非ネイティブ Anthropic 上流による 400 エラーを防止します(既定で有効)。", "enableResponseInputRectifier": "Response Input 整流器を有効化", diff --git a/messages/ru/settings/config.json b/messages/ru/settings/config.json index 52090a13a..c7add884d 100644 --- a/messages/ru/settings/config.json +++ b/messages/ru/settings/config.json @@ -66,6 +66,8 @@ "enableThinkingSignatureRectifierDesc": "Если Anthropic-провайдер возвращает ошибку несовместимой подписи thinking или некорректного запроса, автоматически удаляет несовместимые thinking-блоки и повторяет запрос один раз к тому же провайдеру (включено по умолчанию).", "enableThinkingBudgetRectifier": "Включить исправление thinking-budget", "enableThinkingBudgetRectifierDesc": "Если Anthropic-провайдер возвращает ошибку budget_tokens < 1024, автоматически устанавливает thinking budget на максимум (32000) и при необходимости max_tokens на 64000, затем повторяет запрос один раз (включено по умолчанию).", + "enableThinkingEffortConflictRectifier": "Включить исправление конфликта thinking-effort", + "enableThinkingEffortConflictRectifierDesc": "Если Anthropic-совместимый провайдер (например, DeepSeek или MiMo) возвращает ошибку 400 из-за сочетания отключённого thinking и reasoning_effort, автоматически удаляет поля effort (output_config/reasoning_effort) и повторяет запрос к тому же провайдеру один раз (включено по умолчанию).", "enableBillingHeaderRectifier": "Включить исправление billing-заголовка", "enableBillingHeaderRectifierDesc": "Проактивно удаляет текстовые блоки x-anthropic-billing-header, добавленные клиентом Claude Code в системный промпт, предотвращая ошибки 400 от Amazon Bedrock и других не-Anthropic провайдеров (включено по умолчанию).", "enableResponseInputRectifier": "Включить исправление Response Input", diff --git a/messages/zh-CN/settings/config.json b/messages/zh-CN/settings/config.json index ecd85ddbd..c705683a0 100644 --- a/messages/zh-CN/settings/config.json +++ b/messages/zh-CN/settings/config.json @@ -64,6 +64,8 @@ "enableThinkingSignatureRectifierDesc": "当 Anthropic 类型供应商返回 thinking 签名不兼容或非法请求等错误时,自动移除不兼容的 thinking 相关块并对同一供应商重试一次(默认开启)。", "enableThinkingBudgetRectifier": "启用 thinking 预算整流器", "enableThinkingBudgetRectifierDesc": "当 Anthropic 类型供应商返回 budget_tokens < 1024 错误时,自动将 thinking 预算设为最大值(32000),并在需要时将 max_tokens 设为 64000,然后重试一次(默认开启)。", + "enableThinkingEffortConflictRectifier": "启用 thinking effort 冲突整流器", + "enableThinkingEffortConflictRectifierDesc": "当 Anthropic 兼容供应商(如 DeepSeek、MiMo 等)因 thinking 关闭与 reasoning_effort 同时存在返回 400 错误时,自动剥离 effort 字段(output_config/reasoning_effort)并对同一供应商重试一次(默认开启)。", "enableBillingHeaderRectifier": "启用计费标头整流器", "enableBillingHeaderRectifierDesc": "主动移除 Claude Code 客户端注入到 system 提示中的 x-anthropic-billing-header 文本块,防止 Amazon Bedrock 等非原生 Anthropic 上游返回 400 错误(默认开启)。", "enableResponseInputRectifier": "启用 Response Input 整流器", diff --git a/messages/zh-TW/settings/config.json b/messages/zh-TW/settings/config.json index 7c94a809d..8ef145873 100644 --- a/messages/zh-TW/settings/config.json +++ b/messages/zh-TW/settings/config.json @@ -66,6 +66,8 @@ "enableThinkingSignatureRectifierDesc": "當 Anthropic 類型供應商返回 thinking 簽名不相容或非法請求等錯誤時,自動移除不相容的 thinking 相關區塊並對同一供應商重試一次(預設開啟)。", "enableThinkingBudgetRectifier": "啟用 thinking 預算整流器", "enableThinkingBudgetRectifierDesc": "當 Anthropic 類型供應商返回 budget_tokens < 1024 錯誤時,自動將 thinking 預算設為最大值(32000),並在需要時將 max_tokens 設為 64000,然後重試一次(預設開啟)。", + "enableThinkingEffortConflictRectifier": "啟用 thinking effort 衝突整流器", + "enableThinkingEffortConflictRectifierDesc": "當 Anthropic 相容供應商(如 DeepSeek、MiMo 等)因 thinking 關閉與 reasoning_effort 同時存在返回 400 錯誤時,自動剝離 effort 欄位(output_config/reasoning_effort)並對同一供應商重試一次(預設開啟)。", "enableBillingHeaderRectifier": "啟用計費標頭整流器", "enableBillingHeaderRectifierDesc": "主動移除 Claude Code 客戶端注入到 system 提示中的 x-anthropic-billing-header 文字區塊,防止 Amazon Bedrock 等非原生 Anthropic 上游回傳 400 錯誤(預設開啟)。", "enableResponseInputRectifier": "啟用 Response Input 整流器", diff --git a/package.json b/package.json index 530014dd0..da5ce414b 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "test:coverage:logs-sessionid-time-filter": "vitest run --config tests/configs/logs-sessionid-time-filter.config.ts --coverage", "test:coverage:codex-session-id-completer": "vitest run --config tests/configs/codex-session-id-completer.config.ts --coverage", "test:coverage:thinking-signature-rectifier": "vitest run --config tests/configs/thinking-signature-rectifier.config.ts --coverage", + "test:coverage:thinking-effort-conflict-rectifier": "vitest run --config tests/configs/thinking-effort-conflict-rectifier.config.ts --coverage", "test:coverage:quota": "vitest run --config tests/configs/quota.config.ts --coverage", "test:coverage:my-usage": "vitest run --config tests/configs/my-usage.config.ts --coverage", "test:coverage:proxy-guard-pipeline": "vitest run --config tests/configs/proxy-guard-pipeline.config.ts --coverage", diff --git a/src/actions/system-config.ts b/src/actions/system-config.ts index eb4a24636..c40a7302d 100644 --- a/src/actions/system-config.ts +++ b/src/actions/system-config.ts @@ -73,6 +73,7 @@ export async function saveSystemSettings(formData: { interceptAnthropicWarmupRequests?: boolean; enableThinkingSignatureRectifier?: boolean; enableThinkingBudgetRectifier?: boolean; + enableThinkingEffortConflictRectifier?: boolean; enableBillingHeaderRectifier?: boolean; enableResponseInputRectifier?: boolean; allowNonConversationEndpointProviderFallback?: boolean; @@ -125,6 +126,7 @@ export async function saveSystemSettings(formData: { interceptAnthropicWarmupRequests: validated.interceptAnthropicWarmupRequests, enableThinkingSignatureRectifier: validated.enableThinkingSignatureRectifier, enableThinkingBudgetRectifier: validated.enableThinkingBudgetRectifier, + enableThinkingEffortConflictRectifier: validated.enableThinkingEffortConflictRectifier, enableBillingHeaderRectifier: validated.enableBillingHeaderRectifier, enableResponseInputRectifier: validated.enableResponseInputRectifier, allowNonConversationEndpointProviderFallback: diff --git a/src/app/[locale]/settings/config/_components/system-settings-form.tsx b/src/app/[locale]/settings/config/_components/system-settings-form.tsx index 843ef0338..a6808393e 100644 --- a/src/app/[locale]/settings/config/_components/system-settings-form.tsx +++ b/src/app/[locale]/settings/config/_components/system-settings-form.tsx @@ -76,6 +76,7 @@ interface SystemSettingsFormProps { | "enableBillingHeaderRectifier" | "enableResponseInputRectifier" | "enableThinkingBudgetRectifier" + | "enableThinkingEffortConflictRectifier" | "allowNonConversationEndpointProviderFallback" | "fakeStreamingWhitelist" | "enableCodexSessionIdCompletion" @@ -169,6 +170,8 @@ export function SystemSettingsForm({ initialSettings }: SystemSettingsFormProps) const [enableThinkingBudgetRectifier, setEnableThinkingBudgetRectifier] = useState( initialSettings.enableThinkingBudgetRectifier ); + const [enableThinkingEffortConflictRectifier, setEnableThinkingEffortConflictRectifier] = + useState(initialSettings.enableThinkingEffortConflictRectifier); const [enableCodexSessionIdCompletion, setEnableCodexSessionIdCompletion] = useState( initialSettings.enableCodexSessionIdCompletion ); @@ -317,6 +320,7 @@ export function SystemSettingsForm({ initialSettings }: SystemSettingsFormProps) allowNonConversationEndpointProviderFallback, fakeStreamingWhitelist: sanitizedFakeStreamingWhitelist, enableThinkingBudgetRectifier, + enableThinkingEffortConflictRectifier, enableCodexSessionIdCompletion, enableClaudeMetadataUserIdInjection, enableResponseFixer, @@ -364,6 +368,7 @@ export function SystemSettingsForm({ initialSettings }: SystemSettingsFormProps) })) ); setEnableThinkingBudgetRectifier(result.data.enableThinkingBudgetRectifier); + setEnableThinkingEffortConflictRectifier(result.data.enableThinkingEffortConflictRectifier); setEnableCodexSessionIdCompletion(result.data.enableCodexSessionIdCompletion); setEnableClaudeMetadataUserIdInjection(result.data.enableClaudeMetadataUserIdInjection); setEnableResponseFixer(result.data.enableResponseFixer); @@ -796,6 +801,29 @@ export function SystemSettingsForm({ initialSettings }: SystemSettingsFormProps) /> + {/* Enable Thinking Effort Conflict Rectifier */} +
+
+
+ +
+
+

+ {t("enableThinkingEffortConflictRectifier")} +

+

+ {t("enableThinkingEffortConflictRectifierDesc")} +

+
+
+ setEnableThinkingEffortConflictRectifier(checked)} + disabled={isPending} + /> +
+ {/* Enable Billing Header Rectifier */}
diff --git a/src/app/[locale]/settings/config/page.tsx b/src/app/[locale]/settings/config/page.tsx index 46b992bd0..72cc51a6d 100644 --- a/src/app/[locale]/settings/config/page.tsx +++ b/src/app/[locale]/settings/config/page.tsx @@ -61,6 +61,7 @@ async function SettingsConfigContent({ locale }: { locale: string }) { interceptAnthropicWarmupRequests: settings.interceptAnthropicWarmupRequests, enableThinkingSignatureRectifier: settings.enableThinkingSignatureRectifier, enableThinkingBudgetRectifier: settings.enableThinkingBudgetRectifier, + enableThinkingEffortConflictRectifier: settings.enableThinkingEffortConflictRectifier, enableBillingHeaderRectifier: settings.enableBillingHeaderRectifier, enableResponseInputRectifier: settings.enableResponseInputRectifier, allowNonConversationEndpointProviderFallback: diff --git a/src/app/v1/_lib/proxy/forwarder.ts b/src/app/v1/_lib/proxy/forwarder.ts index a568f0717..523f63a0f 100644 --- a/src/app/v1/_lib/proxy/forwarder.ts +++ b/src/app/v1/_lib/proxy/forwarder.ts @@ -96,6 +96,10 @@ import { detectThinkingBudgetRectifierTrigger, rectifyThinkingBudget, } from "./thinking-budget-rectifier"; +import { + detectThinkingEffortConflictRectifierTrigger, + rectifyThinkingEffortConflict, +} from "./thinking-effort-conflict-rectifier"; import { detectThinkingSignatureRectifierTrigger, rectifyAnthropicRequestMessage, @@ -225,21 +229,27 @@ type StreamingHedgeAttempt = { type ReactiveRectifierRetryState = { thinkingSignatureRetried: boolean; thinkingBudgetRetried: boolean; + thinkingEffortConflictRetried: boolean; }; +type ReactiveRectifierType = + | "thinking_signature_rectifier" + | "thinking_budget_rectifier" + | "thinking_effort_conflict_rectifier"; + type ReactiveRectifierResult = | { matched: false } | { matched: true; applied: false; reason: "already_retried" | "not_applicable"; - rectifierType: "thinking_signature_rectifier" | "thinking_budget_rectifier"; + rectifierType: ReactiveRectifierType; trigger: string; } | { matched: true; applied: true; - rectifierType: "thinking_signature_rectifier" | "thinking_budget_rectifier"; + rectifierType: ReactiveRectifierType; trigger: string; requestDetailsBeforeRectify: ReturnType; }; @@ -762,12 +772,15 @@ function buildRetryFailedChainEntry( }; } -function getReactiveRectifierDisplayName( - rectifierType: "thinking_signature_rectifier" | "thinking_budget_rectifier" -): string { - return rectifierType === "thinking_signature_rectifier" - ? "Thinking signature rectifier" - : "Thinking budget rectifier"; +function getReactiveRectifierDisplayName(rectifierType: ReactiveRectifierType): string { + switch (rectifierType) { + case "thinking_signature_rectifier": + return "Thinking signature rectifier"; + case "thinking_budget_rectifier": + return "Thinking budget rectifier"; + case "thinking_effort_conflict_rectifier": + return "Thinking effort conflict rectifier"; + } } async function tryApplyReactiveAnthropicRectifier(params: { @@ -794,6 +807,68 @@ async function tryApplyReactiveAnthropicRectifier(params: { return { matched: false }; } + // 先于签名整流器检测:effort 冲突的错误文案更具体(reasoning_effort/output_config), + // 避免被签名整流器的通用 invalid request 兜底吞掉。 + const effortConflictTrigger = detectThinkingEffortConflictRectifierTrigger(errorMessage); + if (effortConflictTrigger) { + const settings = await getCachedSystemSettings(); + const enabled = settings.enableThinkingEffortConflictRectifier ?? true; + + if (!enabled) { + return { matched: false }; + } + + if (params.retryState.thinkingEffortConflictRetried) { + return { + matched: true, + applied: false, + reason: "already_retried", + rectifierType: "thinking_effort_conflict_rectifier", + trigger: effortConflictTrigger, + }; + } + + const requestDetailsBeforeRectify = buildRequestDetails(requestSession); + const rectified = rectifyThinkingEffortConflict( + requestSession.request.message as Record + ); + + addSpecialSettingForPersistence(requestSession, persistSession, { + type: "thinking_effort_conflict_rectifier", + scope: "request", + hit: rectified.applied, + providerId: provider.id, + providerName: provider.name, + trigger: effortConflictTrigger, + attemptNumber, + retryAttemptNumber, + removedOutputConfig: rectified.removedOutputConfig, + removedReasoningEffort: rectified.removedReasoningEffort, + thinkingType: rectified.thinkingType, + effort: rectified.effort, + }); + await persistSpecialSettings(persistSession); + + if (!rectified.applied) { + return { + matched: true, + applied: false, + reason: "not_applicable", + rectifierType: "thinking_effort_conflict_rectifier", + trigger: effortConflictTrigger, + }; + } + + params.retryState.thinkingEffortConflictRetried = true; + return { + matched: true, + applied: true, + rectifierType: "thinking_effort_conflict_rectifier", + trigger: effortConflictTrigger, + requestDetailsBeforeRectify, + }; + } + const signatureTrigger = detectThinkingSignatureRectifierTrigger(errorMessage); if (signatureTrigger) { const settings = await getCachedSystemSettings(); @@ -1094,6 +1169,7 @@ export class ProxyForwarder { const reactiveRectifierRetryState: ReactiveRectifierRetryState = { thinkingSignatureRetried: false, thinkingBudgetRetried: false, + thinkingEffortConflictRetried: false, }; const requestPath = session.requestUrl.pathname; @@ -4452,6 +4528,7 @@ export class ProxyForwarder { reactiveRectifierRetryState: { thinkingSignatureRetried: false, thinkingBudgetRetried: false, + thinkingEffortConflictRetried: false, }, settled: false, thresholdTriggered: false, diff --git a/src/app/v1/_lib/proxy/thinking-effort-conflict-rectifier.test.ts b/src/app/v1/_lib/proxy/thinking-effort-conflict-rectifier.test.ts new file mode 100644 index 000000000..983779e42 --- /dev/null +++ b/src/app/v1/_lib/proxy/thinking-effort-conflict-rectifier.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, test } from "vitest"; +import { + detectThinkingEffortConflictRectifierTrigger, + rectifyThinkingEffortConflict, +} from "./thinking-effort-conflict-rectifier"; + +describe("detectThinkingEffortConflictRectifierTrigger", () => { + test("matches the documented DeepSeek conflict error", () => { + expect( + detectThinkingEffortConflictRectifierTrigger( + "thinking options type cannot be disabled when reasoning_effort is set" + ) + ).toBe("thinking_disabled_with_reasoning_effort"); + }); + + test("matches the error embedded in a proxy upstream envelope", () => { + expect( + detectThinkingEffortConflictRectifierTrigger( + 'Provider deepseek returned 400: Provider returned 400: Bad Request | Upstream: {"error":{"message":"thinking options type cannot be disabled when reasoning_effort is set","type":"invalid_request_error","param":null,"code":"invalid_request_error"}}' + ) + ).toBe("thinking_disabled_with_reasoning_effort"); + }); + + test("matches case and quoting variants", () => { + expect( + detectThinkingEffortConflictRectifierTrigger( + "Thinking options `type` cannot be disabled when `reasoning_effort` is set." + ) + ).toBe("thinking_disabled_with_reasoning_effort"); + }); + + test("matches output_config flavored variants", () => { + expect( + detectThinkingEffortConflictRectifierTrigger( + "thinking cannot be disabled when output_config.effort is set" + ) + ).toBe("thinking_disabled_with_reasoning_effort"); + }); + + test("ignores unrelated errors", () => { + expect(detectThinkingEffortConflictRectifierTrigger(null)).toBeNull(); + expect(detectThinkingEffortConflictRectifierTrigger(undefined)).toBeNull(); + expect(detectThinkingEffortConflictRectifierTrigger("")).toBeNull(); + expect( + detectThinkingEffortConflictRectifierTrigger("Invalid `signature` in `thinking` block") + ).toBeNull(); + expect( + detectThinkingEffortConflictRectifierTrigger( + "thinking.enabled.budget_tokens: Input should be greater than or equal to 1024" + ) + ).toBeNull(); + expect( + detectThinkingEffortConflictRectifierTrigger("reasoning_effort must be one of low|medium") + ).toBeNull(); + expect(detectThinkingEffortConflictRectifierTrigger("invalid request: malformed")).toBeNull(); + }); +}); + +describe("rectifyThinkingEffortConflict", () => { + test("removes output_config when thinking is disabled (Claude Code subagent shape)", () => { + const message: Record = { + model: "deepseek-v4-pro", + thinking: { type: "disabled" }, + output_config: { effort: "max" }, + messages: [{ role: "user", content: [{ type: "text", text: "hi" }] }], + }; + + const result = rectifyThinkingEffortConflict(message); + + expect(result.applied).toBe(true); + expect(result.removedOutputConfig).toBe(true); + expect(result.removedReasoningEffort).toBe(false); + expect(result.thinkingType).toBe("disabled"); + expect(result.effort).toBe("max"); + expect("output_config" in message).toBe(false); + expect(message.thinking).toEqual({ type: "disabled" }); + }); + + test("removes a top-level reasoning_effort passthrough as well", () => { + const message: Record = { + thinking: { type: "disabled" }, + reasoning_effort: "high", + messages: [], + }; + + const result = rectifyThinkingEffortConflict(message); + + expect(result.applied).toBe(true); + expect(result.removedOutputConfig).toBe(false); + expect(result.removedReasoningEffort).toBe(true); + expect(result.effort).toBe("high"); + expect("reasoning_effort" in message).toBe(false); + }); + + test("treats a missing thinking field as disabled and strips effort", () => { + const message: Record = { + output_config: { effort: "medium" }, + messages: [], + }; + + const result = rectifyThinkingEffortConflict(message); + + expect(result.applied).toBe(true); + expect(result.removedOutputConfig).toBe(true); + expect(result.thinkingType).toBeNull(); + }); + + test("does not touch requests with thinking enabled", () => { + const message: Record = { + thinking: { type: "enabled", budget_tokens: 2048 }, + output_config: { effort: "max" }, + }; + + const result = rectifyThinkingEffortConflict(message); + + expect(result.applied).toBe(false); + expect(message.output_config).toEqual({ effort: "max" }); + expect(message.thinking).toEqual({ type: "enabled", budget_tokens: 2048 }); + }); + + test("does not touch requests with adaptive thinking", () => { + const message: Record = { + thinking: { type: "adaptive" }, + output_config: { effort: "low" }, + }; + + expect(rectifyThinkingEffortConflict(message).applied).toBe(false); + expect(message.output_config).toEqual({ effort: "low" }); + }); + + test("is a no-op when no effort fields are present", () => { + const message: Record = { + thinking: { type: "disabled" }, + messages: [], + }; + + const result = rectifyThinkingEffortConflict(message); + + expect(result.applied).toBe(false); + expect(result.removedOutputConfig).toBe(false); + expect(result.removedReasoningEffort).toBe(false); + }); + + test("keeps an effort-less output_config in place", () => { + const message: Record = { + thinking: { type: "disabled" }, + output_config: { something_else: true }, + reasoning_effort: "low", + }; + + const result = rectifyThinkingEffortConflict(message); + + expect(result.applied).toBe(true); + expect(result.removedOutputConfig).toBe(false); + expect(result.removedReasoningEffort).toBe(true); + expect(message.output_config).toEqual({ something_else: true }); + }); +}); diff --git a/src/app/v1/_lib/proxy/thinking-effort-conflict-rectifier.ts b/src/app/v1/_lib/proxy/thinking-effort-conflict-rectifier.ts new file mode 100644 index 000000000..c907ee6c5 --- /dev/null +++ b/src/app/v1/_lib/proxy/thinking-effort-conflict-rectifier.ts @@ -0,0 +1,113 @@ +/** + * Thinking Effort Conflict Rectifier - Reactive rectifier for strict Anthropic-compatible + * providers (DeepSeek, MiMo, ...) that reject `thinking: { type: "disabled" }` combined + * with a reasoning-effort field. + * + * Background (issue #1257): Claude Code v2.1.166+ disables thinking for subagent tasks + * but keeps the global `output_config: { effort }` in the payload. The official Anthropic + * API ignores the contradiction; DeepSeek's validation rejects it with + * "thinking options type cannot be disabled when reasoning_effort is set" (DeepSeek docs: + * `output_config` only supports `effort`, which maps to reasoning_effort internally). + * + * Action: strip the effort fields (`output_config.effort` carrier / top-level + * `reasoning_effort`) while keeping thinking disabled, then retry the same provider once. + */ + +export type ThinkingEffortConflictRectifierTrigger = "thinking_disabled_with_reasoning_effort"; + +export type ThinkingEffortConflictRectifierResult = { + applied: boolean; + removedOutputConfig: boolean; + removedReasoningEffort: boolean; + thinkingType: string | null; + effort: string | null; +}; + +/** + * 检测是否需要触发「thinking effort 冲突整流器」 + * + * 注意:不依赖错误规则开关(error rules 可能被用户关闭),仅做字符串匹配。 + */ +export function detectThinkingEffortConflictRectifierTrigger( + errorMessage: string | null | undefined +): ThinkingEffortConflictRectifierTrigger | null { + if (!errorMessage) return null; + + const lower = errorMessage.toLowerCase(); + + const mentionsDisableConflict = + lower.includes("cannot be disabled") || lower.includes("can not be disabled"); + if (!mentionsDisableConflict) return null; + + // DeepSeek 原文:thinking options type cannot be disabled when reasoning_effort is set + if (lower.includes("reasoning_effort")) { + return "thinking_disabled_with_reasoning_effort"; + } + + // 变体兜底:以 output_config(.effort) 表述同一冲突的上游 + if (lower.includes("output_config") && lower.includes("thinking")) { + return "thinking_disabled_with_reasoning_effort"; + } + + return null; +} + +/** + * 对 Anthropic 请求体做最小侵入整流: + * - 仅当 thinking 关闭(或缺省,上游按关闭处理)时生效 + * - 移除携带 effort 的 output_config 与顶层 reasoning_effort 透传字段 + * - 保留 thinking 关闭状态(尊重客户端对子 agent 关闭思考的意图) + * + * 说明:仅在上游报错后、同供应商重试前调用(被动触发),不影响正常请求; + * 原地修改 message 对象。 + */ +export function rectifyThinkingEffortConflict( + message: Record +): ThinkingEffortConflictRectifierResult { + const thinking = message.thinking; + const thinkingType = + thinking && typeof thinking === "object" && !Array.isArray(thinking) + ? typeof (thinking as Record).type === "string" + ? ((thinking as Record).type as string) + : null + : null; + + const result: ThinkingEffortConflictRectifierResult = { + applied: false, + removedOutputConfig: false, + removedReasoningEffort: false, + thinkingType, + effort: null, + }; + + // thinking 显式启用(enabled/adaptive 等)时不属于该冲突,保持原样 + const thinkingDisabled = thinkingType === null || thinkingType === "disabled"; + if (!thinkingDisabled) { + return result; + } + + const outputConfig = message.output_config; + const outputConfigEffort = + outputConfig && typeof outputConfig === "object" && !Array.isArray(outputConfig) + ? (outputConfig as Record).effort + : undefined; + + if (outputConfigEffort !== undefined) { + result.effort = typeof outputConfigEffort === "string" ? outputConfigEffort : null; + delete message.output_config; + result.removedOutputConfig = true; + result.applied = true; + } + + const reasoningEffort = message.reasoning_effort; + if (reasoningEffort !== undefined) { + if (result.effort === null && typeof reasoningEffort === "string") { + result.effort = reasoningEffort; + } + delete message.reasoning_effort; + result.removedReasoningEffort = true; + result.applied = true; + } + + return result; +} diff --git a/src/drizzle/schema.ts b/src/drizzle/schema.ts index 034c07a1f..ff1a76a2a 100644 --- a/src/drizzle/schema.ts +++ b/src/drizzle/schema.ts @@ -804,6 +804,13 @@ export const systemSettings = pgTable('system_settings', { .notNull() .default(true), + // thinking effort 冲突整流器(默认开启) + // 开启后:当 Anthropic 兼容供应商(DeepSeek/MiMo 等)因 thinking 关闭 + reasoning_effort 同时存在 + // 返回 400 错误时,自动剥离 effort 字段并对同供应商重试一次 + enableThinkingEffortConflictRectifier: boolean('enable_thinking_effort_conflict_rectifier') + .notNull() + .default(true), + // billing header 整流器(默认开启) // 开启后:主动移除 Claude Code 客户端注入到 system 提示中的 x-anthropic-billing-header 文本块 enableBillingHeaderRectifier: boolean('enable_billing_header_rectifier') diff --git a/src/lib/api-client/v1/openapi-types.gen.ts b/src/lib/api-client/v1/openapi-types.gen.ts index 3b3aba480..b603e42ec 100644 --- a/src/lib/api-client/v1/openapi-types.gen.ts +++ b/src/lib/api-client/v1/openapi-types.gen.ts @@ -11655,6 +11655,8 @@ export interface operations { enableThinkingSignatureRectifier: boolean; /** @description Whether thinking budget rectifier retries are enabled. */ enableThinkingBudgetRectifier: boolean; + /** @description Whether thinking effort conflict rectifier retries are enabled. */ + enableThinkingEffortConflictRectifier: boolean; /** @description Whether billing-header rectifier is enabled. */ enableBillingHeaderRectifier: boolean; /** @description Whether Responses API input rectifier is enabled. */ @@ -11913,6 +11915,8 @@ export interface operations { enableThinkingSignatureRectifier?: boolean; /** @description Whether thinking budget rectifier retries are enabled. */ enableThinkingBudgetRectifier?: boolean; + /** @description Whether thinking effort conflict rectifier retries are enabled. */ + enableThinkingEffortConflictRectifier?: boolean; /** @description Whether billing-header rectifier is enabled. */ enableBillingHeaderRectifier?: boolean; /** @description Whether Responses API input rectifier is enabled. */ @@ -12044,6 +12048,8 @@ export interface operations { enableThinkingSignatureRectifier: boolean; /** @description Whether thinking budget rectifier retries are enabled. */ enableThinkingBudgetRectifier: boolean; + /** @description Whether thinking effort conflict rectifier retries are enabled. */ + enableThinkingEffortConflictRectifier: boolean; /** @description Whether billing-header rectifier is enabled. */ enableBillingHeaderRectifier: boolean; /** @description Whether Responses API input rectifier is enabled. */ diff --git a/src/lib/api/v1/schemas/system-config.ts b/src/lib/api/v1/schemas/system-config.ts index 92dc06ecb..944699f0c 100644 --- a/src/lib/api/v1/schemas/system-config.ts +++ b/src/lib/api/v1/schemas/system-config.ts @@ -126,6 +126,9 @@ export const SystemSettingsSchema = z enableThinkingBudgetRectifier: z .boolean() .describe("Whether thinking budget rectifier retries are enabled."), + enableThinkingEffortConflictRectifier: z + .boolean() + .describe("Whether thinking effort conflict rectifier retries are enabled."), enableBillingHeaderRectifier: z .boolean() .describe("Whether billing-header rectifier is enabled."), diff --git a/src/lib/config/system-settings-cache.ts b/src/lib/config/system-settings-cache.ts index 232349d56..2b4043460 100644 --- a/src/lib/config/system-settings-cache.ts +++ b/src/lib/config/system-settings-cache.ts @@ -41,6 +41,7 @@ const DEFAULT_SETTINGS: Pick< | "codexPriorityBillingSource" | "enableThinkingSignatureRectifier" | "enableThinkingBudgetRectifier" + | "enableThinkingEffortConflictRectifier" | "enableBillingHeaderRectifier" | "enableResponseInputRectifier" | "allowNonConversationEndpointProviderFallback" @@ -60,6 +61,7 @@ const DEFAULT_SETTINGS: Pick< codexPriorityBillingSource: "requested", enableThinkingSignatureRectifier: true, enableThinkingBudgetRectifier: true, + enableThinkingEffortConflictRectifier: true, enableBillingHeaderRectifier: true, enableResponseInputRectifier: true, // 安全敏感开关:冷缓存 / DB 读取失败时 fail-closed,避免意外重新开启跨供应商 raw fallback。 @@ -148,6 +150,7 @@ export async function getCachedSystemSettings(): Promise { interceptAnthropicWarmupRequests: DEFAULT_SETTINGS.interceptAnthropicWarmupRequests, enableThinkingSignatureRectifier: DEFAULT_SETTINGS.enableThinkingSignatureRectifier, enableThinkingBudgetRectifier: DEFAULT_SETTINGS.enableThinkingBudgetRectifier, + enableThinkingEffortConflictRectifier: DEFAULT_SETTINGS.enableThinkingEffortConflictRectifier, enableBillingHeaderRectifier: DEFAULT_SETTINGS.enableBillingHeaderRectifier, enableResponseInputRectifier: DEFAULT_SETTINGS.enableResponseInputRectifier, allowNonConversationEndpointProviderFallback: diff --git a/src/lib/utils/special-settings.ts b/src/lib/utils/special-settings.ts index a5f31c028..09922e741 100644 --- a/src/lib/utils/special-settings.ts +++ b/src/lib/utils/special-settings.ts @@ -75,6 +75,19 @@ function buildSettingKey(setting: SpecialSetting): string { setting.removedRedactedThinkingBlocks, setting.removedSignatureFields, ]); + case "thinking_effort_conflict_rectifier": + return JSON.stringify([ + setting.type, + setting.hit, + setting.providerId ?? null, + setting.trigger, + setting.attemptNumber, + setting.retryAttemptNumber, + setting.removedOutputConfig, + setting.removedReasoningEffort, + setting.thinkingType, + setting.effort, + ]); case "codex_session_id_completion": return JSON.stringify([ setting.type, diff --git a/src/lib/validation/schemas.ts b/src/lib/validation/schemas.ts index 368f564ab..5640440ff 100644 --- a/src/lib/validation/schemas.ts +++ b/src/lib/validation/schemas.ts @@ -1005,6 +1005,8 @@ export const UpdateSystemSettingsSchema = z.object({ enableThinkingSignatureRectifier: z.boolean().optional(), // thinking budget 整流器(可选) enableThinkingBudgetRectifier: z.boolean().optional(), + // thinking effort 冲突整流器(可选) + enableThinkingEffortConflictRectifier: z.boolean().optional(), // billing header 整流器(可选) enableBillingHeaderRectifier: z.boolean().optional(), // Response API input 整流器(可选) diff --git a/src/repository/_shared/transformers.ts b/src/repository/_shared/transformers.ts index e26969ce0..353b5e5d6 100644 --- a/src/repository/_shared/transformers.ts +++ b/src/repository/_shared/transformers.ts @@ -266,6 +266,8 @@ export function toSystemSettings(dbSettings: any): SystemSettings { interceptAnthropicWarmupRequests: dbSettings?.interceptAnthropicWarmupRequests ?? false, enableThinkingSignatureRectifier: dbSettings?.enableThinkingSignatureRectifier ?? true, enableThinkingBudgetRectifier: dbSettings?.enableThinkingBudgetRectifier ?? true, + enableThinkingEffortConflictRectifier: + dbSettings?.enableThinkingEffortConflictRectifier ?? true, enableBillingHeaderRectifier: dbSettings?.enableBillingHeaderRectifier ?? true, enableResponseInputRectifier: dbSettings?.enableResponseInputRectifier ?? true, allowNonConversationEndpointProviderFallback: diff --git a/src/repository/system-config.ts b/src/repository/system-config.ts index ab83f114d..9dc7722ff 100644 --- a/src/repository/system-config.ts +++ b/src/repository/system-config.ts @@ -163,6 +163,7 @@ function createFallbackSettings(): SystemSettings { interceptAnthropicWarmupRequests: false, enableThinkingSignatureRectifier: true, enableThinkingBudgetRectifier: true, + enableThinkingEffortConflictRectifier: true, enableBillingHeaderRectifier: true, enableResponseInputRectifier: true, allowNonConversationEndpointProviderFallback: true, @@ -218,6 +219,7 @@ export async function getSystemSettings(): Promise { interceptAnthropicWarmupRequests: systemSettings.interceptAnthropicWarmupRequests, enableThinkingSignatureRectifier: systemSettings.enableThinkingSignatureRectifier, enableThinkingBudgetRectifier: systemSettings.enableThinkingBudgetRectifier, + enableThinkingEffortConflictRectifier: systemSettings.enableThinkingEffortConflictRectifier, enableBillingHeaderRectifier: systemSettings.enableBillingHeaderRectifier, enableResponseInputRectifier: systemSettings.enableResponseInputRectifier, allowNonConversationEndpointProviderFallback: @@ -260,6 +262,7 @@ export async function getSystemSettings(): Promise { interceptAnthropicWarmupRequests: systemSettings.interceptAnthropicWarmupRequests, enableThinkingSignatureRectifier: systemSettings.enableThinkingSignatureRectifier, enableThinkingBudgetRectifier: systemSettings.enableThinkingBudgetRectifier, + enableThinkingEffortConflictRectifier: systemSettings.enableThinkingEffortConflictRectifier, enableBillingHeaderRectifier: systemSettings.enableBillingHeaderRectifier, enableResponseInputRectifier: systemSettings.enableResponseInputRectifier, allowNonConversationEndpointProviderFallback: @@ -580,6 +583,7 @@ export async function updateSystemSettings( interceptAnthropicWarmupRequests: systemSettings.interceptAnthropicWarmupRequests, enableThinkingSignatureRectifier: systemSettings.enableThinkingSignatureRectifier, enableThinkingBudgetRectifier: systemSettings.enableThinkingBudgetRectifier, + enableThinkingEffortConflictRectifier: systemSettings.enableThinkingEffortConflictRectifier, enableBillingHeaderRectifier: systemSettings.enableBillingHeaderRectifier, enableResponseInputRectifier: systemSettings.enableResponseInputRectifier, allowNonConversationEndpointProviderFallback: @@ -622,6 +626,7 @@ export async function updateSystemSettings( interceptAnthropicWarmupRequests: systemSettings.interceptAnthropicWarmupRequests, enableThinkingSignatureRectifier: systemSettings.enableThinkingSignatureRectifier, enableThinkingBudgetRectifier: systemSettings.enableThinkingBudgetRectifier, + enableThinkingEffortConflictRectifier: systemSettings.enableThinkingEffortConflictRectifier, enableBillingHeaderRectifier: systemSettings.enableBillingHeaderRectifier, enableResponseInputRectifier: systemSettings.enableResponseInputRectifier, enableCodexSessionIdCompletion: systemSettings.enableCodexSessionIdCompletion, @@ -751,6 +756,11 @@ export async function updateSystemSettings( updates.enableThinkingBudgetRectifier = payload.enableThinkingBudgetRectifier; } + // thinking effort 冲突整流器开关(如果提供) + if (payload.enableThinkingEffortConflictRectifier !== undefined) { + updates.enableThinkingEffortConflictRectifier = payload.enableThinkingEffortConflictRectifier; + } + // billing header 整流器开关(如果提供) if (payload.enableBillingHeaderRectifier !== undefined) { updates.enableBillingHeaderRectifier = payload.enableBillingHeaderRectifier; diff --git a/src/types/special-settings.ts b/src/types/special-settings.ts index a93b8d1fe..c24bc1930 100644 --- a/src/types/special-settings.ts +++ b/src/types/special-settings.ts @@ -11,6 +11,7 @@ export type SpecialSetting = | GuardInterceptSpecialSetting | ThinkingSignatureRectifierSpecialSetting | ThinkingBudgetRectifierSpecialSetting + | ThinkingEffortConflictRectifierSpecialSetting | BillingHeaderRectifierSpecialSetting | CodexSessionIdCompletionSpecialSetting | ClaudeMetadataUserIdInjectionSpecialSetting @@ -148,6 +149,28 @@ export type ThinkingSignatureRectifierSpecialSetting = { removedSignatureFields: number; }; +/** + * Thinking effort 冲突整流器审计 + * + * 用于记录:当 Anthropic 兼容供应商(如 DeepSeek、MiMo 等)因 + * thinking 关闭 + reasoning_effort/output_config.effort 同时存在而返回 400 时, + * 代理剥离 effort 字段并对同供应商自动重试一次的行为。 + */ +export type ThinkingEffortConflictRectifierSpecialSetting = { + type: "thinking_effort_conflict_rectifier"; + scope: "request"; + hit: boolean; + providerId: number | null; + providerName: string | null; + trigger: "thinking_disabled_with_reasoning_effort"; + attemptNumber: number; + retryAttemptNumber: number; + removedOutputConfig: boolean; + removedReasoningEffort: boolean; + thinkingType: string | null; + effort: string | null; +}; + /** * Codex Session ID 补全审计 * diff --git a/src/types/system-config.ts b/src/types/system-config.ts index e0080a37f..86d15124f 100644 --- a/src/types/system-config.ts +++ b/src/types/system-config.ts @@ -91,6 +91,11 @@ export interface SystemSettings { // 目标:当 Anthropic 类型供应商出现 budget_tokens < 1024 错误时,自动整流并重试一次 enableThinkingBudgetRectifier: boolean; + // thinking effort 冲突整流器(默认开启) + // 目标:当 Anthropic 兼容供应商(DeepSeek/MiMo 等)因 thinking 关闭 + reasoning_effort + // 同时存在返回 400 错误时,自动剥离 effort 字段并对同供应商重试一次 + enableThinkingEffortConflictRectifier: boolean; + // billing header 整流器(默认开启) // 目标:主动移除 Claude Code 客户端注入到 system 提示中的 x-anthropic-billing-header 文本块, // 防止 Amazon Bedrock 等非原生 Anthropic 上游返回 400 错误 diff --git a/tests/configs/thinking-effort-conflict-rectifier.config.ts b/tests/configs/thinking-effort-conflict-rectifier.config.ts new file mode 100644 index 000000000..5585c41c1 --- /dev/null +++ b/tests/configs/thinking-effort-conflict-rectifier.config.ts @@ -0,0 +1,12 @@ +import { createCoverageConfig } from "../vitest.base"; + +export default createCoverageConfig({ + name: "thinking-effort-conflict-rectifier", + environment: "node", + testFiles: [ + "src/app/v1/_lib/proxy/thinking-effort-conflict-rectifier.test.ts", + "tests/unit/proxy/proxy-forwarder-thinking-effort-conflict-rectifier.test.ts", + ], + sourceFiles: ["src/app/v1/_lib/proxy/thinking-effort-conflict-rectifier.ts"], + thresholds: { lines: 80, functions: 80, branches: 70, statements: 80 }, +}); diff --git a/tests/unit/actions/system-config-thinking-effort-conflict-setting.test.ts b/tests/unit/actions/system-config-thinking-effort-conflict-setting.test.ts new file mode 100644 index 000000000..c1f980722 --- /dev/null +++ b/tests/unit/actions/system-config-thinking-effort-conflict-setting.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, test, vi } from "vitest"; + +vi.mock("server-only", () => ({})); + +describe("enableThinkingEffortConflictRectifier system setting", () => { + test("defaults to enabled in the DB-row transformer", async () => { + const { toSystemSettings } = await import("@/repository/_shared/transformers"); + + expect(toSystemSettings(undefined).enableThinkingEffortConflictRectifier).toBe(true); + expect( + toSystemSettings({ id: 1, siteTitle: "Claude Code Hub" }) + .enableThinkingEffortConflictRectifier + ).toBe(true); + expect( + toSystemSettings({ id: 1, enableThinkingEffortConflictRectifier: false }) + .enableThinkingEffortConflictRectifier + ).toBe(false); + }); + + test("is accepted by the settings update validation schema", async () => { + const { UpdateSystemSettingsSchema } = await import("@/lib/validation/schemas"); + + const parsed = UpdateSystemSettingsSchema.parse({ + enableThinkingEffortConflictRectifier: false, + }); + expect(parsed.enableThinkingEffortConflictRectifier).toBe(false); + + const empty = UpdateSystemSettingsSchema.parse({}); + expect(empty.enableThinkingEffortConflictRectifier).toBeUndefined(); + }); + + test("is exposed by the v1 system settings response schema", async () => { + const { SystemSettingsSchema } = await import("@/lib/api/v1/schemas/system-config"); + + expect(Object.keys(SystemSettingsSchema.shape)).toContain( + "enableThinkingEffortConflictRectifier" + ); + }); +}); diff --git a/tests/unit/proxy/proxy-forwarder-thinking-effort-conflict-rectifier.test.ts b/tests/unit/proxy/proxy-forwarder-thinking-effort-conflict-rectifier.test.ts new file mode 100644 index 000000000..ad5bd8171 --- /dev/null +++ b/tests/unit/proxy/proxy-forwarder-thinking-effort-conflict-rectifier.test.ts @@ -0,0 +1,253 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; + +const mocks = vi.hoisted(() => { + return { + getCachedSystemSettings: vi.fn(async () => ({ + enableThinkingEffortConflictRectifier: true, + enableHighConcurrencyMode: false, + })), + 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 () => {}), + storeSessionSpecialSettings: vi.fn(async () => {}), + updateSessionBindingSmart: vi.fn(async () => ({ + updated: true, + reason: "first_success", + details: "mocked", + })), + updateSessionProvider: 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, +})); + +vi.mock("@/lib/session-manager", () => ({ + SessionManager: { + storeSessionSpecialSettings: mocks.storeSessionSpecialSettings, + updateSessionBindingSmart: mocks.updateSessionBindingSmart, + updateSessionProvider: mocks.updateSessionProvider, + }, +})); + +import { ProxyError } from "@/app/v1/_lib/proxy/errors"; +import { ProxyForwarder } from "@/app/v1/_lib/proxy/forwarder"; +import { ProxySession } from "@/app/v1/_lib/proxy/session"; +import type { Provider } from "@/types/provider"; + +const DEEPSEEK_CONFLICT_ERROR = + 'Provider returned 400: Bad Request | Upstream: {"error":{"message":"thinking options type cannot be disabled when reasoning_effort is set","type":"invalid_request_error","param":null,"code":"invalid_request_error"}}'; + +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: "deepseek-v4-pro", + log: "", + message: { + model: "deepseek-v4-pro", + thinking: { type: "disabled" }, + output_config: { effort: "max" }, + messages: [{ role: "user", content: [{ type: "text", text: "subagent task" }] }], + }, + }, + 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, + highConcurrencyModeEnabled: false, + isHeaderModified: () => false, + setHighConcurrencyModeEnabled(enabled: boolean) { + this.highConcurrencyModeEnabled = enabled; + }, + shouldPersistSessionDebugArtifacts() { + return !this.highConcurrencyModeEnabled; + }, + shouldTrackSessionObservability() { + return !this.highConcurrencyModeEnabled; + }, + }); + + return session as any; +} + +function createAnthropicProvider(): Provider { + return { + id: 1, + name: "deepseek-anthropic", + providerType: "claude", + url: "https://api.deepseek.com/anthropic/v1/messages", + key: "k", + preserveClientIp: false, + priority: 0, + } as unknown as Provider; +} + +function okResponse(): Response { + 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) }, + }); +} + +describe("ProxyForwarder - thinking effort conflict rectifier", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.getCachedSystemSettings.mockResolvedValue({ + enableThinkingEffortConflictRectifier: true, + enableHighConcurrencyMode: false, + }); + }); + + test("命中 DeepSeek thinking/reasoning_effort 冲突 400 时应剥离 effort 字段并对同供应商重试一次", async () => { + const session = createSession(); + session.setProvider(createAnthropicProvider()); + + const doForward = vi.spyOn(ProxyForwarder as any, "doForward"); + + doForward.mockImplementationOnce(async () => { + throw new ProxyError(DEEPSEEK_CONFLICT_ERROR, 400, { + body: "", + providerId: 1, + providerName: "deepseek-anthropic", + }); + }); + + doForward.mockImplementationOnce(async (s: ProxySession) => { + const msg = s.request.message as any; + expect("output_config" in msg).toBe(false); + expect("reasoning_effort" in msg).toBe(false); + expect(msg.thinking).toEqual({ type: "disabled" }); + return okResponse(); + }); + + const response = await ProxyForwarder.send(session); + + expect(response.status).toBe(200); + expect(doForward).toHaveBeenCalledTimes(2); + expect(session.getProviderChain()?.length).toBeGreaterThanOrEqual(2); + + const special = JSON.stringify(session.getSpecialSettings()); + expect(special).toContain("thinking_effort_conflict_rectifier"); + expect(special).not.toContain("thinking_signature_rectifier"); + expect(mocks.updateMessageRequestDetails).toHaveBeenCalledTimes(1); + }); + + test("开关关闭时不整流也不重试", async () => { + mocks.getCachedSystemSettings.mockResolvedValue({ + enableThinkingEffortConflictRectifier: false, + enableHighConcurrencyMode: false, + }); + + const session = createSession(); + session.setProvider(createAnthropicProvider()); + + const doForward = vi.spyOn(ProxyForwarder as any, "doForward"); + doForward.mockImplementation(async () => { + throw new ProxyError(DEEPSEEK_CONFLICT_ERROR, 400, { + body: "", + providerId: 1, + providerName: "deepseek-anthropic", + }); + }); + + await expect(ProxyForwarder.send(session)).rejects.toThrow(); + + // 开关关闭:请求体不被整流(常规重试策略仍可能多次尝试,但都带原始字段) + const message = session.request.message as Record; + expect(message.output_config).toEqual({ effort: "max" }); + expect(JSON.stringify(session.getSpecialSettings() ?? [])).not.toContain( + "thinking_effort_conflict_rectifier" + ); + expect(doForward.mock.calls.length).toBeGreaterThanOrEqual(1); + }); + + test("请求体不含冲突字段时记录 not_applicable 且不重试", async () => { + const session = createSession(); + const message = session.request.message as Record; + delete message.output_config; + (message.thinking as Record).type = "enabled"; + session.setProvider(createAnthropicProvider()); + + const doForward = vi.spyOn(ProxyForwarder as any, "doForward"); + doForward.mockImplementation(async () => { + throw new ProxyError(DEEPSEEK_CONFLICT_ERROR, 400, { + body: "", + providerId: 1, + providerName: "deepseek-anthropic", + }); + }); + + await expect(ProxyForwarder.send(session)).rejects.toThrow(); + expect(doForward).toHaveBeenCalledTimes(1); + + const special = JSON.stringify(session.getSpecialSettings()); + expect(special).toContain("thinking_effort_conflict_rectifier"); + expect(special).toContain('"hit":false'); + }); + + test("同一供应商最多整流重试一次(第二次命中不再重试)", async () => { + const session = createSession(); + session.setProvider(createAnthropicProvider()); + + const doForward = vi.spyOn(ProxyForwarder as any, "doForward"); + doForward.mockImplementation(async (s: ProxySession) => { + // 即使整流移除了 effort 字段,上游仍持续返回同样错误 + void s; + throw new ProxyError(DEEPSEEK_CONFLICT_ERROR, 400, { + body: "", + providerId: 1, + providerName: "deepseek-anthropic", + }); + }); + + await expect(ProxyForwarder.send(session)).rejects.toThrow(); + expect(doForward).toHaveBeenCalledTimes(2); + }); +}); From 8d7c23e60d2f96f2935c862070ff683e7c555456 Mon Sep 17 00:00:00 2001 From: ding113 Date: Thu, 11 Jun 2026 15:17:23 +0800 Subject: [PATCH 05/24] fix: preserve output_config siblings and extend db fallback chain Add missing enableThinkingEffortConflictRectifier field to UpdateSystemSettingsInput to resolve a typecheck failure. Strip only the effort key from output_config in the thinking effort conflict rectifier instead of deleting the entire object, preserving any sibling configuration fields. Extend the system_settings database degradation fallback chain to handle missing enableThinkingEffortConflictRectifier columns in un-migrated databases for both read and update operations. --- ...thinking-effort-conflict-rectifier.test.ts | 28 ++++++ .../thinking-effort-conflict-rectifier.ts | 8 +- src/repository/system-config.ts | 85 +++++++++++++++---- src/types/system-config.ts | 3 + ...stem-config-update-missing-columns.test.ts | 52 ++++++++++++ 5 files changed, 160 insertions(+), 16 deletions(-) diff --git a/src/app/v1/_lib/proxy/thinking-effort-conflict-rectifier.test.ts b/src/app/v1/_lib/proxy/thinking-effort-conflict-rectifier.test.ts index 983779e42..db01dad57 100644 --- a/src/app/v1/_lib/proxy/thinking-effort-conflict-rectifier.test.ts +++ b/src/app/v1/_lib/proxy/thinking-effort-conflict-rectifier.test.ts @@ -76,6 +76,34 @@ describe("rectifyThinkingEffortConflict", () => { expect(message.thinking).toEqual({ type: "disabled" }); }); + test("strips only effort and preserves sibling keys in output_config", () => { + const message: Record = { + thinking: { type: "disabled" }, + output_config: { effort: "max", verbosity: "high", future_flag: true }, + messages: [], + }; + + const result = rectifyThinkingEffortConflict(message); + + expect(result.applied).toBe(true); + expect(result.removedOutputConfig).toBe(true); + expect(result.effort).toBe("max"); + // Sibling fields must survive; only the conflicting effort carrier is removed. + expect(message.output_config).toEqual({ verbosity: "high", future_flag: true }); + }); + + test("drops output_config entirely when effort was its only key", () => { + const message: Record = { + thinking: { type: "disabled" }, + output_config: { effort: "max" }, + messages: [], + }; + + rectifyThinkingEffortConflict(message); + + expect("output_config" in message).toBe(false); + }); + test("removes a top-level reasoning_effort passthrough as well", () => { const message: Record = { thinking: { type: "disabled" }, diff --git a/src/app/v1/_lib/proxy/thinking-effort-conflict-rectifier.ts b/src/app/v1/_lib/proxy/thinking-effort-conflict-rectifier.ts index c907ee6c5..a05110c73 100644 --- a/src/app/v1/_lib/proxy/thinking-effort-conflict-rectifier.ts +++ b/src/app/v1/_lib/proxy/thinking-effort-conflict-rectifier.ts @@ -94,7 +94,13 @@ export function rectifyThinkingEffortConflict( if (outputConfigEffort !== undefined) { result.effort = typeof outputConfigEffort === "string" ? outputConfigEffort : null; - delete message.output_config; + // 仅剥离冲突的 effort 字段,保留 output_config 中的其他配置;若剥离后为空对象则整体移除。 + const { effort: _removedEffort, ...restOutputConfig } = outputConfig as Record; + if (Object.keys(restOutputConfig).length > 0) { + message.output_config = restOutputConfig; + } else { + delete message.output_config; + } result.removedOutputConfig = true; result.applied = true; } diff --git a/src/repository/system-config.ts b/src/repository/system-config.ts index 9dc7722ff..d3f6262b6 100644 --- a/src/repository/system-config.ts +++ b/src/repository/system-config.ts @@ -305,9 +305,33 @@ export async function getSystemSettings(): Promise { error, }); - // 最新降级:移除最近新增的 billHedgeLosers 列。 + // 最新降级:移除最近新增的 enableThinkingEffortConflictRectifier 列。 + const { + enableThinkingEffortConflictRectifier: _omitEffortConflict, + ...selectionWithoutEffortConflict + } = fullSelection; + + try { + const [row] = await db + .select(selectionWithoutEffortConflict) + .from(systemSettings) + .orderBy(asc(systemSettings.id)) + .limit(1); + return row ?? null; + } catch (effortConflictFallbackError) { + if (!isUndefinedColumnError(effortConflictFallbackError)) { + throw effortConflictFallbackError; + } + + logger.warn( + "system_settings 表除 enableThinkingEffortConflictRectifier 外仍有列缺失,继续回退到上一代字段集。", + { error: effortConflictFallbackError } + ); + } + + // 次新降级:移除 billHedgeLosers 列。 const { billHedgeLosers: _omitBillHedgeLosers, ...selectionWithoutBillHedgeLosers } = - fullSelection; + selectionWithoutEffortConflict; try { const [row] = await db @@ -856,26 +880,57 @@ export async function updateSystemSettings( error, }); - // 最新降级:移除最近新增的 billHedgeLosers 列。 - const { billHedgeLosers: _omitUpdateBillHedgeLosers, ...updatesWithoutBillHedgeLosers } = - updates; - const { billHedgeLosers: _omitReturningBillHedgeLosers, ...returningWithoutBillHedgeLosers } = - fullReturning; + // 最新降级:移除最近新增的 enableThinkingEffortConflictRectifier 列。 + const { + enableThinkingEffortConflictRectifier: _omitUpdateEffortConflict, + ...updatesWithoutEffortConflict + } = updates; + const { + enableThinkingEffortConflictRectifier: _omitReturningEffortConflict, + ...returningWithoutEffortConflict + } = fullReturning; try { [updated] = await executor .update(systemSettings) - .set(updatesWithoutBillHedgeLosers) + .set(updatesWithoutEffortConflict) .where(eq(systemSettings.id, current.id)) - .returning(returningWithoutBillHedgeLosers); - } catch (billHedgeLosersFallbackError) { - if (!isUndefinedColumnError(billHedgeLosersFallbackError)) { - throw billHedgeLosersFallbackError; + .returning(returningWithoutEffortConflict); + } catch (effortConflictFallbackError) { + if (!isUndefinedColumnError(effortConflictFallbackError)) { + throw effortConflictFallbackError; } - logger.warn("system_settings 表除 billHedgeLosers 外仍有列缺失,继续降级更新。", { - error: billHedgeLosersFallbackError, - }); + logger.warn( + "system_settings 表除 enableThinkingEffortConflictRectifier 外仍有列缺失,继续降级更新。", + { + error: effortConflictFallbackError, + } + ); + } + + // 次新降级:移除 billHedgeLosers 列。 + const { billHedgeLosers: _omitUpdateBillHedgeLosers, ...updatesWithoutBillHedgeLosers } = + updatesWithoutEffortConflict; + const { billHedgeLosers: _omitReturningBillHedgeLosers, ...returningWithoutBillHedgeLosers } = + returningWithoutEffortConflict; + + if (!updated) { + try { + [updated] = await executor + .update(systemSettings) + .set(updatesWithoutBillHedgeLosers) + .where(eq(systemSettings.id, current.id)) + .returning(returningWithoutBillHedgeLosers); + } catch (billHedgeLosersFallbackError) { + if (!isUndefinedColumnError(billHedgeLosersFallbackError)) { + throw billHedgeLosersFallbackError; + } + + logger.warn("system_settings 表除 billHedgeLosers 外仍有列缺失,继续降级更新。", { + error: billHedgeLosersFallbackError, + }); + } } // 次新降级:移除 billNonSuccessfulRequests 列。 diff --git a/src/types/system-config.ts b/src/types/system-config.ts index 86d15124f..d0d168ed7 100644 --- a/src/types/system-config.ts +++ b/src/types/system-config.ts @@ -201,6 +201,9 @@ export interface UpdateSystemSettingsInput { // thinking budget 整流器(可选) enableThinkingBudgetRectifier?: boolean; + // thinking effort 冲突整流器(可选) + enableThinkingEffortConflictRectifier?: boolean; + // billing header 整流器(可选) enableBillingHeaderRectifier?: boolean; diff --git a/tests/unit/repository/system-config-update-missing-columns.test.ts b/tests/unit/repository/system-config-update-missing-columns.test.ts index 20aaaa318..66a419655 100644 --- a/tests/unit/repository/system-config-update-missing-columns.test.ts +++ b/tests/unit/repository/system-config-update-missing-columns.test.ts @@ -291,6 +291,58 @@ describe("SystemSettings:数据库缺列时的保存兜底", () => { vi.useRealTimers(); }); + test("getSystemSettings 在仅缺 enable_thinking_effort_conflict_rectifier 新列时应降级读取并默认开启", async () => { + vi.resetModules(); + + const now = new Date("2026-01-04T00:00:00.000Z"); + vi.useFakeTimers(); + vi.setSystemTime(now); + + // 第一次 select(fullSelection) 因新列缺失而抛 42703; + // 第二次 select(selectionWithoutEffortConflict) 命中——验证新列已加入降级链最外层。 + const selectMock = vi + .fn() + .mockReturnValueOnce(createRejectedThenableQuery({ code: "42703" })) + .mockReturnValueOnce( + createThenableQuery([ + { + id: 1, + siteTitle: "Claude Code Hub", + allowGlobalUsageView: false, + currencyDisplay: "USD", + billingModelSource: "original", + codexPriorityBillingSource: "requested", + enableHttp2: true, + enableThinkingSignatureRectifier: true, + enableThinkingBudgetRectifier: true, + createdAt: now, + updatedAt: now, + }, + ]) + ); + + vi.doMock("@/drizzle/db", () => ({ + db: { + select: selectMock, + update: vi.fn(() => createThenableQuery([])), + insert: vi.fn(() => createThenableQuery([])), + execute: vi.fn(async () => ({ count: 0 })), + }, + })); + + const { getSystemSettings } = await import("@/repository/system-config"); + + const result = await getSystemSettings(); + + // 降级读取成功(未抛错),且缺失的新列经 transformer 默认开启。 + expect(selectMock).toHaveBeenCalledTimes(2); + expect(result.siteTitle).toBe("Claude Code Hub"); + expect(result.enableHttp2).toBe(true); + expect(result.enableThinkingEffortConflictRectifier).toBe(true); + + vi.useRealTimers(); + }); + test("getSystemSettings 在缺少新列且无记录时应使用降级插入初始化", async () => { vi.resetModules(); From 162939673edcbbdeb75433bdf1023b93a3864c46 Mon Sep 17 00:00:00 2001 From: ding113 Date: Thu, 11 Jun 2026 15:28:20 +0800 Subject: [PATCH 06/24] fix(keys): tighten self-key creation auth and user targeting Deny-by-default canLoginWebUi check (only explicit true proceeds) to prevent read-tier sessions from minting Web-UI-capable keys. Spread session userId last in addKey payload to prevent the request body from overriding the target user. Add tests covering toVoidActionResult delete-code passthrough and double-403 self-fallback permission denied mapping. --- src/app/api/v1/resources/keys/handlers.ts | 9 ++- tests/unit/api/v1/api-client-actions.test.ts | 66 ++++++++++++++++++++ 2 files changed, 72 insertions(+), 3 deletions(-) diff --git a/src/app/api/v1/resources/keys/handlers.ts b/src/app/api/v1/resources/keys/handlers.ts index d4373c276..d9216768c 100644 --- a/src/app/api/v1/resources/keys/handlers.ts +++ b/src/app/api/v1/resources/keys/handlers.ts @@ -81,8 +81,9 @@ export async function createSelfKey(c: Context): Promise { }); } // Read-tier auth admits canLoginWebUi=false keys as read-only sessions; a - // key write here would let them mint a Web-UI-capable key, so reject them. - if (auth?.session?.key?.canLoginWebUi === false) { + // key write here would let them mint a Web-UI-capable key. Deny-by-default + // (only an explicit canLoginWebUi=true session may proceed). + if (auth?.session?.key?.canLoginWebUi !== true) { return createProblemResponse({ status: 403, instance: new URL(c.req.url).pathname, @@ -93,10 +94,12 @@ export async function createSelfKey(c: Context): Promise { const body = await parseHonoJsonBody(c, KeyCreateSchema); if (!body.ok) return body.response; const actions = await import("@/actions/keys"); + // Session user id is spread last so a future schema change can never let the + // request body steer the target user. const result = await callAction( c, actions.addKey, - [{ userId: sessionUserId, ...body.data }] as never[], + [{ ...body.data, userId: sessionUserId }] as never[], c.get("auth") ); if (!result.ok) return actionError(c, result); diff --git a/tests/unit/api/v1/api-client-actions.test.ts b/tests/unit/api/v1/api-client-actions.test.ts index 9650c350e..0de6aaae5 100644 --- a/tests/unit/api/v1/api-client-actions.test.ts +++ b/tests/unit/api/v1/api-client-actions.test.ts @@ -6,12 +6,14 @@ import { ApiError } from "@/lib/api-client/v1/errors"; const getMock = vi.hoisted(() => vi.fn()); const patchMock = vi.hoisted(() => vi.fn()); const postMock = vi.hoisted(() => vi.fn()); +const deleteMock = vi.hoisted(() => vi.fn()); vi.mock("@/lib/api-client/v1/client", () => ({ apiClient: { get: getMock, patch: patchMock, post: postMock, + delete: deleteMock, }, })); @@ -473,6 +475,70 @@ describe("v1 action compatibility client", () => { }); }); + test("preserves business delete codes through toVoidActionResult (removeKey)", async () => { + // The #1266 contract: CANNOT_DELETE_LAST_KEY must survive the void wrapper + // unchanged (not collapsed to a generic code) so the toast shows the reason. + deleteMock.mockRejectedValueOnce( + new ApiError({ + status: 400, + errorCode: "CANNOT_DELETE_LAST_KEY", + detail: "Bad request", + }) + ); + + const result = await keys.removeKey(7); + + expect(deleteMock).toHaveBeenCalledWith("/api/v1/keys/7"); + expect(result).toEqual({ + ok: false, + error: "Bad request", + errorCode: "CANNOT_DELETE_LAST_KEY", + errorParams: undefined, + }); + }); + + test("maps key.action_failed through toVoidActionResult to OPERATION_FAILED", async () => { + deleteMock.mockRejectedValueOnce( + new ApiError({ status: 400, errorCode: "key.action_failed", detail: "Bad request" }) + ); + + const result = await keys.removeKey(7); + + expect(result).toMatchObject({ ok: false, errorCode: "OPERATION_FAILED" }); + }); + + test("surfaces PERMISSION_DENIED when both admin and self key creation are forbidden", async () => { + // Readonly self-service user: admin route 403s, fallback self route also 403s. + postMock + .mockRejectedValueOnce( + new ApiError({ status: 403, errorCode: "auth.forbidden", detail: "Admin access required." }) + ) + .mockRejectedValueOnce( + new ApiError({ + status: 403, + errorCode: "auth.forbidden", + detail: "Read-only sessions cannot create keys.", + }) + ); + + const result = await keys.addKey({ userId: 2, name: "self-key" }); + + expect(postMock).toHaveBeenCalledTimes(2); + expect(postMock).toHaveBeenNthCalledWith( + 1, + "/api/v1/users/2/keys", + { name: "self-key" }, + undefined + ); + expect(postMock).toHaveBeenNthCalledWith( + 2, + "/api/v1/users:self/keys", + { name: "self-key" }, + undefined + ); + expect(result).toMatchObject({ ok: false, errorCode: "PERMISSION_DENIED" }); + }); + test("maps resource action_failed codes to translatable error codes", async () => { getMock.mockRejectedValue( new ApiError({ From ca35d6f5ec40e665c03cced3d77f3c98466cef80 Mon Sep 17 00:00:00 2001 From: ding113 Date: Thu, 11 Jun 2026 15:46:06 +0800 Subject: [PATCH 07/24] test(repository): assert stripped selection in config fallback guard The previous assertion only checked the transformer default value for the new column, which did not guarantee the fallback query actually stripped the correct column. The test now inspects the second select call to ensure the newly added column is removed while older fallback columns remain, catching regressions in the degradation chain order. --- .../system-config-update-missing-columns.test.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/unit/repository/system-config-update-missing-columns.test.ts b/tests/unit/repository/system-config-update-missing-columns.test.ts index 66a419655..addafccb0 100644 --- a/tests/unit/repository/system-config-update-missing-columns.test.ts +++ b/tests/unit/repository/system-config-update-missing-columns.test.ts @@ -334,11 +334,16 @@ describe("SystemSettings:数据库缺列时的保存兜底", () => { const result = await getSystemSettings(); - // 降级读取成功(未抛错),且缺失的新列经 transformer 默认开启。 + // 降级读取成功(未抛错)。 expect(selectMock).toHaveBeenCalledTimes(2); expect(result.siteTitle).toBe("Claude Code Hub"); expect(result.enableHttp2).toBe(true); - expect(result.enableThinkingEffortConflictRectifier).toBe(true); + + // 关键回归保护:第二次 select 必须恰好剥离了新列(最外层降级), + // 而非旧行为先剥离 billHedgeLosers。若新列未加入降级链最外层,下面两条断言会失败。 + const secondSelection = selectMock.mock.calls[1]?.[0] as Record; + expect(secondSelection).not.toHaveProperty("enableThinkingEffortConflictRectifier"); + expect(secondSelection).toHaveProperty("billHedgeLosers"); vi.useRealTimers(); }); From 70b7c96256a90f1829bb08268083952006ac069b Mon Sep 17 00:00:00 2001 From: ding113 Date: Thu, 11 Jun 2026 15:48:04 +0800 Subject: [PATCH 08/24] test(api-client): reset postMock in double-403 fallback test A preceding test left a persistent implementation on postMock, which interfered with the mockRejectedValueOnce calls in the double-403 self-fallback case. Adding mockReset ensures only the intended rejections are in play. --- tests/unit/api/v1/api-client-actions.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/unit/api/v1/api-client-actions.test.ts b/tests/unit/api/v1/api-client-actions.test.ts index 0de6aaae5..f567f401b 100644 --- a/tests/unit/api/v1/api-client-actions.test.ts +++ b/tests/unit/api/v1/api-client-actions.test.ts @@ -508,6 +508,9 @@ describe("v1 action compatibility client", () => { }); test("surfaces PERMISSION_DENIED when both admin and self key creation are forbidden", async () => { + // Drop any persistent implementation a prior test left on postMock so the + // two Once-rejections below are the only behaviors in play. + postMock.mockReset(); // Readonly self-service user: admin route 403s, fallback self route also 403s. postMock .mockRejectedValueOnce( From d0b53fa803daac28f33b18c5577c0b78c7235ac4 Mon Sep 17 00:00:00 2001 From: ding113 Date: Thu, 11 Jun 2026 16:31:50 +0800 Subject: [PATCH 09/24] fix(providers): require explicit action to close create/edit dialogs Provider create and edit dialogs contain long forms that were previously dismissed by accidental outside clicks, window focus loss, or the Escape key, leading to potential data loss. Introduce explicitCloseOnlyDialogProps to neutralize implicit close triggers and apply it across all provider and vendor key dialogs. The dialogs now only close via the explicit close button, cancel, or successful submission. Includes unit tests for the new dialog utility. --- .../_components/add-provider-dialog.tsx | 6 ++++- .../_components/provider-manager.tsx | 6 ++++- .../_components/provider-rich-list-item.tsx | 11 ++++++-- .../_components/vendor-keys-compact-list.tsx | 11 ++++++-- src/lib/utils/dialog.ts | 20 +++++++++++++++ tests/unit/dialog-explicit-close.test.ts | 25 +++++++++++++++++++ 6 files changed, 73 insertions(+), 6 deletions(-) create mode 100644 src/lib/utils/dialog.ts create mode 100644 tests/unit/dialog-explicit-close.test.ts diff --git a/src/app/[locale]/settings/providers/_components/add-provider-dialog.tsx b/src/app/[locale]/settings/providers/_components/add-provider-dialog.tsx index f11ab9676..f1586a2ed 100644 --- a/src/app/[locale]/settings/providers/_components/add-provider-dialog.tsx +++ b/src/app/[locale]/settings/providers/_components/add-provider-dialog.tsx @@ -6,6 +6,7 @@ import { useState } from "react"; import { FormErrorBoundary } from "@/components/form-error-boundary"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; +import { explicitCloseOnlyDialogProps } from "@/lib/utils/dialog"; import { ProviderForm } from "./forms/provider-form"; interface AddProviderDialogProps { @@ -22,7 +23,10 @@ export function AddProviderDialog({ enableMultiProviderTypes }: AddProviderDialo {t("addProvider")} - + {t("addProvider")} diff --git a/src/app/[locale]/settings/providers/_components/provider-manager.tsx b/src/app/[locale]/settings/providers/_components/provider-manager.tsx index 1af3a3992..cdd74c13d 100644 --- a/src/app/[locale]/settings/providers/_components/provider-manager.tsx +++ b/src/app/[locale]/settings/providers/_components/provider-manager.tsx @@ -27,6 +27,7 @@ import { Skeleton } from "@/components/ui/skeleton"; import { Switch } from "@/components/ui/switch"; import { useDebounce } from "@/lib/hooks/use-debounce"; import type { CurrencyCode } from "@/lib/utils/currency"; +import { explicitCloseOnlyDialogProps } from "@/lib/utils/dialog"; import { parseProviderGroups, resolveProviderGroupsWithDefault } from "@/lib/utils/provider-group"; import type { ProviderDisplay, @@ -712,7 +713,10 @@ export function ProviderManager({ open={editingProvider != null} onOpenChange={(open) => !open && setEditingProviderId(null)} > - + {tStrings("editProvider")} diff --git a/src/app/[locale]/settings/providers/_components/provider-rich-list-item.tsx b/src/app/[locale]/settings/providers/_components/provider-rich-list-item.tsx index f952f0c7a..62e04a6f4 100644 --- a/src/app/[locale]/settings/providers/_components/provider-rich-list-item.tsx +++ b/src/app/[locale]/settings/providers/_components/provider-rich-list-item.tsx @@ -69,6 +69,7 @@ import { copyToClipboard, isClipboardSupported } from "@/lib/utils/clipboard"; import { getContrastTextColor, getGroupColor } from "@/lib/utils/color"; import type { CurrencyCode } from "@/lib/utils/currency"; import { formatCurrency } from "@/lib/utils/currency"; +import { explicitCloseOnlyDialogProps } from "@/lib/utils/dialog"; import { normalizeProviderGroupTag, parseProviderGroups } from "@/lib/utils/provider-group"; import type { ProviderCircuitHealth, @@ -1065,7 +1066,10 @@ function ProviderRichListItemInner({ {/* Edit Dialog */} - + {t("editProvider")} @@ -1088,7 +1092,10 @@ function ProviderRichListItemInner({ {/* Clone Dialog */} - + {t("clone")} diff --git a/src/app/[locale]/settings/providers/_components/vendor-keys-compact-list.tsx b/src/app/[locale]/settings/providers/_components/vendor-keys-compact-list.tsx index d357ad490..784304181 100644 --- a/src/app/[locale]/settings/providers/_components/vendor-keys-compact-list.tsx +++ b/src/app/[locale]/settings/providers/_components/vendor-keys-compact-list.tsx @@ -48,6 +48,7 @@ import { PROVIDER_BATCH_PATCH_ERROR_CODES } from "@/lib/provider-batch-patch-err import { getProviderTypeConfig, getProviderTypeTranslationKey } from "@/lib/provider-type-utils"; import { copyToClipboard, isClipboardSupported } from "@/lib/utils/clipboard"; import { type CurrencyCode, formatCurrency } from "@/lib/utils/currency"; +import { explicitCloseOnlyDialogProps } from "@/lib/utils/dialog"; import type { ProviderDisplay, ProviderEndpoint, @@ -105,7 +106,10 @@ export function VendorKeysCompactList(props: { {t("addVendorKey")} - + {t("addVendorKey")} @@ -503,7 +507,10 @@ function VendorKeyRow(props: { - + {t("editProvider")} diff --git a/src/lib/utils/dialog.ts b/src/lib/utils/dialog.ts new file mode 100644 index 000000000..6dc6fd014 --- /dev/null +++ b/src/lib/utils/dialog.ts @@ -0,0 +1,20 @@ +/** + * Props for a (shadcn/Radix) DialogContent that must close ONLY through an + * explicit user action — the close button, a cancel button, or a successful + * submit that flips the controlled `open` state. The dialog will NOT close on + * an outside click, on browser/window focus loss, or on the Escape key. + * + * Used by the provider create/edit dialogs: they hold long forms that should + * not be discarded by an accidental click-away or a focus change. + * + * Spread onto a DialogContent: ``. + */ +export const explicitCloseOnlyDialogProps = { + // Covers both pointer-down-outside and focus-outside (window/tab blur). + onInteractOutside: (event: Event) => { + event.preventDefault(); + }, + onEscapeKeyDown: (event: KeyboardEvent) => { + event.preventDefault(); + }, +}; diff --git a/tests/unit/dialog-explicit-close.test.ts b/tests/unit/dialog-explicit-close.test.ts new file mode 100644 index 000000000..2ccfc11e2 --- /dev/null +++ b/tests/unit/dialog-explicit-close.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it, vi } from "vitest"; +import { explicitCloseOnlyDialogProps } from "@/lib/utils/dialog"; + +describe("explicitCloseOnlyDialogProps", () => { + it("prevents the dialog from closing on outside interaction (click-away / window focus loss)", () => { + const event = { preventDefault: vi.fn() }; + explicitCloseOnlyDialogProps.onInteractOutside(event as unknown as Event); + expect(event.preventDefault).toHaveBeenCalledTimes(1); + }); + + it("prevents the dialog from closing on the Escape key", () => { + const event = { preventDefault: vi.fn() }; + explicitCloseOnlyDialogProps.onEscapeKeyDown(event as unknown as KeyboardEvent); + expect(event.preventDefault).toHaveBeenCalledTimes(1); + }); + + it("exposes exactly the two implicit-close handlers and nothing that itself closes the dialog", () => { + // The close button / cancel / successful submit still close the dialog via + // its own onOpenChange; these props only neutralize the implicit paths. + expect(Object.keys(explicitCloseOnlyDialogProps).sort()).toEqual([ + "onEscapeKeyDown", + "onInteractOutside", + ]); + }); +}); From 1e0bcfaefa640e01d9fe6bf23c0b8baf71ca3c8f Mon Sep 17 00:00:00 2001 From: ding113 Date: Thu, 11 Jun 2026 16:56:47 +0800 Subject: [PATCH 10/24] fix(dialog): allow Escape to close provider dialogs The previous explicitCloseOnlyDialogProps helper blocked the Escape key along with outside clicks and window blur, which was too restrictive for users trying to dismiss long forms. Rename the helper to preventCloseOnOutsideInteraction and drop the onEscapeKeyDown interceptor. This keeps the protection against accidental click-aways and focus loss while letting Escape close the dialog as expected. --- .../_components/add-provider-dialog.tsx | 4 ++-- .../_components/provider-manager.tsx | 4 ++-- .../_components/provider-rich-list-item.tsx | 6 ++--- .../_components/vendor-keys-compact-list.tsx | 6 ++--- src/lib/utils/dialog.ts | 16 ++++++------- tests/unit/dialog-explicit-close.test.ts | 23 ++++++++----------- 6 files changed, 26 insertions(+), 33 deletions(-) diff --git a/src/app/[locale]/settings/providers/_components/add-provider-dialog.tsx b/src/app/[locale]/settings/providers/_components/add-provider-dialog.tsx index f1586a2ed..469e3d85b 100644 --- a/src/app/[locale]/settings/providers/_components/add-provider-dialog.tsx +++ b/src/app/[locale]/settings/providers/_components/add-provider-dialog.tsx @@ -6,7 +6,7 @@ import { useState } from "react"; import { FormErrorBoundary } from "@/components/form-error-boundary"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; -import { explicitCloseOnlyDialogProps } from "@/lib/utils/dialog"; +import { preventCloseOnOutsideInteraction } from "@/lib/utils/dialog"; import { ProviderForm } from "./forms/provider-form"; interface AddProviderDialogProps { @@ -24,7 +24,7 @@ export function AddProviderDialog({ enableMultiProviderTypes }: AddProviderDialo diff --git a/src/app/[locale]/settings/providers/_components/provider-manager.tsx b/src/app/[locale]/settings/providers/_components/provider-manager.tsx index cdd74c13d..b5774a311 100644 --- a/src/app/[locale]/settings/providers/_components/provider-manager.tsx +++ b/src/app/[locale]/settings/providers/_components/provider-manager.tsx @@ -27,7 +27,7 @@ import { Skeleton } from "@/components/ui/skeleton"; import { Switch } from "@/components/ui/switch"; import { useDebounce } from "@/lib/hooks/use-debounce"; import type { CurrencyCode } from "@/lib/utils/currency"; -import { explicitCloseOnlyDialogProps } from "@/lib/utils/dialog"; +import { preventCloseOnOutsideInteraction } from "@/lib/utils/dialog"; import { parseProviderGroups, resolveProviderGroupsWithDefault } from "@/lib/utils/provider-group"; import type { ProviderDisplay, @@ -714,7 +714,7 @@ export function ProviderManager({ onOpenChange={(open) => !open && setEditingProviderId(null)} > diff --git a/src/app/[locale]/settings/providers/_components/provider-rich-list-item.tsx b/src/app/[locale]/settings/providers/_components/provider-rich-list-item.tsx index 62e04a6f4..6e210cf88 100644 --- a/src/app/[locale]/settings/providers/_components/provider-rich-list-item.tsx +++ b/src/app/[locale]/settings/providers/_components/provider-rich-list-item.tsx @@ -69,7 +69,7 @@ import { copyToClipboard, isClipboardSupported } from "@/lib/utils/clipboard"; import { getContrastTextColor, getGroupColor } from "@/lib/utils/color"; import type { CurrencyCode } from "@/lib/utils/currency"; import { formatCurrency } from "@/lib/utils/currency"; -import { explicitCloseOnlyDialogProps } from "@/lib/utils/dialog"; +import { preventCloseOnOutsideInteraction } from "@/lib/utils/dialog"; import { normalizeProviderGroupTag, parseProviderGroups } from "@/lib/utils/provider-group"; import type { ProviderCircuitHealth, @@ -1067,7 +1067,7 @@ function ProviderRichListItemInner({ {/* Edit Dialog */} @@ -1093,7 +1093,7 @@ function ProviderRichListItemInner({ {/* Clone Dialog */} diff --git a/src/app/[locale]/settings/providers/_components/vendor-keys-compact-list.tsx b/src/app/[locale]/settings/providers/_components/vendor-keys-compact-list.tsx index 784304181..29a9da1b5 100644 --- a/src/app/[locale]/settings/providers/_components/vendor-keys-compact-list.tsx +++ b/src/app/[locale]/settings/providers/_components/vendor-keys-compact-list.tsx @@ -48,7 +48,7 @@ import { PROVIDER_BATCH_PATCH_ERROR_CODES } from "@/lib/provider-batch-patch-err import { getProviderTypeConfig, getProviderTypeTranslationKey } from "@/lib/provider-type-utils"; import { copyToClipboard, isClipboardSupported } from "@/lib/utils/clipboard"; import { type CurrencyCode, formatCurrency } from "@/lib/utils/currency"; -import { explicitCloseOnlyDialogProps } from "@/lib/utils/dialog"; +import { preventCloseOnOutsideInteraction } from "@/lib/utils/dialog"; import type { ProviderDisplay, ProviderEndpoint, @@ -107,7 +107,7 @@ export function VendorKeysCompactList(props: { @@ -508,7 +508,7 @@ function VendorKeyRow(props: { diff --git a/src/lib/utils/dialog.ts b/src/lib/utils/dialog.ts index 6dc6fd014..03bc2d930 100644 --- a/src/lib/utils/dialog.ts +++ b/src/lib/utils/dialog.ts @@ -1,20 +1,18 @@ /** - * Props for a (shadcn/Radix) DialogContent that must close ONLY through an - * explicit user action — the close button, a cancel button, or a successful - * submit that flips the controlled `open` state. The dialog will NOT close on - * an outside click, on browser/window focus loss, or on the Escape key. + * Props for a (shadcn/Radix) DialogContent that must NOT close on an outside + * click or on browser/window focus loss (tab/app blur). The Escape key and the + * explicit close paths (close button, cancel, a successful submit that flips the + * controlled `open` state) still close it as usual. * * Used by the provider create/edit dialogs: they hold long forms that should * not be discarded by an accidental click-away or a focus change. * - * Spread onto a DialogContent: ``. + * Spread onto a DialogContent: ``. */ -export const explicitCloseOnlyDialogProps = { +export const preventCloseOnOutsideInteraction = { // Covers both pointer-down-outside and focus-outside (window/tab blur). + // Escape is intentionally left untouched so it still dismisses the dialog. onInteractOutside: (event: Event) => { event.preventDefault(); }, - onEscapeKeyDown: (event: KeyboardEvent) => { - event.preventDefault(); - }, }; diff --git a/tests/unit/dialog-explicit-close.test.ts b/tests/unit/dialog-explicit-close.test.ts index 2ccfc11e2..6ff25d00d 100644 --- a/tests/unit/dialog-explicit-close.test.ts +++ b/tests/unit/dialog-explicit-close.test.ts @@ -1,25 +1,20 @@ import { describe, expect, it, vi } from "vitest"; -import { explicitCloseOnlyDialogProps } from "@/lib/utils/dialog"; +import { preventCloseOnOutsideInteraction } from "@/lib/utils/dialog"; -describe("explicitCloseOnlyDialogProps", () => { +describe("preventCloseOnOutsideInteraction", () => { it("prevents the dialog from closing on outside interaction (click-away / window focus loss)", () => { const event = { preventDefault: vi.fn() }; - explicitCloseOnlyDialogProps.onInteractOutside(event as unknown as Event); + preventCloseOnOutsideInteraction.onInteractOutside(event as unknown as Event); expect(event.preventDefault).toHaveBeenCalledTimes(1); }); - it("prevents the dialog from closing on the Escape key", () => { - const event = { preventDefault: vi.fn() }; - explicitCloseOnlyDialogProps.onEscapeKeyDown(event as unknown as KeyboardEvent); - expect(event.preventDefault).toHaveBeenCalledTimes(1); + it("does NOT intercept the Escape key, so Escape still dismisses the dialog", () => { + // Escape closing is intentionally preserved; the helper must not register an + // onEscapeKeyDown handler (which would otherwise need preventDefault to block it). + expect(preventCloseOnOutsideInteraction).not.toHaveProperty("onEscapeKeyDown"); }); - it("exposes exactly the two implicit-close handlers and nothing that itself closes the dialog", () => { - // The close button / cancel / successful submit still close the dialog via - // its own onOpenChange; these props only neutralize the implicit paths. - expect(Object.keys(explicitCloseOnlyDialogProps).sort()).toEqual([ - "onEscapeKeyDown", - "onInteractOutside", - ]); + it("exposes only the outside-interaction guard and nothing that itself closes the dialog", () => { + expect(Object.keys(preventCloseOnOutsideInteraction)).toEqual(["onInteractOutside"]); }); }); From 1edf230457b4875a0ca02a3e82387392f0d00c1c Mon Sep 17 00:00:00 2001 From: ding113 Date: Thu, 11 Jun 2026 17:15:41 +0800 Subject: [PATCH 11/24] test(dialog): rename test file to match new export name The file dialog-explicit-close.test.ts is renamed to dialog-prevent-close-on-outside-interaction.test.ts to align with the preventCloseOnOutsideInteraction helper it actually covers. --- ...event-close-on-outside-interaction.test.ts | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 tests/unit/dialog-prevent-close-on-outside-interaction.test.ts diff --git a/tests/unit/dialog-prevent-close-on-outside-interaction.test.ts b/tests/unit/dialog-prevent-close-on-outside-interaction.test.ts new file mode 100644 index 000000000..6ff25d00d --- /dev/null +++ b/tests/unit/dialog-prevent-close-on-outside-interaction.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it, vi } from "vitest"; +import { preventCloseOnOutsideInteraction } from "@/lib/utils/dialog"; + +describe("preventCloseOnOutsideInteraction", () => { + it("prevents the dialog from closing on outside interaction (click-away / window focus loss)", () => { + const event = { preventDefault: vi.fn() }; + preventCloseOnOutsideInteraction.onInteractOutside(event as unknown as Event); + expect(event.preventDefault).toHaveBeenCalledTimes(1); + }); + + it("does NOT intercept the Escape key, so Escape still dismisses the dialog", () => { + // Escape closing is intentionally preserved; the helper must not register an + // onEscapeKeyDown handler (which would otherwise need preventDefault to block it). + expect(preventCloseOnOutsideInteraction).not.toHaveProperty("onEscapeKeyDown"); + }); + + it("exposes only the outside-interaction guard and nothing that itself closes the dialog", () => { + expect(Object.keys(preventCloseOnOutsideInteraction)).toEqual(["onInteractOutside"]); + }); +}); From fe1fc52b4d1be3e09198cc2bbb30cd9727e70b3f Mon Sep 17 00:00:00 2001 From: ding113 Date: Thu, 11 Jun 2026 17:38:43 +0800 Subject: [PATCH 12/24] refactor(providers): extract ProviderFormDialogContent wrapper Consolidate the preventCloseOnOutsideInteraction guard and standard layout classes into a shared ProviderFormDialogContent component. This ensures all provider create, edit, and clone dialogs consistently prevent accidental dismissal via outside clicks or window blur without requiring manual prop spreading. Remove the standalone unit test for the interaction guard since the behavior is now encapsulated within the component. --- .../_components/add-provider-dialog.tsx | 11 +++--- .../provider-form-dialog-content.tsx | 34 +++++++++++++++++++ .../_components/provider-manager.tsx | 11 +++--- .../_components/provider-rich-list-item.tsx | 16 +++------ .../_components/vendor-keys-compact-list.tsx | 16 +++------ tests/unit/dialog-explicit-close.test.ts | 20 ----------- 6 files changed, 52 insertions(+), 56 deletions(-) create mode 100644 src/app/[locale]/settings/providers/_components/provider-form-dialog-content.tsx delete mode 100644 tests/unit/dialog-explicit-close.test.ts diff --git a/src/app/[locale]/settings/providers/_components/add-provider-dialog.tsx b/src/app/[locale]/settings/providers/_components/add-provider-dialog.tsx index 469e3d85b..f31fac825 100644 --- a/src/app/[locale]/settings/providers/_components/add-provider-dialog.tsx +++ b/src/app/[locale]/settings/providers/_components/add-provider-dialog.tsx @@ -5,9 +5,9 @@ import { useTranslations } from "next-intl"; import { useState } from "react"; import { FormErrorBoundary } from "@/components/form-error-boundary"; import { Button } from "@/components/ui/button"; -import { Dialog, DialogContent, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; -import { preventCloseOnOutsideInteraction } from "@/lib/utils/dialog"; +import { Dialog, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; import { ProviderForm } from "./forms/provider-form"; +import { ProviderFormDialogContent } from "./provider-form-dialog-content"; interface AddProviderDialogProps { enableMultiProviderTypes: boolean; @@ -23,10 +23,7 @@ export function AddProviderDialog({ enableMultiProviderTypes }: AddProviderDialo {t("addProvider")} - + {t("addProvider")} @@ -39,7 +36,7 @@ export function AddProviderDialog({ enableMultiProviderTypes }: AddProviderDialo }} /> - + ); } diff --git a/src/app/[locale]/settings/providers/_components/provider-form-dialog-content.tsx b/src/app/[locale]/settings/providers/_components/provider-form-dialog-content.tsx new file mode 100644 index 000000000..ecf9dc033 --- /dev/null +++ b/src/app/[locale]/settings/providers/_components/provider-form-dialog-content.tsx @@ -0,0 +1,34 @@ +import type { ComponentProps } from "react"; +import { DialogContent } from "@/components/ui/dialog"; +import { cn } from "@/lib/utils"; +import { preventCloseOnOutsideInteraction } from "@/lib/utils/dialog"; + +/** + * DialogContent preset for the provider create/edit forms: a tall, scrollable + * shell that closes ONLY on explicit action (close button / cancel / successful + * submit / Escape) and never on an outside click or window/tab blur, so a long + * form is not discarded by an accidental click-away. + * + * Pass only the per-dialog max-width via `className`; the shared layout and the + * no-close-on-outside-interaction behavior are baked in. Using this instead of a + * raw `` makes it impossible for a new provider dialog to forget + * the close behavior. + */ +const PROVIDER_FORM_DIALOG_SHELL = + "max-h-[var(--cch-viewport-height-90)] flex flex-col overflow-hidden p-0 gap-0"; + +export function ProviderFormDialogContent({ + className, + children, + ...props +}: ComponentProps) { + return ( + + {children} + + ); +} diff --git a/src/app/[locale]/settings/providers/_components/provider-manager.tsx b/src/app/[locale]/settings/providers/_components/provider-manager.tsx index b5774a311..e099d686d 100644 --- a/src/app/[locale]/settings/providers/_components/provider-manager.tsx +++ b/src/app/[locale]/settings/providers/_components/provider-manager.tsx @@ -13,7 +13,7 @@ import { useTranslations } from "next-intl"; import { type ReactNode, useCallback, useEffect, useMemo, useState } from "react"; import { Button } from "@/components/ui/button"; import { Collapsible, CollapsibleContent } from "@/components/ui/collapsible"; -import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"; +import { Dialog, DialogTitle } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { @@ -27,7 +27,6 @@ import { Skeleton } from "@/components/ui/skeleton"; import { Switch } from "@/components/ui/switch"; import { useDebounce } from "@/lib/hooks/use-debounce"; import type { CurrencyCode } from "@/lib/utils/currency"; -import { preventCloseOnOutsideInteraction } from "@/lib/utils/dialog"; import { parseProviderGroups, resolveProviderGroupsWithDefault } from "@/lib/utils/provider-group"; import type { ProviderDisplay, @@ -43,6 +42,7 @@ import { ProviderBatchToolbar, } from "./batch-edit"; import { ProviderForm } from "./forms/provider-form"; +import { ProviderFormDialogContent } from "./provider-form-dialog-content"; import { ProviderGroupTab } from "./provider-group-tab"; import { ProviderList } from "./provider-list"; import { ProviderSortDropdown, type SortKey } from "./provider-sort-dropdown"; @@ -713,10 +713,7 @@ export function ProviderManager({ open={editingProvider != null} onOpenChange={(open) => !open && setEditingProviderId(null)} > - + {tStrings("editProvider")} @@ -730,7 +727,7 @@ export function ProviderManager({ }} /> ) : null} - +
); diff --git a/src/app/[locale]/settings/providers/_components/provider-rich-list-item.tsx b/src/app/[locale]/settings/providers/_components/provider-rich-list-item.tsx index 6e210cf88..d84cabd5b 100644 --- a/src/app/[locale]/settings/providers/_components/provider-rich-list-item.tsx +++ b/src/app/[locale]/settings/providers/_components/provider-rich-list-item.tsx @@ -69,7 +69,6 @@ import { copyToClipboard, isClipboardSupported } from "@/lib/utils/clipboard"; import { getContrastTextColor, getGroupColor } from "@/lib/utils/color"; import type { CurrencyCode } from "@/lib/utils/currency"; import { formatCurrency } from "@/lib/utils/currency"; -import { preventCloseOnOutsideInteraction } from "@/lib/utils/dialog"; import { normalizeProviderGroupTag, parseProviderGroups } from "@/lib/utils/provider-group"; import type { ProviderCircuitHealth, @@ -84,6 +83,7 @@ import { InlineEditPopover } from "./inline-edit-popover"; import { invalidateProviderQueries } from "./invalidate-provider-queries"; import { PriorityEditPopover } from "./priority-edit-popover"; import { ProviderEndpointHover } from "./provider-endpoint-hover"; +import { ProviderFormDialogContent } from "./provider-form-dialog-content"; interface ProviderRichListItemProps { provider: ProviderDisplay; @@ -1066,10 +1066,7 @@ function ProviderRichListItemInner({ {/* Edit Dialog */} - + {t("editProvider")} @@ -1087,15 +1084,12 @@ function ProviderRichListItemInner({ ) : ( )} - + {/* Clone Dialog */} - + {t("clone")} @@ -1113,7 +1107,7 @@ function ProviderRichListItemInner({ ) : ( )} - + {/* API Key 展示 Dialog */} diff --git a/src/app/[locale]/settings/providers/_components/vendor-keys-compact-list.tsx b/src/app/[locale]/settings/providers/_components/vendor-keys-compact-list.tsx index 29a9da1b5..07ef71108 100644 --- a/src/app/[locale]/settings/providers/_components/vendor-keys-compact-list.tsx +++ b/src/app/[locale]/settings/providers/_components/vendor-keys-compact-list.tsx @@ -48,7 +48,6 @@ import { PROVIDER_BATCH_PATCH_ERROR_CODES } from "@/lib/provider-batch-patch-err import { getProviderTypeConfig, getProviderTypeTranslationKey } from "@/lib/provider-type-utils"; import { copyToClipboard, isClipboardSupported } from "@/lib/utils/clipboard"; import { type CurrencyCode, formatCurrency } from "@/lib/utils/currency"; -import { preventCloseOnOutsideInteraction } from "@/lib/utils/dialog"; import type { ProviderDisplay, ProviderEndpoint, @@ -58,6 +57,7 @@ import type { import type { User } from "@/types/user"; import { ProviderForm } from "./forms/provider-form"; import { InlineEditPopover } from "./inline-edit-popover"; +import { ProviderFormDialogContent } from "./provider-form-dialog-content"; function buildDefaultProviderName(input: { vendorWebsiteDomain: string; @@ -106,10 +106,7 @@ export function VendorKeysCompactList(props: { {t("addVendorKey")} - + {t("addVendorKey")} @@ -153,7 +150,7 @@ export function VendorKeysCompactList(props: { }} /> - + ) : null}
@@ -507,10 +504,7 @@ function VendorKeyRow(props: { - + {t("editProvider")} @@ -531,7 +525,7 @@ function VendorKeyRow(props: { }} /> - + ) : null} {props.canEdit && ( diff --git a/tests/unit/dialog-explicit-close.test.ts b/tests/unit/dialog-explicit-close.test.ts deleted file mode 100644 index 6ff25d00d..000000000 --- a/tests/unit/dialog-explicit-close.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { preventCloseOnOutsideInteraction } from "@/lib/utils/dialog"; - -describe("preventCloseOnOutsideInteraction", () => { - it("prevents the dialog from closing on outside interaction (click-away / window focus loss)", () => { - const event = { preventDefault: vi.fn() }; - preventCloseOnOutsideInteraction.onInteractOutside(event as unknown as Event); - expect(event.preventDefault).toHaveBeenCalledTimes(1); - }); - - it("does NOT intercept the Escape key, so Escape still dismisses the dialog", () => { - // Escape closing is intentionally preserved; the helper must not register an - // onEscapeKeyDown handler (which would otherwise need preventDefault to block it). - expect(preventCloseOnOutsideInteraction).not.toHaveProperty("onEscapeKeyDown"); - }); - - it("exposes only the outside-interaction guard and nothing that itself closes the dialog", () => { - expect(Object.keys(preventCloseOnOutsideInteraction)).toEqual(["onInteractOutside"]); - }); -}); From 63b5c63d2b30549b9797bb93a53f0a9cffcfb2a7 Mon Sep 17 00:00:00 2001 From: Brisbanehuang Date: Thu, 11 Jun 2026 22:38:10 +0800 Subject: [PATCH 13/24] fix(request-filters): preserve advanced mode when rebinding (#1264) --- src/actions/request-filters.ts | 5 ++ .../request-filters-cache-reload.test.ts | 63 ++++++++++++++++++- 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/src/actions/request-filters.ts b/src/actions/request-filters.ts index 4f8f62242..2593bea04 100644 --- a/src/actions/request-filters.ts +++ b/src/actions/request-filters.ts @@ -364,6 +364,9 @@ export async function updateRequestFilterAction( updates.providerIds !== undefined ? updates.providerIds : existing!.providerIds; const effectiveGroupTags = updates.groupTags !== undefined ? updates.groupTags : existing!.groupTags; + const effectiveRuleMode = updates.ruleMode ?? existing!.ruleMode; + const effectiveOperations = + updates.operations !== undefined ? updates.operations : existing!.operations; const validationError = validatePayload({ name: existing!.name, @@ -373,6 +376,8 @@ export async function updateRequestFilterAction( bindingType: effectiveBindingType, providerIds: effectiveProviderIds, groupTags: effectiveGroupTags, + ruleMode: effectiveRuleMode, + operations: effectiveOperations, }); if (validationError) { diff --git a/tests/unit/actions/request-filters-cache-reload.test.ts b/tests/unit/actions/request-filters-cache-reload.test.ts index b7a3e4bb8..3f3584fa0 100644 --- a/tests/unit/actions/request-filters-cache-reload.test.ts +++ b/tests/unit/actions/request-filters-cache-reload.test.ts @@ -5,6 +5,7 @@ const reloadMock = vi.fn(async () => {}); const createRequestFilterMock = vi.fn(); const updateRequestFilterMock = vi.fn(); const deleteRequestFilterMock = vi.fn(); +const getRequestFilterByIdMock = vi.fn(async () => null); vi.mock("@/lib/auth", () => ({ getSession: getSessionMock, @@ -29,7 +30,7 @@ vi.mock("@/repository/request-filters", () => ({ createRequestFilter: createRequestFilterMock, deleteRequestFilter: deleteRequestFilterMock, getAllRequestFilters: vi.fn(async () => []), - getRequestFilterById: vi.fn(async () => null), + getRequestFilterById: getRequestFilterByIdMock, updateRequestFilter: updateRequestFilterMock, })); @@ -112,6 +113,66 @@ describe("request-filters actions reload the engine on mutation", () => { expect(reloadMock).toHaveBeenCalledTimes(1); }); + it("allows updating provider bindings on an advanced final filter with an empty target", async () => { + const advancedFilter = { + ...baseFilter, + target: "", + bindingType: "providers" as const, + providerIds: [5, 4, 6], + ruleMode: "advanced" as const, + executionPhase: "final" as const, + operations: [ + { + op: "remove" as const, + scope: "body" as const, + path: "tools", + matcher: { field: "type", value: "image_generation", matchType: "exact" as const }, + }, + ], + }; + getRequestFilterByIdMock.mockResolvedValue(advancedFilter); + updateRequestFilterMock.mockResolvedValue({ ...advancedFilter, providerIds: [5, 4, 6, 24] }); + + const { updateRequestFilterAction } = await import("@/actions/request-filters"); + const res = await updateRequestFilterAction(1, { providerIds: [5, 4, 6, 24] }); + + expect(res.ok).toBe(true); + expect(updateRequestFilterMock).toHaveBeenCalledWith(1, { providerIds: [5, 4, 6, 24] }); + expect(reloadMock).toHaveBeenCalledTimes(1); + }); + + it("validates binding updates against the effective updated rule mode", async () => { + const advancedFilter = { + ...baseFilter, + target: "", + bindingType: "providers" as const, + providerIds: [5, 4, 6], + ruleMode: "advanced" as const, + executionPhase: "final" as const, + operations: [ + { + op: "remove" as const, + scope: "body" as const, + path: "tools", + matcher: { field: "type", value: "image_generation", matchType: "exact" as const }, + }, + ], + }; + getRequestFilterByIdMock.mockResolvedValue(advancedFilter); + + const { updateRequestFilterAction } = await import("@/actions/request-filters"); + const res = await updateRequestFilterAction(1, { + providerIds: [5, 4, 6, 24], + ruleMode: "simple", + operations: null, + }); + + expect(res.ok).toBe(false); + expect(res.error).toBe("目标字段不能为空"); + expect(updateRequestFilterMock).not.toHaveBeenCalled(); + expect(reloadMock).not.toHaveBeenCalled(); + }); + it("deleteRequestFilterAction reloads the engine after a successful delete", async () => { deleteRequestFilterMock.mockResolvedValue(true); From 9fcb5acaf4f2a36c6c2e5f37959948405ada254a Mon Sep 17 00:00:00 2001 From: Brisbanehuang Date: Thu, 11 Jun 2026 22:41:38 +0800 Subject: [PATCH 14/24] fix(proxy): finalize completed responses streams after client abort (#1251) * fix(proxy): finalize complete responses after client abort * fix(proxy): sanitize inert chat chunks in responses streams --- src/app/v1/_lib/proxy/response-fixer/index.ts | 125 +++++ .../response-fixer/response-fixer.test.ts | 171 ++++++ src/app/v1/_lib/proxy/response-handler.ts | 74 ++- ...esponse-handler-client-abort-drain.test.ts | 512 ++++++++++++++++++ 4 files changed, 867 insertions(+), 15 deletions(-) create mode 100644 tests/unit/proxy/response-handler-client-abort-drain.test.ts diff --git a/src/app/v1/_lib/proxy/response-fixer/index.ts b/src/app/v1/_lib/proxy/response-fixer/index.ts index 56f25928c..8fdae6aa3 100644 --- a/src/app/v1/_lib/proxy/response-fixer/index.ts +++ b/src/app/v1/_lib/proxy/response-fixer/index.ts @@ -24,6 +24,9 @@ const DEFAULT_CONFIG: ResponseFixerConfig = { maxFixSize: 1024 * 1024, }; +const UTF8_DECODER = new TextDecoder(); +const UTF8_ENCODER = new TextEncoder(); + function nowMs(): number { if (typeof performance !== "undefined" && typeof performance.now === "function") { return performance.now(); @@ -31,6 +34,45 @@ function nowMs(): number { return Date.now(); } +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function hasMeaningfulValue(value: unknown): boolean { + if (value == null) return false; + if (typeof value === "string") return value.length > 0; + if (Array.isArray(value)) return value.length > 0; + if (isRecord(value)) return Object.keys(value).length > 0; + return true; +} + +function isInertChatCompletionChoice(choice: unknown): boolean { + if (!isRecord(choice)) return false; + if (choice.finish_reason != null) return false; + + const delta = choice.delta; + if (!isRecord(delta)) { + return true; + } + + for (const [key, value] of Object.entries(delta)) { + if (key === "role") continue; + if (hasMeaningfulValue(value)) return false; + } + + return true; +} + +function isInertChatCompletionChunkPayload(payload: unknown): boolean { + if (!isRecord(payload)) return false; + if (payload.object !== "chat.completion.chunk") return false; + if (hasMeaningfulValue(payload.usage)) return false; + + const choices = payload.choices; + if (!Array.isArray(choices) || choices.length === 0) return false; + return choices.every(isInertChatCompletionChoice); +} + function toArrayBufferUint8Array(input: Uint8Array): Uint8Array { // Response/BodyInit 在 DOM 类型中要求 ArrayBufferView(buffer 为 ArrayBuffer),这里避免 SharedArrayBuffer 类型污染 if (input.buffer instanceof ArrayBuffer) { @@ -385,6 +427,13 @@ export class ResponseFixer { } } + const filtered = ResponseFixer.filterInertResponsesChatCompletionChunks(session, data); + if (filtered.applied) { + applied.sse.applied = true; + applied.sse.details ??= filtered.details; + data = filtered.data; + } + controller.enqueue(data); }, flush(controller) { @@ -418,6 +467,13 @@ export class ResponseFixer { } } + const filtered = ResponseFixer.filterInertResponsesChatCompletionChunks(session, data); + if (filtered.applied) { + applied.sse.applied = true; + applied.sse.details ??= filtered.details; + data = filtered.data; + } + controller.enqueue(data); } @@ -526,6 +582,75 @@ export class ResponseFixer { return { data: concatUint8Chunks(chunks), applied }; } + private static filterInertResponsesChatCompletionChunks( + session: ProxySession, + data: Uint8Array + ): { data: Uint8Array; applied: boolean; details?: string } { + if (session.originalFormat !== "response") { + return { data, applied: false }; + } + + const text = UTF8_DECODER.decode(data); + if (!text.includes('"chat.completion.chunk"')) { + return { data, applied: false }; + } + + const lines = text.split("\n"); + const out: string[] = []; + let applied = false; + let skipNextBlankLine = false; + + for (let i = 0; i < lines.length; i += 1) { + const line = lines[i]; + const hasLineBreak = i < lines.length - 1; + + if (skipNextBlankLine && ResponseFixer.isBlankSseSeparatorLine(line)) { + skipNextBlankLine = false; + continue; + } + skipNextBlankLine = false; + + if (ResponseFixer.isInertChatCompletionDataLine(line)) { + applied = true; + skipNextBlankLine = true; + continue; + } + + out.push(line); + if (hasLineBreak) out.push("\n"); + } + + if (!applied) { + return { data, applied: false }; + } + + return { + data: UTF8_ENCODER.encode(out.join("")), + applied: true, + details: "filtered_inert_chat_completion_chunk", + }; + } + + private static isInertChatCompletionDataLine(line: string): boolean { + if (!line.startsWith("data:")) return false; + + let payloadText = line.slice(5); + if (payloadText.startsWith(" ")) { + payloadText = payloadText.slice(1); + } + if (!payloadText.startsWith("{")) return false; + + try { + return isInertChatCompletionChunkPayload(JSON.parse(payloadText)); + } catch { + return false; + } + } + + private static isBlankSseSeparatorLine(line: string): boolean { + return line === "" || line === "\r"; + } + private static fixMaybeDataJsonLine( line: Uint8Array, jsonFixer: JsonFixer diff --git a/src/app/v1/_lib/proxy/response-fixer/response-fixer.test.ts b/src/app/v1/_lib/proxy/response-fixer/response-fixer.test.ts index 2e4f2728c..e99602d55 100644 --- a/src/app/v1/_lib/proxy/response-fixer/response-fixer.test.ts +++ b/src/app/v1/_lib/proxy/response-fixer/response-fixer.test.ts @@ -44,6 +44,20 @@ function createSession() { } as any; } +function createSseResponse(payloadLines: string[]): Response { + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(payloadLines.join("\n"))); + controller.close(); + }, + }); + + return new Response(stream, { + headers: { "content-type": "text/event-stream" }, + }); +} + describe("ResponseFixer", () => { beforeEach(() => { vi.clearAllMocks(); @@ -205,6 +219,163 @@ describe("ResponseFixer", () => { expect(session.getSpecialSettings()).toBeNull(); }); + test("流式 Responses SSE:应过滤上游混入的空 Chat Completions chunk", async () => { + const { ResponseFixer } = await import("./index"); + + const session = createSession(); + session.originalFormat = "response"; + const encoder = new TextEncoder(); + const emptyChatChunk = { + id: "chatcmpl-dummy", + object: "chat.completion.chunk", + created: 1780753978, + model: "gpt-5.5", + choices: [{ index: 0, delta: { role: "assistant", content: "" } }], + }; + const responseDelta = { + type: "response.output_text.delta", + delta: "Hi", + }; + const responseCompleted = { + type: "response.completed", + response: { id: "resp_test", object: "response" }, + }; + + const stream = new ReadableStream({ + start(controller) { + controller.enqueue( + encoder.encode( + [ + `data: ${JSON.stringify(emptyChatChunk)}`, + "", + "event: response.output_text.delta", + `data: ${JSON.stringify(responseDelta)}`, + "", + "event: response.completed", + `data: ${JSON.stringify(responseCompleted)}`, + "", + ].join("\n") + ) + ); + controller.close(); + }, + }); + + const response = new Response(stream, { + headers: { "content-type": "text/event-stream" }, + }); + + const fixed = await ResponseFixer.process(session, response); + const text = await fixed.text(); + + expect(text).not.toContain("chat.completion.chunk"); + expect(text).not.toContain("chatcmpl-dummy"); + expect(text.startsWith("event: response.output_text.delta")).toBe(true); + expect(text).toContain("response.output_text.delta"); + expect(text).toContain("response.completed"); + expect(session.getSpecialSettings()).not.toBeNull(); + }); + + test("流式 Responses SSE:包含实际 content 的 Chat Completions chunk 应保留", async () => { + const { ResponseFixer } = await import("./index"); + + const session = createSession(); + session.originalFormat = "response"; + const chatChunk = { + id: "chatcmpl-content", + object: "chat.completion.chunk", + created: 1780753978, + model: "gpt-5.5", + choices: [{ index: 0, delta: { role: "assistant", content: "Hi" } }], + }; + + const fixed = await ResponseFixer.process( + session, + createSseResponse([`data: ${JSON.stringify(chatChunk)}`, ""]) + ); + const text = await fixed.text(); + + expect(text).toContain("chat.completion.chunk"); + expect(text).toContain("chatcmpl-content"); + expect(text).toContain('"content":"Hi"'); + expect(session.getSpecialSettings()).toBeNull(); + }); + + test("流式 Responses SSE:带 finish_reason 的 Chat Completions chunk 应保留", async () => { + const { ResponseFixer } = await import("./index"); + + const session = createSession(); + session.originalFormat = "response"; + const chatChunk = { + id: "chatcmpl-finish", + object: "chat.completion.chunk", + created: 1780753978, + model: "gpt-5.5", + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + }; + + const fixed = await ResponseFixer.process( + session, + createSseResponse([`data: ${JSON.stringify(chatChunk)}`, ""]) + ); + const text = await fixed.text(); + + expect(text).toContain("chat.completion.chunk"); + expect(text).toContain("chatcmpl-finish"); + expect(text).toContain("finish_reason"); + expect(session.getSpecialSettings()).toBeNull(); + }); + + test("流式 Responses SSE:带 usage 的 Chat Completions chunk 应保留", async () => { + const { ResponseFixer } = await import("./index"); + + const session = createSession(); + session.originalFormat = "response"; + const chatChunk = { + id: "chatcmpl-usage", + object: "chat.completion.chunk", + created: 1780753978, + model: "gpt-5.5", + choices: [{ index: 0, delta: { role: "assistant", content: "" } }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }; + + const fixed = await ResponseFixer.process( + session, + createSseResponse([`data: ${JSON.stringify(chatChunk)}`, ""]) + ); + const text = await fixed.text(); + + expect(text).toContain("chat.completion.chunk"); + expect(text).toContain("chatcmpl-usage"); + expect(text).toContain("prompt_tokens"); + expect(session.getSpecialSettings()).toBeNull(); + }); + + test("流式非 Responses SSE:空 Chat Completions chunk 应保留", async () => { + const { ResponseFixer } = await import("./index"); + + const session = createSession(); + session.originalFormat = "chat"; + const emptyChatChunk = { + id: "chatcmpl-chat-format", + object: "chat.completion.chunk", + created: 1780753978, + model: "gpt-5.5", + choices: [{ index: 0, delta: { role: "assistant", content: "" } }], + }; + + const fixed = await ResponseFixer.process( + session, + createSseResponse([`data: ${JSON.stringify(emptyChatChunk)}`, ""]) + ); + const text = await fixed.text(); + + expect(text).toContain("chat.completion.chunk"); + expect(text).toContain("chatcmpl-chat-format"); + expect(session.getSpecialSettings()).toBeNull(); + }); + test("流式 SSE:无换行且超过 maxFixSize 时应降级输出,避免无限缓冲", async () => { const { ResponseFixer } = await import("./index"); diff --git a/src/app/v1/_lib/proxy/response-handler.ts b/src/app/v1/_lib/proxy/response-handler.ts index e811b3b59..76d2c83d3 100644 --- a/src/app/v1/_lib/proxy/response-handler.ts +++ b/src/app/v1/_lib/proxy/response-handler.ts @@ -560,6 +560,24 @@ async function finalizeDeferredStreamingFinalizationIfNeeded( const detected = shouldDetectFake200 ? detectUpstreamErrorFromSseOrJsonText(allContent) : ({ isError: false } as const); + const clientAbortCompleteSuccess = (() => { + if ( + streamEndedNormally || + !clientAborted || + upstreamStatusCode < 200 || + upstreamStatusCode >= 300 + ) { + return false; + } + + const abortDetected = detectUpstreamErrorFromSseOrJsonText(allContent); + if (abortDetected.isError) { + return false; + } + + const { usageMetrics } = parseUsageFromResponseText(allContent, provider?.providerType); + return hasPositiveBillableTokens(usageMetrics); + })(); // “内部结算用”的状态码(不会改变客户端实际 HTTP 状态码)。 // - 假 200:优先映射为“推断得到的 4xx/5xx”(未命中则回退 502),确保内部统计/熔断/会话绑定把它当作失败。 @@ -578,6 +596,9 @@ async function finalizeDeferredStreamingFinalizationIfNeeded( effectiveStatusCode = 502; } errorMessage = detected.detail ? `${detected.code}: ${detected.detail}` : detected.code; + } else if (clientAbortCompleteSuccess) { + effectiveStatusCode = upstreamStatusCode; + errorMessage = null; } else if (!streamEndedNormally) { effectiveStatusCode = clientAborted ? 499 : 502; errorMessage = clientAborted ? "CLIENT_ABORTED" : (abortReason ?? "STREAM_ABORTED"); @@ -596,7 +617,7 @@ async function finalizeDeferredStreamingFinalizationIfNeeded( } const shouldClearSessionBindingOnFailure = - !streamEndedNormally || + (!streamEndedNormally && !clientAbortCompleteSuccess) || detected.isError || (upstreamStatusCode >= 400 && errorMessage !== null); @@ -659,7 +680,7 @@ async function finalizeDeferredStreamingFinalizationIfNeeded( // 同时,为了让故障转移/熔断能正确工作: // - 客户端主动中断:不计入熔断器(这通常不是供应商问题) // - 非客户端中断:计入 provider/endpoint 熔断失败(与 timeout 路径保持一致) - if (!streamEndedNormally) { + if (!streamEndedNormally && !clientAbortCompleteSuccess) { await clearSessionBinding(); if (!clientAborted && session.getEndpointPolicy().allowCircuitBreakerAccounting) { @@ -2246,28 +2267,53 @@ export class ProxyResponseHandler { // 使用 AsyncTaskManager 管理后台处理任务 const taskId = `stream-${messageContext?.id || `unknown-${Date.now()}`}`; const abortController = new AbortController(); + const idleTimeoutMs = + provider.streamingIdleTimeoutMs > 0 ? provider.streamingIdleTimeoutMs : Infinity; + const clientAbortDrainTimeoutMs = idleTimeoutMs === Infinity ? 60_000 : idleTimeoutMs; // ⭐ 提升 idleTimeoutId 到外部作用域,以便客户端断开时能清除 let idleTimeoutId: NodeJS.Timeout | null = null; + let clientAbortDrainTimeoutId: NodeJS.Timeout | null = null; + const clearClientAbortDrainTimer = () => { + if (clientAbortDrainTimeoutId) { + clearTimeout(clientAbortDrainTimeoutId); + clientAbortDrainTimeoutId = null; + } + }; const cleanupClientAbortListener = bindClientAbortListener(session.clientAbortSignal, () => { logger.debug("ResponseHandler: Client disconnected, cleaning up", { taskId, providerId: provider.id, messageId: messageContext.id, }); - - // 客户端断开时清除 idle timeout,避免任务已取消后仍误触发。 - if (idleTimeoutId) { - clearTimeout(idleTimeoutId); - idleTimeoutId = null; - logger.debug("ResponseHandler: Idle timeout cleared due to client disconnect", { + // Do not cancel internal accounting on pure client disconnect. If the + // upstream stream has already completed, the tee'd internal branch can + // still drain buffered final usage and record the request as successful. + // Idle/response timeout paths still abort via abortController. + clearClientAbortDrainTimer(); + clientAbortDrainTimeoutId = setTimeout(() => { + logger.info("ResponseHandler: Client abort drain window exceeded", { taskId, providerId: provider.id, + messageId: messageContext.id, + clientAbortDrainTimeoutMs, }); - } - AsyncTaskManager.cancel(taskId); - abortController.abort(); + try { + const sessionWithController = session as typeof session & { + responseController?: AbortController; + }; + sessionWithController.responseController?.abort(new Error("client_abort_drain_timeout")); + } catch (e) { + logger.warn("ResponseHandler: Failed to abort upstream after client drain timeout", { + taskId, + providerId: provider.id, + error: e, + }); + } + + abortController.abort(new Error("client_abort_drain_timeout")); + }, clientAbortDrainTimeoutMs); }); const processingPromise = (async () => { @@ -2280,9 +2326,6 @@ export class ProxyResponseHandler { let usageForCost: UsageMetrics | null = null; let isFirstChunk = true; // ⭐ 标记是否为第一块数据 - // ⭐ 静默期 Watchdog:监控流式请求中途卡住(无新数据推送) - const idleTimeoutMs = - provider.streamingIdleTimeoutMs > 0 ? provider.streamingIdleTimeoutMs : Infinity; const startIdleTimer = () => { if (idleTimeoutMs === Infinity) return; // 禁用时跳过 clearIdleTimer(); // 清除旧的 @@ -2655,7 +2698,7 @@ export class ProxyResponseHandler { let streamEndedNormally = false; while (true) { // 检查取消信号 - if (session.clientAbortSignal?.aborted || abortController.signal.aborted) { + if (abortController.signal.aborted) { logger.info("ResponseHandler: Stream processing cancelled", { taskId, providerId: provider.id, @@ -2933,6 +2976,7 @@ export class ProxyResponseHandler { } finally { // 确保资源释放 cleanupClientAbortListener(); + clearClientAbortDrainTimer(); clearIdleTimer(); // ⭐ 清除静默期计时器(防止泄漏) try { reader.releaseLock(); diff --git a/tests/unit/proxy/response-handler-client-abort-drain.test.ts b/tests/unit/proxy/response-handler-client-abort-drain.test.ts new file mode 100644 index 000000000..d2dfa54be --- /dev/null +++ b/tests/unit/proxy/response-handler-client-abort-drain.test.ts @@ -0,0 +1,512 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { resolveEndpointPolicy } from "@/app/v1/_lib/proxy/endpoint-policy"; +import { ProxyResponseHandler } from "@/app/v1/_lib/proxy/response-handler"; +import { ProxySession } from "@/app/v1/_lib/proxy/session"; +import { setDeferredStreamingFinalization } from "@/app/v1/_lib/proxy/stream-finalization"; +import { AsyncTaskManager } from "@/lib/async-task-manager"; +import { updateMessageRequestDetails, updateMessageRequestDuration } from "@/repository/message"; +import type { Provider } from "@/types/provider"; + +const asyncTasks: Promise[] = []; + +vi.mock("@/app/v1/_lib/proxy/response-fixer", () => ({ + ResponseFixer: { + process: async (_session: unknown, response: Response) => response, + }, +})); + +vi.mock("@/lib/async-task-manager", () => ({ + AsyncTaskManager: { + register: vi.fn((_taskId: string, promise: Promise) => { + asyncTasks.push(promise); + return new AbortController(); + }), + cleanup: vi.fn(), + cancel: vi.fn(), + }, +})); + +vi.mock("@/lib/config/system-settings-cache", () => ({ + getCachedSystemSettings: vi.fn(async () => ({ billNonSuccessfulRequests: false })), +})); + +vi.mock("@/lib/langfuse/emit-proxy-trace", () => ({ + emitProxyLangfuseTrace: vi.fn(), +})); + +vi.mock("@/lib/logger", () => ({ + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + trace: vi.fn(), + }, +})); + +vi.mock("@/lib/price-sync/cloud-price-updater", () => ({ + requestCloudPriceTableSync: vi.fn(), +})); + +vi.mock("@/lib/proxy-status-tracker", () => ({ + ProxyStatusTracker: { + getInstance: () => ({ + endRequest: vi.fn(), + }), + }, +})); + +vi.mock("@/lib/rate-limit", () => ({ + RateLimitService: { + trackCost: vi.fn(), + trackUserDailyCost: vi.fn(), + decrementLeaseBudget: vi.fn(), + }, +})); + +vi.mock("@/lib/redis/live-chain-store", () => ({ + deleteLiveChain: vi.fn(), +})); + +vi.mock("@/lib/session-manager", () => ({ + SessionManager: { + clearSessionProvider: vi.fn(), + extractCodexPromptCacheKey: vi.fn(), + storeSessionResponse: vi.fn(), + storeSessionRequestPhaseSnapshot: vi.fn(), + storeSessionResponsePhaseSnapshot: vi.fn(), + storeSessionRequestHeaders: vi.fn(), + storeSessionResponseHeaders: vi.fn(), + storeSessionSpecialSettings: vi.fn(), + storeSessionUpstreamRequestMeta: vi.fn(), + storeSessionUpstreamResponseMeta: vi.fn(), + updateSessionProvider: vi.fn(), + updateSessionUsage: vi.fn(), + updateSessionWithCodexCacheKey: vi.fn(), + }, +})); + +vi.mock("@/lib/session-tracker", () => ({ + SessionTracker: { + refreshSession: vi.fn(), + }, +})); + +vi.mock("@/lib/circuit-breaker", () => ({ + recordFailure: vi.fn(), + recordSuccess: vi.fn(), +})); + +vi.mock("@/lib/endpoint-circuit-breaker", () => ({ + recordEndpointFailure: vi.fn(), + recordEndpointSuccess: vi.fn(), + resetEndpointCircuit: vi.fn(), +})); + +vi.mock("@/repository/message", () => ({ + updateMessageRequestCostWithBreakdown: vi.fn(), + updateMessageRequestDetails: vi.fn(), + updateMessageRequestDuration: vi.fn(), +})); + +function createProvider(): Provider { + return { + id: 1, + name: "avemujica-responses", + url: "https://api.test.invalid/v1", + key: "sk-test", + providerVendorId: null, + providerType: "codex", + isEnabled: true, + weight: 1, + priority: 1, + groupPriorities: null, + costMultiplier: 1, + groupTag: "OpenAI", + modelRedirects: null, + allowedModels: null, + mcpPassthroughType: "none", + mcpPassthroughUrl: null, + preserveClientIp: false, + limit5hUsd: null, + limitDailyUsd: null, + dailyResetMode: "fixed", + dailyResetTime: "00:00", + limitWeeklyUsd: null, + limitMonthlyUsd: null, + limitTotalUsd: null, + totalCostResetAt: null, + limitConcurrentSessions: 0, + maxRetryAttempts: null, + circuitBreakerFailureThreshold: 5, + circuitBreakerOpenDuration: 1_800_000, + circuitBreakerHalfOpenSuccessThreshold: 2, + proxyUrl: null, + proxyFallbackToDirect: false, + firstByteTimeoutStreamingMs: 0, + streamingIdleTimeoutMs: 0, + requestTimeoutNonStreamingMs: 0, + websiteUrl: null, + faviconUrl: null, + cacheTtlPreference: null, + context1mPreference: null, + codexReasoningEffortPreference: null, + codexReasoningSummaryPreference: null, + codexTextVerbosityPreference: null, + codexParallelToolCallsPreference: null, + anthropicMaxTokensPreference: null, + anthropicThinkingBudgetPreference: null, + geminiGoogleSearchPreference: null, + tpm: 0, + rpm: 0, + rpd: 0, + cc: 0, + createdAt: new Date(), + updatedAt: new Date(), + deletedAt: null, + } as Provider; +} + +function createSession(signal: AbortSignal): ProxySession { + const provider = createProvider(); + const user = { id: 1, name: "admin" }; + const key = { id: 2, name: "Omni" }; + const session = Object.create(ProxySession.prototype) as ProxySession; + + Object.assign(session, { + authState: { success: true, user, key, apiKey: "sk-test" }, + cacheTtlResolved: null, + clientAbortSignal: signal, + context: {}, + context1mApplied: false, + forwardedRequestBody: "", + headerLog: "", + headers: new Headers(), + method: "POST", + messageContext: { + id: 123, + createdAt: new Date(), + user, + key, + apiKey: "sk-test", + }, + originalFormat: "response", + originalModelName: "gpt-5.4-mini", + originalUrlPathname: "/v1/responses", + provider, + providerChain: [], + providerType: "codex", + request: { + log: "", + message: { model: "gpt-5.4-mini", stream: true }, + model: "gpt-5.4-mini", + }, + requestSequence: 1, + requestUrl: new URL("http://localhost/v1/responses"), + sessionId: null, + specialSettings: [], + startTime: Date.now(), + ttfbMs: null, + userAgent: "Go-http-client/1.1", + userName: "admin", + addProviderToChain(this: ProxySession & { providerChain: unknown[] }, prov: Provider, meta) { + this.providerChain.push({ id: prov.id, name: prov.name, ...(meta ?? {}) }); + }, + clearResponseTimeout: vi.fn(), + getContext1mApplied: () => false, + getCurrentModel: () => "gpt-5.4-mini", + getEndpoint: () => "/v1/responses", + getEndpointPolicy: () => resolveEndpointPolicy("/v1/responses"), + getGroupCostMultiplier: () => 1, + getOriginalModel: () => "gpt-5.4-mini", + getProviderChain: () => session.providerChain, + getResolvedPricingByBillingSource: async () => null, + getSpecialSettings: () => [], + isHeaderModified: () => false, + recordTtfb: vi.fn(), + releaseAgent: vi.fn(), + setContext1mApplied: vi.fn(), + shouldPersistSessionDebugArtifacts: () => false, + shouldTrackSessionObservability: () => false, + }); + + return session; +} + +function createResponsesSse(): Response { + const body = [ + `event: response.output_text.done\ndata: ${JSON.stringify({ + type: "response.output_text.done", + text: "短标题", + })}`, + `event: response.completed\ndata: ${JSON.stringify({ + type: "response.completed", + response: { + id: "resp_test", + model: "gpt-5.4-mini-2026-03-17", + usage: { + input_tokens: 463, + output_tokens: 11, + }, + }, + })}`, + "", + ].join("\n\n"); + + return new Response(body, { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); +} + +function createErroredResponsesSse(): Response { + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + start(controller) { + controller.enqueue( + encoder.encode( + `event: response.output_text.delta\ndata: ${JSON.stringify({ + type: "response.output_text.delta", + delta: "短", + })}\n\n` + ) + ); + const error = new Error("Response transmission interrupted"); + error.name = "ResponseAborted"; + controller.error(error); + }, + }); + + return new Response(stream, { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); +} + +function createHangingResponsesSse(upstreamSignal: AbortSignal): Response { + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + start(controller) { + controller.enqueue( + encoder.encode( + `event: response.output_text.delta\ndata: ${JSON.stringify({ + type: "response.output_text.delta", + delta: "短", + })}\n\n` + ) + ); + upstreamSignal.addEventListener( + "abort", + () => { + const error = new Error("client_abort_drain_timeout"); + error.name = "AbortError"; + controller.error(error); + }, + { once: true } + ); + }, + }); + + return new Response(stream, { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); +} + +function createCompletedThenErroredResponsesSse(): Response { + const encoder = new TextEncoder(); + const chunks = [ + `event: response.output_text.done\ndata: ${JSON.stringify({ + type: "response.output_text.done", + text: "短标题", + })}\n\n`, + `event: response.completed\ndata: ${JSON.stringify({ + type: "response.completed", + response: { + id: "resp_test", + model: "gpt-5.4-mini-2026-03-17", + usage: { + input_tokens: 463, + output_tokens: 11, + }, + }, + })}\n\n`, + ]; + let index = 0; + + const stream = new ReadableStream({ + pull(controller) { + if (index < chunks.length) { + controller.enqueue(encoder.encode(chunks[index++])); + return; + } + + const error = new Error("Response transmission interrupted after final usage"); + error.name = "ResponseAborted"; + controller.error(error); + }, + }); + + return new Response(stream, { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); +} + +async function drainAsyncTasks(): Promise { + while (asyncTasks.length > 0) { + const tasks = asyncTasks.splice(0, asyncTasks.length); + await Promise.allSettled(tasks); + await new Promise((resolve) => setTimeout(resolve, 0)); + } +} + +describe("ProxyResponseHandler stream client abort finalization", () => { + beforeEach(() => { + asyncTasks.splice(0, asyncTasks.length); + vi.clearAllMocks(); + }); + + it("finalizes a complete upstream responses stream as success when the downstream client already closed", async () => { + const controller = new AbortController(); + controller.abort(); + const session = createSession(controller.signal); + setDeferredStreamingFinalization(session, { + providerId: 1, + providerName: "avemujica-responses", + providerPriority: 1, + attemptNumber: 1, + totalProvidersAttempted: 1, + isFirstAttempt: true, + isFailoverSuccess: false, + endpointId: 42, + endpointUrl: "https://api.test.invalid/v1", + upstreamStatusCode: 200, + }); + + await ProxyResponseHandler.dispatch(session, createResponsesSse()); + await drainAsyncTasks(); + + expect(AsyncTaskManager.cancel).not.toHaveBeenCalled(); + expect(updateMessageRequestDuration).toHaveBeenCalledWith(123, expect.any(Number)); + expect(updateMessageRequestDetails).toHaveBeenCalledWith( + 123, + expect.objectContaining({ + statusCode: 200, + inputTokens: 463, + outputTokens: 11, + }) + ); + }); + + it("reclassifies a client-aborted stream as success when final usage was already received", async () => { + const controller = new AbortController(); + controller.abort(); + const session = createSession(controller.signal); + setDeferredStreamingFinalization(session, { + providerId: 1, + providerName: "avemujica-responses", + providerPriority: 1, + attemptNumber: 1, + totalProvidersAttempted: 1, + isFirstAttempt: true, + isFailoverSuccess: false, + endpointId: 42, + endpointUrl: "https://api.test.invalid/v1", + upstreamStatusCode: 200, + }); + + await ProxyResponseHandler.dispatch(session, createCompletedThenErroredResponsesSse()); + await drainAsyncTasks(); + + expect(AsyncTaskManager.cancel).not.toHaveBeenCalled(); + expect(updateMessageRequestDetails).toHaveBeenCalledWith( + 123, + expect.objectContaining({ + statusCode: 200, + inputTokens: 463, + outputTokens: 11, + providerChain: [ + expect.objectContaining({ + reason: "request_success", + statusCode: 200, + }), + ], + }) + ); + }); + + it("keeps a genuinely aborted upstream responses stream as 499", async () => { + const controller = new AbortController(); + controller.abort(); + const session = createSession(controller.signal); + setDeferredStreamingFinalization(session, { + providerId: 1, + providerName: "avemujica-responses", + providerPriority: 1, + attemptNumber: 1, + totalProvidersAttempted: 1, + isFirstAttempt: true, + isFailoverSuccess: false, + endpointId: 42, + endpointUrl: "https://api.test.invalid/v1", + upstreamStatusCode: 200, + }); + + await ProxyResponseHandler.dispatch(session, createErroredResponsesSse()); + await drainAsyncTasks(); + + expect(AsyncTaskManager.cancel).not.toHaveBeenCalled(); + expect(updateMessageRequestDetails).toHaveBeenCalledWith( + 123, + expect.objectContaining({ + statusCode: 499, + errorMessage: "CLIENT_ABORTED", + }) + ); + }); + + it("bounds client-abort drain when the upstream stream hangs", async () => { + vi.useFakeTimers(); + try { + const clientController = new AbortController(); + const upstreamController = new AbortController(); + const session = createSession(clientController.signal); + Object.assign(session, { responseController: upstreamController }); + setDeferredStreamingFinalization(session, { + providerId: 1, + providerName: "avemujica-responses", + providerPriority: 1, + attemptNumber: 1, + totalProvidersAttempted: 1, + isFirstAttempt: true, + isFailoverSuccess: false, + endpointId: 42, + endpointUrl: "https://api.test.invalid/v1", + upstreamStatusCode: 200, + }); + + await ProxyResponseHandler.dispatch( + session, + createHangingResponsesSse(upstreamController.signal) + ); + clientController.abort(); + + await vi.advanceTimersByTimeAsync(60_000); + const tasks = asyncTasks.splice(0, asyncTasks.length); + await Promise.allSettled(tasks); + + expect(upstreamController.signal.aborted).toBe(true); + expect(AsyncTaskManager.cancel).not.toHaveBeenCalled(); + expect(updateMessageRequestDetails).toHaveBeenCalledWith( + 123, + expect.objectContaining({ + statusCode: 499, + errorMessage: "CLIENT_ABORTED", + }) + ); + } finally { + vi.useRealTimers(); + } + }); +}); From 553a31056885ed177a0999d6d1bd116f0bb76980 Mon Sep 17 00:00:00 2001 From: Brisbanehuang Date: Thu, 11 Jun 2026 22:42:18 +0800 Subject: [PATCH 15/24] fix(proxy): preserve hedge loser Codex priority billing (#1255) * fix(proxy): preserve hedge loser Codex priority billing * fix(proxy): track hedge loser Redis cost with billing snapshot --- src/app/v1/_lib/proxy/forwarder.ts | 4 + src/app/v1/_lib/proxy/response-handler.ts | 52 +++- ...ponse-handler-hedge-loser-priority.test.ts | 284 ++++++++++++++++++ 3 files changed, 328 insertions(+), 12 deletions(-) create mode 100644 tests/unit/proxy/response-handler-hedge-loser-priority.test.ts diff --git a/src/app/v1/_lib/proxy/forwarder.ts b/src/app/v1/_lib/proxy/forwarder.ts index 523f63a0f..655c36c63 100644 --- a/src/app/v1/_lib/proxy/forwarder.ts +++ b/src/app/v1/_lib/proxy/forwarder.ts @@ -221,6 +221,7 @@ type StreamingHedgeAttempt = { billingSnapshot: { originalModel: string | null; redirectedModel: string | null; + requestedServiceTier: string | null; context1mApplied: boolean; groupCostMultiplier: number; } | null; @@ -4361,9 +4362,12 @@ export class ProxyForwarder { // loser's own billing context FIRST so it is priced against its own model, not the winner's. for (const other of attempts) { if (other !== attempt && other.session === session && other.billAsLoser) { + const loserRequest = session.request.message as Record; other.billingSnapshot = { originalModel: session.getOriginalModel(), redirectedModel: session.getCurrentModel(), + requestedServiceTier: + typeof loserRequest.service_tier === "string" ? loserRequest.service_tier : null, context1mApplied: session.getContext1mApplied(), groupCostMultiplier: session.getGroupCostMultiplier(), }; diff --git a/src/app/v1/_lib/proxy/response-handler.ts b/src/app/v1/_lib/proxy/response-handler.ts index 76d2c83d3..5bd1b6066 100644 --- a/src/app/v1/_lib/proxy/response-handler.ts +++ b/src/app/v1/_lib/proxy/response-handler.ts @@ -188,8 +188,11 @@ function ensurePricingResolutionSpecialSetting( }); } -function getRequestedCodexServiceTier(session: ProxySession): string | null { - if (session.provider?.providerType !== "codex") { +function getRequestedCodexServiceTier( + session: ProxySession, + provider?: Provider | null +): string | null { + if ((provider ?? session.provider)?.providerType !== "codex") { return null; } @@ -248,13 +251,21 @@ type CodexPriorityBillingDecision = { async function resolveCodexPriorityBillingDecision( session: ProxySession, - actualServiceTier: string | null + actualServiceTier: string | null, + options?: { + provider?: Provider | null; + requestedServiceTier?: string | null; + } ): Promise { - if (session.provider?.providerType !== "codex") { + const provider = options?.provider ?? session.provider; + if (provider?.providerType !== "codex") { return null; } - const requestedServiceTier = getRequestedCodexServiceTier(session); + const requestedServiceTier = + options?.requestedServiceTier !== undefined + ? options.requestedServiceTier + : getRequestedCodexServiceTier(session, provider); let billingSourcePreference: Awaited> = "requested"; @@ -3817,6 +3828,7 @@ export async function finalizeHedgeLoserBilling(params: { billingContext?: { originalModel: string | null; redirectedModel: string | null; + requestedServiceTier: string | null; context1mApplied: boolean; groupCostMultiplier: number; }; @@ -3880,8 +3892,12 @@ export async function finalizeHedgeLoserBilling(params: { const actualServiceTier = parseServiceTierFromResponseText(allContent); const priorityServiceTierApplied = - (await resolveCodexPriorityBillingDecision(loserSession, actualServiceTier)) - ?.effectivePriority ?? false; + ( + await resolveCodexPriorityBillingDecision(loserSession, actualServiceTier, { + provider, + ...(billingContext ? { requestedServiceTier: billingContext.requestedServiceTier } : {}), + }) + )?.effectivePriority ?? false; // Mirror the winner: a Codex loser with a large prompt must trigger the 1M context tier, // else it under-bills. Only mutate for shadow-session losers (no snapshot) — the initial // loser uses its pre-pollution snapshot and must not mutate the shared/original session. @@ -3931,7 +3947,12 @@ export async function finalizeHedgeLoserBilling(params: { billableUsage, priorityServiceTierApplied, resolvedPricing, - longContextPricing + longContextPricing, + { + provider, + context1mApplied, + groupCostMultiplier, + } ); logger.info("[HedgeLoserBilling] Billed hedge loser", { @@ -4199,6 +4220,12 @@ export async function finalizeRequestStats( /** * 追踪消费到 Redis(用于限流) */ +type TrackCostBillingOverrides = { + provider?: Provider | null; + context1mApplied?: boolean; + groupCostMultiplier?: number; +}; + async function trackCostToRedis( session: ProxySession, usage: UsageMetrics | null, @@ -4206,14 +4233,15 @@ async function trackCostToRedis( resolvedPricingOverride?: Awaited< ReturnType > | null, - longContextPricingOverride?: ResolvedLongContextPricing | null + longContextPricingOverride?: ResolvedLongContextPricing | null, + billingOverrides?: TrackCostBillingOverrides ): Promise { if (!usage || !session.sessionId) return; if (isNonBillingUsageEndpoint(session)) return; try { const messageContext = session.messageContext; - const provider = session.provider; + const provider = billingOverrides?.provider ?? session.provider; const key = session.authState?.key; const user = session.authState?.user; @@ -4239,10 +4267,10 @@ async function trackCostToRedis( resolvedPricing.priceData, buildCostCalculationOptions( provider.costMultiplier, - session.getContext1mApplied(), + billingOverrides?.context1mApplied ?? session.getContext1mApplied(), priorityServiceTierApplied, longContextPricing, - session.getGroupCostMultiplier() + billingOverrides?.groupCostMultiplier ?? session.getGroupCostMultiplier() ) ); if (cost.lte(0)) return; diff --git a/tests/unit/proxy/response-handler-hedge-loser-priority.test.ts b/tests/unit/proxy/response-handler-hedge-loser-priority.test.ts new file mode 100644 index 000000000..d22d19f36 --- /dev/null +++ b/tests/unit/proxy/response-handler-hedge-loser-priority.test.ts @@ -0,0 +1,284 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + addMessageRequestHedgeLoserCost: vi.fn(async () => {}), + detectUpstreamErrorFromSseOrJsonText: vi.fn(() => ({ isError: false })), + isNonBillingEndpoint: vi.fn(() => false), + trackCost: vi.fn(async () => {}), + trackUserDailyCost: vi.fn(async () => {}), + decrementLeaseBudget: vi.fn(async () => {}), +})); + +vi.mock("@/repository/message", () => ({ + addMessageRequestHedgeLoserCost: mocks.addMessageRequestHedgeLoserCost, + updateMessageRequestCostWithBreakdown: vi.fn(), + updateMessageRequestDetails: vi.fn(), + updateMessageRequestDuration: vi.fn(), + updateMessageRequestWinnerCost: vi.fn(), +})); + +vi.mock("@/lib/logger", () => ({ + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + trace: vi.fn(), + fatal: vi.fn(), + }, +})); + +vi.mock("@/lib/async-task-manager", () => ({ + AsyncTaskManager: { + register: () => new AbortController(), + cleanup: vi.fn(), + cancel: vi.fn(), + }, +})); + +vi.mock("@/lib/utils/upstream-error-detection", () => ({ + detectUpstreamErrorFromSseOrJsonText: mocks.detectUpstreamErrorFromSseOrJsonText, + inferUpstreamErrorStatusCodeFromText: vi.fn(() => null), +})); + +vi.mock("@/lib/rate-limit", () => ({ + RateLimitService: { + trackCost: mocks.trackCost, + trackUserDailyCost: mocks.trackUserDailyCost, + decrementLeaseBudget: mocks.decrementLeaseBudget, + }, +})); + +vi.mock(import("@/lib/utils/performance-formatter"), async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + isNonBillingEndpoint: mocks.isNonBillingEndpoint, + }; +}); + +import { finalizeHedgeLoserBilling } from "@/app/v1/_lib/proxy/response-handler"; +import type { Provider } from "@/types/provider"; + +function createCodexProvider(overrides: Partial = {}): Provider { + return { + id: 11, + name: "initial-codex-loser", + url: "https://codex.example.com/v1", + key: "sk-test", + providerVendorId: null, + isEnabled: true, + weight: 1, + priority: 0, + groupPriorities: null, + costMultiplier: 1, + groupTag: null, + providerType: "codex", + preserveClientIp: false, + modelRedirects: null, + allowedModels: null, + mcpPassthroughType: "none", + mcpPassthroughUrl: null, + limit5hUsd: null, + limitDailyUsd: null, + dailyResetMode: "fixed", + dailyResetTime: "00:00", + limitWeeklyUsd: null, + limitMonthlyUsd: null, + limitTotalUsd: null, + totalCostResetAt: null, + limitConcurrentSessions: 0, + maxRetryAttempts: 1, + circuitBreakerFailureThreshold: 5, + circuitBreakerOpenDuration: 1_800_000, + circuitBreakerHalfOpenSuccessThreshold: 2, + proxyUrl: null, + proxyFallbackToDirect: false, + firstByteTimeoutStreamingMs: 0, + streamingIdleTimeoutMs: 0, + requestTimeoutNonStreamingMs: 0, + websiteUrl: null, + faviconUrl: null, + cacheTtlPreference: null, + context1mPreference: null, + codexReasoningEffortPreference: null, + codexReasoningSummaryPreference: null, + codexTextVerbosityPreference: null, + codexParallelToolCallsPreference: null, + codexServiceTierPreference: null, + anthropicMaxTokensPreference: null, + anthropicThinkingBudgetPreference: null, + anthropicAdaptiveThinking: null, + geminiGoogleSearchPreference: null, + tpm: 0, + rpm: 0, + rpd: 0, + cc: 0, + createdAt: new Date(), + updatedAt: new Date(), + deletedAt: null, + ...overrides, + }; +} + +function createLoserSession( + provider: Provider, + overrides: { + sessionId?: string | null; + context1mApplied?: boolean; + groupCostMultiplier?: number; + } = {} +) { + return { + provider, + request: { + model: "winner-default-model", + message: { + model: "winner-default-model", + service_tier: "default", + }, + }, + sessionId: overrides.sessionId ?? "session-1", + messageContext: { id: 123, createdAt: new Date("2026-06-08T00:00:00.000Z") }, + authState: { + key: { + id: 10, + limit5hResetMode: "rolling", + dailyResetTime: "00:00", + dailyResetMode: "fixed", + }, + user: { + id: 20, + limit5hResetMode: "rolling", + dailyResetTime: "00:00", + dailyResetMode: "fixed", + }, + }, + getEndpoint: () => "/v1/responses", + getOriginalModel: () => "winner-default-model", + getCurrentModel: () => "winner-default-model", + getContext1mApplied: () => overrides.context1mApplied ?? false, + setContext1mApplied: vi.fn(), + getGroupCostMultiplier: () => overrides.groupCostMultiplier ?? 1, + getSpecialSettings: () => [], + addSpecialSetting: vi.fn(), + shouldTrackSessionObservability: () => false, + getCodexPriorityBillingSource: vi.fn(async () => "requested"), + getResolvedPricingByBillingSource: vi.fn(async () => ({ + resolvedModelName: "gpt-5.5", + resolvedPricingProviderKey: "openai", + source: "official_fallback", + priceData: { + input_cost_per_token: 1, + output_cost_per_token: 10, + input_cost_per_token_priority: 2, + output_cost_per_token_priority: 20, + }, + })), + }; +} + +describe("finalizeHedgeLoserBilling Codex priority snapshot", () => { + beforeEach(() => { + mocks.addMessageRequestHedgeLoserCost.mockClear(); + mocks.detectUpstreamErrorFromSseOrJsonText.mockReturnValue({ isError: false }); + mocks.isNonBillingEndpoint.mockReturnValue(false); + mocks.trackCost.mockClear(); + mocks.trackUserDailyCost.mockClear(); + mocks.decrementLeaseBudget.mockClear(); + }); + + it("uses the initial loser's captured requested service tier after winner session sync", async () => { + const provider = createCodexProvider(); + const loserSession = createLoserSession(provider); + const responseBody = JSON.stringify({ + usage: { + input_tokens: 100, + output_tokens: 10, + }, + }); + + const billed = await finalizeHedgeLoserBilling({ + messageRequestId: 123, + loserSession: loserSession as any, + provider, + attemptNumber: 1, + upstreamStatusCode: 200, + allContent: responseBody, + drainComplete: true, + billingContext: { + originalModel: "gpt-5.5", + redirectedModel: "gpt-5.5", + requestedServiceTier: "priority", + context1mApplied: false, + groupCostMultiplier: 1, + }, + }); + + expect(billed).toBe("400"); + expect(mocks.addMessageRequestHedgeLoserCost).toHaveBeenCalledTimes(1); + expect(mocks.addMessageRequestHedgeLoserCost.mock.calls[0]?.[1].toString()).toBe("400"); + }); + + it("tracks Redis loser cost with the captured billing provider and multiplier", async () => { + const loserProvider = createCodexProvider({ + id: 11, + name: "initial-codex-loser", + costMultiplier: 2, + }); + const winnerProvider = createCodexProvider({ + id: 99, + name: "winner-polluted-provider", + costMultiplier: 10, + }); + const loserSession = createLoserSession(winnerProvider, { + context1mApplied: true, + groupCostMultiplier: 99, + }); + const responseBody = JSON.stringify({ + usage: { + input_tokens: 100, + output_tokens: 10, + }, + }); + + const billed = await finalizeHedgeLoserBilling({ + messageRequestId: 123, + loserSession: loserSession as any, + provider: loserProvider, + attemptNumber: 1, + upstreamStatusCode: 200, + allContent: responseBody, + drainComplete: true, + billingContext: { + originalModel: "gpt-5.5", + redirectedModel: "gpt-5.5", + requestedServiceTier: "priority", + context1mApplied: false, + groupCostMultiplier: 3, + }, + }); + + expect(billed).toBe("2400"); + expect(mocks.trackCost).toHaveBeenCalledTimes(1); + expect(mocks.trackCost).toHaveBeenCalledWith( + 10, + loserProvider.id, + "session-1", + 2400, + expect.objectContaining({ + userId: 20, + requestId: 123, + }) + ); + expect(mocks.trackUserDailyCost).toHaveBeenCalledWith( + 20, + 2400, + "00:00", + "fixed", + expect.objectContaining({ + requestId: 123, + }) + ); + }); +}); From 04834e977cb58676c2b15230ec974762aa03b891 Mon Sep 17 00:00:00 2001 From: ding113 Date: Fri, 12 Jun 2026 07:16:44 +0800 Subject: [PATCH 16/24] fix(proxy): require terminal marker to bill client-aborted streams as success Prior behaviour reclassified truncated streams as 200 success if they carried positive usage tokens, but providers like Anthropic emit usage in the first event. Now checks for a format-appropriate completion marker before billing. --- src/app/v1/_lib/proxy/response-handler.ts | 33 +++ ...esponse-handler-client-abort-drain.test.ts | 200 ++++++++++++++++-- 2 files changed, 221 insertions(+), 12 deletions(-) diff --git a/src/app/v1/_lib/proxy/response-handler.ts b/src/app/v1/_lib/proxy/response-handler.ts index 5bd1b6066..a1a9e4999 100644 --- a/src/app/v1/_lib/proxy/response-handler.ts +++ b/src/app/v1/_lib/proxy/response-handler.ts @@ -413,6 +413,28 @@ function hasPositiveBillableTokens(usage: UsageMetrics | null): boolean { return tokens > 0; } +const FINISH_REASON_MARKER = /"finish_reason"\s*:\s*"[a-z_]+"/; +const GEMINI_FINISH_REASON_MARKER = /"finishReason"\s*:\s*"[A-Z_]+"/; + +/** + * 判断流式响应文本中是否存在“与格式匹配的终止完成标记”,用以区分 + * “上游已完整结束(仅客户端先断开)”与“流被客户端中断而截断”。 + * + * 仅 usage>0 不足以证明完成:Anthropic 在首个 `message_start` 即带 usage、 + * Gemini 在中间事件即带 usageMetadata,截断流同样会出现正向 token。 + */ +function hasStreamCompletionMarker(text: string): boolean { + if ( + text.includes("response.completed") || // OpenAI Responses / Codex + text.includes("message_stop") || // Anthropic Messages + text.includes("[DONE]") // OpenAI Chat Completions + ) { + return true; + } + // OpenAI chat / Gemini:非空 finish reason 标记最终块。 + return FINISH_REASON_MARKER.test(text) || GEMINI_FINISH_REASON_MARKER.test(text); +} + export async function resolveBillableUsageMetricsForCost( session: ProxySession, provider: Provider | null, @@ -586,6 +608,17 @@ async function finalizeDeferredStreamingFinalizationIfNeeded( return false; } + // U01: positive usage alone is NOT proof the stream completed — Anthropic + // emits usage in the FIRST `message_start` event and Gemini in intermediate + // `usageMetadata`, so a stream truncated by the client abort still shows + // tokens. Only reclassify as a success when a format-appropriate terminal + // completion marker is present, proving the upstream finished before the + // client stopped reading. Otherwise keep the pre-PR safe default (499, + // unbilled). + if (!hasStreamCompletionMarker(allContent)) { + return false; + } + const { usageMetrics } = parseUsageFromResponseText(allContent, provider?.providerType); return hasPositiveBillableTokens(usageMetrics); })(); diff --git a/tests/unit/proxy/response-handler-client-abort-drain.test.ts b/tests/unit/proxy/response-handler-client-abort-drain.test.ts index d2dfa54be..8f3efa29b 100644 --- a/tests/unit/proxy/response-handler-client-abort-drain.test.ts +++ b/tests/unit/proxy/response-handler-client-abort-drain.test.ts @@ -167,8 +167,22 @@ function createProvider(): Provider { } as Provider; } -function createSession(signal: AbortSignal): ProxySession { +function createSession( + signal: AbortSignal, + overrides: { + providerType?: string; + originalFormat?: string; + endpoint?: string; + model?: string; + } = {} +): ProxySession { const provider = createProvider(); + if (overrides.providerType) { + (provider as { providerType: string }).providerType = overrides.providerType; + } + const originalFormat = overrides.originalFormat ?? "response"; + const endpoint = overrides.endpoint ?? "/v1/responses"; + const model = overrides.model ?? "gpt-5.4-mini"; const user = { id: 1, name: "admin" }; const key = { id: 2, name: "Omni" }; const session = Object.create(ProxySession.prototype) as ProxySession; @@ -190,19 +204,19 @@ function createSession(signal: AbortSignal): ProxySession { key, apiKey: "sk-test", }, - originalFormat: "response", - originalModelName: "gpt-5.4-mini", - originalUrlPathname: "/v1/responses", + originalFormat, + originalModelName: model, + originalUrlPathname: endpoint, provider, providerChain: [], - providerType: "codex", + providerType: overrides.providerType ?? "codex", request: { log: "", - message: { model: "gpt-5.4-mini", stream: true }, - model: "gpt-5.4-mini", + message: { model, stream: true }, + model, }, requestSequence: 1, - requestUrl: new URL("http://localhost/v1/responses"), + requestUrl: new URL(`http://localhost${endpoint}`), sessionId: null, specialSettings: [], startTime: Date.now(), @@ -214,11 +228,11 @@ function createSession(signal: AbortSignal): ProxySession { }, clearResponseTimeout: vi.fn(), getContext1mApplied: () => false, - getCurrentModel: () => "gpt-5.4-mini", - getEndpoint: () => "/v1/responses", - getEndpointPolicy: () => resolveEndpointPolicy("/v1/responses"), + getCurrentModel: () => model, + getEndpoint: () => endpoint, + getEndpointPolicy: () => resolveEndpointPolicy(endpoint), getGroupCostMultiplier: () => 1, - getOriginalModel: () => "gpt-5.4-mini", + getOriginalModel: () => model, getProviderChain: () => session.providerChain, getResolvedPricingByBillingSource: async () => null, getSpecialSettings: () => [], @@ -353,6 +367,93 @@ function createCompletedThenErroredResponsesSse(): Response { }); } +// U01: Anthropic streams carry usage in the FIRST `message_start` event, so a +// truncated mid-stream abort already has positive billable tokens. Without a +// completion marker it must NOT be reclassified as a 200 success. +function createTruncatedClaudeSse(): Response { + const encoder = new TextEncoder(); + // pull-based so the enqueued chunks are actually delivered to the internal + // (tee'd) branch before the error surfaces — a synchronous enqueue+error in + // start() would drop them and the body would read as empty. + const chunks = [ + `event: message_start\ndata: ${JSON.stringify({ + type: "message_start", + message: { + id: "msg_test", + model: "claude-x", + usage: { input_tokens: 463, output_tokens: 1 }, + }, + })}\n\n`, + `event: content_block_delta\ndata: ${JSON.stringify({ + type: "content_block_delta", + delta: { type: "text_delta", text: "部分" }, + })}\n\n`, + ]; + let index = 0; + + const stream = new ReadableStream({ + pull(controller) { + if (index < chunks.length) { + controller.enqueue(encoder.encode(chunks[index++])); + return; + } + // Truncated mid-stream: no message_delta, no terminal message_stop. + const error = new Error("Response transmission interrupted"); + error.name = "ResponseAborted"; + controller.error(error); + }, + }); + + return new Response(stream, { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); +} + +// A genuinely complete Claude stream (terminal `message_stop`) whose socket is +// then dropped by the already-departed client must still bill as success. +function createCompletedThenAbortedClaudeSse(): Response { + const encoder = new TextEncoder(); + const chunks = [ + `event: message_start\ndata: ${JSON.stringify({ + type: "message_start", + message: { + id: "msg_test", + model: "claude-x", + usage: { input_tokens: 463, output_tokens: 1 }, + }, + })}\n\n`, + `event: content_block_delta\ndata: ${JSON.stringify({ + type: "content_block_delta", + delta: { type: "text_delta", text: "完整" }, + })}\n\n`, + `event: message_delta\ndata: ${JSON.stringify({ + type: "message_delta", + delta: { stop_reason: "end_turn" }, + usage: { output_tokens: 11 }, + })}\n\n`, + `event: message_stop\ndata: ${JSON.stringify({ type: "message_stop" })}\n\n`, + ]; + let index = 0; + + const stream = new ReadableStream({ + pull(controller) { + if (index < chunks.length) { + controller.enqueue(encoder.encode(chunks[index++])); + return; + } + const error = new Error("Response transmission interrupted after message_stop"); + error.name = "ResponseAborted"; + controller.error(error); + }, + }); + + return new Response(stream, { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); +} + async function drainAsyncTasks(): Promise { while (asyncTasks.length > 0) { const tasks = asyncTasks.splice(0, asyncTasks.length); @@ -466,6 +567,81 @@ describe("ProxyResponseHandler stream client abort finalization", () => { ); }); + it("keeps a truncated client-aborted Claude stream as 499 despite message_start usage (U01)", async () => { + const controller = new AbortController(); + controller.abort(); + const session = createSession(controller.signal, { + providerType: "anthropic", + originalFormat: "claude", + endpoint: "/v1/messages", + model: "claude-x", + }); + setDeferredStreamingFinalization(session, { + providerId: 1, + providerName: "avemujica-responses", + providerPriority: 1, + attemptNumber: 1, + totalProvidersAttempted: 1, + isFirstAttempt: true, + isFailoverSuccess: false, + endpointId: 42, + endpointUrl: "https://api.test.invalid/v1", + upstreamStatusCode: 200, + }); + + await ProxyResponseHandler.dispatch(session, createTruncatedClaudeSse()); + await drainAsyncTasks(); + + expect(updateMessageRequestDetails).toHaveBeenCalledWith( + 123, + expect.objectContaining({ + statusCode: 499, + errorMessage: "CLIENT_ABORTED", + }) + ); + // Must NOT have been recorded as a billed 200 success. + const calls = (updateMessageRequestDetails as unknown as { mock: { calls: unknown[][] } }).mock + .calls; + const recorded = calls.find((c) => (c[0] as number) === 123)?.[1] as + | { statusCode?: number } + | undefined; + expect(recorded?.statusCode).not.toBe(200); + }); + + it("bills a complete-then-aborted Claude stream as success on the message_stop marker (U01)", async () => { + const controller = new AbortController(); + controller.abort(); + const session = createSession(controller.signal, { + providerType: "anthropic", + originalFormat: "claude", + endpoint: "/v1/messages", + model: "claude-x", + }); + setDeferredStreamingFinalization(session, { + providerId: 1, + providerName: "avemujica-responses", + providerPriority: 1, + attemptNumber: 1, + totalProvidersAttempted: 1, + isFirstAttempt: true, + isFailoverSuccess: false, + endpointId: 42, + endpointUrl: "https://api.test.invalid/v1", + upstreamStatusCode: 200, + }); + + await ProxyResponseHandler.dispatch(session, createCompletedThenAbortedClaudeSse()); + await drainAsyncTasks(); + + expect(updateMessageRequestDetails).toHaveBeenCalledWith( + 123, + expect.objectContaining({ + statusCode: 200, + inputTokens: 463, + }) + ); + }); + it("bounds client-abort drain when the upstream stream hangs", async () => { vi.useFakeTimers(); try { From 7b6ae61651a490eb82077dcc288572db471a751c Mon Sep 17 00:00:00 2001 From: ding113 Date: Fri, 12 Jun 2026 07:16:44 +0800 Subject: [PATCH 17/24] fix(actions): return machine-readable error codes for key creation failures Surfaces specific error codes and parameters when self-service key creation fails due to duplicate names or limit violations, replacing generic failures. --- src/actions/keys.ts | 34 +++++ .../unit/actions/add-key-error-codes.test.ts | 119 ++++++++++++++++++ 2 files changed, 153 insertions(+) create mode 100644 tests/unit/actions/add-key-error-codes.test.ts diff --git a/src/actions/keys.ts b/src/actions/keys.ts index f2863ab6b..230feac5b 100644 --- a/src/actions/keys.ts +++ b/src/actions/keys.ts @@ -189,6 +189,10 @@ export async function addKey(data: { return { ok: false, error: `名为"${validatedData.name}"的密钥已存在且正在生效中,请使用不同的名称`, + // U04: carry a machine-readable code so the self-service REST route can + // surface the specific reason instead of a generic OPERATION_FAILED. + errorCode: ERROR_CODES.DUPLICATE_NAME, + errorParams: { name: validatedData.name }, }; } @@ -206,6 +210,11 @@ export async function addKey(data: { keyLimit: String(validatedData.limit5hUsd), userLimit: String(user.limit5hUsd), }), + errorCode: "KEY_LIMIT_5H_EXCEEDS_USER_LIMIT", + errorParams: { + keyLimit: String(validatedData.limit5hUsd), + userLimit: String(user.limit5hUsd), + }, }; } @@ -222,6 +231,11 @@ export async function addKey(data: { keyLimit: String(validatedData.limitDailyUsd), userLimit: String(user.dailyQuota), }), + errorCode: "KEY_LIMIT_DAILY_EXCEEDS_USER_LIMIT", + errorParams: { + keyLimit: String(validatedData.limitDailyUsd), + userLimit: String(user.dailyQuota), + }, }; } @@ -238,6 +252,11 @@ export async function addKey(data: { keyLimit: String(validatedData.limitWeeklyUsd), userLimit: String(user.limitWeeklyUsd), }), + errorCode: "KEY_LIMIT_WEEKLY_EXCEEDS_USER_LIMIT", + errorParams: { + keyLimit: String(validatedData.limitWeeklyUsd), + userLimit: String(user.limitWeeklyUsd), + }, }; } @@ -254,6 +273,11 @@ export async function addKey(data: { keyLimit: String(validatedData.limitMonthlyUsd), userLimit: String(user.limitMonthlyUsd), }), + errorCode: "KEY_LIMIT_MONTHLY_EXCEEDS_USER_LIMIT", + errorParams: { + keyLimit: String(validatedData.limitMonthlyUsd), + userLimit: String(user.limitMonthlyUsd), + }, }; } @@ -270,6 +294,11 @@ export async function addKey(data: { keyLimit: String(validatedData.limitTotalUsd), userLimit: String(user.limitTotalUsd), }), + errorCode: "KEY_LIMIT_TOTAL_EXCEEDS_USER_LIMIT", + errorParams: { + keyLimit: String(validatedData.limitTotalUsd), + userLimit: String(user.limitTotalUsd), + }, }; } @@ -286,6 +315,11 @@ export async function addKey(data: { keyLimit: String(validatedData.limitConcurrentSessions), userLimit: String(user.limitConcurrentSessions), }), + errorCode: "KEY_LIMIT_CONCURRENT_EXCEEDS_USER_LIMIT", + errorParams: { + keyLimit: String(validatedData.limitConcurrentSessions), + userLimit: String(user.limitConcurrentSessions), + }, }; } diff --git a/tests/unit/actions/add-key-error-codes.test.ts b/tests/unit/actions/add-key-error-codes.test.ts new file mode 100644 index 000000000..f947eba70 --- /dev/null +++ b/tests/unit/actions/add-key-error-codes.test.ts @@ -0,0 +1,119 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// U04: addKey business-validation failures (duplicate name, limit-exceeds-user) +// must carry a machine-readable errorCode (+ errorParams) so the self-service +// REST route (/users:self/keys) surfaces the specific reason instead of a +// generic OPERATION_FAILED toast. Translations are asserted as the identity +// key here (getTranslations mock returns the key verbatim). + +const getSessionMock = vi.fn(); +vi.mock("@/lib/auth", () => ({ + getSession: getSessionMock, +})); + +vi.mock("next/cache", () => ({ + revalidatePath: vi.fn(), +})); + +vi.mock("next-intl/server", () => ({ + getTranslations: vi.fn(async () => (key: string) => key), +})); + +const findActiveKeyByUserIdAndNameMock = vi.fn(async () => null as unknown); +const findKeyListMock = vi.fn(async () => [] as unknown[]); +vi.mock("@/repository/key", () => ({ + countActiveKeysByUser: vi.fn(async () => 0), + createKey: vi.fn(async () => ({ id: 1 })), + findActiveKeyByUserIdAndName: findActiveKeyByUserIdAndNameMock, + findKeyById: vi.fn(), + findKeyList: findKeyListMock, + findKeysWithStatistics: vi.fn(async () => []), + resetKeyCostResetAt: vi.fn(), + updateKey: vi.fn(async () => ({})), +})); + +const findUserByIdMock = vi.fn(); +vi.mock("@/repository/user", () => ({ + findUserById: findUserByIdMock, +})); + +vi.mock("@/actions/users", () => ({ + syncUserProviderGroupFromKeys: vi.fn(async () => undefined), +})); + +vi.mock("@/lib/audit/emit", () => ({ + emitActionAudit: vi.fn(), +})); + +const baseUser = { + id: 7, + name: "self-user", + role: "user" as const, + providerGroup: "default", + limit5hUsd: null, + dailyQuota: null, + limitWeeklyUsd: null, + limitMonthlyUsd: null, + limitTotalUsd: null, + limitConcurrentSessions: null, +}; + +function selfKeyInput(overrides: Record = {}) { + return { + userId: 7, + name: "work", + providerGroup: "default", + ...overrides, + }; +} + +describe("addKey action error codes (self-service surfaceable)", () => { + beforeEach(() => { + vi.clearAllMocks(); + // Non-admin user creating a key for themselves; owns a default-group key so + // the providerGroup subset check passes and we reach the business checks. + getSessionMock.mockResolvedValue({ user: { id: 7, role: "user" } }); + findUserByIdMock.mockResolvedValue(baseUser); + findKeyListMock.mockResolvedValue([{ providerGroup: "default" }]); + findActiveKeyByUserIdAndNameMock.mockResolvedValue(null); + }); + + it("returns DUPLICATE_NAME errorCode + name param on a duplicate active key name", async () => { + findActiveKeyByUserIdAndNameMock.mockResolvedValue({ id: 99, name: "work" }); + + const { addKey } = await import("@/actions/keys"); + const result = await addKey(selfKeyInput()); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errorCode).toBe("DUPLICATE_NAME"); + expect(result.errorParams).toMatchObject({ name: "work" }); + } + }); + + it("returns KEY_LIMIT_5H_EXCEEDS_USER_LIMIT errorCode + params when the key 5h limit exceeds the user cap", async () => { + findUserByIdMock.mockResolvedValue({ ...baseUser, limit5hUsd: 10 }); + + const { addKey } = await import("@/actions/keys"); + const result = await addKey(selfKeyInput({ limit5hUsd: 20 })); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errorCode).toBe("KEY_LIMIT_5H_EXCEEDS_USER_LIMIT"); + expect(result.errorParams).toMatchObject({ keyLimit: "20", userLimit: "10" }); + } + }); + + it("returns KEY_LIMIT_TOTAL_EXCEEDS_USER_LIMIT errorCode + params when the total limit exceeds the user cap", async () => { + findUserByIdMock.mockResolvedValue({ ...baseUser, limitTotalUsd: 100 }); + + const { addKey } = await import("@/actions/keys"); + const result = await addKey(selfKeyInput({ limitTotalUsd: 500 })); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errorCode).toBe("KEY_LIMIT_TOTAL_EXCEEDS_USER_LIMIT"); + expect(result.errorParams).toMatchObject({ keyLimit: "500", userLimit: "100" }); + } + }); +}); From 7a0346229067d4206a2e92f6c8b1cb1ef8ccfddc Mon Sep 17 00:00:00 2001 From: ding113 Date: Fri, 12 Jun 2026 07:16:44 +0800 Subject: [PATCH 18/24] fix(actions): use effective target when validating request filter updates Resolves a regression where converting an advanced filter to simple mode with a new target was rejected because validation checked the stale empty target. --- src/actions/request-filters.ts | 10 ++++- .../request-filters-cache-reload.test.ts | 41 +++++++++++++++++++ 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/src/actions/request-filters.ts b/src/actions/request-filters.ts index 2593bea04..984118c2d 100644 --- a/src/actions/request-filters.ts +++ b/src/actions/request-filters.ts @@ -368,11 +368,17 @@ export async function updateRequestFilterAction( const effectiveOperations = updates.operations !== undefined ? updates.operations : existing!.operations; + // U05: validate against the EFFECTIVE post-update name/target so an + // advanced->simple conversion that supplies a fresh target is not rejected + // by the stale empty target stored on the advanced filter. + const effectiveName = updates.name ?? existing!.name; + const effectiveTarget = updates.target !== undefined ? updates.target : existing!.target; + const validationError = validatePayload({ - name: existing!.name, + name: effectiveName, scope: existing!.scope, action: existing!.action, - target: existing!.target, + target: effectiveTarget, bindingType: effectiveBindingType, providerIds: effectiveProviderIds, groupTags: effectiveGroupTags, diff --git a/tests/unit/actions/request-filters-cache-reload.test.ts b/tests/unit/actions/request-filters-cache-reload.test.ts index 3f3584fa0..c56d351ac 100644 --- a/tests/unit/actions/request-filters-cache-reload.test.ts +++ b/tests/unit/actions/request-filters-cache-reload.test.ts @@ -173,6 +173,47 @@ describe("request-filters actions reload the engine on mutation", () => { expect(reloadMock).not.toHaveBeenCalled(); }); + it("allows advanced->simple conversion when a non-empty target is supplied", async () => { + // Regression (U05): the binding-block revalidation must use the EFFECTIVE + // post-update target, not the stale empty target stored on the advanced + // filter. Switching to simple mode WITH a target must succeed. + const advancedFilter = { + ...baseFilter, + target: "", + bindingType: "providers" as const, + providerIds: [5, 4, 6], + ruleMode: "advanced" as const, + executionPhase: "final" as const, + operations: [ + { + op: "remove" as const, + scope: "body" as const, + path: "tools", + matcher: { field: "type", value: "image_generation", matchType: "exact" as const }, + }, + ], + }; + getRequestFilterByIdMock.mockResolvedValue(advancedFilter); + updateRequestFilterMock.mockResolvedValue({ + ...advancedFilter, + target: "x-my-header", + ruleMode: "simple", + operations: null, + }); + + const { updateRequestFilterAction } = await import("@/actions/request-filters"); + const res = await updateRequestFilterAction(1, { + providerIds: [5, 4, 6, 24], + ruleMode: "simple", + operations: null, + target: "x-my-header", + }); + + expect(res.ok).toBe(true); + expect(updateRequestFilterMock).toHaveBeenCalledTimes(1); + expect(reloadMock).toHaveBeenCalledTimes(1); + }); + it("deleteRequestFilterAction reloads the engine after a successful delete", async () => { deleteRequestFilterMock.mockResolvedValue(true); From c4e1ff474a47e29624750d59c8a6f57ff9486d50 Mon Sep 17 00:00:00 2001 From: ding113 Date: Fri, 12 Jun 2026 07:16:44 +0800 Subject: [PATCH 19/24] fix(ui): hide dashboard link for read-only sessions in usage docs Read-only sessions can view docs but not the dashboard, so the back link dead-ended at login. Gates the dashboard access predicate on canLoginWebUi. --- src/app/[locale]/usage-doc/layout.tsx | 8 +++++++- tests/unit/usage-doc/usage-doc-auth-state.test.tsx | 14 +++++++++++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/src/app/[locale]/usage-doc/layout.tsx b/src/app/[locale]/usage-doc/layout.tsx index 67507770c..2577fe8b5 100644 --- a/src/app/[locale]/usage-doc/layout.tsx +++ b/src/app/[locale]/usage-doc/layout.tsx @@ -44,6 +44,12 @@ export default async function UsageDocLayout({ getUsageTranslations(locale), ]); + // U06: read-only sessions (canLoginWebUi=false) can open the docs but cannot + // use the dashboard, so the "Back to Dashboard" quick link would dead-end at + // the login form. Gate it on the same predicate as DashboardHeader. + const canUseDashboard = + !!session && (session.user?.role === "admin" || session.key?.canLoginWebUi); + return (
{/* 条件渲染头部:已登录显示 DashboardHeader,未登录显示简化版头部 */} @@ -68,7 +74,7 @@ export default async function UsageDocLayout({ )}
- {children} + {children}
); diff --git a/tests/unit/usage-doc/usage-doc-auth-state.test.tsx b/tests/unit/usage-doc/usage-doc-auth-state.test.tsx index 54b1ffb8c..c472c3ada 100644 --- a/tests/unit/usage-doc/usage-doc-auth-state.test.tsx +++ b/tests/unit/usage-doc/usage-doc-auth-state.test.tsx @@ -121,7 +121,19 @@ describe("usage-doc auth state - HttpOnly cookie alignment", () => { "utf8" ); expect(srcContent).toContain("UsageDocAuthProvider"); - expect(srcContent).toContain("isLoggedIn={!!session}"); + expect(srcContent).toContain("isLoggedIn={canUseDashboard}"); expect(srcContent).toContain("getSession({ allowReadOnlyAccess: true })"); }); + + test("layout gates dashboard access on canLoginWebUi for read-only sessions (U06)", async () => { + const srcContent = fs.readFileSync( + path.join(process.cwd(), "src", "app", "[locale]", "usage-doc", "layout.tsx"), + "utf8" + ); + // The provider's isLoggedIn must be derived from a dashboard-access predicate + // (admin OR canLoginWebUi), not from mere session presence, otherwise a + // read-only session gets a "Back to Dashboard" link that dead-ends at /login. + expect(srcContent).toContain("canUseDashboard"); + expect(srcContent).toContain("canLoginWebUi"); + }); }); From 1addcc785b13f01b49848a91e4e0c4f968dc8891 Mon Sep 17 00:00:00 2001 From: ding113 Date: Fri, 12 Jun 2026 10:25:06 +0800 Subject: [PATCH 20/24] fix(i18n): correct documentation label in zh-TW --- messages/zh-TW/myUsage.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/messages/zh-TW/myUsage.json b/messages/zh-TW/myUsage.json index 772f88b47..952f05acb 100644 --- a/messages/zh-TW/myUsage.json +++ b/messages/zh-TW/myUsage.json @@ -2,7 +2,7 @@ "header": { "title": "我的使用量", "welcome": "歡迎,{name}", - "documentation": "使用文件", + "documentation": "使用說明", "logout": "登出", "keyLabel": "金鑰", "userLabel": "使用者", From 632e590ae14381d107216548f3fa4cb15755f9d6 Mon Sep 17 00:00:00 2001 From: ding113 Date: Fri, 12 Jun 2026 10:25:06 +0800 Subject: [PATCH 21/24] refactor(config): make system settings degradation ladder data-driven --- src/repository/system-config.ts | 946 ++++++------------ .../system-config-degradation-ladder.test.ts | 470 +++++++++ 2 files changed, 790 insertions(+), 626 deletions(-) create mode 100644 tests/unit/repository/system-config-degradation-ladder.test.ts diff --git a/src/repository/system-config.ts b/src/repository/system-config.ts index d3f6262b6..a5225e7b9 100644 --- a/src/repository/system-config.ts +++ b/src/repository/system-config.ts @@ -1,6 +1,7 @@ "use server"; import { asc, eq } from "drizzle-orm"; +import type { AnyPgColumn } from "drizzle-orm/pg-core"; import { db } from "@/drizzle/db"; import { systemSettings } from "@/drizzle/schema"; import { logger } from "@/lib/logger"; @@ -196,334 +197,248 @@ function createFallbackSettings(): SystemSettings { }; } -/** - * 获取系统设置,如果不存在则创建默认记录 - */ -export async function getSystemSettings(): Promise { - async function selectSettingsRow() { - const selectionWithoutHighConcurrencyMode = { - id: systemSettings.id, - siteTitle: systemSettings.siteTitle, - allowGlobalUsageView: systemSettings.allowGlobalUsageView, - currencyDisplay: systemSettings.currencyDisplay, - billingModelSource: systemSettings.billingModelSource, - timezone: systemSettings.timezone, - enableAutoCleanup: systemSettings.enableAutoCleanup, - cleanupRetentionDays: systemSettings.cleanupRetentionDays, - cleanupSchedule: systemSettings.cleanupSchedule, - cleanupBatchSize: systemSettings.cleanupBatchSize, - enableClientVersionCheck: systemSettings.enableClientVersionCheck, - verboseProviderError: systemSettings.verboseProviderError, - enableHttp2: systemSettings.enableHttp2, - codexPriorityBillingSource: systemSettings.codexPriorityBillingSource, - interceptAnthropicWarmupRequests: systemSettings.interceptAnthropicWarmupRequests, - enableThinkingSignatureRectifier: systemSettings.enableThinkingSignatureRectifier, - enableThinkingBudgetRectifier: systemSettings.enableThinkingBudgetRectifier, - enableThinkingEffortConflictRectifier: systemSettings.enableThinkingEffortConflictRectifier, - enableBillingHeaderRectifier: systemSettings.enableBillingHeaderRectifier, - enableResponseInputRectifier: systemSettings.enableResponseInputRectifier, - allowNonConversationEndpointProviderFallback: - systemSettings.allowNonConversationEndpointProviderFallback, - enableCodexSessionIdCompletion: systemSettings.enableCodexSessionIdCompletion, - enableClaudeMetadataUserIdInjection: systemSettings.enableClaudeMetadataUserIdInjection, - enableResponseFixer: systemSettings.enableResponseFixer, - responseFixerConfig: systemSettings.responseFixerConfig, - quotaDbRefreshIntervalSeconds: systemSettings.quotaDbRefreshIntervalSeconds, - quotaLeasePercent5h: systemSettings.quotaLeasePercent5h, - quotaLeasePercentDaily: systemSettings.quotaLeasePercentDaily, - quotaLeasePercentWeekly: systemSettings.quotaLeasePercentWeekly, - quotaLeasePercentMonthly: systemSettings.quotaLeasePercentMonthly, - quotaLeaseCapUsd: systemSettings.quotaLeaseCapUsd, - publicStatusWindowHours: systemSettings.publicStatusWindowHours, - publicStatusAggregationIntervalMinutes: systemSettings.publicStatusAggregationIntervalMinutes, - createdAt: systemSettings.createdAt, - updatedAt: systemSettings.updatedAt, - }; - const selectionWithoutPassThrough = { - ...selectionWithoutHighConcurrencyMode, - enableHighConcurrencyMode: systemSettings.enableHighConcurrencyMode, - ipExtractionConfig: systemSettings.ipExtractionConfig, - ipGeoLookupEnabled: systemSettings.ipGeoLookupEnabled, - }; - const selectionWithoutCodexAndHighConcurrency = { - id: systemSettings.id, - siteTitle: systemSettings.siteTitle, - allowGlobalUsageView: systemSettings.allowGlobalUsageView, - currencyDisplay: systemSettings.currencyDisplay, - billingModelSource: systemSettings.billingModelSource, - timezone: systemSettings.timezone, - enableAutoCleanup: systemSettings.enableAutoCleanup, - cleanupRetentionDays: systemSettings.cleanupRetentionDays, - cleanupSchedule: systemSettings.cleanupSchedule, - cleanupBatchSize: systemSettings.cleanupBatchSize, - enableClientVersionCheck: systemSettings.enableClientVersionCheck, - verboseProviderError: systemSettings.verboseProviderError, - enableHttp2: systemSettings.enableHttp2, - interceptAnthropicWarmupRequests: systemSettings.interceptAnthropicWarmupRequests, - enableThinkingSignatureRectifier: systemSettings.enableThinkingSignatureRectifier, - enableThinkingBudgetRectifier: systemSettings.enableThinkingBudgetRectifier, - enableThinkingEffortConflictRectifier: systemSettings.enableThinkingEffortConflictRectifier, - enableBillingHeaderRectifier: systemSettings.enableBillingHeaderRectifier, - enableResponseInputRectifier: systemSettings.enableResponseInputRectifier, - allowNonConversationEndpointProviderFallback: - systemSettings.allowNonConversationEndpointProviderFallback, - enableCodexSessionIdCompletion: systemSettings.enableCodexSessionIdCompletion, - enableClaudeMetadataUserIdInjection: systemSettings.enableClaudeMetadataUserIdInjection, - enableResponseFixer: systemSettings.enableResponseFixer, - responseFixerConfig: systemSettings.responseFixerConfig, - quotaDbRefreshIntervalSeconds: systemSettings.quotaDbRefreshIntervalSeconds, - quotaLeasePercent5h: systemSettings.quotaLeasePercent5h, - quotaLeasePercentDaily: systemSettings.quotaLeasePercentDaily, - quotaLeasePercentWeekly: systemSettings.quotaLeasePercentWeekly, - quotaLeasePercentMonthly: systemSettings.quotaLeasePercentMonthly, - quotaLeaseCapUsd: systemSettings.quotaLeaseCapUsd, - publicStatusWindowHours: systemSettings.publicStatusWindowHours, - publicStatusAggregationIntervalMinutes: systemSettings.publicStatusAggregationIntervalMinutes, - createdAt: systemSettings.createdAt, - updatedAt: systemSettings.updatedAt, - }; - const fullSelection = { - billHedgeLosers: systemSettings.billHedgeLosers, - billNonSuccessfulRequests: systemSettings.billNonSuccessfulRequests, - passThroughUpstreamErrorMessage: systemSettings.passThroughUpstreamErrorMessage, - fakeStreamingWhitelist: systemSettings.fakeStreamingWhitelist, - enableOpenaiResponsesWebsocket: systemSettings.enableOpenaiResponsesWebsocket, - ...selectionWithoutPassThrough, - }; +// --------------------------------------------------------------------------- +// system_settings 列降级阶梯(数据驱动) +// +// 滚动部署期间线上 schema 可能落后于代码(新列尚未迁移)。读取 / 更新遇到 +// 42703(列缺失)时按以下顺序逐层缩小字段集重试: +// 1. 全量字段集; +// 2. 近代新增列按引入顺序逐列累计剥离(RECENT_COLUMN_LADDER,最新列在最前); +// 3. 历史世代字段集(passThrough -> highConcurrency -> codex 世代,已冻结); +// 4. 仅读取链:最小核心字段集,缺失字段交给 toSystemSettings 补默认值。 +// +// 新增 system_settings 列时,在 RECENT_COLUMN_LADDER 头部插入一项即可同时 +// 接入读取与更新的降级链(全量字段集由 BASE + 阶梯自动合成)。 +// --------------------------------------------------------------------------- + +type SettingsSelection = Record; + +// 引入逐列降级阶梯之前已存在的列(含历史世代字段集仍会选取的列)。 +const BASE_SETTINGS_COLUMNS: SettingsSelection = { + id: systemSettings.id, + siteTitle: systemSettings.siteTitle, + allowGlobalUsageView: systemSettings.allowGlobalUsageView, + currencyDisplay: systemSettings.currencyDisplay, + billingModelSource: systemSettings.billingModelSource, + timezone: systemSettings.timezone, + enableAutoCleanup: systemSettings.enableAutoCleanup, + cleanupRetentionDays: systemSettings.cleanupRetentionDays, + cleanupSchedule: systemSettings.cleanupSchedule, + cleanupBatchSize: systemSettings.cleanupBatchSize, + enableClientVersionCheck: systemSettings.enableClientVersionCheck, + verboseProviderError: systemSettings.verboseProviderError, + enableHttp2: systemSettings.enableHttp2, + codexPriorityBillingSource: systemSettings.codexPriorityBillingSource, + interceptAnthropicWarmupRequests: systemSettings.interceptAnthropicWarmupRequests, + enableThinkingSignatureRectifier: systemSettings.enableThinkingSignatureRectifier, + enableThinkingBudgetRectifier: systemSettings.enableThinkingBudgetRectifier, + enableBillingHeaderRectifier: systemSettings.enableBillingHeaderRectifier, + enableResponseInputRectifier: systemSettings.enableResponseInputRectifier, + enableCodexSessionIdCompletion: systemSettings.enableCodexSessionIdCompletion, + enableClaudeMetadataUserIdInjection: systemSettings.enableClaudeMetadataUserIdInjection, + enableResponseFixer: systemSettings.enableResponseFixer, + responseFixerConfig: systemSettings.responseFixerConfig, + quotaDbRefreshIntervalSeconds: systemSettings.quotaDbRefreshIntervalSeconds, + quotaLeasePercent5h: systemSettings.quotaLeasePercent5h, + quotaLeasePercentDaily: systemSettings.quotaLeasePercentDaily, + quotaLeasePercentWeekly: systemSettings.quotaLeasePercentWeekly, + quotaLeasePercentMonthly: systemSettings.quotaLeasePercentMonthly, + quotaLeaseCapUsd: systemSettings.quotaLeaseCapUsd, + publicStatusWindowHours: systemSettings.publicStatusWindowHours, + publicStatusAggregationIntervalMinutes: systemSettings.publicStatusAggregationIntervalMinutes, + createdAt: systemSettings.createdAt, + updatedAt: systemSettings.updatedAt, + passThroughUpstreamErrorMessage: systemSettings.passThroughUpstreamErrorMessage, + enableHighConcurrencyMode: systemSettings.enableHighConcurrencyMode, + ipExtractionConfig: systemSettings.ipExtractionConfig, + ipGeoLookupEnabled: systemSettings.ipGeoLookupEnabled, +}; + +// 近代新增列的降级阶梯:最新列在最前,第 N 层降级累计剥离前 N 项。 +const RECENT_COLUMN_LADDER: ReadonlyArray<{ + key: string; + column: AnyPgColumn; + // 本层读取失败(仍有列缺失)时记录的告警 + selectWarn: string; + // 本层更新失败(仍有列缺失)时记录的告警 + updateWarn: string; +}> = [ + { + key: "enableThinkingEffortConflictRectifier", + column: systemSettings.enableThinkingEffortConflictRectifier, + selectWarn: + "system_settings 表除 enableThinkingEffortConflictRectifier 外仍有列缺失,继续回退到上一代字段集。", + updateWarn: + "system_settings 表除 enableThinkingEffortConflictRectifier 外仍有列缺失,继续降级更新。", + }, + { + key: "billHedgeLosers", + column: systemSettings.billHedgeLosers, + selectWarn: "system_settings 表除 billHedgeLosers 外仍有列缺失,继续回退到上一代字段集。", + updateWarn: "system_settings 表除 billHedgeLosers 外仍有列缺失,继续降级更新。", + }, + { + key: "billNonSuccessfulRequests", + column: systemSettings.billNonSuccessfulRequests, + selectWarn: + "system_settings 表除 billNonSuccessfulRequests 外仍有列缺失,继续回退到上一代字段集。", + updateWarn: "system_settings 表除 billNonSuccessfulRequests 外仍有列缺失,继续降级更新。", + }, + { + key: "enableOpenaiResponsesWebsocket", + column: systemSettings.enableOpenaiResponsesWebsocket, + selectWarn: + "system_settings 表除 enableOpenaiResponsesWebsocket 外仍有列缺失,继续回退到上一代字段集。", + updateWarn: "system_settings 表除 enableOpenaiResponsesWebsocket 外仍有列缺失,继续降级更新。", + }, + { + key: "fakeStreamingWhitelist", + column: systemSettings.fakeStreamingWhitelist, + selectWarn: + "system_settings 表除 fakeStreamingWhitelist 外仍有列缺失,继续回退到上一代字段集。", + updateWarn: + "system_settings 表除 fakeStreamingWhitelist 外仍有列缺失,继续回退到 allowNonConversationEndpointProviderFallback 之外的字段集。", + }, + { + key: "allowNonConversationEndpointProviderFallback", + column: systemSettings.allowNonConversationEndpointProviderFallback, + selectWarn: + "system_settings 表除新增列外仍有列缺失,继续回退到 withoutHighConcurrencyMode 字段集。", + updateWarn: + "system_settings 表除新增列外仍有列缺失,继续回退到 passThrough / highConcurrency 字段集更新。", + }, +]; + +// 历史世代字段集(冻结):passThrough 世代之前的 schema 没有以下五列。 +// 注意:世代字段集相对近代阶梯末层会重新选取更晚引入的列(与历史实现一致)。 +const PASS_THROUGH_ERA_OMIT: readonly string[] = [ + "billHedgeLosers", + "billNonSuccessfulRequests", + "passThroughUpstreamErrorMessage", + "fakeStreamingWhitelist", + "enableOpenaiResponsesWebsocket", +]; +const HIGH_CONCURRENCY_ERA_OMIT: readonly string[] = [ + ...PASS_THROUGH_ERA_OMIT, + "enableHighConcurrencyMode", + "ipExtractionConfig", + "ipGeoLookupEnabled", +]; +// 历史实现的不对称:读取链的 codex 世代保留 allowNonConversationEndpointProviderFallback, +// 更新链的 codex 世代连同剥离。 +const CODEX_ERA_SELECT_OMIT: readonly string[] = [ + ...HIGH_CONCURRENCY_ERA_OMIT, + "codexPriorityBillingSource", +]; +const CODEX_ERA_RETURNING_OMIT: readonly string[] = [ + ...CODEX_ERA_SELECT_OMIT, + "allowNonConversationEndpointProviderFallback", +]; +// 最终回退:仅查询最小核心字段,剩余字段交给 toSystemSettings 补默认值。 +const MINIMAL_SELECTION_KEYS: readonly string[] = [ + "id", + "siteTitle", + "allowGlobalUsageView", + "currencyDisplay", + "billingModelSource", + "createdAt", + "updatedAt", +]; + +function omitKeys>(source: T, keys: readonly string[]): T { + const result: Record = { ...source }; + for (const key of keys) { + delete result[key]; + } + return result as T; +} + +function pickKeys(source: SettingsSelection, keys: readonly string[]): SettingsSelection { + const result: SettingsSelection = {}; + for (const key of keys) { + result[key] = source[key]; + } + return result; +} + +function buildFullSettingsSelection(): SettingsSelection { + const selection: SettingsSelection = { ...BASE_SETTINGS_COLUMNS }; + for (const rung of RECENT_COLUMN_LADDER) { + selection[rung.key] = rung.column; + } + return selection; +} + +type SelectAttempt = { + selection: SettingsSelection; + // 本层读取失败时记录的告警;最后一层不设告警,错误直接向上抛出。 + warnOnMissingColumn?: string; +}; + +function buildSelectAttempts(): SelectAttempt[] { + const fullSelection = buildFullSettingsSelection(); + const attempts: SelectAttempt[] = [ + { + selection: fullSelection, + warnOnMissingColumn: "system_settings 表列缺失,使用降级字段集读取(建议运行数据库迁移)。", + }, + ]; + + const strippedKeys: string[] = []; + for (const rung of RECENT_COLUMN_LADDER) { + strippedKeys.push(rung.key); + attempts.push({ + selection: omitKeys(fullSelection, strippedKeys), + warnOnMissingColumn: rung.selectWarn, + }); + } + + attempts.push( + { + selection: omitKeys(fullSelection, PASS_THROUGH_ERA_OMIT), + warnOnMissingColumn: + "system_settings 表缺少 passThroughUpstreamErrorMessage 之外的新列,继续降级读取。", + }, + { + selection: omitKeys(fullSelection, HIGH_CONCURRENCY_ERA_OMIT), + warnOnMissingColumn: "system_settings 表存在多个缺失列,继续使用 legacy 字段集读取。", + }, + { + selection: omitKeys(fullSelection, CODEX_ERA_SELECT_OMIT), + warnOnMissingColumn: "system_settings 表存在更多缺失列,继续使用最小字段集读取。", + }, + { selection: pickKeys(fullSelection, MINIMAL_SELECTION_KEYS) } + ); + return attempts; +} + +async function selectSettingsRow() { + for (const attempt of buildSelectAttempts()) { try { const [row] = await db - .select(fullSelection) + .select(attempt.selection) .from(systemSettings) .orderBy(asc(systemSettings.id)) .limit(1); return row ?? null; } catch (error) { - // 兼容旧版本数据库:system_settings 表存在但列未迁移齐全 - if (isUndefinedColumnError(error)) { - logger.warn("system_settings 表列缺失,使用降级字段集读取(建议运行数据库迁移)。", { - error, - }); - - // 最新降级:移除最近新增的 enableThinkingEffortConflictRectifier 列。 - const { - enableThinkingEffortConflictRectifier: _omitEffortConflict, - ...selectionWithoutEffortConflict - } = fullSelection; - - try { - const [row] = await db - .select(selectionWithoutEffortConflict) - .from(systemSettings) - .orderBy(asc(systemSettings.id)) - .limit(1); - return row ?? null; - } catch (effortConflictFallbackError) { - if (!isUndefinedColumnError(effortConflictFallbackError)) { - throw effortConflictFallbackError; - } - - logger.warn( - "system_settings 表除 enableThinkingEffortConflictRectifier 外仍有列缺失,继续回退到上一代字段集。", - { error: effortConflictFallbackError } - ); - } - - // 次新降级:移除 billHedgeLosers 列。 - const { billHedgeLosers: _omitBillHedgeLosers, ...selectionWithoutBillHedgeLosers } = - selectionWithoutEffortConflict; - - try { - const [row] = await db - .select(selectionWithoutBillHedgeLosers) - .from(systemSettings) - .orderBy(asc(systemSettings.id)) - .limit(1); - return row ?? null; - } catch (billHedgeLosersFallbackError) { - if (!isUndefinedColumnError(billHedgeLosersFallbackError)) { - throw billHedgeLosersFallbackError; - } - - logger.warn( - "system_settings 表除 billHedgeLosers 外仍有列缺失,继续回退到上一代字段集。", - { error: billHedgeLosersFallbackError } - ); - } - - // 次新降级:移除 billNonSuccessfulRequests 列。 - const { - billNonSuccessfulRequests: _omitBillNonSuccessful, - ...selectionWithoutBillNonSuccessful - } = selectionWithoutBillHedgeLosers; - - try { - const [row] = await db - .select(selectionWithoutBillNonSuccessful) - .from(systemSettings) - .orderBy(asc(systemSettings.id)) - .limit(1); - return row ?? null; - } catch (billNonSuccessfulFallbackError) { - if (!isUndefinedColumnError(billNonSuccessfulFallbackError)) { - throw billNonSuccessfulFallbackError; - } - - logger.warn( - "system_settings 表除 billNonSuccessfulRequests 外仍有列缺失,继续回退到上一代字段集。", - { error: billNonSuccessfulFallbackError } - ); - } - - // 第零层降级:仅移除最新增加的 enableOpenaiResponsesWebsocket 列。 - const { - enableOpenaiResponsesWebsocket: _omitOpenaiResponsesWebsocket, - ...selectionWithoutOpenaiResponsesWebsocket - } = selectionWithoutBillNonSuccessful; - - try { - const [row] = await db - .select(selectionWithoutOpenaiResponsesWebsocket) - .from(systemSettings) - .orderBy(asc(systemSettings.id)) - .limit(1); - return row ?? null; - } catch (openaiResponsesWebsocketFallbackError) { - if (!isUndefinedColumnError(openaiResponsesWebsocketFallbackError)) { - throw openaiResponsesWebsocketFallbackError; - } - - logger.warn( - "system_settings 表除 enableOpenaiResponsesWebsocket 外仍有列缺失,继续回退到上一代字段集。", - { error: openaiResponsesWebsocketFallbackError } - ); - } - - // 第一层降级:再移除 fakeStreamingWhitelist 列。 - const { - fakeStreamingWhitelist: _omitFakeStreamingWhitelist, - ...selectionWithoutFakeStreamingWhitelist - } = selectionWithoutOpenaiResponsesWebsocket; - - try { - const [row] = await db - .select(selectionWithoutFakeStreamingWhitelist) - .from(systemSettings) - .orderBy(asc(systemSettings.id)) - .limit(1); - return row ?? null; - } catch (fakeStreamingFallbackError) { - if (!isUndefinedColumnError(fakeStreamingFallbackError)) { - throw fakeStreamingFallbackError; - } - - logger.warn( - "system_settings 表除 fakeStreamingWhitelist 外仍有列缺失,继续回退到上一代字段集。", - { error: fakeStreamingFallbackError } - ); - } - - // 第二层降级:再移除 allowNonConversationEndpointProviderFallback 列。 - const { - allowNonConversationEndpointProviderFallback: _omitNonConversationFallback, - ...selectionWithoutNonConversationFallback - } = selectionWithoutFakeStreamingWhitelist; - - try { - const [row] = await db - .select(selectionWithoutNonConversationFallback) - .from(systemSettings) - .orderBy(asc(systemSettings.id)) - .limit(1); - return row ?? null; - } catch (nonConversationFallbackError) { - if (!isUndefinedColumnError(nonConversationFallbackError)) { - throw nonConversationFallbackError; - } - - logger.warn( - "system_settings 表除新增列外仍有列缺失,继续回退到 withoutHighConcurrencyMode 字段集。", - { error: nonConversationFallbackError } - ); - } - - try { - const [row] = await db - .select(selectionWithoutPassThrough) - .from(systemSettings) - .orderBy(asc(systemSettings.id)) - .limit(1); - return row ?? null; - } catch (passThroughFallbackError) { - if (!isUndefinedColumnError(passThroughFallbackError)) { - throw passThroughFallbackError; - } - - logger.warn( - "system_settings 表缺少 passThroughUpstreamErrorMessage 之外的新列,继续降级读取。", - { - error: passThroughFallbackError, - } - ); - - try { - const [row] = await db - .select(selectionWithoutHighConcurrencyMode) - .from(systemSettings) - .orderBy(asc(systemSettings.id)) - .limit(1); - return row ?? null; - } catch (fallbackError) { - if (!isUndefinedColumnError(fallbackError)) { - throw fallbackError; - } - - logger.warn("system_settings 表存在多个缺失列,继续使用 legacy 字段集读取。", { - error: fallbackError, - }); - - try { - const [row] = await db - .select(selectionWithoutCodexAndHighConcurrency) - .from(systemSettings) - .orderBy(asc(systemSettings.id)) - .limit(1); - return row ?? null; - } catch (legacyFallbackError) { - if (!isUndefinedColumnError(legacyFallbackError)) { - throw legacyFallbackError; - } - - logger.warn("system_settings 表存在更多缺失列,继续使用最小字段集读取。", { - error: legacyFallbackError, - }); - - // 第三层 / 最终回退:仅查询最小核心字段,剩余字段交给 toSystemSettings 补默认值。 - const minimalSelection = { - id: systemSettings.id, - siteTitle: systemSettings.siteTitle, - allowGlobalUsageView: systemSettings.allowGlobalUsageView, - currencyDisplay: systemSettings.currencyDisplay, - billingModelSource: systemSettings.billingModelSource, - createdAt: systemSettings.createdAt, - updatedAt: systemSettings.updatedAt, - }; - - const [row] = await db - .select(minimalSelection) - .from(systemSettings) - .orderBy(asc(systemSettings.id)) - .limit(1); - return row ?? null; - } - } - } + // 兼容旧版本数据库:system_settings 表存在但列未迁移齐全。 + // 没有告警文案说明已是最低字段集,错误向上抛出。 + if (attempt.warnOnMissingColumn === undefined || !isUndefinedColumnError(error)) { + throw error; } - - throw error; + logger.warn(attempt.warnOnMissingColumn, { error }); } } + throw new Error("system_settings 降级读取链意外耗尽"); +} + +/** + * 获取系统设置,如果不存在则创建默认记录 + */ +export async function getSystemSettings(): Promise { try { const settings = await selectSettingsRow(); @@ -582,6 +497,75 @@ export async function getSystemSettings(): Promise { } } +// 近代阶梯全部失败后的历史世代降级更新(passThrough -> highConcurrency -> codex)。 +async function runLegacyUpdateFallbacks( + executor: SystemSettingsMutationExecutor, + settingsId: number, + prunedUpdates: Partial +) { + const fullSelection = buildFullSettingsSelection(); + const returningWithoutPassThrough = omitKeys(fullSelection, PASS_THROUGH_ERA_OMIT); + const returningWithoutHighConcurrencyMode = omitKeys(fullSelection, HIGH_CONCURRENCY_ERA_OMIT); + const returningWithoutCodexAndHighConcurrency = omitKeys(fullSelection, CODEX_ERA_RETURNING_OMIT); + + let updated; + try { + // 从已剥离近代列的对象继续裁剪,避免把新列重新带回旧 schema 再次失败。 + const withoutPassThroughUpdates = omitKeys(prunedUpdates, ["passThroughUpstreamErrorMessage"]); + [updated] = await executor + .update(systemSettings) + .set(withoutPassThroughUpdates) + .where(eq(systemSettings.id, settingsId)) + .returning(returningWithoutPassThrough); + } catch (passThroughFallbackError) { + if (!isUndefinedColumnError(passThroughFallbackError)) { + throw passThroughFallbackError; + } + + const downgradedUpdates = omitKeys(prunedUpdates, [ + "passThroughUpstreamErrorMessage", + "enableHighConcurrencyMode", + "publicStatusWindowHours", + "publicStatusAggregationIntervalMinutes", + "ipExtractionConfig", + "ipGeoLookupEnabled", + ]); + const legacyUpdates = omitKeys(downgradedUpdates, ["codexPriorityBillingSource"]); + + try { + [updated] = await executor + .update(systemSettings) + .set(downgradedUpdates) + .where(eq(systemSettings.id, settingsId)) + .returning(returningWithoutHighConcurrencyMode); + } catch (downgradedFallbackError) { + if (!isUndefinedColumnError(downgradedFallbackError)) { + throw downgradedFallbackError; + } + + logger.warn("system_settings 表缺少 codexPriorityBillingSource 之外的新列,继续降级重试。", { + error: downgradedFallbackError, + }); + + [updated] = await executor + .update(systemSettings) + .set(legacyUpdates) + .where(eq(systemSettings.id, settingsId)) + .returning(returningWithoutCodexAndHighConcurrency); + } + + if (!updated) { + [updated] = await executor + .update(systemSettings) + .set(legacyUpdates) + .where(eq(systemSettings.id, settingsId)) + .returning(returningWithoutCodexAndHighConcurrency); + } + } + + return updated; +} + /** * 更新系统设置 */ @@ -589,94 +573,6 @@ export async function updateSystemSettings( payload: UpdateSystemSettingsInput, executor: SystemSettingsMutationExecutor = db ): Promise { - const returningWithoutHighConcurrencyMode = { - id: systemSettings.id, - siteTitle: systemSettings.siteTitle, - allowGlobalUsageView: systemSettings.allowGlobalUsageView, - currencyDisplay: systemSettings.currencyDisplay, - billingModelSource: systemSettings.billingModelSource, - timezone: systemSettings.timezone, - enableAutoCleanup: systemSettings.enableAutoCleanup, - cleanupRetentionDays: systemSettings.cleanupRetentionDays, - cleanupSchedule: systemSettings.cleanupSchedule, - cleanupBatchSize: systemSettings.cleanupBatchSize, - enableClientVersionCheck: systemSettings.enableClientVersionCheck, - verboseProviderError: systemSettings.verboseProviderError, - enableHttp2: systemSettings.enableHttp2, - codexPriorityBillingSource: systemSettings.codexPriorityBillingSource, - interceptAnthropicWarmupRequests: systemSettings.interceptAnthropicWarmupRequests, - enableThinkingSignatureRectifier: systemSettings.enableThinkingSignatureRectifier, - enableThinkingBudgetRectifier: systemSettings.enableThinkingBudgetRectifier, - enableThinkingEffortConflictRectifier: systemSettings.enableThinkingEffortConflictRectifier, - enableBillingHeaderRectifier: systemSettings.enableBillingHeaderRectifier, - enableResponseInputRectifier: systemSettings.enableResponseInputRectifier, - allowNonConversationEndpointProviderFallback: - systemSettings.allowNonConversationEndpointProviderFallback, - enableCodexSessionIdCompletion: systemSettings.enableCodexSessionIdCompletion, - enableClaudeMetadataUserIdInjection: systemSettings.enableClaudeMetadataUserIdInjection, - enableResponseFixer: systemSettings.enableResponseFixer, - responseFixerConfig: systemSettings.responseFixerConfig, - quotaDbRefreshIntervalSeconds: systemSettings.quotaDbRefreshIntervalSeconds, - quotaLeasePercent5h: systemSettings.quotaLeasePercent5h, - quotaLeasePercentDaily: systemSettings.quotaLeasePercentDaily, - quotaLeasePercentWeekly: systemSettings.quotaLeasePercentWeekly, - quotaLeasePercentMonthly: systemSettings.quotaLeasePercentMonthly, - quotaLeaseCapUsd: systemSettings.quotaLeaseCapUsd, - publicStatusWindowHours: systemSettings.publicStatusWindowHours, - publicStatusAggregationIntervalMinutes: systemSettings.publicStatusAggregationIntervalMinutes, - createdAt: systemSettings.createdAt, - updatedAt: systemSettings.updatedAt, - }; - const returningWithoutPassThrough = { - ...returningWithoutHighConcurrencyMode, - enableHighConcurrencyMode: systemSettings.enableHighConcurrencyMode, - ipExtractionConfig: systemSettings.ipExtractionConfig, - ipGeoLookupEnabled: systemSettings.ipGeoLookupEnabled, - }; - const returningWithoutCodexAndHighConcurrency = { - id: systemSettings.id, - siteTitle: systemSettings.siteTitle, - allowGlobalUsageView: systemSettings.allowGlobalUsageView, - currencyDisplay: systemSettings.currencyDisplay, - billingModelSource: systemSettings.billingModelSource, - timezone: systemSettings.timezone, - enableAutoCleanup: systemSettings.enableAutoCleanup, - cleanupRetentionDays: systemSettings.cleanupRetentionDays, - cleanupSchedule: systemSettings.cleanupSchedule, - cleanupBatchSize: systemSettings.cleanupBatchSize, - enableClientVersionCheck: systemSettings.enableClientVersionCheck, - verboseProviderError: systemSettings.verboseProviderError, - enableHttp2: systemSettings.enableHttp2, - interceptAnthropicWarmupRequests: systemSettings.interceptAnthropicWarmupRequests, - enableThinkingSignatureRectifier: systemSettings.enableThinkingSignatureRectifier, - enableThinkingBudgetRectifier: systemSettings.enableThinkingBudgetRectifier, - enableThinkingEffortConflictRectifier: systemSettings.enableThinkingEffortConflictRectifier, - enableBillingHeaderRectifier: systemSettings.enableBillingHeaderRectifier, - enableResponseInputRectifier: systemSettings.enableResponseInputRectifier, - enableCodexSessionIdCompletion: systemSettings.enableCodexSessionIdCompletion, - enableClaudeMetadataUserIdInjection: systemSettings.enableClaudeMetadataUserIdInjection, - enableResponseFixer: systemSettings.enableResponseFixer, - responseFixerConfig: systemSettings.responseFixerConfig, - quotaDbRefreshIntervalSeconds: systemSettings.quotaDbRefreshIntervalSeconds, - quotaLeasePercent5h: systemSettings.quotaLeasePercent5h, - quotaLeasePercentDaily: systemSettings.quotaLeasePercentDaily, - quotaLeasePercentWeekly: systemSettings.quotaLeasePercentWeekly, - quotaLeasePercentMonthly: systemSettings.quotaLeasePercentMonthly, - quotaLeaseCapUsd: systemSettings.quotaLeaseCapUsd, - publicStatusWindowHours: systemSettings.publicStatusWindowHours, - publicStatusAggregationIntervalMinutes: systemSettings.publicStatusAggregationIntervalMinutes, - createdAt: systemSettings.createdAt, - updatedAt: systemSettings.updatedAt, - }; - const fullReturning = { - billHedgeLosers: systemSettings.billHedgeLosers, - billNonSuccessfulRequests: systemSettings.billNonSuccessfulRequests, - passThroughUpstreamErrorMessage: systemSettings.passThroughUpstreamErrorMessage, - fakeStreamingWhitelist: systemSettings.fakeStreamingWhitelist, - enableOpenaiResponsesWebsocket: systemSettings.enableOpenaiResponsesWebsocket, - ...returningWithoutPassThrough, - }; - try { const current = await getSystemSettings(); @@ -870,7 +766,7 @@ export async function updateSystemSettings( .update(systemSettings) .set(updates) .where(eq(systemSettings.id, current.id)) - .returning(fullReturning); + .returning(buildFullSettingsSelection()); } catch (error) { if (!isUndefinedColumnError(error)) { throw error; @@ -880,236 +776,34 @@ export async function updateSystemSettings( error, }); - // 最新降级:移除最近新增的 enableThinkingEffortConflictRectifier 列。 - const { - enableThinkingEffortConflictRectifier: _omitUpdateEffortConflict, - ...updatesWithoutEffortConflict - } = updates; - const { - enableThinkingEffortConflictRectifier: _omitReturningEffortConflict, - ...returningWithoutEffortConflict - } = fullReturning; - - try { - [updated] = await executor - .update(systemSettings) - .set(updatesWithoutEffortConflict) - .where(eq(systemSettings.id, current.id)) - .returning(returningWithoutEffortConflict); - } catch (effortConflictFallbackError) { - if (!isUndefinedColumnError(effortConflictFallbackError)) { - throw effortConflictFallbackError; - } - - logger.warn( - "system_settings 表除 enableThinkingEffortConflictRectifier 外仍有列缺失,继续降级更新。", - { - error: effortConflictFallbackError, - } - ); - } - - // 次新降级:移除 billHedgeLosers 列。 - const { billHedgeLosers: _omitUpdateBillHedgeLosers, ...updatesWithoutBillHedgeLosers } = - updatesWithoutEffortConflict; - const { billHedgeLosers: _omitReturningBillHedgeLosers, ...returningWithoutBillHedgeLosers } = - returningWithoutEffortConflict; - - if (!updated) { - try { - [updated] = await executor - .update(systemSettings) - .set(updatesWithoutBillHedgeLosers) - .where(eq(systemSettings.id, current.id)) - .returning(returningWithoutBillHedgeLosers); - } catch (billHedgeLosersFallbackError) { - if (!isUndefinedColumnError(billHedgeLosersFallbackError)) { - throw billHedgeLosersFallbackError; - } - - logger.warn("system_settings 表除 billHedgeLosers 外仍有列缺失,继续降级更新。", { - error: billHedgeLosersFallbackError, - }); - } - } - - // 次新降级:移除 billNonSuccessfulRequests 列。 - const { - billNonSuccessfulRequests: _omitUpdateBillNonSuccessful, - ...updatesWithoutBillNonSuccessful - } = updatesWithoutBillHedgeLosers; - const { - billNonSuccessfulRequests: _omitReturningBillNonSuccessful, - ...returningWithoutBillNonSuccessful - } = returningWithoutBillHedgeLosers; - - if (!updated) { - try { - [updated] = await executor - .update(systemSettings) - .set(updatesWithoutBillNonSuccessful) - .where(eq(systemSettings.id, current.id)) - .returning(returningWithoutBillNonSuccessful); - } catch (billNonSuccessfulFallbackError) { - if (!isUndefinedColumnError(billNonSuccessfulFallbackError)) { - throw billNonSuccessfulFallbackError; - } - - logger.warn( - "system_settings 表除 billNonSuccessfulRequests 外仍有列缺失,继续降级更新。", - { error: billNonSuccessfulFallbackError } - ); + // 按阶梯逐层剥离近代新增列重试;某层命中行则停止, + // 命中空结果(列齐全但行不匹配)则静默继续向下尝试。 + let prunedUpdates = updates; + let prunedReturning = buildFullSettingsSelection(); + for (let index = 0; index < RECENT_COLUMN_LADDER.length; index++) { + const rung = RECENT_COLUMN_LADDER[index]; + prunedUpdates = omitKeys(prunedUpdates, [rung.key]); + prunedReturning = omitKeys(prunedReturning, [rung.key]); + + if (updated) { + continue; } - } - // 第零层降级:仅移除最新增加的 enableOpenaiResponsesWebsocket 列。 - const { - enableOpenaiResponsesWebsocket: _omitUpdateOpenaiResponsesWebsocket, - ...updatesWithoutOpenaiResponsesWebsocket - } = updatesWithoutBillNonSuccessful; - const { - enableOpenaiResponsesWebsocket: _omitReturningOpenaiResponsesWebsocket, - ...returningWithoutOpenaiResponsesWebsocket - } = returningWithoutBillNonSuccessful; - - if (!updated) { try { [updated] = await executor .update(systemSettings) - .set(updatesWithoutOpenaiResponsesWebsocket) + .set(prunedUpdates) .where(eq(systemSettings.id, current.id)) - .returning(returningWithoutOpenaiResponsesWebsocket); - } catch (openaiResponsesWebsocketFallbackError) { - if (!isUndefinedColumnError(openaiResponsesWebsocketFallbackError)) { - throw openaiResponsesWebsocketFallbackError; + .returning(prunedReturning); + } catch (rungError) { + if (!isUndefinedColumnError(rungError)) { + throw rungError; } - logger.warn( - "system_settings 表除 enableOpenaiResponsesWebsocket 外仍有列缺失,继续降级更新。", - { error: openaiResponsesWebsocketFallbackError } - ); - } - } - - // 第一层降级:再移除 fakeStreamingWhitelist 列。 - const { - fakeStreamingWhitelist: _omitUpdateFakeStreaming, - ...updatesWithoutFakeStreamingWhitelist - } = updatesWithoutOpenaiResponsesWebsocket; - const { - fakeStreamingWhitelist: _omitReturningFakeStreaming, - ...returningWithoutFakeStreamingWhitelist - } = returningWithoutOpenaiResponsesWebsocket; - - if (!updated) { - try { - [updated] = await executor - .update(systemSettings) - .set(updatesWithoutFakeStreamingWhitelist) - .where(eq(systemSettings.id, current.id)) - .returning(returningWithoutFakeStreamingWhitelist); - } catch (fakeStreamingFallbackError) { - if (!isUndefinedColumnError(fakeStreamingFallbackError)) { - throw fakeStreamingFallbackError; - } - - logger.warn( - "system_settings 表除 fakeStreamingWhitelist 外仍有列缺失,继续回退到 allowNonConversationEndpointProviderFallback 之外的字段集。", - { error: fakeStreamingFallbackError } - ); - } - } - - // 第二层降级:再移除 allowNonConversationEndpointProviderFallback 列。 - const { - allowNonConversationEndpointProviderFallback: _omitUpdate, - ...updatesWithoutNonConversationFallback - } = updatesWithoutFakeStreamingWhitelist; - const { - allowNonConversationEndpointProviderFallback: _omitReturning, - ...returningWithoutNonConversationFallback - } = returningWithoutFakeStreamingWhitelist; - - if (!updated) { - try { - [updated] = await executor - .update(systemSettings) - .set(updatesWithoutNonConversationFallback) - .where(eq(systemSettings.id, current.id)) - .returning(returningWithoutNonConversationFallback); - } catch (nonConversationFallbackError) { - if (!isUndefinedColumnError(nonConversationFallbackError)) { - throw nonConversationFallbackError; - } + logger.warn(rung.updateWarn, { error: rungError }); - logger.warn( - "system_settings 表除新增列外仍有列缺失,继续回退到 passThrough / highConcurrency 字段集更新。", - { error: nonConversationFallbackError } - ); - - try { - // Continue pruning from the already-reduced object, otherwise the - // freshly removed `fakeStreamingWhitelist` (and any other newer - // columns) would be reintroduced and fail again on legacy schemas. - const withoutPassThroughUpdates = { ...updatesWithoutNonConversationFallback }; - delete withoutPassThroughUpdates.passThroughUpstreamErrorMessage; - [updated] = await executor - .update(systemSettings) - .set(withoutPassThroughUpdates) - .where(eq(systemSettings.id, current.id)) - .returning(returningWithoutPassThrough); - } catch (passThroughFallbackError) { - if (!isUndefinedColumnError(passThroughFallbackError)) { - throw passThroughFallbackError; - } - - // Same rationale: clone from the already-pruned object to avoid - // re-introducing newer columns the legacy schema can't handle. - const downgradedUpdates = { ...updatesWithoutNonConversationFallback }; - delete downgradedUpdates.passThroughUpstreamErrorMessage; - delete downgradedUpdates.enableHighConcurrencyMode; - delete downgradedUpdates.publicStatusWindowHours; - delete downgradedUpdates.publicStatusAggregationIntervalMinutes; - delete downgradedUpdates.ipExtractionConfig; - delete downgradedUpdates.ipGeoLookupEnabled; - - // legacyUpdates already inherits the pruning from - // updatesWithoutNonConversationFallback (which dropped - // allowNonConversationEndpointProviderFallback), so we only need - // to additionally remove codexPriorityBillingSource here. - const legacyUpdates = { ...downgradedUpdates }; - delete legacyUpdates.codexPriorityBillingSource; - - try { - [updated] = await executor - .update(systemSettings) - .set(downgradedUpdates) - .where(eq(systemSettings.id, current.id)) - .returning(returningWithoutHighConcurrencyMode); - } catch (downgradedFallbackError) { - if (!isUndefinedColumnError(downgradedFallbackError)) { - throw downgradedFallbackError; - } - - logger.warn( - "system_settings 表缺少 codexPriorityBillingSource 之外的新列,继续降级重试。", - { error: downgradedFallbackError } - ); - - [updated] = await executor - .update(systemSettings) - .set(legacyUpdates) - .where(eq(systemSettings.id, current.id)) - .returning(returningWithoutCodexAndHighConcurrency); - } - - if (!updated) { - [updated] = await executor - .update(systemSettings) - .set(legacyUpdates) - .where(eq(systemSettings.id, current.id)) - .returning(returningWithoutCodexAndHighConcurrency); - } + if (index === RECENT_COLUMN_LADDER.length - 1) { + updated = await runLegacyUpdateFallbacks(executor, current.id, prunedUpdates); } } } diff --git a/tests/unit/repository/system-config-degradation-ladder.test.ts b/tests/unit/repository/system-config-degradation-ladder.test.ts new file mode 100644 index 000000000..15afff84f --- /dev/null +++ b/tests/unit/repository/system-config-degradation-ladder.test.ts @@ -0,0 +1,470 @@ +import { describe, expect, test, vi } from "vitest"; +import type { UpdateSystemSettingsInput } from "@/types/system-config"; + +// 行为锁定测试:system_settings 列降级阶梯。 +// 逐层尝试的列集合、尝试顺序与最终结果必须与既有实现完全一致, +// 防止数据驱动重构(或后续新增列)意外改变降级语义。 + +// 近代新增列(最新在前),降级链按引入顺序逐层累计剥离。 +const RECENT_COLUMNS = [ + "enableThinkingEffortConflictRectifier", + "billHedgeLosers", + "billNonSuccessfulRequests", + "enableOpenaiResponsesWebsocket", + "fakeStreamingWhitelist", + "allowNonConversationEndpointProviderFallback", +] as const; + +// 全量字段集(43 列)。 +const FULL_COLUMNS = [ + "billHedgeLosers", + "billNonSuccessfulRequests", + "passThroughUpstreamErrorMessage", + "fakeStreamingWhitelist", + "enableOpenaiResponsesWebsocket", + "id", + "siteTitle", + "allowGlobalUsageView", + "currencyDisplay", + "billingModelSource", + "timezone", + "enableAutoCleanup", + "cleanupRetentionDays", + "cleanupSchedule", + "cleanupBatchSize", + "enableClientVersionCheck", + "verboseProviderError", + "enableHttp2", + "codexPriorityBillingSource", + "interceptAnthropicWarmupRequests", + "enableThinkingSignatureRectifier", + "enableThinkingBudgetRectifier", + "enableThinkingEffortConflictRectifier", + "enableBillingHeaderRectifier", + "enableResponseInputRectifier", + "allowNonConversationEndpointProviderFallback", + "enableCodexSessionIdCompletion", + "enableClaudeMetadataUserIdInjection", + "enableResponseFixer", + "responseFixerConfig", + "quotaDbRefreshIntervalSeconds", + "quotaLeasePercent5h", + "quotaLeasePercentDaily", + "quotaLeasePercentWeekly", + "quotaLeasePercentMonthly", + "quotaLeaseCapUsd", + "publicStatusWindowHours", + "publicStatusAggregationIntervalMinutes", + "createdAt", + "updatedAt", + "enableHighConcurrencyMode", + "ipExtractionConfig", + "ipGeoLookupEnabled", +] as const; + +// 历史世代字段集(冻结):passThrough 世代之前的 schema 没有以下五列, +// 但仍包含 enableThinkingEffortConflictRectifier / allowNonConversationEndpointProviderFallback。 +const PASS_THROUGH_ERA_OMIT = [ + "billHedgeLosers", + "billNonSuccessfulRequests", + "passThroughUpstreamErrorMessage", + "fakeStreamingWhitelist", + "enableOpenaiResponsesWebsocket", +] as const; +const HIGH_CONCURRENCY_ERA_OMIT = [ + ...PASS_THROUGH_ERA_OMIT, + "enableHighConcurrencyMode", + "ipExtractionConfig", + "ipGeoLookupEnabled", +] as const; +// 读取链的 codex 世代保留 allowNonConversationEndpointProviderFallback;更新链连同剥离。 +const CODEX_ERA_SELECT_OMIT = [...HIGH_CONCURRENCY_ERA_OMIT, "codexPriorityBillingSource"] as const; +const CODEX_ERA_RETURNING_OMIT = [ + ...CODEX_ERA_SELECT_OMIT, + "allowNonConversationEndpointProviderFallback", +] as const; +const MINIMAL_COLUMNS = [ + "id", + "siteTitle", + "allowGlobalUsageView", + "currencyDisplay", + "billingModelSource", + "createdAt", + "updatedAt", +] as const; + +function omit(keys: readonly string[], dropped: readonly string[]): string[] { + return keys.filter((key) => !dropped.includes(key)); +} + +function sorted(keys: readonly string[]): string[] { + return [...keys].sort(); +} + +function sortedKeys(value: unknown): string[] { + return Object.keys(value as Record).sort(); +} + +function createRejectingSelectQuery(error: unknown) { + const query: Record = {}; + query.from = vi.fn(() => query); + query.orderBy = vi.fn(() => query); + query.limit = vi.fn(() => Promise.reject(error)); + return query; +} + +function createResolvingSelectQuery(rows: unknown[]) { + const query: any = Promise.resolve(rows); + query.from = vi.fn(() => query); + query.orderBy = vi.fn(() => query); + query.limit = vi.fn(() => query); + return query; +} + +describe("SystemSettings:列降级阶梯的尝试序列锁定", () => { + test("getSystemSettings 全部列缺失时按既定顺序尝试 11 套字段集", async () => { + vi.resetModules(); + + const selections: string[][] = []; + const selectMock = vi.fn((selection: Record) => { + selections.push(sortedKeys(selection)); + return createRejectingSelectQuery({ code: "42703" }); + }); + + vi.doMock("@/drizzle/db", () => ({ + db: { + select: selectMock, + update: vi.fn(), + insert: vi.fn(), + execute: vi.fn(async () => ({ count: 0 })), + }, + })); + + const { getSystemSettings } = await import("@/repository/system-config"); + + await expect(getSystemSettings()).rejects.toMatchObject({ code: "42703" }); + + const expectedSequence = [ + [...FULL_COLUMNS], + ...RECENT_COLUMNS.map((_, index) => omit(FULL_COLUMNS, RECENT_COLUMNS.slice(0, index + 1))), + omit(FULL_COLUMNS, PASS_THROUGH_ERA_OMIT), + omit(FULL_COLUMNS, HIGH_CONCURRENCY_ERA_OMIT), + omit(FULL_COLUMNS, CODEX_ERA_SELECT_OMIT), + [...MINIMAL_COLUMNS], + ].map(sorted); + + expect(selections).toEqual(expectedSequence); + }); + + test("getSystemSettings 在 passThrough 世代命中时重新选取更晚引入的列", async () => { + vi.resetModules(); + + const now = new Date("2026-01-04T00:00:00.000Z"); + const selections: string[][] = []; + let callIndex = 0; + const selectMock = vi.fn((selection: Record) => { + selections.push(sortedKeys(selection)); + callIndex += 1; + if (callIndex < 8) { + return createRejectingSelectQuery({ code: "42703" }); + } + return createResolvingSelectQuery([ + { + id: 1, + siteTitle: "Era Row", + allowGlobalUsageView: false, + currencyDisplay: "USD", + billingModelSource: "original", + codexPriorityBillingSource: "actual", + enableThinkingEffortConflictRectifier: false, + allowNonConversationEndpointProviderFallback: false, + enableHighConcurrencyMode: true, + createdAt: now, + updatedAt: now, + }, + ]); + }); + + vi.doMock("@/drizzle/db", () => ({ + db: { + select: selectMock, + update: vi.fn(), + insert: vi.fn(), + execute: vi.fn(async () => ({ count: 0 })), + }, + })); + + const { getSystemSettings } = await import("@/repository/system-config"); + + const result = await getSystemSettings(); + + expect(selectMock).toHaveBeenCalledTimes(8); + // 第 7 次(近代链末层)不含这两列;第 8 次(passThrough 世代)重新包含。 + expect(selections[6]).not.toContain("enableThinkingEffortConflictRectifier"); + expect(selections[6]).not.toContain("allowNonConversationEndpointProviderFallback"); + expect(selections[6]).toContain("passThroughUpstreamErrorMessage"); + expect(selections[7]).toContain("enableThinkingEffortConflictRectifier"); + expect(selections[7]).toContain("allowNonConversationEndpointProviderFallback"); + expect(selections[7]).not.toContain("passThroughUpstreamErrorMessage"); + + // 世代字段集选出的真实值要透传,缺失列由 transformer 落默认值。 + expect(result.siteTitle).toBe("Era Row"); + expect(result.enableThinkingEffortConflictRectifier).toBe(false); + expect(result.allowNonConversationEndpointProviderFallback).toBe(false); + expect(result.enableHighConcurrencyMode).toBe(true); + expect(result.codexPriorityBillingSource).toBe("actual"); + expect(result.passThroughUpstreamErrorMessage).toBe(true); + }); + + test("updateSystemSettings 全部列缺失时按既定顺序尝试 10 套 set/returning 组合", async () => { + vi.resetModules(); + + const now = new Date("2026-01-04T00:00:00.000Z"); + const selectMock = vi.fn(() => + createResolvingSelectQuery([ + { + id: 1, + siteTitle: "Claude Code Hub", + allowGlobalUsageView: false, + currencyDisplay: "USD", + billingModelSource: "original", + createdAt: now, + updatedAt: now, + }, + ]) + ); + + const setKeySequence: string[][] = []; + const returningKeySequence: string[][] = []; + const updateMock = vi.fn(() => { + const query: Record = {}; + query.set = vi.fn((updates: Record) => { + setKeySequence.push(sortedKeys(updates)); + return query; + }); + query.where = vi.fn(() => query); + query.returning = vi.fn((returning: Record) => { + returningKeySequence.push(sortedKeys(returning)); + return Promise.reject({ code: "42703" }); + }); + return query; + }); + + vi.doMock("@/drizzle/db", () => ({ + db: { + select: selectMock, + update: updateMock, + insert: vi.fn(), + execute: vi.fn(async () => ({ count: 0 })), + }, + })); + + const { updateSystemSettings } = await import("@/repository/system-config"); + + const payload: UpdateSystemSettingsInput = { + siteTitle: "Ladder Pin", + codexPriorityBillingSource: "actual", + billNonSuccessfulRequests: true, + billHedgeLosers: false, + passThroughUpstreamErrorMessage: false, + enableOpenaiResponsesWebsocket: false, + enableHighConcurrencyMode: true, + enableThinkingEffortConflictRectifier: false, + allowNonConversationEndpointProviderFallback: false, + fakeStreamingWhitelist: [], + publicStatusWindowHours: 48, + publicStatusAggregationIntervalMinutes: 10, + ipExtractionConfig: null, + ipGeoLookupEnabled: false, + }; + + await expect(updateSystemSettings(payload)).rejects.toThrow( + "system_settings 表列缺失,请执行数据库迁移以升级数据库结构。" + ); + + expect(updateMock).toHaveBeenCalledTimes(10); + + const expectedReturningSequence = [ + [...FULL_COLUMNS], + ...RECENT_COLUMNS.map((_, index) => omit(FULL_COLUMNS, RECENT_COLUMNS.slice(0, index + 1))), + omit(FULL_COLUMNS, PASS_THROUGH_ERA_OMIT), + omit(FULL_COLUMNS, HIGH_CONCURRENCY_ERA_OMIT), + omit(FULL_COLUMNS, CODEX_ERA_RETURNING_OMIT), + ].map(sorted); + expect(returningKeySequence).toEqual(expectedReturningSequence); + + const fullSetKeys = [ + "updatedAt", + "siteTitle", + "codexPriorityBillingSource", + "billNonSuccessfulRequests", + "billHedgeLosers", + "passThroughUpstreamErrorMessage", + "enableOpenaiResponsesWebsocket", + "enableHighConcurrencyMode", + "enableThinkingEffortConflictRectifier", + "allowNonConversationEndpointProviderFallback", + "fakeStreamingWhitelist", + "publicStatusWindowHours", + "publicStatusAggregationIntervalMinutes", + "ipExtractionConfig", + "ipGeoLookupEnabled", + ]; + const downgradedSetOmit = [ + ...RECENT_COLUMNS, + "passThroughUpstreamErrorMessage", + "enableHighConcurrencyMode", + "publicStatusWindowHours", + "publicStatusAggregationIntervalMinutes", + "ipExtractionConfig", + "ipGeoLookupEnabled", + ]; + const expectedSetSequence = [ + fullSetKeys, + ...RECENT_COLUMNS.map((_, index) => omit(fullSetKeys, RECENT_COLUMNS.slice(0, index + 1))), + omit(fullSetKeys, [...RECENT_COLUMNS, "passThroughUpstreamErrorMessage"]), + omit(fullSetKeys, downgradedSetOmit), + omit(fullSetKeys, [...downgradedSetOmit, "codexPriorityBillingSource"]), + ].map(sorted); + expect(setKeySequence).toEqual(expectedSetSequence); + }); + + test("updateSystemSettings 在 highConcurrency 世代命中时停止降级并返回行值", async () => { + vi.resetModules(); + + const now = new Date("2026-01-04T00:00:00.000Z"); + const selectMock = vi.fn(() => + createResolvingSelectQuery([ + { + id: 1, + siteTitle: "Claude Code Hub", + allowGlobalUsageView: false, + currencyDisplay: "USD", + billingModelSource: "original", + createdAt: now, + updatedAt: now, + }, + ]) + ); + + let updateCallIndex = 0; + const updateMock = vi.fn(() => { + updateCallIndex += 1; + const shouldResolve = updateCallIndex === 9; + const query: Record = {}; + query.set = vi.fn(() => query); + query.where = vi.fn(() => query); + query.returning = vi.fn(() => + shouldResolve + ? Promise.resolve([ + { + id: 1, + siteTitle: "Tail Success", + allowGlobalUsageView: false, + currencyDisplay: "USD", + billingModelSource: "original", + codexPriorityBillingSource: "actual", + createdAt: now, + updatedAt: now, + }, + ]) + : Promise.reject({ code: "42703" }) + ); + return query; + }); + + vi.doMock("@/drizzle/db", () => ({ + db: { + select: selectMock, + update: updateMock, + insert: vi.fn(), + execute: vi.fn(async () => ({ count: 0 })), + }, + })); + + const { updateSystemSettings } = await import("@/repository/system-config"); + + const result = await updateSystemSettings({ + siteTitle: "Tail Success", + codexPriorityBillingSource: "actual", + }); + + expect(updateMock).toHaveBeenCalledTimes(9); + expect(result.siteTitle).toBe("Tail Success"); + expect(result.codexPriorityBillingSource).toBe("actual"); + }); + + test("updateSystemSettings 某层返回空结果时继续向下尝试而不报错", async () => { + vi.resetModules(); + + const now = new Date("2026-01-04T00:00:00.000Z"); + const selectMock = vi.fn(() => + createResolvingSelectQuery([ + { + id: 1, + siteTitle: "Claude Code Hub", + allowGlobalUsageView: false, + currencyDisplay: "USD", + billingModelSource: "original", + createdAt: now, + updatedAt: now, + }, + ]) + ); + + const returningKeySequence: string[][] = []; + let updateCallIndex = 0; + const updateMock = vi.fn(() => { + updateCallIndex += 1; + const currentCall = updateCallIndex; + const query: Record = {}; + query.set = vi.fn(() => query); + query.where = vi.fn(() => query); + query.returning = vi.fn((returning: Record) => { + returningKeySequence.push(sortedKeys(returning)); + if (currentCall === 1) { + return Promise.reject({ code: "42703" }); + } + if (currentCall === 2) { + // 列存在但未命中行:应继续尝试下一层而不是直接失败。 + return Promise.resolve([]); + } + return Promise.resolve([ + { + id: 1, + siteTitle: "Empty Then Hit", + allowGlobalUsageView: false, + currencyDisplay: "USD", + billingModelSource: "original", + createdAt: now, + updatedAt: now, + }, + ]); + }); + return query; + }); + + vi.doMock("@/drizzle/db", () => ({ + db: { + select: selectMock, + update: updateMock, + insert: vi.fn(), + execute: vi.fn(async () => ({ count: 0 })), + }, + })); + + const { updateSystemSettings } = await import("@/repository/system-config"); + + const result = await updateSystemSettings({ siteTitle: "Empty Then Hit" }); + + expect(updateMock).toHaveBeenCalledTimes(3); + expect(returningKeySequence).toEqual( + [ + [...FULL_COLUMNS], + omit(FULL_COLUMNS, RECENT_COLUMNS.slice(0, 1)), + omit(FULL_COLUMNS, RECENT_COLUMNS.slice(0, 2)), + ].map(sorted) + ); + expect(result.siteTitle).toBe("Empty Then Hit"); + }); +}); From ea1a3b2e046474d5e34c2149e5669dc9398aa4e7 Mon Sep 17 00:00:00 2001 From: ding113 Date: Fri, 12 Jun 2026 10:25:06 +0800 Subject: [PATCH 22/24] refactor(proxy): extract reactive rectifier registry and optimize stream fixers --- src/app/v1/_lib/proxy/forwarder.ts | 323 +++++++++--------- src/app/v1/_lib/proxy/response-fixer/index.ts | 142 ++++---- .../response-fixer/response-fixer.test.ts | 92 +++++ src/app/v1/_lib/proxy/response-handler.ts | 170 ++++----- ...thinking-effort-conflict-rectifier.test.ts | 12 +- .../thinking-effort-conflict-rectifier.ts | 6 +- src/lib/utils/special-settings.ts | 2 +- src/types/special-settings.ts | 2 +- 8 files changed, 429 insertions(+), 320 deletions(-) diff --git a/src/app/v1/_lib/proxy/forwarder.ts b/src/app/v1/_lib/proxy/forwarder.ts index 655c36c63..28bc7eb3f 100644 --- a/src/app/v1/_lib/proxy/forwarder.ts +++ b/src/app/v1/_lib/proxy/forwarder.ts @@ -46,6 +46,7 @@ import type { ClaudeMetadataUserIdInjectionSpecialSetting, SpecialSetting, } from "@/types/special-settings"; +import type { SystemSettings } from "@/types/system-config"; import { GeminiAuth } from "../gemini/auth"; import { GEMINI_PROTOCOL } from "../gemini/protocol"; @@ -95,14 +96,20 @@ import { setDeferredStreamingFinalization } from "./stream-finalization"; import { detectThinkingBudgetRectifierTrigger, rectifyThinkingBudget, + type ThinkingBudgetRectifierResult, + type ThinkingBudgetRectifierTrigger, } from "./thinking-budget-rectifier"; import { detectThinkingEffortConflictRectifierTrigger, rectifyThinkingEffortConflict, + type ThinkingEffortConflictRectifierResult, + type ThinkingEffortConflictRectifierTrigger, } from "./thinking-effort-conflict-rectifier"; import { detectThinkingSignatureRectifierTrigger, rectifyAnthropicRequestMessage, + type ThinkingSignatureRectifierResult, + type ThinkingSignatureRectifierTrigger, } from "./thinking-signature-rectifier"; /** Default User-Agent for Codex CLI requests when none is provided */ @@ -773,15 +780,143 @@ function buildRetryFailedChainEntry( }; } +/** 整流器构造审计对象时可用的上下文(trigger 为该描述符 detect 的命中结果) */ +type ReactiveRectifierAuditContext = { + trigger: TTrigger; + provider: Provider; + attemptNumber: number; + retryAttemptNumber: number; +}; + +/** + * Reactive rectifier 描述符:上游 4xx 命中触发词后“整流请求体 + 同供应商重试一次”。 + * + * 泛型让各描述符内部保持窄类型;成员声明为方法签名(参数双变检查), + * 注册表才能按基类型 ReactiveRectifierDescriptor 统一持有。 + */ +type ReactiveRectifierDescriptor< + TTrigger extends string = string, + TRectified extends { applied: boolean } = { applied: boolean }, +> = { + /** special-setting type,同时作为返回给重试决策的 rectifierType */ + type: ReactiveRectifierType; + /** 日志展示名 */ + displayName: string; + /** 从上游错误文案检测触发词;未命中返回 null */ + detect(errorMessage: string): TTrigger | null; + /** 系统设置开关(默认开启)。命中触发词但被禁用时整体按未命中处理,不回退到后续整流器 */ + isEnabled(settings: SystemSettings): boolean; + /** “同供应商仅重试一次”状态读写 */ + hasRetried(state: ReactiveRectifierRetryState): boolean; + markRetried(state: ReactiveRectifierRetryState): void; + /** 原地整流请求体 */ + rectify(message: Record): TRectified; + /** 构造 special-setting 审计对象(payload 形状必须与历史实现保持一致) */ + buildAuditSetting( + rectified: TRectified, + context: ReactiveRectifierAuditContext + ): SpecialSetting; +}; + +const thinkingEffortConflictRectifierDescriptor: ReactiveRectifierDescriptor< + ThinkingEffortConflictRectifierTrigger, + ThinkingEffortConflictRectifierResult +> = { + type: "thinking_effort_conflict_rectifier", + displayName: "Thinking effort conflict rectifier", + detect: detectThinkingEffortConflictRectifierTrigger, + isEnabled: (settings) => settings.enableThinkingEffortConflictRectifier ?? true, + hasRetried: (state) => state.thinkingEffortConflictRetried, + markRetried: (state) => { + state.thinkingEffortConflictRetried = true; + }, + rectify: rectifyThinkingEffortConflict, + buildAuditSetting: (rectified, context) => ({ + type: "thinking_effort_conflict_rectifier", + scope: "request", + hit: rectified.applied, + providerId: context.provider.id, + providerName: context.provider.name, + trigger: context.trigger, + attemptNumber: context.attemptNumber, + retryAttemptNumber: context.retryAttemptNumber, + removedOutputConfigEffort: rectified.removedOutputConfigEffort, + removedReasoningEffort: rectified.removedReasoningEffort, + thinkingType: rectified.thinkingType, + effort: rectified.effort, + }), +}; + +const thinkingSignatureRectifierDescriptor: ReactiveRectifierDescriptor< + ThinkingSignatureRectifierTrigger, + ThinkingSignatureRectifierResult +> = { + type: "thinking_signature_rectifier", + displayName: "Thinking signature rectifier", + detect: detectThinkingSignatureRectifierTrigger, + isEnabled: (settings) => settings.enableThinkingSignatureRectifier ?? true, + hasRetried: (state) => state.thinkingSignatureRetried, + markRetried: (state) => { + state.thinkingSignatureRetried = true; + }, + rectify: rectifyAnthropicRequestMessage, + buildAuditSetting: (rectified, context) => ({ + type: "thinking_signature_rectifier", + scope: "request", + hit: rectified.applied, + providerId: context.provider.id, + providerName: context.provider.name, + trigger: context.trigger, + attemptNumber: context.attemptNumber, + retryAttemptNumber: context.retryAttemptNumber, + removedThinkingBlocks: rectified.removedThinkingBlocks, + removedRedactedThinkingBlocks: rectified.removedRedactedThinkingBlocks, + removedSignatureFields: rectified.removedSignatureFields, + }), +}; + +const thinkingBudgetRectifierDescriptor: ReactiveRectifierDescriptor< + ThinkingBudgetRectifierTrigger, + ThinkingBudgetRectifierResult +> = { + type: "thinking_budget_rectifier", + displayName: "Thinking budget rectifier", + detect: detectThinkingBudgetRectifierTrigger, + isEnabled: (settings) => settings.enableThinkingBudgetRectifier ?? true, + hasRetried: (state) => state.thinkingBudgetRetried, + markRetried: (state) => { + state.thinkingBudgetRetried = true; + }, + rectify: rectifyThinkingBudget, + buildAuditSetting: (rectified, context) => ({ + type: "thinking_budget_rectifier", + scope: "request", + hit: rectified.applied, + providerId: context.provider.id, + providerName: context.provider.name, + trigger: context.trigger, + attemptNumber: context.attemptNumber, + retryAttemptNumber: context.retryAttemptNumber, + before: rectified.before, + after: rectified.after, + }), +}; + +// 注册表顺序即检测优先级:effort 冲突的错误文案更具体(reasoning_effort/output_config), +// 必须先于签名整流器检测,避免被签名整流器的通用 invalid request 兜底吞掉。 +const REACTIVE_ANTHROPIC_RECTIFIERS: readonly ReactiveRectifierDescriptor[] = [ + thinkingEffortConflictRectifierDescriptor, + thinkingSignatureRectifierDescriptor, + thinkingBudgetRectifierDescriptor, +]; + function getReactiveRectifierDisplayName(rectifierType: ReactiveRectifierType): string { - switch (rectifierType) { - case "thinking_signature_rectifier": - return "Thinking signature rectifier"; - case "thinking_budget_rectifier": - return "Thinking budget rectifier"; - case "thinking_effort_conflict_rectifier": - return "Thinking effort conflict rectifier"; + for (const descriptor of REACTIVE_ANTHROPIC_RECTIFIERS) { + if (descriptor.type === rectifierType) { + return descriptor.displayName; + } } + return rectifierType; } async function tryApplyReactiveAnthropicRectifier(params: { @@ -808,105 +943,41 @@ async function tryApplyReactiveAnthropicRectifier(params: { return { matched: false }; } - // 先于签名整流器检测:effort 冲突的错误文案更具体(reasoning_effort/output_config), - // 避免被签名整流器的通用 invalid request 兜底吞掉。 - const effortConflictTrigger = detectThinkingEffortConflictRectifierTrigger(errorMessage); - if (effortConflictTrigger) { - const settings = await getCachedSystemSettings(); - const enabled = settings.enableThinkingEffortConflictRectifier ?? true; - - if (!enabled) { - return { matched: false }; - } - - if (params.retryState.thinkingEffortConflictRetried) { - return { - matched: true, - applied: false, - reason: "already_retried", - rectifierType: "thinking_effort_conflict_rectifier", - trigger: effortConflictTrigger, - }; + for (const descriptor of REACTIVE_ANTHROPIC_RECTIFIERS) { + const trigger = descriptor.detect(errorMessage); + if (!trigger) { + continue; } - const requestDetailsBeforeRectify = buildRequestDetails(requestSession); - const rectified = rectifyThinkingEffortConflict( - requestSession.request.message as Record - ); - - addSpecialSettingForPersistence(requestSession, persistSession, { - type: "thinking_effort_conflict_rectifier", - scope: "request", - hit: rectified.applied, - providerId: provider.id, - providerName: provider.name, - trigger: effortConflictTrigger, - attemptNumber, - retryAttemptNumber, - removedOutputConfig: rectified.removedOutputConfig, - removedReasoningEffort: rectified.removedReasoningEffort, - thinkingType: rectified.thinkingType, - effort: rectified.effort, - }); - await persistSpecialSettings(persistSession); - - if (!rectified.applied) { - return { - matched: true, - applied: false, - reason: "not_applicable", - rectifierType: "thinking_effort_conflict_rectifier", - trigger: effortConflictTrigger, - }; - } - - params.retryState.thinkingEffortConflictRetried = true; - return { - matched: true, - applied: true, - rectifierType: "thinking_effort_conflict_rectifier", - trigger: effortConflictTrigger, - requestDetailsBeforeRectify, - }; - } - - const signatureTrigger = detectThinkingSignatureRectifierTrigger(errorMessage); - if (signatureTrigger) { + // 命中触发词后即终结(后续分支不再检测),与历史的 if/else 级联保持一致 const settings = await getCachedSystemSettings(); - const enabled = settings.enableThinkingSignatureRectifier ?? true; - - if (!enabled) { + if (!descriptor.isEnabled(settings)) { return { matched: false }; } - if (params.retryState.thinkingSignatureRetried) { + if (descriptor.hasRetried(params.retryState)) { return { matched: true, applied: false, reason: "already_retried", - rectifierType: "thinking_signature_rectifier", - trigger: signatureTrigger, + rectifierType: descriptor.type, + trigger, }; } const requestDetailsBeforeRectify = buildRequestDetails(requestSession); - const rectified = rectifyAnthropicRequestMessage( - requestSession.request.message as Record - ); + const rectified = descriptor.rectify(requestSession.request.message as Record); - addSpecialSettingForPersistence(requestSession, persistSession, { - type: "thinking_signature_rectifier", - scope: "request", - hit: rectified.applied, - providerId: provider.id, - providerName: provider.name, - trigger: signatureTrigger, - attemptNumber, - retryAttemptNumber, - removedThinkingBlocks: rectified.removedThinkingBlocks, - removedRedactedThinkingBlocks: rectified.removedRedactedThinkingBlocks, - removedSignatureFields: rectified.removedSignatureFields, - }); + addSpecialSettingForPersistence( + requestSession, + persistSession, + descriptor.buildAuditSetting(rectified, { + trigger, + provider, + attemptNumber, + retryAttemptNumber, + }) + ); await persistSpecialSettings(persistSession); if (!rectified.applied) { @@ -914,80 +985,22 @@ async function tryApplyReactiveAnthropicRectifier(params: { matched: true, applied: false, reason: "not_applicable", - rectifierType: "thinking_signature_rectifier", - trigger: signatureTrigger, + rectifierType: descriptor.type, + trigger, }; } - params.retryState.thinkingSignatureRetried = true; + descriptor.markRetried(params.retryState); return { matched: true, applied: true, - rectifierType: "thinking_signature_rectifier", - trigger: signatureTrigger, + rectifierType: descriptor.type, + trigger, requestDetailsBeforeRectify, }; } - const budgetTrigger = detectThinkingBudgetRectifierTrigger(errorMessage); - if (!budgetTrigger) { - return { matched: false }; - } - - const settings = await getCachedSystemSettings(); - const enabled = settings.enableThinkingBudgetRectifier ?? true; - - if (!enabled) { - return { matched: false }; - } - - if (params.retryState.thinkingBudgetRetried) { - return { - matched: true, - applied: false, - reason: "already_retried", - rectifierType: "thinking_budget_rectifier", - trigger: budgetTrigger, - }; - } - - const requestDetailsBeforeRectify = buildRequestDetails(requestSession); - const rectified = rectifyThinkingBudget( - requestSession.request.message as Record - ); - - addSpecialSettingForPersistence(requestSession, persistSession, { - type: "thinking_budget_rectifier", - scope: "request", - hit: rectified.applied, - providerId: provider.id, - providerName: provider.name, - trigger: budgetTrigger, - attemptNumber, - retryAttemptNumber, - before: rectified.before, - after: rectified.after, - }); - await persistSpecialSettings(persistSession); - - if (!rectified.applied) { - return { - matched: true, - applied: false, - reason: "not_applicable", - rectifierType: "thinking_budget_rectifier", - trigger: budgetTrigger, - }; - } - - params.retryState.thinkingBudgetRetried = true; - return { - matched: true, - applied: true, - rectifierType: "thinking_budget_rectifier", - trigger: budgetTrigger, - requestDetailsBeforeRectify, - }; + return { matched: false }; } /** diff --git a/src/app/v1/_lib/proxy/response-fixer/index.ts b/src/app/v1/_lib/proxy/response-fixer/index.ts index 8fdae6aa3..1f1e84397 100644 --- a/src/app/v1/_lib/proxy/response-fixer/index.ts +++ b/src/app/v1/_lib/proxy/response-fixer/index.ts @@ -27,6 +27,9 @@ const DEFAULT_CONFIG: ResponseFixerConfig = { const UTF8_DECODER = new TextDecoder(); const UTF8_ENCODER = new TextEncoder(); +// '"chat.completion.chunk"' 的 ASCII 字节序列:用于解码前的字节级预扫描 +const CHAT_COMPLETION_CHUNK_MARKER = UTF8_ENCODER.encode('"chat.completion.chunk"'); + function nowMs(): number { if (typeof performance !== "undefined" && typeof performance.now === "function") { return performance.now(); @@ -371,6 +374,48 @@ export class ResponseFixer { const headers = cleanResponseHeaders(response.headers); + // transform 与 flush 共用的修复序列:encoding -> sse -> data 行 JSON -> inert chunk 过滤 + const applyStreamFixers = (input: Uint8Array): Uint8Array => { + let data: Uint8Array = input; + + if (encodingFixer) { + const res = encodingFixer.fix(data); + if (res.applied) { + applied.encoding.applied = true; + applied.encoding.details ??= res.details; + data = res.data; + } + } + + if (sseFixer) { + const res = sseFixer.fix(data); + if (res.applied) { + applied.sse.applied = true; + applied.sse.details ??= res.details; + data = res.data; + } + } + + if (jsonFixer) { + const res = ResponseFixer.fixSseJsonLines(data, jsonFixer); + if (res.applied) { + applied.json.applied = true; + applied.json.details ??= res.details; + data = res.data; + } + } + + // inert chunk 过滤是序列的固定末步,审计上计入 sse 修复 + const filtered = ResponseFixer.filterInertResponsesChatCompletionChunks(session, data); + if (filtered.applied) { + applied.sse.applied = true; + applied.sse.details ??= filtered.details; + data = filtered.data; + } + + return data; + }; + const transform = new TransformStream({ transform(chunk, controller) { audit.totalBytesProcessed += chunk.length; @@ -396,85 +441,11 @@ export class ResponseFixer { return; } - const toProcess = buffer.take(end); - - let data: Uint8Array = toProcess; - - if (encodingFixer) { - const res = encodingFixer.fix(data); - if (res.applied) { - applied.encoding.applied = true; - applied.encoding.details ??= res.details; - data = res.data; - } - } - - if (sseFixer) { - const res = sseFixer.fix(data); - if (res.applied) { - applied.sse.applied = true; - applied.sse.details ??= res.details; - data = res.data; - } - } - - if (jsonFixer) { - const res = ResponseFixer.fixSseJsonLines(data, jsonFixer); - if (res.applied) { - applied.json.applied = true; - applied.json.details ??= res.details; - data = res.data; - } - } - - const filtered = ResponseFixer.filterInertResponsesChatCompletionChunks(session, data); - if (filtered.applied) { - applied.sse.applied = true; - applied.sse.details ??= filtered.details; - data = filtered.data; - } - - controller.enqueue(data); + controller.enqueue(applyStreamFixers(buffer.take(end))); }, flush(controller) { if (buffer.length > 0) { - let data: Uint8Array = buffer.drain(); - - if (encodingFixer) { - const res = encodingFixer.fix(data); - if (res.applied) { - applied.encoding.applied = true; - applied.encoding.details ??= res.details; - data = res.data; - } - } - - if (sseFixer) { - const res = sseFixer.fix(data); - if (res.applied) { - applied.sse.applied = true; - applied.sse.details ??= res.details; - data = res.data; - } - } - - if (jsonFixer) { - const res = ResponseFixer.fixSseJsonLines(data, jsonFixer); - if (res.applied) { - applied.json.applied = true; - applied.json.details ??= res.details; - data = res.data; - } - } - - const filtered = ResponseFixer.filterInertResponsesChatCompletionChunks(session, data); - if (filtered.applied) { - applied.sse.applied = true; - applied.sse.details ??= filtered.details; - data = filtered.data; - } - - controller.enqueue(data); + controller.enqueue(applyStreamFixers(buffer.drain())); } audit.hit = applied.encoding.applied || applied.sse.applied || applied.json.applied; @@ -590,11 +561,13 @@ export class ResponseFixer { return { data, applied: false }; } - const text = UTF8_DECODER.decode(data); - if (!text.includes('"chat.completion.chunk"')) { + // 解码前先做字节级预扫描:标记为纯 ASCII,字节比较与解码后的子串匹配完全等价 + // (UTF-8 多字节序列不会解码出 ASCII 字符),绝大多数块可免去整块解码的开销 + if (ResponseFixer.byteIndexOf(data, CHAT_COMPLETION_CHUNK_MARKER) < 0) { return { data, applied: false }; } + const text = UTF8_DECODER.decode(data); const lines = text.split("\n"); const out: string[] = []; let applied = false; @@ -631,6 +604,19 @@ export class ResponseFixer { }; } + private static byteIndexOf(haystack: Uint8Array, needle: Uint8Array): number { + if (needle.length === 0) return 0; + const limit = haystack.length - needle.length; + const first = needle[0]; + for (let i = 0; i <= limit; i += 1) { + if (haystack[i] !== first) continue; + let j = 1; + while (j < needle.length && haystack[i + j] === needle[j]) j += 1; + if (j === needle.length) return i; + } + return -1; + } + private static isInertChatCompletionDataLine(line: string): boolean { if (!line.startsWith("data:")) return false; diff --git a/src/app/v1/_lib/proxy/response-fixer/response-fixer.test.ts b/src/app/v1/_lib/proxy/response-fixer/response-fixer.test.ts index e99602d55..f39e6775e 100644 --- a/src/app/v1/_lib/proxy/response-fixer/response-fixer.test.ts +++ b/src/app/v1/_lib/proxy/response-fixer/response-fixer.test.ts @@ -376,6 +376,98 @@ describe("ResponseFixer", () => { expect(session.getSpecialSettings()).toBeNull(); }); + test("流式 Responses SSE:过滤 inert chunk 时相邻行的多字节 CJK 内容应按字节原样保留", async () => { + const { ResponseFixer } = await import("./index"); + + const session = createSession(); + session.originalFormat = "response"; + const encoder = new TextEncoder(); + const emptyChatChunk = { + id: "chatcmpl-dummy", + object: "chat.completion.chunk", + created: 1780753978, + model: "gpt-5.5", + choices: [{ index: 0, delta: { role: "assistant", content: "" } }], + }; + // 含 3 字节 CJK 与 4 字节扩展区 CJK(U+20000),覆盖多字节 UTF-8 往返 + const cjkDeltaBefore = { + type: "response.output_text.delta", + delta: "你好,", + }; + const cjkDeltaAfter = { + type: "response.output_text.delta", + delta: "世界𠀀", + }; + + const fixed = await ResponseFixer.process( + session, + createSseResponse([ + `data: ${JSON.stringify(cjkDeltaBefore)}`, + "", + `data: ${JSON.stringify(emptyChatChunk)}`, + "", + "event: response.output_text.delta", + `data: ${JSON.stringify(cjkDeltaAfter)}`, + "", + "", + ]) + ); + + const bytes = new Uint8Array(await fixed.arrayBuffer()); + const expected = encoder.encode( + [ + `data: ${JSON.stringify(cjkDeltaBefore)}`, + "", + "event: response.output_text.delta", + `data: ${JSON.stringify(cjkDeltaAfter)}`, + "", + "", + ].join("\n") + ); + + expect(Array.from(bytes)).toEqual(Array.from(expected)); + + // inert 过滤应计入 sse 修复的审计项 + const settings = session.getSpecialSettings() as Array<{ + fixersApplied: Array<{ fixer: string; applied: boolean }>; + }> | null; + expect(settings).not.toBeNull(); + expect(settings?.[0]?.fixersApplied).toEqual( + expect.arrayContaining([expect.objectContaining({ fixer: "sse", applied: true })]) + ); + }); + + test("流式 Responses SSE:不含 chat.completion.chunk 标记的块应原引用返回(字节预扫描早退)", async () => { + const { ResponseFixer } = await import("./index"); + + const session = createSession(); + session.originalFormat = "response"; + const data = new TextEncoder().encode( + 'event: response.output_text.delta\ndata: {"type":"response.output_text.delta","delta":"你好"}\n\n' + ); + + const result = (ResponseFixer as any).filterInertResponsesChatCompletionChunks( + session, + data + ) as { data: Uint8Array; applied: boolean }; + + expect(result.applied).toBe(false); + expect(result.data).toBe(data); + }); + + test("byteIndexOf:部分匹配回退与边界场景", async () => { + const { ResponseFixer } = await import("./index"); + const encoder = new TextEncoder(); + const indexOf = (haystack: string, needle: string) => + (ResponseFixer as any).byteIndexOf(encoder.encode(haystack), encoder.encode(needle)); + + expect(indexOf("aaab", "aab")).toBe(1); + expect(indexOf('x"chat.completion.chunk"', '"chat.completion.chunk"')).toBe(1); + expect(indexOf("abc", "abc")).toBe(0); + expect(indexOf("ab", "abc")).toBe(-1); + expect(indexOf("abc", "xyz")).toBe(-1); + }); + test("流式 SSE:无换行且超过 maxFixSize 时应降级输出,避免无限缓冲", async () => { const { ResponseFixer } = await import("./index"); diff --git a/src/app/v1/_lib/proxy/response-handler.ts b/src/app/v1/_lib/proxy/response-handler.ts index a1a9e4999..e4cb75244 100644 --- a/src/app/v1/_lib/proxy/response-handler.ts +++ b/src/app/v1/_lib/proxy/response-handler.ts @@ -546,6 +546,14 @@ type FinalizeDeferredStreamingResult = { * 以便与异步累加的输家费用共存而互不覆盖。 */ billHedgeLosers: boolean; + /** + * clientAbortCompleteSuccess 门控解析出的 usage(U11:drain 路径避免对同一份 + * allContent 二次解析)。providerType 与调用方不一致时调用方需自行重新解析。 + */ + clientAbortGateUsage?: { + usageMetrics: UsageMetrics | null; + providerType: Provider["providerType"] | undefined; + }; }; /** @@ -593,6 +601,7 @@ async function finalizeDeferredStreamingFinalizationIfNeeded( const detected = shouldDetectFake200 ? detectUpstreamErrorFromSseOrJsonText(allContent) : ({ isError: false } as const); + let clientAbortGateUsage: FinalizeDeferredStreamingResult["clientAbortGateUsage"]; const clientAbortCompleteSuccess = (() => { if ( streamEndedNormally || @@ -620,6 +629,7 @@ async function finalizeDeferredStreamingFinalizationIfNeeded( } const { usageMetrics } = parseUsageFromResponseText(allContent, provider?.providerType); + clientAbortGateUsage = { usageMetrics, providerType: provider?.providerType }; return hasPositiveBillableTokens(usageMetrics); })(); @@ -679,6 +689,7 @@ async function finalizeDeferredStreamingFinalizationIfNeeded( providerIdForPersistence, isHedgeWinner, billHedgeLosers, + clientAbortGateUsage, }; } @@ -761,6 +772,7 @@ async function finalizeDeferredStreamingFinalizationIfNeeded( providerIdForPersistence, isHedgeWinner, billHedgeLosers, + clientAbortGateUsage, }; } @@ -821,6 +833,7 @@ async function finalizeDeferredStreamingFinalizationIfNeeded( providerIdForPersistence, isHedgeWinner, billHedgeLosers, + clientAbortGateUsage, }; } @@ -873,6 +886,7 @@ async function finalizeDeferredStreamingFinalizationIfNeeded( providerIdForPersistence, isHedgeWinner, billHedgeLosers, + clientAbortGateUsage, }; } @@ -975,6 +989,7 @@ async function finalizeDeferredStreamingFinalizationIfNeeded( providerIdForPersistence, isHedgeWinner, billHedgeLosers, + clientAbortGateUsage, }; } @@ -1406,28 +1421,22 @@ export class ProxyResponseHandler { } if (billableUsageMetrics && messageContext) { + const billing = sessionBillingInputs(session, provider, priorityServiceTierApplied); const costUpdateResult = await updateRequestCostFromUsage( messageContext.id, session, billableUsageMetrics, - provider, - provider.costMultiplier, - session.getContext1mApplied(), - priorityServiceTierApplied, - session.getGroupCostMultiplier() + billing ); if (costUpdateResult.longContextPricingApplied) { ensureLongContextPricingAudit(session, costUpdateResult.longContextPricing); } // 追踪消费到 Redis(用于限流) - await trackCostToRedis( - session, - billableUsageMetrics, - priorityServiceTierApplied, - costUpdateResult.resolvedPricing, - costUpdateResult.longContextPricing - ); + await trackCostToRedis(session, billableUsageMetrics, billing, { + resolvedPricing: costUpdateResult.resolvedPricing, + longContextPricing: costUpdateResult.longContextPricing, + }); } // Calculate cost for session tracking (with multiplier) and Langfuse (raw) @@ -2496,7 +2505,11 @@ export class ProxyResponseHandler { const tracker = ProxyStatusTracker.getInstance(); tracker.endRequest(messageContext.user.id, messageContext.id); - const usageResult = parseUsageFromResponseText(allContent, provider.providerType); + // U11:门控已在 finalize 内解析过同一份 allContent,类型一致时直接复用 + const usageResult = + finalized.clientAbortGateUsage?.providerType === provider.providerType + ? { usageMetrics: finalized.clientAbortGateUsage.usageMetrics } + : parseUsageFromResponseText(allContent, provider.providerType); usageForCost = usageResult.usageMetrics; const actualServiceTier = parseServiceTierFromResponseText(allContent); @@ -2554,15 +2567,12 @@ export class ProxyResponseHandler { allContent ); + const billing = sessionBillingInputs(session, provider, priorityServiceTierApplied); const costUpdateResult = await updateRequestCostFromUsage( messageContext.id, session, billableUsageForCost, - provider, - provider.costMultiplier, - session.getContext1mApplied(), - priorityServiceTierApplied, - session.getGroupCostMultiplier(), + billing, // Any hedge-path winner with loser billing on uses the loser-sum-aware write. // Gate on billHedgeLosers (not the racy isHedgeWinner/launchedProviderCount): // an alternative can still be mid-launch when the initial provider commits, so @@ -2575,13 +2585,10 @@ export class ProxyResponseHandler { } // 追踪消费到 Redis(用于限流) - await trackCostToRedis( - session, - billableUsageForCost, - priorityServiceTierApplied, - costUpdateResult.resolvedPricing, - costUpdateResult.longContextPricing - ); + await trackCostToRedis(session, billableUsageForCost, billing, { + resolvedPricing: costUpdateResult.resolvedPricing, + longContextPricing: costUpdateResult.longContextPricing, + }); // Calculate cost for session tracking (with multiplier) and Langfuse (raw) let costUsdStr: string | undefined; @@ -3652,11 +3659,7 @@ async function updateRequestCostFromUsage( messageId: number, session: ProxySession, usage: UsageMetrics | null, - provider: Provider | null, - costMultiplier: number = 1.0, - context1mApplied: boolean = false, - priorityServiceTierApplied: boolean = false, - groupCostMultiplier: number = 1.0, + billing: BillingComputeInputs, // When true the winner cost is written via a direct, idempotent, loser-sum-aware // replacement (cost_usd = winnerCost + SUM(hedge_losers[].costUsd)) so it coexists // with losers' concurrent additive writes without clobbering or double-counting. @@ -3668,6 +3671,13 @@ async function updateRequestCostFromUsage( longContextPricing: ResolvedLongContextPricing | null; longContextPricingApplied: boolean; }> { + const { + provider, + costMultiplier, + context1mApplied, + priorityServiceTierApplied, + groupCostMultiplier, + } = billing; if (!usage) { logger.warn("[CostCalculation] No usage data, skipping cost update", { messageId, @@ -3978,14 +3988,14 @@ export async function finalizeHedgeLoserBilling(params: { await trackCostToRedis( loserSession, billableUsage, - priorityServiceTierApplied, - resolvedPricing, - longContextPricing, { provider, + costMultiplier, context1mApplied, + priorityServiceTierApplied, groupCostMultiplier, - } + }, + { resolvedPricing, longContextPricing } ); logger.info("[HedgeLoserBilling] Billed hedge loser", { @@ -4065,15 +4075,12 @@ export async function finalizeRequestStats( let perRequestCostUsd: string | undefined; if (billablePerRequestUsage) { + const billing = sessionBillingInputs(session, provider, priorityServiceTierApplied); const costUpdateResult = await updateRequestCostFromUsage( messageContext.id, session, billablePerRequestUsage, - provider, - provider.costMultiplier, - session.getContext1mApplied(), - priorityServiceTierApplied, - session.getGroupCostMultiplier(), + billing, winnerLoserAware ); if (costUpdateResult.resolvedPricing) { @@ -4083,13 +4090,10 @@ export async function finalizeRequestStats( ensureLongContextPricingAudit(session, costUpdateResult.longContextPricing); } - await trackCostToRedis( - session, - billablePerRequestUsage, - priorityServiceTierApplied, - costUpdateResult.resolvedPricing, - costUpdateResult.longContextPricing - ); + await trackCostToRedis(session, billablePerRequestUsage, billing, { + resolvedPricing: costUpdateResult.resolvedPricing, + longContextPricing: costUpdateResult.longContextPricing, + }); perRequestCostUsd = costUpdateResult.costUsd ?? undefined; } @@ -4143,15 +4147,12 @@ export async function finalizeRequestStats( maybeSetCodexContext1m(session, provider, billableNormalizedUsage.input_tokens); } + const billing = sessionBillingInputs(session, provider, priorityServiceTierApplied); const costUpdateResult = await updateRequestCostFromUsage( messageContext.id, session, normalizedUsage, - provider, - provider.costMultiplier, - session.getContext1mApplied(), - priorityServiceTierApplied, - session.getGroupCostMultiplier(), + billing, winnerLoserAware ); if (costUpdateResult.longContextPricingApplied) { @@ -4159,13 +4160,10 @@ export async function finalizeRequestStats( } // 5. 追踪消费到 Redis(用于限流) - await trackCostToRedis( - session, - normalizedUsage, - priorityServiceTierApplied, - costUpdateResult.resolvedPricing, - costUpdateResult.longContextPricing - ); + await trackCostToRedis(session, normalizedUsage, billing, { + resolvedPricing: costUpdateResult.resolvedPricing, + longContextPricing: costUpdateResult.longContextPricing, + }); // 6. 更新 session usage if (session.sessionId) { @@ -4253,28 +4251,48 @@ export async function finalizeRequestStats( /** * 追踪消费到 Redis(用于限流) */ -type TrackCostBillingOverrides = { - provider?: Provider | null; - context1mApplied?: boolean; - groupCostMultiplier?: number; +/** + * 计费五元组(U19):一次构造,贯穿 updateRequestCostFromUsage / trackCostToRedis / + * buildCostCalculationOptions。正常路径用 sessionBillingInputs 从会话即时取值; + * hedge loser 路径用 commitWinner 之前的快照构造,避免被赢家提交污染。 + */ +type BillingComputeInputs = { + provider: Provider | null; + costMultiplier: number; + context1mApplied: boolean; + priorityServiceTierApplied: boolean; + groupCostMultiplier: number; }; +function sessionBillingInputs( + session: ProxySession, + provider: Provider, + priorityServiceTierApplied: boolean +): BillingComputeInputs { + return { + provider, + costMultiplier: provider.costMultiplier, + context1mApplied: session.getContext1mApplied(), + priorityServiceTierApplied, + groupCostMultiplier: session.getGroupCostMultiplier(), + }; +} + async function trackCostToRedis( session: ProxySession, usage: UsageMetrics | null, - priorityServiceTierApplied: boolean = false, - resolvedPricingOverride?: Awaited< - ReturnType - > | null, - longContextPricingOverride?: ResolvedLongContextPricing | null, - billingOverrides?: TrackCostBillingOverrides + billing: BillingComputeInputs, + pricingOverrides?: { + resolvedPricing?: Awaited> | null; + longContextPricing?: ResolvedLongContextPricing | null; + } ): Promise { if (!usage || !session.sessionId) return; if (isNonBillingUsageEndpoint(session)) return; try { const messageContext = session.messageContext; - const provider = billingOverrides?.provider ?? session.provider; + const { provider, priorityServiceTierApplied } = billing; const key = session.authState?.key; const user = session.authState?.user; @@ -4284,26 +4302,26 @@ async function trackCostToRedis( if (!modelName) return; const resolvedPricing = - resolvedPricingOverride === undefined + pricingOverrides?.resolvedPricing === undefined ? await session.getResolvedPricingByBillingSource(provider) - : resolvedPricingOverride; + : pricingOverrides.resolvedPricing; if (!resolvedPricing) return; ensurePricingResolutionSpecialSetting(session, resolvedPricing); const longContextPricing = - longContextPricingOverride === undefined + pricingOverrides?.longContextPricing === undefined ? (matchLongContextPricing(usage, resolvedPricing.priceData)?.pricing ?? null) - : longContextPricingOverride; + : pricingOverrides.longContextPricing; const cost = calculateRequestCost( usage, resolvedPricing.priceData, buildCostCalculationOptions( - provider.costMultiplier, - billingOverrides?.context1mApplied ?? session.getContext1mApplied(), + billing.costMultiplier, + billing.context1mApplied, priorityServiceTierApplied, longContextPricing, - billingOverrides?.groupCostMultiplier ?? session.getGroupCostMultiplier() + billing.groupCostMultiplier ) ); if (cost.lte(0)) return; diff --git a/src/app/v1/_lib/proxy/thinking-effort-conflict-rectifier.test.ts b/src/app/v1/_lib/proxy/thinking-effort-conflict-rectifier.test.ts index db01dad57..06ad8e0bd 100644 --- a/src/app/v1/_lib/proxy/thinking-effort-conflict-rectifier.test.ts +++ b/src/app/v1/_lib/proxy/thinking-effort-conflict-rectifier.test.ts @@ -68,7 +68,7 @@ describe("rectifyThinkingEffortConflict", () => { const result = rectifyThinkingEffortConflict(message); expect(result.applied).toBe(true); - expect(result.removedOutputConfig).toBe(true); + expect(result.removedOutputConfigEffort).toBe(true); expect(result.removedReasoningEffort).toBe(false); expect(result.thinkingType).toBe("disabled"); expect(result.effort).toBe("max"); @@ -86,7 +86,7 @@ describe("rectifyThinkingEffortConflict", () => { const result = rectifyThinkingEffortConflict(message); expect(result.applied).toBe(true); - expect(result.removedOutputConfig).toBe(true); + expect(result.removedOutputConfigEffort).toBe(true); expect(result.effort).toBe("max"); // Sibling fields must survive; only the conflicting effort carrier is removed. expect(message.output_config).toEqual({ verbosity: "high", future_flag: true }); @@ -114,7 +114,7 @@ describe("rectifyThinkingEffortConflict", () => { const result = rectifyThinkingEffortConflict(message); expect(result.applied).toBe(true); - expect(result.removedOutputConfig).toBe(false); + expect(result.removedOutputConfigEffort).toBe(false); expect(result.removedReasoningEffort).toBe(true); expect(result.effort).toBe("high"); expect("reasoning_effort" in message).toBe(false); @@ -129,7 +129,7 @@ describe("rectifyThinkingEffortConflict", () => { const result = rectifyThinkingEffortConflict(message); expect(result.applied).toBe(true); - expect(result.removedOutputConfig).toBe(true); + expect(result.removedOutputConfigEffort).toBe(true); expect(result.thinkingType).toBeNull(); }); @@ -165,7 +165,7 @@ describe("rectifyThinkingEffortConflict", () => { const result = rectifyThinkingEffortConflict(message); expect(result.applied).toBe(false); - expect(result.removedOutputConfig).toBe(false); + expect(result.removedOutputConfigEffort).toBe(false); expect(result.removedReasoningEffort).toBe(false); }); @@ -179,7 +179,7 @@ describe("rectifyThinkingEffortConflict", () => { const result = rectifyThinkingEffortConflict(message); expect(result.applied).toBe(true); - expect(result.removedOutputConfig).toBe(false); + expect(result.removedOutputConfigEffort).toBe(false); expect(result.removedReasoningEffort).toBe(true); expect(message.output_config).toEqual({ something_else: true }); }); diff --git a/src/app/v1/_lib/proxy/thinking-effort-conflict-rectifier.ts b/src/app/v1/_lib/proxy/thinking-effort-conflict-rectifier.ts index a05110c73..279776815 100644 --- a/src/app/v1/_lib/proxy/thinking-effort-conflict-rectifier.ts +++ b/src/app/v1/_lib/proxy/thinking-effort-conflict-rectifier.ts @@ -17,7 +17,7 @@ export type ThinkingEffortConflictRectifierTrigger = "thinking_disabled_with_rea export type ThinkingEffortConflictRectifierResult = { applied: boolean; - removedOutputConfig: boolean; + removedOutputConfigEffort: boolean; removedReasoningEffort: boolean; thinkingType: string | null; effort: string | null; @@ -74,7 +74,7 @@ export function rectifyThinkingEffortConflict( const result: ThinkingEffortConflictRectifierResult = { applied: false, - removedOutputConfig: false, + removedOutputConfigEffort: false, removedReasoningEffort: false, thinkingType, effort: null, @@ -101,7 +101,7 @@ export function rectifyThinkingEffortConflict( } else { delete message.output_config; } - result.removedOutputConfig = true; + result.removedOutputConfigEffort = true; result.applied = true; } diff --git a/src/lib/utils/special-settings.ts b/src/lib/utils/special-settings.ts index 09922e741..af5b448e9 100644 --- a/src/lib/utils/special-settings.ts +++ b/src/lib/utils/special-settings.ts @@ -83,7 +83,7 @@ function buildSettingKey(setting: SpecialSetting): string { setting.trigger, setting.attemptNumber, setting.retryAttemptNumber, - setting.removedOutputConfig, + setting.removedOutputConfigEffort, setting.removedReasoningEffort, setting.thinkingType, setting.effort, diff --git a/src/types/special-settings.ts b/src/types/special-settings.ts index c24bc1930..98d12b456 100644 --- a/src/types/special-settings.ts +++ b/src/types/special-settings.ts @@ -165,7 +165,7 @@ export type ThinkingEffortConflictRectifierSpecialSetting = { trigger: "thinking_disabled_with_reasoning_effort"; attemptNumber: number; retryAttemptNumber: number; - removedOutputConfig: boolean; + removedOutputConfigEffort: boolean; removedReasoningEffort: boolean; thinkingType: string | null; effort: string | null; From 36b61fee04b552f8461a6bab20587fd0dde55955 Mon Sep 17 00:00:00 2001 From: ding113 Date: Fri, 12 Jun 2026 10:25:06 +0800 Subject: [PATCH 23/24] feat(keys): enable self-service key management for web-ui sessions --- src/actions/keys.ts | 64 ++++- .../_components/user/forms/add-key-form.tsx | 10 +- src/app/api/v1/resources/keys/handlers.ts | 33 +++ src/app/api/v1/resources/keys/router.ts | 35 +-- src/app/api/v1/resources/me/handlers.ts | 1 + src/lib/api-client/v1/actions/keys.ts | 21 +- src/lib/api-client/v1/openapi-types.gen.ts | 10 +- src/lib/utils/error-messages.ts | 1 + .../actions/keys-remove-error-codes.test.ts | 14 +- .../actions/keys-self-service-authz.test.ts | 221 ++++++++++++++++++ tests/unit/api/v1/api-client-actions.test.ts | 72 +++--- .../api/v1/keys-delete-error-mapping.test.ts | 15 +- .../api/v1/keys-write-handlers-authz.test.ts | 131 +++++++++++ .../add-key-form-balance-page-toggle.test.tsx | 5 +- .../add-key-form-expiry-clear-ui.test.tsx | 5 +- .../add-key-form-self-service.test.tsx | 147 ++++++++++++ 16 files changed, 695 insertions(+), 90 deletions(-) create mode 100644 tests/unit/actions/keys-self-service-authz.test.ts create mode 100644 tests/unit/api/v1/keys-write-handlers-authz.test.ts create mode 100644 tests/unit/dashboard/add-key-form-self-service.test.tsx diff --git a/src/actions/keys.ts b/src/actions/keys.ts index 230feac5b..7568db63e 100644 --- a/src/actions/keys.ts +++ b/src/actions/keys.ts @@ -7,7 +7,7 @@ import { getTranslations } from "next-intl/server"; import { db } from "@/drizzle/db"; import { keys as keysTable, users as usersTable } from "@/drizzle/schema"; import { emitActionAudit } from "@/lib/audit/emit"; -import { getSession } from "@/lib/auth"; +import { type AuthSession, getSession } from "@/lib/auth"; import { PROVIDER_GROUP } from "@/lib/constants/provider.constants"; import { logger } from "@/lib/logger"; import { resolveKeyConcurrentSessionLimit } from "@/lib/rate-limit/concurrent-session-limit"; @@ -35,6 +35,23 @@ import type { Key } from "@/types/key"; import type { ActionResult } from "./types"; import { type BatchUpdateResult, syncUserProviderGroupFromKeys } from "./users"; +// U02: key 写操作的会话级守卫。REST read 层与 legacy adapter 的 scoped 上下文 +// 会放行 canLoginWebUi=false 的只读会话;写操作必须是管理员或完整 Web 会话, +// 否则只读 key 可改写自身 canLoginWebUi 自提权。 +function denyKeyWriteForReadOnlySession( + session: AuthSession, + tError: (key: string) => string +): { ok: false; error: string; errorCode: string } | null { + if (session.user.role === "admin" || session.key?.canLoginWebUi === true) { + return null; + } + return { + ok: false, + error: tError("PERMISSION_DENIED"), + errorCode: ERROR_CODES.PERMISSION_DENIED, + }; +} + type TranslationFunction = (key: string, values?: Record) => string; function validateNonAdminProviderGroup( @@ -447,6 +464,11 @@ export async function editKey( }; } + const readOnlyDenied = denyKeyWriteForReadOnlySession(session, tError); + if (readOnlyDenied) { + return readOnlyDenied; + } + const key = await findKeyById(keyId); if (!key) { return { ok: false, error: "密钥不存在" }; @@ -475,6 +497,19 @@ export async function editKey( } } + // 非管理员经 PATCH isEnabled=false 关停 key 时,沿用 toggleKeyEnabled 的 + // 最后一个启用 key 保护(U02 路由放开后该路径对自助用户可达) + if (session.user.role !== "admin" && data.isEnabled === false && key.isEnabled) { + const activeKeyCount = await countActiveKeysByUser(key.userId); + if (activeKeyCount <= 1) { + return { + ok: false, + error: tError("CANNOT_DISABLE_LAST_KEY"), + errorCode: ERROR_CODES.OPERATION_FAILED, + }; + } + } + // 仅当调用方显式携带 expiresAt 字段时才更新/清除该字段: // - 避免像“仅修改限额”这类局部更新把 expiresAt 意外清空 const hasExpiresAtField = Object.hasOwn(data, "expiresAt"); @@ -732,12 +767,17 @@ export async function removeKey(keyId: number): Promise { }; } + const readOnlyDenied = denyKeyWriteForReadOnlySession(session, tError); + if (readOnlyDenied) { + return readOnlyDenied; + } + const key = await findKeyById(keyId); if (!key) { return { ok: false, error: tError("KEY_NOT_FOUND"), - errorCode: ERROR_CODES.NOT_FOUND, + errorCode: ERROR_CODES.KEY_NOT_FOUND, }; } @@ -1103,7 +1143,7 @@ export async function resetKeyLimitsOnly(keyId: number): Promise { return { ok: false, error: tError("KEY_NOT_FOUND"), - errorCode: ERROR_CODES.NOT_FOUND, + errorCode: ERROR_CODES.KEY_NOT_FOUND, }; } @@ -1112,7 +1152,7 @@ export async function resetKeyLimitsOnly(keyId: number): Promise { return { ok: false, error: tError("KEY_NOT_FOUND"), - errorCode: ERROR_CODES.NOT_FOUND, + errorCode: ERROR_CODES.KEY_NOT_FOUND, }; } @@ -1172,12 +1212,17 @@ export async function toggleKeyEnabled(keyId: number, enabled: boolean): Promise }; } + const readOnlyDenied = denyKeyWriteForReadOnlySession(session, tError); + if (readOnlyDenied) { + return readOnlyDenied; + } + const key = await findKeyById(keyId); if (!key) { return { ok: false, error: tError("KEY_NOT_FOUND"), - errorCode: ERROR_CODES.NOT_FOUND, + errorCode: ERROR_CODES.KEY_NOT_FOUND, }; } @@ -1479,12 +1524,17 @@ export async function renewKeyExpiresAt( }; } + const readOnlyDenied = denyKeyWriteForReadOnlySession(session, tError); + if (readOnlyDenied) { + return readOnlyDenied; + } + const key = await findKeyById(keyId); if (!key) { return { ok: false, error: tError("KEY_NOT_FOUND"), - errorCode: ERROR_CODES.NOT_FOUND, + errorCode: ERROR_CODES.KEY_NOT_FOUND, }; } @@ -1550,7 +1600,7 @@ export async function patchKeyLimit( const key = await findKeyById(keyId); if (!key) { - return { ok: false, error: tError("KEY_NOT_FOUND"), errorCode: ERROR_CODES.NOT_FOUND }; + return { ok: false, error: tError("KEY_NOT_FOUND"), errorCode: ERROR_CODES.KEY_NOT_FOUND }; } if (session.user.role !== "admin" && session.user.id !== key.userId) { diff --git a/src/app/[locale]/dashboard/_components/user/forms/add-key-form.tsx b/src/app/[locale]/dashboard/_components/user/forms/add-key-form.tsx index a9e8f5f16..eba6b6a72 100644 --- a/src/app/[locale]/dashboard/_components/user/forms/add-key-form.tsx +++ b/src/app/[locale]/dashboard/_components/user/forms/add-key-form.tsx @@ -15,7 +15,7 @@ import { SelectValue, } from "@/components/ui/select"; import { Switch } from "@/components/ui/switch"; -import { addKey } from "@/lib/api-client/v1/actions/keys"; +import { addKey, addOwnKey } from "@/lib/api-client/v1/actions/keys"; import { getAvailableProviderGroups } from "@/lib/api-client/v1/actions/providers"; import { PROVIDER_GROUP } from "@/lib/constants/provider.constants"; import { useZodForm } from "@/lib/hooks/use-zod-form"; @@ -76,8 +76,7 @@ export function AddKeyForm({ userId, user, isAdmin = false, onSuccess }: AddKeyF } try { - const result = await addKey({ - userId: userId!, + const body = { name: data.name, // 重要:清除到期时间时用空字符串表达,避免 undefined 在 Server Action 序列化时被丢弃 expiresAt: data.expiresAt ?? "", @@ -93,7 +92,10 @@ export function AddKeyForm({ userId, user, isAdmin = false, onSuccess }: AddKeyF limitConcurrentSessions: data.limitConcurrentSessions, cacheTtlPreference: data.cacheTtlPreference, providerGroup: data.providerGroup || PROVIDER_GROUP.DEFAULT, - }); + }; + // 非管理员走会话定向的自助端点,目标用户由服务端会话决定(U03: + // 避免 admin 路由 403 后静默改为给会话用户建 key) + const result = isAdmin ? await addKey({ userId: userId!, ...body }) : await addOwnKey(body); if (!result.ok) { const msg = result.errorCode diff --git a/src/app/api/v1/resources/keys/handlers.ts b/src/app/api/v1/resources/keys/handlers.ts index d9216768c..7b294dbe8 100644 --- a/src/app/api/v1/resources/keys/handlers.ts +++ b/src/app/api/v1/resources/keys/handlers.ts @@ -122,9 +122,36 @@ export async function getKey(c: Context): Promise { return jsonResponse({ id: params.keyId, limitUsage: result.data }); } +// U02: write routes relaxed from admin to read tier must re-enforce a full +// Web-UI session here — read-tier auth admits canLoginWebUi=false keys, and a +// key write would let a read-only key escalate itself (e.g. PATCH +// canLoginWebUi). Ownership (self-or-admin) is enforced by the actions. +function requireKeyWriteSession(c: Context): Response | null { + const session = c.get("auth")?.session; + if (!session?.user?.id) { + return createProblemResponse({ + status: 401, + instance: new URL(c.req.url).pathname, + errorCode: "auth.missing", + detail: "Authentication is required.", + }); + } + if (session.user.role !== "admin" && session.key?.canLoginWebUi !== true) { + return createProblemResponse({ + status: 403, + instance: new URL(c.req.url).pathname, + errorCode: "auth.forbidden", + detail: "Read-only sessions cannot manage keys.", + }); + } + return null; +} + export async function updateKey(c: Context): Promise { const params = parseKeyParams(c); if (params instanceof Response) return params; + const denied = requireKeyWriteSession(c); + if (denied) return denied; const body = await parseHonoJsonBody(c, KeyUpdateSchema); if (!body.ok) return body.response; const actions = await import("@/actions/keys"); @@ -137,6 +164,8 @@ export async function updateKey(c: Context): Promise { export async function deleteKey(c: Context): Promise { const params = parseKeyParams(c); if (params instanceof Response) return params; + const denied = requireKeyWriteSession(c); + if (denied) return denied; const actions = await import("@/actions/keys"); const result = await callAction(c, actions.removeKey, [params.keyId] as never[], c.get("auth")); if (!result.ok) return actionError(c, result); @@ -146,6 +175,8 @@ export async function deleteKey(c: Context): Promise { export async function enableKey(c: Context): Promise { const params = parseKeyParams(c); if (params instanceof Response) return params; + const denied = requireKeyWriteSession(c); + if (denied) return denied; const body = await parseHonoJsonBody(c, KeyEnableSchema); if (!body.ok) return body.response; const actions = await import("@/actions/keys"); @@ -163,6 +194,8 @@ export async function enableKey(c: Context): Promise { export async function renewKey(c: Context): Promise { const params = parseKeyParams(c); if (params instanceof Response) return params; + const denied = requireKeyWriteSession(c); + if (denied) return denied; const body = await parseHonoJsonBody(c, KeyRenewSchema); if (!body.ok) return body.response; const actions = await import("@/actions/keys"); diff --git a/src/app/api/v1/resources/keys/router.ts b/src/app/api/v1/resources/keys/router.ts index 6ad705991..90e87f1c5 100644 --- a/src/app/api/v1/resources/keys/router.ts +++ b/src/app/api/v1/resources/keys/router.ts @@ -146,8 +146,9 @@ const enableKeyRoute = createRoute({ path: "/keys/{keyId}:enable", tags: ["Keys"], summary: "Set key enabled state", - description: "Enables or disables one key.", - "x-required-access": "admin", + description: + "Enables or disables one key. Admins may toggle any key; regular users with Web UI access may toggle only the keys they own. Read-only sessions are rejected.", + "x-required-access": "read", security, request: { params: KeyIdParamSchema, @@ -163,15 +164,16 @@ const enableKeyRoute = createRoute({ }); keysRouter.openAPIRegistry.registerPath(enableKeyRoute); -keysRouter.post("/keys/:keyId{[0-9]+:enable}", requireAuth("admin"), enableKey); +keysRouter.post("/keys/:keyId{[0-9]+:enable}", requireAuth("read"), enableKey); const renewKeyRoute = createRoute({ method: "post", path: "/keys/{keyId}:renew", tags: ["Keys"], summary: "Renew key expiration", - description: "Updates one key expiration date.", - "x-required-access": "admin", + description: + "Updates one key expiration date. Admins may renew any key; regular users with Web UI access may renew only the keys they own. Read-only sessions are rejected.", + "x-required-access": "read", security, request: { params: KeyIdParamSchema, @@ -187,7 +189,7 @@ const renewKeyRoute = createRoute({ }); keysRouter.openAPIRegistry.registerPath(renewKeyRoute); -keysRouter.post("/keys/:keyId{[0-9]+:renew}", requireAuth("admin"), renewKey); +keysRouter.post("/keys/:keyId{[0-9]+:renew}", requireAuth("read"), renewKey); const revealKeyRoute = createRoute({ method: "get", @@ -239,11 +241,12 @@ keysRouter.openapi( createRoute({ method: "patch", path: "/keys/{keyId}", - middleware: requireAuth("admin"), + middleware: requireAuth("read"), tags: ["Keys"], summary: "Update key", - description: "Updates one key.", - "x-required-access": "admin", + description: + "Updates one key. Admins may update any key; regular users with Web UI access may update only the keys they own. Read-only sessions are rejected.", + "x-required-access": "read", security, request: { params: KeyIdParamSchema, @@ -264,11 +267,12 @@ keysRouter.openapi( createRoute({ method: "delete", path: "/keys/{keyId}", - middleware: requireAuth("admin"), + middleware: requireAuth("read"), tags: ["Keys"], summary: "Delete key", - description: "Deletes one key.", - "x-required-access": "admin", + description: + "Deletes one key. Admins may delete any key; regular users with Web UI access may delete only the keys they own. Read-only sessions are rejected.", + "x-required-access": "read", security, request: { params: KeyIdParamSchema }, responses: { 204: { description: "Key deleted." }, ...problemResponses }, @@ -296,11 +300,12 @@ keysRouter.openapi( createRoute({ method: "get", path: "/keys/{keyId}/limit-usage", - middleware: requireAuth("admin"), + middleware: requireAuth("read"), tags: ["Keys"], summary: "Get key limit usage", - description: "Returns all key cost buckets and concurrent session usage.", - "x-required-access": "admin", + description: + "Returns all key cost buckets and concurrent session usage. Admins may query any key; regular users may query only the keys they own (enforced by the action).", + "x-required-access": "read", security, request: { params: KeyIdParamSchema }, responses: { diff --git a/src/app/api/v1/resources/me/handlers.ts b/src/app/api/v1/resources/me/handlers.ts index 79924caff..6ca2c8193 100644 --- a/src/app/api/v1/resources/me/handlers.ts +++ b/src/app/api/v1/resources/me/handlers.ts @@ -186,6 +186,7 @@ function actionError(c: Context, result: Extract, { ok: fa result.errorCode === "UNAUTHORIZED" || detail.toLowerCase().includes("unauthorized") ? 401 : result.errorCode === "NOT_FOUND" || + result.errorCode?.endsWith("_NOT_FOUND") === true || detail.toLowerCase().includes("not found") || detail.includes("不存在") ? 404 diff --git a/src/lib/api-client/v1/actions/keys.ts b/src/lib/api-client/v1/actions/keys.ts index 6d7babca8..8dc4a4374 100644 --- a/src/lib/api-client/v1/actions/keys.ts +++ b/src/lib/api-client/v1/actions/keys.ts @@ -1,5 +1,4 @@ import type { BatchUpdateKeysParams, PatchKeyLimitField } from "@/actions/keys"; -import { isAdminForbidden } from "@/lib/api-client/v1/errors"; import type { Key } from "@/types/key"; import { apiDelete, @@ -16,17 +15,15 @@ export type { Key } from "@/types/key"; export function addKey(data: { userId: number } & Record) { const { userId, ...body } = data; - // NOTE(#1259): the per-user route is admin-only. Non-admin self-service runs - // into auth.forbidden there, so retry via the read-tier self endpoint, which - // derives the target user from the session (mirrors getUsers()'s fallback). - return toActionResult( - apiPost(`/api/v1/users/${userId}/keys`, body).catch((error: unknown) => { - if (isAdminForbidden(error)) { - return apiPost("/api/v1/users:self/keys", body); - } - throw error; - }) - ); + // Admin-only route; a 403 must surface instead of silently retargeting the + // key to the session user (U03). Self-service callers use addOwnKey(). + return toActionResult(apiPost(`/api/v1/users/${userId}/keys`, body)); +} + +export function addOwnKey(body: Record) { + // Session-scoped endpoint (#1259): the server derives the target user from + // the authenticated session and rejects read-only sessions. + return toActionResult(apiPost("/api/v1/users:self/keys", body)); } export function editKey(keyId: number, data: unknown) { diff --git a/src/lib/api-client/v1/openapi-types.gen.ts b/src/lib/api-client/v1/openapi-types.gen.ts index dcb27e819..ef097e7bf 100644 --- a/src/lib/api-client/v1/openapi-types.gen.ts +++ b/src/lib/api-client/v1/openapi-types.gen.ts @@ -2499,7 +2499,7 @@ export interface paths { put?: never; /** * Set key enabled state - * @description Enables or disables one key. + * @description Enables or disables one key. Admins may toggle any key; regular users with Web UI access may toggle only the keys they own. Read-only sessions are rejected. */ post: operations["postKeysByKeyidEnable"]; delete?: never; @@ -2519,7 +2519,7 @@ export interface paths { put?: never; /** * Renew key expiration - * @description Updates one key expiration date. + * @description Updates one key expiration date. Admins may renew any key; regular users with Web UI access may renew only the keys they own. Read-only sessions are rejected. */ post: operations["postKeysByKeyidRenew"]; delete?: never; @@ -2564,14 +2564,14 @@ export interface paths { post?: never; /** * Delete key - * @description Deletes one key. + * @description Deletes one key. Admins may delete any key; regular users with Web UI access may delete only the keys they own. Read-only sessions are rejected. */ delete: operations["deleteKeysByKeyid"]; options?: never; head?: never; /** * Update key - * @description Updates one key. + * @description Updates one key. Admins may update any key; regular users with Web UI access may update only the keys they own. Read-only sessions are rejected. */ patch: operations["patchKeysByKeyid"]; trace?: never; @@ -2605,7 +2605,7 @@ export interface paths { }; /** * Get key limit usage - * @description Returns all key cost buckets and concurrent session usage. + * @description Returns all key cost buckets and concurrent session usage. Admins may query any key; regular users may query only the keys they own (enforced by the action). */ get: operations["getKeysByKeyidLimitUsage"]; put?: never; diff --git a/src/lib/utils/error-messages.ts b/src/lib/utils/error-messages.ts index c7a02d7db..70d5425e3 100644 --- a/src/lib/utils/error-messages.ts +++ b/src/lib/utils/error-messages.ts @@ -100,6 +100,7 @@ export const BUSINESS_ERRORS = { USER_STATS_RESET_PARTIAL_FAILURE: "USER_STATS_RESET_PARTIAL_FAILURE", CANNOT_DELETE_LAST_KEY: "CANNOT_DELETE_LAST_KEY", CANNOT_DELETE_LAST_GROUP_KEY: "CANNOT_DELETE_LAST_GROUP_KEY", + KEY_NOT_FOUND: "KEY_NOT_FOUND", } as const; // Rate Limit Error Codes diff --git a/tests/unit/actions/keys-remove-error-codes.test.ts b/tests/unit/actions/keys-remove-error-codes.test.ts index 3481eb625..370f2bb97 100644 --- a/tests/unit/actions/keys-remove-error-codes.test.ts +++ b/tests/unit/actions/keys-remove-error-codes.test.ts @@ -62,7 +62,7 @@ describe("removeKey action error codes", () => { } }); - it("returns NOT_FOUND when the key does not exist", async () => { + it("returns KEY_NOT_FOUND when the key does not exist", async () => { getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } }); findKeyByIdMock.mockResolvedValueOnce(null); @@ -71,13 +71,16 @@ describe("removeKey action error codes", () => { expect(result.ok).toBe(false); if (!result.ok) { - expect(result.errorCode).toBe("NOT_FOUND"); + expect(result.errorCode).toBe("KEY_NOT_FOUND"); expect(result.error).toBe("KEY_NOT_FOUND"); } }); it("returns PERMISSION_DENIED when a non-admin deletes someone else's key", async () => { - getSessionMock.mockResolvedValue({ user: { id: 7, role: "user" } }); + getSessionMock.mockResolvedValue({ + user: { id: 7, role: "user" }, + key: { canLoginWebUi: true }, + }); findKeyByIdMock.mockResolvedValueOnce({ id: 42, userId: 99, @@ -119,7 +122,10 @@ describe("removeKey action error codes", () => { }); it("returns CANNOT_DELETE_LAST_GROUP_KEY when deleting would leave no provider group", async () => { - getSessionMock.mockResolvedValue({ user: { id: 99, role: "user" } }); + getSessionMock.mockResolvedValue({ + user: { id: 99, role: "user" }, + key: { canLoginWebUi: true }, + }); findKeyByIdMock.mockResolvedValueOnce({ id: 42, userId: 99, diff --git a/tests/unit/actions/keys-self-service-authz.test.ts b/tests/unit/actions/keys-self-service-authz.test.ts new file mode 100644 index 000000000..236a4bf95 --- /dev/null +++ b/tests/unit/actions/keys-self-service-authz.test.ts @@ -0,0 +1,221 @@ +/** + * U02: key 写操作的 self-or-admin 鉴权矩阵 + * + * REST 路由放开到 read 层后,action 层必须兜底拒绝只读会话 + * (canLoginWebUi=false 的 key 经 read 层 / legacy adapter 的 scoped 上下文 + * 可以拿到非空 session),否则只读 key 可 PATCH 自己的 canLoginWebUi 自提权。 + */ + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const getSessionMock = vi.fn(); +vi.mock("@/lib/auth", () => ({ + getSession: getSessionMock, +})); + +vi.mock("next/cache", () => ({ + revalidatePath: vi.fn(), +})); + +vi.mock("next-intl/server", () => ({ + getTranslations: vi.fn(async () => (key: string) => key), +})); + +const findKeyByIdMock = vi.fn(); +const countActiveKeysByUserMock = vi.fn(); +const findKeyListMock = vi.fn(); +const deleteKeyMock = vi.fn(); +const updateKeyMock = vi.fn(); +vi.mock("@/repository/key", () => ({ + countActiveKeysByUser: countActiveKeysByUserMock, + createKey: vi.fn(async () => ({})), + deleteKey: deleteKeyMock, + findActiveKeyByUserIdAndName: vi.fn(async () => null), + findKeyById: findKeyByIdMock, + findKeyList: findKeyListMock, + findKeysWithStatistics: vi.fn(async () => []), + resetKeyCostResetAt: vi.fn(), + updateKey: updateKeyMock, +})); + +const findUserByIdMock = vi.fn(); +vi.mock("@/repository/user", () => ({ + findUserById: findUserByIdMock, +})); + +vi.mock("@/actions/users", () => ({ + syncUserProviderGroupFromKeys: vi.fn(async () => undefined), +})); + +vi.mock("@/lib/audit/emit", () => ({ + emitActionAudit: vi.fn(), +})); + +vi.mock("@/lib/utils/timezone", () => ({ + resolveSystemTimezone: vi.fn(async () => "UTC"), +})); + +const OWN_KEY = { + id: 42, + userId: 99, + name: "own-key", + isEnabled: true, + providerGroup: "default", + canLoginWebUi: false, +}; + +const readOnlySession = { user: { id: 99, role: "user" }, key: { canLoginWebUi: false } }; +const webSession = { user: { id: 99, role: "user" }, key: { canLoginWebUi: true } }; +const adminSession = { user: { id: 1, role: "admin" }, key: { canLoginWebUi: true } }; + +beforeEach(() => { + vi.clearAllMocks(); + deleteKeyMock.mockResolvedValue(true); + updateKeyMock.mockResolvedValue({}); + findKeyByIdMock.mockResolvedValue({ ...OWN_KEY }); + findUserByIdMock.mockResolvedValue({ id: 99, providerGroup: "default" }); + countActiveKeysByUserMock.mockResolvedValue(5); + findKeyListMock.mockResolvedValue([ + { id: 42, providerGroup: "default" }, + { id: 43, providerGroup: "default" }, + ]); +}); + +describe("removeKey read-only session guard", () => { + it("denies a read-only session deleting its own key", async () => { + getSessionMock.mockResolvedValue(readOnlySession); + + const { removeKey } = await import("@/actions/keys"); + const result = await removeKey(42); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errorCode).toBe("PERMISSION_DENIED"); + } + expect(deleteKeyMock).not.toHaveBeenCalled(); + }); + + it("allows a Web-UI session deleting its own key", async () => { + getSessionMock.mockResolvedValue(webSession); + + const { removeKey } = await import("@/actions/keys"); + const result = await removeKey(42); + + expect(result.ok).toBe(true); + expect(deleteKeyMock).toHaveBeenCalledWith(42); + }); +}); + +describe("editKey self-service authorization", () => { + it("denies a read-only session editing its own key (canLoginWebUi escalation)", async () => { + getSessionMock.mockResolvedValue(readOnlySession); + + const { editKey } = await import("@/actions/keys"); + const result = await editKey(42, { name: "own-key", canLoginWebUi: true }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errorCode).toBe("PERMISSION_DENIED"); + } + expect(updateKeyMock).not.toHaveBeenCalled(); + }); + + it("allows a Web-UI session editing its own key", async () => { + getSessionMock.mockResolvedValue(webSession); + + const { editKey } = await import("@/actions/keys"); + const result = await editKey(42, { name: "renamed-key" }); + + expect(result.ok).toBe(true); + expect(updateKeyMock).toHaveBeenCalled(); + }); + + it("denies a Web-UI session editing someone else's key", async () => { + getSessionMock.mockResolvedValue(webSession); + findKeyByIdMock.mockResolvedValue({ ...OWN_KEY, userId: 7 }); + + const { editKey } = await import("@/actions/keys"); + const result = await editKey(42, { name: "renamed-key" }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errorCode).toBe("PERMISSION_DENIED"); + } + expect(updateKeyMock).not.toHaveBeenCalled(); + }); + + it("blocks a non-admin from disabling the last enabled key through PATCH isEnabled", async () => { + getSessionMock.mockResolvedValue(webSession); + countActiveKeysByUserMock.mockResolvedValue(1); + + const { editKey } = await import("@/actions/keys"); + const result = await editKey(42, { name: "own-key", isEnabled: false }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toBe("CANNOT_DISABLE_LAST_KEY"); + } + expect(updateKeyMock).not.toHaveBeenCalled(); + }); + + it("still allows an admin to disable the last enabled key through PATCH isEnabled", async () => { + getSessionMock.mockResolvedValue(adminSession); + countActiveKeysByUserMock.mockResolvedValue(1); + + const { editKey } = await import("@/actions/keys"); + const result = await editKey(42, { name: "own-key", isEnabled: false }); + + expect(result.ok).toBe(true); + expect(updateKeyMock).toHaveBeenCalled(); + }); +}); + +describe("toggleKeyEnabled self-service authorization", () => { + it("denies a read-only session toggling its own key", async () => { + getSessionMock.mockResolvedValue(readOnlySession); + + const { toggleKeyEnabled } = await import("@/actions/keys"); + const result = await toggleKeyEnabled(42, false); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errorCode).toBe("PERMISSION_DENIED"); + } + expect(updateKeyMock).not.toHaveBeenCalled(); + }); + + it("allows a Web-UI session toggling its own key", async () => { + getSessionMock.mockResolvedValue(webSession); + + const { toggleKeyEnabled } = await import("@/actions/keys"); + const result = await toggleKeyEnabled(42, false); + + expect(result.ok).toBe(true); + expect(updateKeyMock).toHaveBeenCalledWith(42, { is_enabled: false }); + }); +}); + +describe("renewKeyExpiresAt self-service authorization", () => { + it("denies a read-only session renewing its own key", async () => { + getSessionMock.mockResolvedValue(readOnlySession); + + const { renewKeyExpiresAt } = await import("@/actions/keys"); + const result = await renewKeyExpiresAt(42, { expiresAt: "2027-01-01" }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errorCode).toBe("PERMISSION_DENIED"); + } + expect(updateKeyMock).not.toHaveBeenCalled(); + }); + + it("allows a Web-UI session renewing its own key", async () => { + getSessionMock.mockResolvedValue(webSession); + + const { renewKeyExpiresAt } = await import("@/actions/keys"); + const result = await renewKeyExpiresAt(42, { expiresAt: "2027-01-01" }); + + expect(result.ok).toBe(true); + expect(updateKeyMock).toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/api/v1/api-client-actions.test.ts b/tests/unit/api/v1/api-client-actions.test.ts index f567f401b..5ce5534c5 100644 --- a/tests/unit/api/v1/api-client-actions.test.ts +++ b/tests/unit/api/v1/api-client-actions.test.ts @@ -424,27 +424,35 @@ describe("v1 action compatibility client", () => { }); }); - test("falls back to the self key-creation endpoint for non-admin key creation", async () => { - postMock - .mockRejectedValueOnce( - new ApiError({ - status: 403, - errorCode: "auth.forbidden", - detail: "Admin access is required.", - }) - ) - .mockResolvedValueOnce({ id: 77, generatedKey: "sk-new", name: "self-key" }); + test("addKey hard-fails on 403 instead of silently retargeting to the self endpoint", async () => { + // U03: the old 403 fallback could turn "create a key for user X" into + // "create a key for myself" when the session lost admin rights mid-flight. + postMock.mockRejectedValueOnce( + new ApiError({ + status: 403, + errorCode: "auth.forbidden", + detail: "Admin access is required.", + }) + ); const result = await keys.addKey({ userId: 2, name: "self-key", providerGroup: "default" }); - expect(postMock).toHaveBeenNthCalledWith( - 1, + expect(postMock).toHaveBeenCalledTimes(1); + expect(postMock).toHaveBeenCalledWith( "/api/v1/users/2/keys", { name: "self-key", providerGroup: "default" }, undefined ); - expect(postMock).toHaveBeenNthCalledWith( - 2, + expect(result).toMatchObject({ ok: false, errorCode: "PERMISSION_DENIED" }); + }); + + test("addOwnKey posts directly to the self key-creation endpoint", async () => { + postMock.mockResolvedValueOnce({ id: 77, generatedKey: "sk-new", name: "self-key" }); + + const result = await keys.addOwnKey({ name: "self-key", providerGroup: "default" }); + + expect(postMock).toHaveBeenCalledTimes(1); + expect(postMock).toHaveBeenCalledWith( "/api/v1/users:self/keys", { name: "self-key", providerGroup: "default" }, undefined @@ -507,34 +515,22 @@ describe("v1 action compatibility client", () => { expect(result).toMatchObject({ ok: false, errorCode: "OPERATION_FAILED" }); }); - test("surfaces PERMISSION_DENIED when both admin and self key creation are forbidden", async () => { + test("addOwnKey surfaces PERMISSION_DENIED for read-only sessions", async () => { // Drop any persistent implementation a prior test left on postMock so the - // two Once-rejections below are the only behaviors in play. + // Once-rejection below is the only behavior in play. postMock.mockReset(); - // Readonly self-service user: admin route 403s, fallback self route also 403s. - postMock - .mockRejectedValueOnce( - new ApiError({ status: 403, errorCode: "auth.forbidden", detail: "Admin access required." }) - ) - .mockRejectedValueOnce( - new ApiError({ - status: 403, - errorCode: "auth.forbidden", - detail: "Read-only sessions cannot create keys.", - }) - ); + postMock.mockRejectedValueOnce( + new ApiError({ + status: 403, + errorCode: "auth.forbidden", + detail: "Read-only sessions cannot create keys.", + }) + ); - const result = await keys.addKey({ userId: 2, name: "self-key" }); + const result = await keys.addOwnKey({ name: "self-key" }); - expect(postMock).toHaveBeenCalledTimes(2); - expect(postMock).toHaveBeenNthCalledWith( - 1, - "/api/v1/users/2/keys", - { name: "self-key" }, - undefined - ); - expect(postMock).toHaveBeenNthCalledWith( - 2, + expect(postMock).toHaveBeenCalledTimes(1); + expect(postMock).toHaveBeenCalledWith( "/api/v1/users:self/keys", { name: "self-key" }, undefined diff --git a/tests/unit/api/v1/keys-delete-error-mapping.test.ts b/tests/unit/api/v1/keys-delete-error-mapping.test.ts index 0da684cb3..d53e98610 100644 --- a/tests/unit/api/v1/keys-delete-error-mapping.test.ts +++ b/tests/unit/api/v1/keys-delete-error-mapping.test.ts @@ -18,7 +18,10 @@ function makeContext(keyId = "12"): Context { param: (name: string) => (name === "keyId" ? keyId : undefined), url: `http://localhost/api/v1/keys/${keyId}`, }, - get: () => undefined, + get: (name: string) => + name === "auth" + ? { session: { user: { id: 1, role: "admin" }, key: { canLoginWebUi: true } } } + : undefined, } as unknown as Context; } @@ -48,6 +51,16 @@ describe("DELETE /api/v1/keys/{keyId} error mapping", () => { expect(await response.json()).toMatchObject({ errorCode: "NOT_FOUND" }); }); + it("maps KEY_NOT_FOUND to 404 and keeps the action error code", async () => { + const response = await runDelete({ + ok: false, + error: "key missing", + errorCode: "KEY_NOT_FOUND", + }); + expect(response.status).toBe(404); + expect(await response.json()).toMatchObject({ errorCode: "KEY_NOT_FOUND" }); + }); + it("maps PERMISSION_DENIED to 403 and keeps the action error code", async () => { const response = await runDelete({ ok: false, diff --git a/tests/unit/api/v1/keys-write-handlers-authz.test.ts b/tests/unit/api/v1/keys-write-handlers-authz.test.ts new file mode 100644 index 000000000..60cd490db --- /dev/null +++ b/tests/unit/api/v1/keys-write-handlers-authz.test.ts @@ -0,0 +1,131 @@ +/** + * U02: key 写 handler 的会话级守卫 + * + * PATCH/DELETE /keys/{id}、:enable、:renew 从 admin 层放开到 read 层后, + * handler 必须拒绝只读会话(read 层接纳 canLoginWebUi=false 的 key 会话), + * 完整 Web 会话与管理员放行到 action 层做 self-or-admin 所有权检查。 + */ + +import type { Context } from "hono"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const removeKeyMock = vi.fn(); +const editKeyMock = vi.fn(); +const toggleKeyEnabledMock = vi.fn(); +const renewKeyExpiresAtMock = vi.fn(); +vi.mock("@/actions/keys", () => ({ + removeKey: removeKeyMock, + editKey: editKeyMock, + toggleKeyEnabled: toggleKeyEnabledMock, + renewKeyExpiresAt: renewKeyExpiresAtMock, +})); + +vi.mock("@/lib/api/v1/_shared/action-bridge", () => ({ + callAction: vi.fn(async (_c: unknown, action: (...args: unknown[]) => unknown, args: unknown[]) => + action(...(args ?? [])) + ), +})); + +type AuthValue = { + session: { + user: { id: number; role: string }; + key: { canLoginWebUi: boolean }; + } | null; +} | null; + +function makeContext(auth: AuthValue, body?: unknown, keyId = "12"): Context { + return { + req: { + param: (name: string) => (name === "keyId" ? keyId : undefined), + url: `http://localhost/api/v1/keys/${keyId}`, + header: (name: string) => + name.toLowerCase() === "content-type" ? "application/json" : undefined, + json: async () => body, + raw: { headers: new Headers({ "content-type": "application/json" }) }, + }, + get: (name: string) => (name === "auth" ? auth : undefined), + } as unknown as Context; +} + +const readOnlyAuth: AuthValue = { + session: { user: { id: 9, role: "user" }, key: { canLoginWebUi: false } }, +}; +const webAuth: AuthValue = { + session: { user: { id: 9, role: "user" }, key: { canLoginWebUi: true } }, +}; +const adminAuth: AuthValue = { + session: { user: { id: 1, role: "admin" }, key: { canLoginWebUi: true } }, +}; + +beforeEach(() => { + vi.clearAllMocks(); + removeKeyMock.mockResolvedValue({ ok: true }); + editKeyMock.mockResolvedValue({ ok: true }); + toggleKeyEnabledMock.mockResolvedValue({ ok: true }); + renewKeyExpiresAtMock.mockResolvedValue({ ok: true }); +}); + +describe("key write handlers reject read-only sessions (U02)", () => { + it("deleteKey: read-only session gets 403 and the action is not called", async () => { + const { deleteKey } = await import("@/app/api/v1/resources/keys/handlers"); + const response = await deleteKey(makeContext(readOnlyAuth)); + expect(response.status).toBe(403); + expect(removeKeyMock).not.toHaveBeenCalled(); + }); + + it("deleteKey: missing session gets 401", async () => { + const { deleteKey } = await import("@/app/api/v1/resources/keys/handlers"); + const response = await deleteKey(makeContext(null)); + expect(response.status).toBe(401); + expect(removeKeyMock).not.toHaveBeenCalled(); + }); + + it("deleteKey: Web-UI session passes through to the action", async () => { + const { deleteKey } = await import("@/app/api/v1/resources/keys/handlers"); + const response = await deleteKey(makeContext(webAuth)); + expect(response.status).toBe(204); + expect(removeKeyMock).toHaveBeenCalledWith(12); + }); + + it("updateKey: read-only session gets 403 and the action is not called", async () => { + const { updateKey } = await import("@/app/api/v1/resources/keys/handlers"); + const response = await updateKey(makeContext(readOnlyAuth, { name: "renamed" })); + expect(response.status).toBe(403); + expect(editKeyMock).not.toHaveBeenCalled(); + }); + + it("updateKey: admin session passes through to the action", async () => { + const { updateKey } = await import("@/app/api/v1/resources/keys/handlers"); + const response = await updateKey(makeContext(adminAuth, { name: "renamed" })); + expect(response.status).toBe(200); + expect(editKeyMock).toHaveBeenCalled(); + }); + + it("enableKey: read-only session gets 403 and the action is not called", async () => { + const { enableKey } = await import("@/app/api/v1/resources/keys/handlers"); + const response = await enableKey(makeContext(readOnlyAuth, { enabled: false })); + expect(response.status).toBe(403); + expect(toggleKeyEnabledMock).not.toHaveBeenCalled(); + }); + + it("enableKey: Web-UI session passes through to the action", async () => { + const { enableKey } = await import("@/app/api/v1/resources/keys/handlers"); + const response = await enableKey(makeContext(webAuth, { enabled: false })); + expect(response.status).toBe(200); + expect(toggleKeyEnabledMock).toHaveBeenCalledWith(12, false); + }); + + it("renewKey: read-only session gets 403 and the action is not called", async () => { + const { renewKey } = await import("@/app/api/v1/resources/keys/handlers"); + const response = await renewKey(makeContext(readOnlyAuth, { expiresAt: "2027-01-01" })); + expect(response.status).toBe(403); + expect(renewKeyExpiresAtMock).not.toHaveBeenCalled(); + }); + + it("renewKey: Web-UI session passes through to the action", async () => { + const { renewKey } = await import("@/app/api/v1/resources/keys/handlers"); + const response = await renewKey(makeContext(webAuth, { expiresAt: "2027-01-01" })); + expect(response.status).toBe(200); + expect(renewKeyExpiresAtMock).toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/dashboard/add-key-form-balance-page-toggle.test.tsx b/tests/unit/dashboard/add-key-form-balance-page-toggle.test.tsx index 840ac2686..73e1cbfca 100644 --- a/tests/unit/dashboard/add-key-form-balance-page-toggle.test.tsx +++ b/tests/unit/dashboard/add-key-form-balance-page-toggle.test.tsx @@ -30,13 +30,14 @@ vi.mock("sonner", () => sonnerMocks); const keysActionMocks = vi.hoisted(() => ({ addKey: vi.fn(async () => ({ ok: true, data: { generatedKey: "sk-test", name: "test" } })), + addOwnKey: vi.fn(async () => ({ ok: true, data: { generatedKey: "sk-test", name: "test" } })), })); -vi.mock("@/actions/keys", () => keysActionMocks); +vi.mock("@/lib/api-client/v1/actions/keys", () => keysActionMocks); const providersActionMocks = vi.hoisted(() => ({ getAvailableProviderGroups: vi.fn(async () => []), })); -vi.mock("@/actions/providers", () => providersActionMocks); +vi.mock("@/lib/api-client/v1/actions/providers", () => providersActionMocks); function loadMessages() { const base = path.join(process.cwd(), "messages/en"); diff --git a/tests/unit/dashboard/add-key-form-expiry-clear-ui.test.tsx b/tests/unit/dashboard/add-key-form-expiry-clear-ui.test.tsx index ba96678e0..bc50a2a5d 100644 --- a/tests/unit/dashboard/add-key-form-expiry-clear-ui.test.tsx +++ b/tests/unit/dashboard/add-key-form-expiry-clear-ui.test.tsx @@ -29,13 +29,14 @@ vi.mock("sonner", () => sonnerMocks); const keysActionMocks = vi.hoisted(() => ({ addKey: vi.fn(async () => ({ ok: true, data: { generatedKey: "sk-test", name: "test" } })), + addOwnKey: vi.fn(async () => ({ ok: true, data: { generatedKey: "sk-test", name: "test" } })), })); -vi.mock("@/actions/keys", () => keysActionMocks); +vi.mock("@/lib/api-client/v1/actions/keys", () => keysActionMocks); const providersActionMocks = vi.hoisted(() => ({ getAvailableProviderGroups: vi.fn(async () => []), })); -vi.mock("@/actions/providers", () => providersActionMocks); +vi.mock("@/lib/api-client/v1/actions/providers", () => providersActionMocks); function loadMessages() { const base = path.join(process.cwd(), "messages/en"); diff --git a/tests/unit/dashboard/add-key-form-self-service.test.tsx b/tests/unit/dashboard/add-key-form-self-service.test.tsx new file mode 100644 index 000000000..ecb9ce0fb --- /dev/null +++ b/tests/unit/dashboard/add-key-form-self-service.test.tsx @@ -0,0 +1,147 @@ +/** + * @vitest-environment happy-dom + * + * AddKeyForm: 自助建 key 路由测试 (U03) + * 非管理员提交必须直达会话定向端点 addOwnKey(payload 不含 userId), + * 管理员提交走 addKey(携带目标 userId),两条路径互不触碰。 + */ + +import fs from "node:fs"; +import path from "node:path"; +import type { ReactNode } from "react"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { NextIntlClientProvider } from "next-intl"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { Dialog } from "@/components/ui/dialog"; +import { AddKeyForm } from "@/app/[locale]/dashboard/_components/user/forms/add-key-form"; + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ refresh: vi.fn() }), +})); + +const sonnerMocks = vi.hoisted(() => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + }, +})); +vi.mock("sonner", () => sonnerMocks); + +const keysActionMocks = vi.hoisted(() => ({ + addKey: vi.fn(async () => ({ ok: true, data: { generatedKey: "sk-test", name: "test" } })), + addOwnKey: vi.fn(async () => ({ ok: true, data: { generatedKey: "sk-test", name: "test" } })), +})); +vi.mock("@/lib/api-client/v1/actions/keys", () => keysActionMocks); + +const providersActionMocks = vi.hoisted(() => ({ + getAvailableProviderGroups: vi.fn(async () => []), +})); +vi.mock("@/lib/api-client/v1/actions/providers", () => providersActionMocks); + +function loadMessages() { + const base = path.join(process.cwd(), "messages/en"); + const read = (name: string) => JSON.parse(fs.readFileSync(path.join(base, name), "utf8")); + + return { + common: read("common.json"), + errors: read("errors.json"), + quota: read("quota.json"), + ui: read("ui.json"), + dashboard: read("dashboard.json"), + forms: read("forms.json"), + }; +} + +function render(node: ReactNode) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render(node); + }); + + return { + container, + unmount: () => { + act(() => root.unmount()); + container.remove(); + }, + }; +} + +async function fillNameAndSubmit() { + const nameInput = document.body.querySelector('input[placeholder*="key"]') as HTMLInputElement; + expect(nameInput).toBeTruthy(); + // 通过原生 setter 写值,绕过 React 的 value tracker 去重,否则合成 onChange 不触发 + const nativeValueSetter = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, + "value" + )?.set; + await act(async () => { + nativeValueSetter?.call(nameInput, "test-key"); + nameInput.dispatchEvent(new Event("input", { bubbles: true })); + }); + + // happy-dom 不会因点击 submit 按钮触发表单提交,直接派发 submit 事件; + // useZodForm.handleSubmit 内部仍会跑 zod 校验,未通过则不会调用 action。 + const formEl = document.body.querySelector("form") as HTMLFormElement | null; + expect(formEl).toBeTruthy(); + await act(async () => { + formEl?.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })); + await new Promise((r) => setTimeout(r, 50)); + }); +} + +describe("AddKeyForm: self-service routing (U03)", () => { + beforeEach(() => { + vi.clearAllMocks(); + document.body.innerHTML = ""; + }); + + test("non-admin submit calls addOwnKey without userId and never calls addKey", async () => { + const messages = loadMessages(); + + const { unmount } = render( + + {}}> + + + + ); + + await fillNameAndSubmit(); + + expect(keysActionMocks.addOwnKey).toHaveBeenCalledTimes(1); + expect(keysActionMocks.addKey).not.toHaveBeenCalled(); + + const payload = keysActionMocks.addOwnKey.mock.calls[0][0] as Record; + expect(Object.hasOwn(payload, "userId")).toBe(false); + expect(payload.name).toBe("test-key"); + + unmount(); + }); + + test("admin submit calls addKey with the target userId and never calls addOwnKey", async () => { + const messages = loadMessages(); + + const { unmount } = render( + + {}}> + + + + ); + + await fillNameAndSubmit(); + + expect(keysActionMocks.addKey).toHaveBeenCalledTimes(1); + expect(keysActionMocks.addOwnKey).not.toHaveBeenCalled(); + + const payload = keysActionMocks.addKey.mock.calls[0][0] as Record; + expect(payload.userId).toBe(42); + + unmount(); + }); +}); From 8f652c2e99d4d2ecb44807a6d6df9c0689ee2b20 Mon Sep 17 00:00:00 2001 From: ding113 Date: Fri, 12 Jun 2026 13:28:04 +0800 Subject: [PATCH 24/24] docs(readme): add refactoring notice and drop AICodeMirror sponsor Add an important notice regarding the active refactoring of the project into Claude Code Hub Plus, noting that development and community support for the current Node.js version may experience delays. Additionally, remove the AICodeMirror sponsor block from both English and Chinese README files. --- README.en.md | 20 +++++--------------- README.md | 21 +++++---------------- 2 files changed, 10 insertions(+), 31 deletions(-) diff --git a/README.en.md b/README.en.md index 0257fdd1f..11706cdbb 100644 --- a/README.en.md +++ b/README.en.md @@ -22,6 +22,11 @@ Claude Code Hub combines Next.js 15, Hono, PostgreSQL, and Redis to deliver a Cl --- +> [!IMPORTANT] +> **This project is currently under active refactoring** +> +> Claude Code Hub Plus, the refactored version of Claude Code Hub, is expected to be open-sourced under the AGPL license in Q3. Claude Code Hub Plus is dedicated to building a high-performance, stable, commercial-grade LLM gateway, offering comprehensive commercial features such as format conversion, privacy filtering, a model marketplace, and top-up billing, while significantly improving the theoretical performance of the forwarding core. During development of the refactored version, progress and community support for the Node.js version may be delayed — thank you for your understanding. +
@@ -78,21 +83,6 @@ AIGoCode offers a special bonus for CCH users — register via this link and rec
- - - - - -
- -AICodeMirror Logo - - -💎 Special Offer: Thanks to AICodeMirror for sponsoring this project! AICodeMirror provides official high-stability relay services for Claude Code / Codex / Gemini CLI, with enterprise-grade concurrency, fast invoicing, and 24/7 dedicated technical support.
-Claude Code / Codex / Gemini official channels at 38% / 6% / 9% of original price, with extra discounts on top-ups!
-For claude-code-hub users, AICodeMirror offers special benefits: register via this link to enjoy 20% off your first top-up, and enterprise customers can get up to 25% off! → Visit Now -
-
diff --git a/README.md b/README.md index 1dd018c66..803adc68d 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,11 @@ Claude Code Hub 通过 Next.js 15 + Hono + PostgreSQL + Redis 组合,实现 Cl --- +> [!IMPORTANT] +> **当前项目正处于积极重构阶段** +> +> Claude Code Hub 的重构版本 Claude Code Hub Plus 预计将于第三季度以 AGPL 形式开源。Claude Code Hub Plus 致力于打造高性能、稳定的商用级 LLM 网关,提供格式转换、隐私过滤、模型广场、充值计费等完善的商用功能,并显著提升转发核心的理论性能。重构版本开发期间,Node.js 版本的开发进度和社区支持可能延误,敬请理解。 +
@@ -78,22 +83,6 @@ AIGoCode 为 CCH 的用户提供了特别福利,通过此链接注册的用户
- - - - - -
- -AICodeMirror Logo - - -💎 特别优惠:感谢 AICodeMirror 对本项目的赞助!AICodeMirror 提供 Claude Code / Codex / Gemini CLI 官方高稳定性中转服务,支持企业级并发、快速开票、7×24 小时专属技术支持。
-Claude Code / Codex / Gemini 官方渠道价格低至原价的 38% / 6% / 9%,充值还有额外折扣!
-针对 claude-code-hub 用户,AICodeMirror 特别推出福利:通过下方链接注册,首充立享 8 折 优惠;企业客户更可享受最高 7.5 折 折上折。
-通过此链接注册即可享受优惠 → 立即访问 -
-