diff --git a/src/actions/active-sessions.ts b/src/actions/active-sessions.ts index ba1cf03ae..baeee2e32 100644 --- a/src/actions/active-sessions.ts +++ b/src/actions/active-sessions.ts @@ -9,6 +9,7 @@ import { } from "@/lib/cache/session-cache"; import { logger } from "@/lib/logger"; import { normalizeRequestSequence } from "@/lib/utils/request-sequence"; +import { buildUnifiedSpecialSettings } from "@/lib/utils/special-settings"; import type { ActiveSessionInfo } from "@/types/session"; import type { SpecialSetting } from "@/types/special-settings"; import { summarizeTerminateSessionsBatch } from "./active-sessions-utils"; @@ -588,7 +589,8 @@ export async function getSessionDetails( const normalizedSequence = normalizeRequestSequence(requestSequence); const effectiveSequence = normalizedSequence ?? (requestCount > 0 ? requestCount : undefined); - const { findAdjacentRequestSequences } = await import("@/repository/message"); + const { findAdjacentRequestSequences, findMessageRequestAuditBySessionIdAndSequence } = + await import("@/repository/message"); const adjacent = effectiveSequence == null ? { prevSequence: null, nextSequence: null } @@ -618,7 +620,8 @@ export async function getSessionDetails( clientReqMeta, upstreamReqMeta, upstreamResMeta, - specialSettings, + redisSpecialSettings, + requestAudit, ] = await Promise.all([ SessionManager.getSessionRequestBody(sessionId, effectiveSequence), SessionManager.getSessionMessages(sessionId, effectiveSequence), @@ -629,6 +632,9 @@ export async function getSessionDetails( SessionManager.getSessionUpstreamRequestMeta(sessionId, effectiveSequence), SessionManager.getSessionUpstreamResponseMeta(sessionId, effectiveSequence), SessionManager.getSessionSpecialSettings(sessionId, effectiveSequence), + effectiveSequence + ? findMessageRequestAuditBySessionIdAndSequence(sessionId, effectiveSequence) + : Promise.resolve(null), ]); // 兼容:历史/异常数据可能是 JSON 字符串(前端需要根级对象/数组) @@ -646,6 +652,23 @@ export async function getSessionDetails( statusCode: upstreamResMeta?.statusCode ?? null, }; + const mergedSpecialSettings: SpecialSetting[] = [ + ...(Array.isArray(redisSpecialSettings) ? (redisSpecialSettings as SpecialSetting[]) : []), + ...(Array.isArray(requestAudit?.specialSettings) + ? (requestAudit.specialSettings as SpecialSetting[]) + : []), + ]; + const existingSpecialSettings = mergedSpecialSettings.length > 0 ? mergedSpecialSettings : null; + + const unifiedSpecialSettings = buildUnifiedSpecialSettings({ + existing: existingSpecialSettings, + blockedBy: requestAudit?.blockedBy ?? null, + blockedReason: requestAudit?.blockedReason ?? null, + statusCode: requestAudit?.statusCode ?? null, + cacheTtlApplied: requestAudit?.cacheTtlApplied ?? null, + context1mApplied: requestAudit?.context1mApplied ?? null, + }); + return { ok: true, data: { @@ -656,7 +679,7 @@ export async function getSessionDetails( responseHeaders, requestMeta, responseMeta, - specialSettings, + specialSettings: unifiedSpecialSettings, sessionStats, currentSequence: effectiveSequence ?? null, prevSequence: adjacent.prevSequence, diff --git a/src/app/[locale]/dashboard/logs/_components/error-details-dialog.test.tsx b/src/app/[locale]/dashboard/logs/_components/error-details-dialog.test.tsx index 7a7bf207b..823386ded 100644 --- a/src/app/[locale]/dashboard/logs/_components/error-details-dialog.test.tsx +++ b/src/app/[locale]/dashboard/logs/_components/error-details-dialog.test.tsx @@ -70,6 +70,9 @@ const messages = { processing: "Processing", success: "Success", error: "Error", + specialSettings: { + title: "Special settings", + }, billingDetails: { title: "Billing details", }, @@ -121,6 +124,33 @@ function getBillingAndPerformanceGrid(document: ReturnType) { } describe("error-details-dialog layout", () => { + test("renders special settings section when specialSettings exists", () => { + const html = renderWithIntl( + + ); + + expect(html).toContain("Special settings"); + expect(html).toContain("provider_parameter_override"); + }); + test("renders billing + performance as two-column grid on md when both present", () => { const html = renderWithIntl( - {log.specialSettings && log.specialSettings.length > 0 ? ( - - {t("logs.table.specialSettings")} - - ) : null} diff --git a/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx b/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx index 0f27deffe..2638493fe 100644 --- a/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx +++ b/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx @@ -578,6 +578,7 @@ export function VirtualizedLogsTable({ messagesCount={log.messagesCount} endpoint={log.endpoint} billingModelSource={billingModelSource} + specialSettings={log.specialSettings} inputTokens={log.inputTokens} outputTokens={log.outputTokens} cacheCreationInputTokens={log.cacheCreationInputTokens} diff --git a/src/lib/utils/special-settings.ts b/src/lib/utils/special-settings.ts new file mode 100644 index 000000000..b66cb07a7 --- /dev/null +++ b/src/lib/utils/special-settings.ts @@ -0,0 +1,126 @@ +import { CONTEXT_1M_BETA_HEADER } from "@/lib/special-attributes"; +import type { SpecialSetting } from "@/types/special-settings"; + +type BuildUnifiedSpecialSettingsParams = { + /** + * 已有 specialSettings(通常来自 DB special_settings 或 Session Redis 缓存) + */ + existing?: SpecialSetting[] | null; + /** + * 拦截类型(如 warmup / sensitive_word) + */ + blockedBy?: string | null; + /** + * 拦截原因(通常为 JSON 字符串) + */ + blockedReason?: string | null; + /** + * HTTP 状态码(用于补齐守卫拦截信息) + */ + statusCode?: number | null; + /** + * Cache TTL 实际应用值(用于展示 TTL/标头覆写命中) + */ + cacheTtlApplied?: string | null; + /** + * 1M 上下文是否应用(用于展示 1M 标头覆写命中) + */ + context1mApplied?: boolean | null; +}; + +function buildSettingKey(setting: SpecialSetting): string { + switch (setting.type) { + case "provider_parameter_override": + return JSON.stringify([ + setting.type, + setting.providerId ?? null, + setting.providerType ?? null, + setting.hit, + setting.changed, + [...setting.changes] + .map((change) => [change.path, change.before, change.after, change.changed] as const) + .sort((a, b) => a[0].localeCompare(b[0])), + ]); + case "response_fixer": + return JSON.stringify([ + setting.type, + setting.hit, + [...setting.fixersApplied] + .map((fixer) => [fixer.fixer, fixer.applied] as const) + .sort((a, b) => a[0].localeCompare(b[0])), + ]); + case "guard_intercept": + return JSON.stringify([setting.type, setting.guard, setting.action, setting.statusCode]); + case "anthropic_cache_ttl_header_override": + return JSON.stringify([setting.type, setting.ttl]); + case "anthropic_context_1m_header_override": + return JSON.stringify([setting.type, setting.header, setting.flag]); + default: { + // 兜底:保证即使未来扩展类型也不会导致运行时崩溃 + const _exhaustive: never = setting; + return JSON.stringify(_exhaustive); + } + } +} + +/** + * 构建“统一特殊设置”展示数据 + * + * 目标:把 DB 字段(blockedBy/cacheTtlApplied/context1mApplied)与既有 special_settings 合并, + * 统一在以下位置展示:日志列表/日志详情弹窗/Session 详情页。 + */ +export function buildUnifiedSpecialSettings( + params: BuildUnifiedSpecialSettingsParams +): SpecialSetting[] | null { + const base = params.existing ?? []; + const derived: SpecialSetting[] = []; + + if (params.blockedBy) { + const guard = params.blockedBy; + const action = guard === "warmup" ? "intercept_response" : "block_request"; + + derived.push({ + type: "guard_intercept", + scope: "guard", + hit: true, + guard, + action, + statusCode: params.statusCode ?? null, + reason: params.blockedReason ?? null, + }); + } + + if (params.cacheTtlApplied) { + derived.push({ + type: "anthropic_cache_ttl_header_override", + scope: "request_header", + hit: true, + ttl: params.cacheTtlApplied, + }); + } + + if (params.context1mApplied === true) { + derived.push({ + type: "anthropic_context_1m_header_override", + scope: "request_header", + hit: true, + header: "anthropic-beta", + flag: CONTEXT_1M_BETA_HEADER, + }); + } + + if (base.length === 0 && derived.length === 0) { + return null; + } + + const seen = new Set(); + const result: SpecialSetting[] = []; + for (const item of [...base, ...derived]) { + const key = buildSettingKey(item); + if (seen.has(key)) continue; + seen.add(key); + result.push(item); + } + + return result.length > 0 ? result : null; +} diff --git a/src/repository/message.ts b/src/repository/message.ts index 2bdacf5c8..50255c685 100644 --- a/src/repository/message.ts +++ b/src/repository/message.ts @@ -6,6 +6,7 @@ import { keys as keysTable, messageRequest, providers, users } from "@/drizzle/s import { getEnvConfig } from "@/lib/config/env.schema"; import { formatCostForStorage } from "@/lib/utils/currency"; import type { CreateMessageRequestData, MessageRequest } from "@/types/message"; +import type { SpecialSetting } from "@/types/special-settings"; import { EXCLUDE_WARMUP_CONDITION } from "./_shared/message-request-conditions"; import { toMessageRequest } from "./_shared/transformers"; import { enqueueMessageRequestUpdate } from "./message-write-buffer"; @@ -270,6 +271,52 @@ export async function findMessageRequestBySessionId( return toMessageRequest(result); } +/** + * 按 (sessionId, requestSequence) 获取请求的审计字段(用于 Session 详情页补齐特殊设置展示) + */ +export async function findMessageRequestAuditBySessionIdAndSequence( + sessionId: string, + requestSequence: number +): Promise<{ + statusCode: number | null; + blockedBy: string | null; + blockedReason: string | null; + cacheTtlApplied: string | null; + context1mApplied: boolean | null; + specialSettings: SpecialSetting[] | null; +} | null> { + const [row] = await db + .select({ + statusCode: messageRequest.statusCode, + blockedBy: messageRequest.blockedBy, + blockedReason: messageRequest.blockedReason, + cacheTtlApplied: messageRequest.cacheTtlApplied, + context1mApplied: messageRequest.context1mApplied, + specialSettings: messageRequest.specialSettings, + }) + .from(messageRequest) + .where( + and( + eq(messageRequest.sessionId, sessionId), + eq(messageRequest.requestSequence, requestSequence), + isNull(messageRequest.deletedAt) + ) + ) + .limit(1); + + if (!row) return null; + return { + statusCode: row.statusCode, + blockedBy: row.blockedBy, + blockedReason: row.blockedReason, + cacheTtlApplied: row.cacheTtlApplied, + context1mApplied: row.context1mApplied, + specialSettings: Array.isArray(row.specialSettings) + ? (row.specialSettings as SpecialSetting[]) + : null, + }; +} + /** * 聚合查询指定 session 的所有请求数据 * 返回总成本、总 Token、请求次数、供应商列表等 diff --git a/src/repository/usage-logs.ts b/src/repository/usage-logs.ts index 7d9f8dcde..1098107ce 100644 --- a/src/repository/usage-logs.ts +++ b/src/repository/usage-logs.ts @@ -3,6 +3,7 @@ import { and, desc, eq, isNull, sql } from "drizzle-orm"; import { db } from "@/drizzle/db"; import { keys as keysTable, messageRequest, providers, users } from "@/drizzle/schema"; +import { buildUnifiedSpecialSettings } from "@/lib/utils/special-settings"; import type { ProviderChainItem } from "@/types/message"; import type { SpecialSetting } from "@/types/special-settings"; import { EXCLUDE_WARMUP_CONDITION } from "./_shared/message-request-conditions"; @@ -241,6 +242,19 @@ export async function findUsageLogsBatch( (row.cacheCreationInputTokens ?? 0) + (row.cacheReadInputTokens ?? 0); + const existingSpecialSettings = Array.isArray(row.specialSettings) + ? (row.specialSettings as SpecialSetting[]) + : null; + + const unifiedSpecialSettings = buildUnifiedSpecialSettings({ + existing: existingSpecialSettings, + blockedBy: row.blockedBy, + blockedReason: row.blockedReason, + statusCode: row.statusCode, + cacheTtlApplied: row.cacheTtlApplied, + context1mApplied: row.context1mApplied, + }); + return { ...row, requestSequence: row.requestSequence ?? null, @@ -251,6 +265,7 @@ export async function findUsageLogsBatch( costUsd: row.costUsd?.toString() ?? null, providerChain: row.providerChain as ProviderChainItem[] | null, endpoint: row.endpoint, + specialSettings: unifiedSpecialSettings, }; }); @@ -447,6 +462,19 @@ export async function findUsageLogsWithDetails(filters: UsageLogFilters): Promis (row.cacheCreationInputTokens ?? 0) + (row.cacheReadInputTokens ?? 0); + const existingSpecialSettings = Array.isArray(row.specialSettings) + ? (row.specialSettings as SpecialSetting[]) + : null; + + const unifiedSpecialSettings = buildUnifiedSpecialSettings({ + existing: existingSpecialSettings, + blockedBy: row.blockedBy, + blockedReason: row.blockedReason, + statusCode: row.statusCode, + cacheTtlApplied: row.cacheTtlApplied, + context1mApplied: row.context1mApplied, + }); + return { ...row, requestSequence: row.requestSequence ?? null, @@ -457,6 +485,7 @@ export async function findUsageLogsWithDetails(filters: UsageLogFilters): Promis costUsd: row.costUsd?.toString() ?? null, providerChain: row.providerChain as ProviderChainItem[] | null, endpoint: row.endpoint, + specialSettings: unifiedSpecialSettings, }; }); diff --git a/src/types/special-settings.ts b/src/types/special-settings.ts index db06ac784..7ef0d2cca 100644 --- a/src/types/special-settings.ts +++ b/src/types/special-settings.ts @@ -5,7 +5,12 @@ * 便于在请求记录与请求详情中展示,支持后续扩展更多类型。 */ -export type SpecialSetting = ProviderParameterOverrideSpecialSetting | ResponseFixerSpecialSetting; +export type SpecialSetting = + | ProviderParameterOverrideSpecialSetting + | ResponseFixerSpecialSetting + | GuardInterceptSpecialSetting + | AnthropicCacheTtlHeaderOverrideSpecialSetting + | AnthropicContext1mHeaderOverrideSpecialSetting; export type SpecialSettingChangeValue = string | number | boolean | null; @@ -37,3 +42,46 @@ export type ResponseFixerSpecialSetting = { totalBytesProcessed: number; processingTimeMs: number; }; + +/** + * 守卫拦截/阻断审计 + * + * 用于把 warmup 抢答、敏感词拦截等“请求未进入上游”但会影响请求/响应结果的行为, + * 统一纳入 specialSettings 展示区域,方便在日志详情与 Session 详情中排查。 + */ +export type GuardInterceptSpecialSetting = { + type: "guard_intercept"; + scope: "guard"; + hit: boolean; + guard: string; + action: "intercept_response" | "block_request"; + statusCode: number | null; + /** + * 原始原因(通常为 JSON 字符串),保持原样以便前端与日志一致展示。 + */ + reason: string | null; +}; + +/** + * Anthropic 缓存 TTL 相关标头覆写审计 + * + * 说明:当系统根据配置/偏好对请求应用缓存 TTL 能力时,需要在“特殊设置”中可见, + * 便于审计与排查(与计费字段/Token 字段的展示互补)。 + */ +export type AnthropicCacheTtlHeaderOverrideSpecialSetting = { + type: "anthropic_cache_ttl_header_override"; + scope: "request_header"; + hit: boolean; + ttl: string; +}; + +/** + * Anthropic 1M 上下文相关标头覆写审计 + */ +export type AnthropicContext1mHeaderOverrideSpecialSetting = { + type: "anthropic_context_1m_header_override"; + scope: "request_header"; + hit: boolean; + header: "anthropic-beta"; + flag: string; +}; diff --git a/tests/unit/actions/active-sessions-special-settings.test.ts b/tests/unit/actions/active-sessions-special-settings.test.ts new file mode 100644 index 000000000..11fc63c2a --- /dev/null +++ b/tests/unit/actions/active-sessions-special-settings.test.ts @@ -0,0 +1,182 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; + +const getSessionMock = vi.fn(); + +const getSessionDetailsCacheMock = vi.fn(); +const setSessionDetailsCacheMock = vi.fn(); + +const getSessionRequestCountMock = vi.fn(); +const getSessionRequestBodyMock = vi.fn(); +const getSessionMessagesMock = vi.fn(); +const getSessionResponseMock = vi.fn(); +const getSessionRequestHeadersMock = vi.fn(); +const getSessionResponseHeadersMock = vi.fn(); +const getSessionClientRequestMetaMock = vi.fn(); +const getSessionUpstreamRequestMetaMock = vi.fn(); +const getSessionUpstreamResponseMetaMock = vi.fn(); +const getSessionSpecialSettingsMock = vi.fn(); + +const aggregateSessionStatsMock = vi.fn(); +const findAdjacentRequestSequencesMock = vi.fn(); +const findMessageRequestAuditBySessionIdAndSequenceMock = vi.fn(); + +vi.mock("@/lib/auth", () => ({ + getSession: getSessionMock, +})); + +vi.mock("@/lib/cache/session-cache", () => ({ + getActiveSessionsCache: vi.fn(() => null), + setActiveSessionsCache: vi.fn(), + getSessionDetailsCache: getSessionDetailsCacheMock, + setSessionDetailsCache: setSessionDetailsCacheMock, + clearActiveSessionsCache: vi.fn(), + clearSessionDetailsCache: vi.fn(), + clearAllSessionsCache: vi.fn(), +})); + +vi.mock("@/lib/logger", () => ({ + logger: { + trace: vi.fn(), + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +vi.mock("@/lib/session-manager", () => ({ + SessionManager: { + getSessionRequestCount: getSessionRequestCountMock, + getSessionRequestBody: getSessionRequestBodyMock, + getSessionMessages: getSessionMessagesMock, + getSessionResponse: getSessionResponseMock, + getSessionRequestHeaders: getSessionRequestHeadersMock, + getSessionResponseHeaders: getSessionResponseHeadersMock, + getSessionClientRequestMeta: getSessionClientRequestMetaMock, + getSessionUpstreamRequestMeta: getSessionUpstreamRequestMetaMock, + getSessionUpstreamResponseMeta: getSessionUpstreamResponseMetaMock, + getSessionSpecialSettings: getSessionSpecialSettingsMock, + }, +})); + +vi.mock("@/repository/message", () => ({ + aggregateSessionStats: aggregateSessionStatsMock, + findAdjacentRequestSequences: findAdjacentRequestSequencesMock, + findMessageRequestAuditBySessionIdAndSequence: findMessageRequestAuditBySessionIdAndSequenceMock, +})); + +describe("getSessionDetails - unified specialSettings", () => { + beforeEach(() => { + vi.clearAllMocks(); + + getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } }); + getSessionDetailsCacheMock.mockReturnValue(null); + + aggregateSessionStatsMock.mockResolvedValue({ + sessionId: "sess_x", + requestCount: 1, + totalCostUsd: "0", + totalInputTokens: 0, + totalOutputTokens: 0, + totalCacheCreationTokens: 0, + totalCacheReadTokens: 0, + totalDurationMs: 0, + firstRequestAt: new Date(), + lastRequestAt: new Date(), + providers: [], + models: [], + userName: "u", + userId: 1, + keyName: "k", + keyId: 1, + userAgent: null, + apiType: "chat", + cacheTtlApplied: null, + }); + + findAdjacentRequestSequencesMock.mockResolvedValue({ prevSequence: null, nextSequence: null }); + + getSessionRequestCountMock.mockResolvedValue(1); + getSessionRequestBodyMock.mockResolvedValue(null); + getSessionMessagesMock.mockResolvedValue(null); + getSessionResponseMock.mockResolvedValue(null); + getSessionRequestHeadersMock.mockResolvedValue(null); + getSessionResponseHeadersMock.mockResolvedValue(null); + getSessionClientRequestMetaMock.mockResolvedValue(null); + getSessionUpstreamRequestMetaMock.mockResolvedValue(null); + getSessionUpstreamResponseMetaMock.mockResolvedValue(null); + }); + + test("当 Redis specialSettings 为空时,应由 DB 审计字段派生特殊设置", async () => { + getSessionSpecialSettingsMock.mockResolvedValue(null); + findMessageRequestAuditBySessionIdAndSequenceMock.mockResolvedValue({ + statusCode: 200, + blockedBy: "warmup", + blockedReason: JSON.stringify({ reason: "anthropic_warmup_intercepted" }), + cacheTtlApplied: "1h", + context1mApplied: true, + specialSettings: null, + }); + + const { getSessionDetails } = await import("@/actions/active-sessions"); + const result = await getSessionDetails("sess_x", 1); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + const types = (result.data.specialSettings ?? []).map((s) => s.type).sort(); + expect(types).toEqual( + [ + "anthropic_cache_ttl_header_override", + "anthropic_context_1m_header_override", + "guard_intercept", + ].sort() + ); + }); + + test("当 Redis 与 DB 同时存在 specialSettings 时,应合并并去重", async () => { + getSessionSpecialSettingsMock.mockResolvedValue([ + { + type: "provider_parameter_override", + scope: "provider", + providerId: 1, + providerName: "p", + providerType: "codex", + hit: true, + changed: true, + changes: [{ path: "temperature", before: 1, after: 0.2, changed: true }], + }, + ]); + + findMessageRequestAuditBySessionIdAndSequenceMock.mockResolvedValue({ + statusCode: 200, + blockedBy: "warmup", + blockedReason: JSON.stringify({ reason: "anthropic_warmup_intercepted" }), + cacheTtlApplied: null, + context1mApplied: false, + specialSettings: [ + { + type: "provider_parameter_override", + scope: "provider", + providerId: 1, + providerName: "p", + providerType: "codex", + hit: true, + changed: true, + changes: [{ path: "temperature", before: 1, after: 0.2, changed: true }], + }, + ], + }); + + const { getSessionDetails } = await import("@/actions/active-sessions"); + const result = await getSessionDetails("sess_x", 1); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + const settings = result.data.specialSettings ?? []; + expect(settings.some((s) => s.type === "provider_parameter_override")).toBe(true); + expect(settings.some((s) => s.type === "guard_intercept")).toBe(true); + expect(settings.filter((s) => s.type === "provider_parameter_override").length).toBe(1); + }); +}); diff --git a/tests/unit/dashboard-logs-virtualized-special-settings-ui.test.tsx b/tests/unit/dashboard-logs-virtualized-special-settings-ui.test.tsx new file mode 100644 index 000000000..acfda7403 --- /dev/null +++ b/tests/unit/dashboard-logs-virtualized-special-settings-ui.test.tsx @@ -0,0 +1,162 @@ +/** + * @vitest-environment happy-dom + */ + +import fs from "node:fs"; +import path from "node:path"; +import type { ReactNode } from "react"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { NextIntlClientProvider } from "next-intl"; +import { describe, expect, test, vi } from "vitest"; +import { VirtualizedLogsTable } from "@/app/[locale]/dashboard/logs/_components/virtualized-logs-table"; +import type { UsageLogRow } from "@/repository/usage-logs"; + +// Note: The virtualized table relies on element measurements and ResizeObserver; happy-dom may not render rows. +// Stub useVirtualizer to "render only the first row" to keep UI assertions stable. +vi.mock("@/hooks/use-virtualizer", () => ({ + useVirtualizer: () => ({ + getVirtualItems: () => [{ index: 0, size: 52, start: 0 }], + getTotalSize: () => 52, + }), +})); + +vi.mock("@/actions/usage-logs", () => ({ + getUsageLogsBatch: vi.fn(async () => ({ + ok: true, + data: { + logs: [ + { + id: 1, + createdAt: new Date(), + sessionId: "session_test", + requestSequence: 1, + userName: "user", + keyName: "key", + providerName: "provider", + model: "claude-sonnet-4-5-20250929", + originalModel: "claude-sonnet-4-5-20250929", + endpoint: "/v1/messages", + statusCode: 200, + inputTokens: 1, + outputTokens: 1, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cacheCreation5mInputTokens: 0, + cacheCreation1hInputTokens: 0, + cacheTtlApplied: null, + totalTokens: 2, + costUsd: "0.000001", + costMultiplier: null, + durationMs: 10, + ttfbMs: 5, + errorMessage: null, + providerChain: null, + blockedBy: null, + blockedReason: null, + userAgent: "claude_cli/1.0", + messagesCount: 1, + context1mApplied: false, + specialSettings: [ + { + type: "provider_parameter_override", + scope: "provider", + providerId: 1, + providerName: "p", + providerType: "codex", + hit: true, + changed: true, + changes: [{ path: "temperature", before: 1, after: 0.2, changed: true }], + }, + ], + } satisfies UsageLogRow, + ], + nextCursor: null, + hasMore: false, + }, + })), +})); + +// Avoid importing the real next-intl navigation implementation in tests (it depends on Next.js runtime). +vi.mock("@/i18n/routing", () => ({ + Link: ({ children }: { children: ReactNode }) => children, +})); + +const dashboardMessages = JSON.parse( + fs.readFileSync(path.join(process.cwd(), "messages/en/dashboard.json"), "utf8") +); +const providerChainMessages = JSON.parse( + fs.readFileSync(path.join(process.cwd(), "messages/en/provider-chain.json"), "utf8") +); + +function renderWithIntl(node: ReactNode) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + refetchOnWindowFocus: false, + }, + }, + }); + + act(() => { + root.render( + + + {node} + + + ); + }); + + return { + container, + unmount: () => { + act(() => root.unmount()); + container.remove(); + }, + }; +} + +async function flushMicrotasks() { + await act(async () => { + await Promise.resolve(); + }); +} + +async function waitForText(container: HTMLElement, text: string, timeoutMs = 2000) { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if ((container.textContent || "").includes(text)) return; + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 20)); + }); + } + throw new Error(`Timeout waiting for text: ${text}`); +} + +describe("VirtualizedLogsTable - specialSettings display", () => { + test("should not display specialSettings badge in the logs list row", async () => { + const { container, unmount } = renderWithIntl( + + ); + + await flushMicrotasks(); + + // Wait for initial data to render (avoid assertion stuck in Loading state). + await waitForText(container, "Loaded 1 records"); + + expect(container.textContent).not.toContain(dashboardMessages.logs.table.specialSettings); + + unmount(); + }); +}); diff --git a/tests/unit/lib/utils/special-settings.test.ts b/tests/unit/lib/utils/special-settings.test.ts new file mode 100644 index 000000000..5bd4e041e --- /dev/null +++ b/tests/unit/lib/utils/special-settings.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, test } from "vitest"; +import type { SpecialSetting } from "@/types/special-settings"; +import { buildUnifiedSpecialSettings } from "@/lib/utils/special-settings"; + +describe("buildUnifiedSpecialSettings", () => { + test("无任何输入时应返回 null", () => { + expect(buildUnifiedSpecialSettings({ existing: null })).toBe(null); + }); + + test("blockedBy=warmup 时应派生 guard_intercept 特殊设置", () => { + const settings = buildUnifiedSpecialSettings({ + existing: null, + blockedBy: "warmup", + blockedReason: JSON.stringify({ reason: "anthropic_warmup_intercepted" }), + statusCode: 200, + }); + + expect(settings).not.toBeNull(); + expect(settings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: "guard_intercept", + scope: "guard", + hit: true, + guard: "warmup", + action: "intercept_response", + statusCode: 200, + }), + ]) + ); + }); + + test("blockedBy=sensitive_word 时应派生 guard_intercept 特殊设置", () => { + const settings = buildUnifiedSpecialSettings({ + existing: null, + blockedBy: "sensitive_word", + blockedReason: JSON.stringify({ word: "x" }), + statusCode: 400, + }); + + expect(settings).not.toBeNull(); + expect(settings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: "guard_intercept", + scope: "guard", + hit: true, + guard: "sensitive_word", + action: "block_request", + statusCode: 400, + }), + ]) + ); + }); + + test("cacheTtlApplied 存在时应派生 anthropic_cache_ttl_header_override 特殊设置", () => { + const settings = buildUnifiedSpecialSettings({ + existing: null, + cacheTtlApplied: "1h", + }); + + expect(settings).not.toBeNull(); + expect(settings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: "anthropic_cache_ttl_header_override", + scope: "request_header", + hit: true, + ttl: "1h", + }), + ]) + ); + }); + + test("context1mApplied=true 时应派生 anthropic_context_1m_header_override 特殊设置", () => { + const settings = buildUnifiedSpecialSettings({ + existing: null, + context1mApplied: true, + }); + + expect(settings).not.toBeNull(); + expect(settings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: "anthropic_context_1m_header_override", + scope: "request_header", + hit: true, + header: "anthropic-beta", + }), + ]) + ); + }); + + test("应合并 existing specialSettings 与派生 specialSettings", () => { + const existing: SpecialSetting[] = [ + { + type: "provider_parameter_override", + scope: "provider", + providerId: 1, + providerName: "p", + providerType: "codex", + hit: true, + changed: true, + changes: [{ path: "temperature", before: 1, after: 0.2, changed: true }], + }, + ]; + + const settings = buildUnifiedSpecialSettings({ + existing, + blockedBy: "warmup", + blockedReason: JSON.stringify({ reason: "anthropic_warmup_intercepted" }), + statusCode: 200, + }); + + expect(settings).not.toBeNull(); + expect(settings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: "provider_parameter_override" }), + expect.objectContaining({ type: "guard_intercept", guard: "warmup" }), + ]) + ); + }); + + test("应对重复的派生项去重(例如 existing 已包含同类 guard_intercept)", () => { + const existing: SpecialSetting[] = [ + { + type: "guard_intercept", + scope: "guard", + hit: true, + guard: "warmup", + action: "intercept_response", + statusCode: 200, + reason: JSON.stringify({ reason: "anthropic_warmup_intercepted" }), + }, + ]; + + const settings = buildUnifiedSpecialSettings({ + existing, + blockedBy: "warmup", + blockedReason: JSON.stringify({ reason: "anthropic_warmup_intercepted" }), + statusCode: 200, + }); + + expect(settings).not.toBeNull(); + expect(settings?.filter((s) => s.type === "guard_intercept").length).toBe(1); + }); + + test("guard_intercept 去重时不应受 reason 差异影响", () => { + const existing: SpecialSetting[] = [ + { + type: "guard_intercept", + scope: "guard", + hit: true, + guard: "warmup", + action: "intercept_response", + statusCode: 200, + reason: JSON.stringify({ reason: "a" }), + }, + ]; + + const settings = buildUnifiedSpecialSettings({ + existing, + blockedBy: "warmup", + blockedReason: JSON.stringify({ reason: "b" }), + statusCode: 200, + }); + + expect(settings).not.toBeNull(); + expect(settings?.filter((s) => s.type === "guard_intercept").length).toBe(1); + }); +});