diff --git a/.env.example b/.env.example index 522c007f5..102f5c5fa 100644 --- a/.env.example +++ b/.env.example @@ -149,6 +149,16 @@ FETCH_HEADERS_TIMEOUT=600000 FETCH_BODY_TIMEOUT=600000 MAX_RETRY_ATTEMPTS_DEFAULT=2 # 单供应商最大尝试次数(含首次调用),范围 1-10,留空使用默认值 2 +# 入站压缩请求体(content-encoding: zstd/gzip/deflate/br)解压上限(字节) +# 功能说明:/v1、/v1beta 代理路径不受 proxyClientMaxBodySize 钳制,这两项是入站解压的内存/CPU 兜底。 +# - MAX_DECOMPRESSED_REQUEST_BYTES:解压输出上限,防御解压炸弹,超过按 413 拒绝。默认 100MB。 +# 作用:内存受限部署可下调来收紧鉴权前的解压开销;代理刻意支持大请求体,默认值已较宽松。 +# - MAX_COMPRESSED_REQUEST_BYTES:压缩输入(线上字节)上限,解压前即校验,超过按 413 拒绝。 +# 默认与 MAX_DECOMPRESSED_REQUEST_BYTES 一致(真实压缩比下合法请求的压缩体一定不超过其解压体, +# 故默认不会误拒大上下文/图片压缩请求);下调解压上限时本上限随之收紧,也可单独覆盖。 +# MAX_DECOMPRESSED_REQUEST_BYTES=104857600 +# MAX_COMPRESSED_REQUEST_BYTES=104857600 + # Langfuse Observability (optional, auto-enabled when keys are set) # 功能说明:企业级 LLM 可观测性集成,自动追踪所有代理请求的完整生命周期 # - 配置 PUBLIC_KEY 和 SECRET_KEY 后自动启用 diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index c68e73ac3..c06a6d2b3 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -31,7 +31,7 @@ jobs: - name: 🟢 Setup Node.js uses: actions/setup-node@v6 with: - node-version: '20' + node-version: '22' - name: Cache Bun package cache uses: actions/cache@v5 diff --git a/Dockerfile b/Dockerfile index 06dc8b4b1..5518ae51e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,7 +12,7 @@ ENV NEXT_TELEMETRY_DISABLED=1 ENV CI=true RUN --mount=type=cache,target=/app/.next/cache bun run build -FROM node:20-slim AS runner +FROM node:22-slim AS runner WORKDIR /app ENV NODE_ENV=production ENV PORT=3000 diff --git a/README.en.md b/README.en.md index 9b867709f..0257fdd1f 100644 --- a/README.en.md +++ b/README.en.md @@ -124,7 +124,7 @@ Register now via this link ### Requirements - Docker and Docker Compose (latest version recommended) -- Optional (for local development): Node.js ≥ 20, Bun ≥ 1.3 +- Optional (for local development): Node.js ≥ 22.15 (inbound zstd request-body decompression uses native `node:zlib` zstd), Bun ≥ 1.3 ### 🚀 One-Click Deployment Script (✨ Recommended - Fully Automated) diff --git a/README.md b/README.md index e72b82e46..1dd018c66 100644 --- a/README.md +++ b/README.md @@ -125,7 +125,7 @@ Claude Code / Codex / Gemini 官方渠道价格低至原价的 38% / 6% / 9%, ### 环境要求 - Docker 与 Docker Compose(推荐使用最新版本) -- 可选(本地开发):Node.js ≥ 20,Bun ≥ 1.3 +- 可选(本地开发):Node.js ≥ 22.15(入站请求体 zstd 解压依赖原生 `node:zlib` zstd),Bun ≥ 1.3 ### 🚀 一键部署脚本(✨ 推荐方式,全自动安装) diff --git a/deploy/Dockerfile b/deploy/Dockerfile index 2c6596280..27d5c62bb 100644 --- a/deploy/Dockerfile +++ b/deploy/Dockerfile @@ -28,6 +28,9 @@ ENV CI=true RUN bun run build # 运行阶段:使用 Node.js(避免 Bun 流式响应内存泄漏 Issue #18488) +# 要求 Node >= 22.15(入站请求体 zstd 解压依赖 node:zlib 原生 zstd,见 +# src/app/v1/_lib/proxy/request-body-codec.ts 与 package.json engines)。 +# node:trixie-slim 基于 Debian Trixie,提供 Node 24+,满足该要求。 FROM node:trixie-slim AS runner ENV NODE_ENV=production ENV PORT=3000 diff --git a/messages/en/dashboard.json b/messages/en/dashboard.json index 48fee963a..6c5c5cbac 100644 --- a/messages/en/dashboard.json +++ b/messages/en/dashboard.json @@ -97,6 +97,8 @@ "exportError": "Export failed", "exportPreparing": "Preparing export...", "exportProgress": "Exported {current} / {total}", + "exportAsCsv": "Export as CSV", + "exportAsXlsx": "Export as XLSX (with summary)", "quickFilters": { "today": "Today", "thisWeek": "This Week", @@ -359,7 +361,8 @@ }, "effort": { "label": "Effort", - "overridden": "Overridden by provider" + "overridden": "Overridden by provider", + "tooltip": "Thinking effort requested by the client (output_config.effort), shown verbatim. The proxy does not rename or convert levels." }, "logicTrace": { "title": "Decision Chain", diff --git a/messages/ja/dashboard.json b/messages/ja/dashboard.json index 74a9ba3c8..c74b91c9a 100644 --- a/messages/ja/dashboard.json +++ b/messages/ja/dashboard.json @@ -97,6 +97,8 @@ "exportError": "エクスポートに失敗しました", "exportPreparing": "エクスポートを準備中...", "exportProgress": "{current} / {total} 件をエクスポート済み", + "exportAsCsv": "CSV としてエクスポート", + "exportAsXlsx": "XLSX としてエクスポート (集計付き)", "quickFilters": { "today": "今日", "thisWeek": "今週", @@ -359,7 +361,8 @@ }, "effort": { "label": "Effort", - "overridden": "プロバイダーにより上書き" + "overridden": "プロバイダーにより上書き", + "tooltip": "クライアントがリクエストボディで指定した思考強度 (output_config.effort)。プロキシは値をそのまま表示し、レベル名を変換しません。" }, "logicTrace": { "title": "決定チェーン", diff --git a/messages/ru/dashboard.json b/messages/ru/dashboard.json index 680a6d01e..95b204f5c 100644 --- a/messages/ru/dashboard.json +++ b/messages/ru/dashboard.json @@ -97,6 +97,8 @@ "exportError": "Ошибка экспорта", "exportPreparing": "Подготовка экспорта...", "exportProgress": "Экспортировано {current} / {total}", + "exportAsCsv": "Экспорт в CSV", + "exportAsXlsx": "Экспорт в XLSX (со сводкой)", "quickFilters": { "today": "Сегодня", "thisWeek": "Эта неделя", @@ -359,7 +361,8 @@ }, "effort": { "label": "Effort", - "overridden": "Переопределено провайдером" + "overridden": "Переопределено провайдером", + "tooltip": "Уровень усилий на размышления, запрошенный клиентом (output_config.effort); показан как есть — прокси не переименовывает и не преобразует уровни." }, "logicTrace": { "title": "Цепочка решений", diff --git a/messages/zh-CN/dashboard.json b/messages/zh-CN/dashboard.json index 7a173ce43..14c6e0a76 100644 --- a/messages/zh-CN/dashboard.json +++ b/messages/zh-CN/dashboard.json @@ -97,6 +97,8 @@ "exportError": "导出失败", "exportPreparing": "正在准备导出...", "exportProgress": "已导出 {current} / {total} 条", + "exportAsCsv": "导出为 CSV", + "exportAsXlsx": "导出为 XLSX(含汇总表)", "quickFilters": { "today": "今天", "thisWeek": "本周", @@ -359,7 +361,8 @@ }, "effort": { "label": "Effort", - "overridden": "已被供应商覆写" + "overridden": "已被供应商覆写", + "tooltip": "客户端在请求体中声明的思考强度(output_config.effort),按原值显示,代理不会重命名或转换等级。" }, "logicTrace": { "title": "决策链", diff --git a/messages/zh-TW/dashboard.json b/messages/zh-TW/dashboard.json index bafb784d9..6b6f96f4c 100644 --- a/messages/zh-TW/dashboard.json +++ b/messages/zh-TW/dashboard.json @@ -97,6 +97,8 @@ "exportError": "匯出失敗", "exportPreparing": "正在準備匯出...", "exportProgress": "已匯出 {current} / {total} 筆", + "exportAsCsv": "匯出為 CSV", + "exportAsXlsx": "匯出為 XLSX(含彙總表)", "quickFilters": { "today": "今天", "thisWeek": "本週", @@ -359,7 +361,8 @@ }, "effort": { "label": "Effort", - "overridden": "已被供應商覆寫" + "overridden": "已被供應商覆寫", + "tooltip": "用戶端在請求體中宣告的思考強度(output_config.effort),按原值顯示,代理不會重新命名或轉換等級。" }, "logicTrace": { "title": "決策鏈", diff --git a/package.json b/package.json index 05cf18c45..506c51648 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,9 @@ "name": "claude-code-hub", "version": "0.8.0", "private": true, + "engines": { + "node": ">=22.15.0" + }, "scripts": { "dev": "tsgo -p tsconfig.json --noEmit && next dev --port 13500", "dev:server": "NODE_ENV=development node server.js", @@ -89,6 +92,7 @@ "dotenv": "^17", "drizzle-orm": "^0.45", "fetch-socks": "^1", + "fflate": "^0.8.2", "framer-motion": "^12", "hono": "^4", "html2canvas": "^1", diff --git a/src/actions/error-rules.ts b/src/actions/error-rules.ts index 978e2e316..9c77a2b5b 100644 --- a/src/actions/error-rules.ts +++ b/src/actions/error-rules.ts @@ -178,6 +178,13 @@ export async function createErrorRuleAction(data: { // 刷新缓存(事件广播,支持多 worker 同步) await emitErrorRulesUpdated(); + // 上面的 emit 已在本进程触发一次携带最新数据的 reload,这里复用它即可(无需补跑第二轮)。 + // reload 仅为本进程缓存同步(跨 worker 由 emit 覆盖),失败不应把已成功的写入误报为失败。 + try { + await errorRuleDetector.reload(); + } catch (reloadError) { + logger.warn("[ErrorRulesAction] Failed to reload detector after mutation", { reloadError }); + } revalidatePath("/settings/error-rules"); @@ -311,6 +318,13 @@ export async function updateErrorRuleAction( // 刷新缓存(事件广播,支持多 worker 同步) await emitErrorRulesUpdated(); + // 上面的 emit 已在本进程触发一次携带最新数据的 reload,这里复用它即可(无需补跑第二轮)。 + // reload 仅为本进程缓存同步(跨 worker 由 emit 覆盖),失败不应把已成功的写入误报为失败。 + try { + await errorRuleDetector.reload(); + } catch (reloadError) { + logger.warn("[ErrorRulesAction] Failed to reload detector after mutation", { reloadError }); + } revalidatePath("/settings/error-rules"); @@ -365,6 +379,13 @@ export async function deleteErrorRuleAction(id: number): Promise { // 刷新缓存(事件广播,支持多 worker 同步) await emitErrorRulesUpdated(); + // 上面的 emit 已在本进程触发一次携带最新数据的 reload,这里复用它即可(无需补跑第二轮)。 + // reload 仅为本进程缓存同步(跨 worker 由 emit 覆盖),失败不应把已成功的写入误报为失败。 + try { + await errorRuleDetector.reload(); + } catch (reloadError) { + logger.warn("[ErrorRulesAction] Failed to reload detector after mutation", { reloadError }); + } revalidatePath("/settings/error-rules"); @@ -412,8 +433,9 @@ export async function refreshCacheAction(): Promise< // 1. 同步默认规则到数据库 const syncResult = await repo.syncDefaultErrorRules(); - // 2. 重新加载缓存 - await errorRuleDetector.reload(); + // 2. 重新加载缓存:手动刷新必须读到刚同步的默认规则, + // 若已有在途 reload 则排队补跑一轮(queueIfRunning),确保拿到同步后的最新快照。 + await errorRuleDetector.reload({ queueIfRunning: true }); const stats = errorRuleDetector.getStats(); diff --git a/src/actions/model-prices.ts b/src/actions/model-prices.ts index e7d4d3e9f..30d43c7b0 100644 --- a/src/actions/model-prices.ts +++ b/src/actions/model-prices.ts @@ -25,6 +25,7 @@ import { import type { ModelPrice, ModelPriceData, + ModelPriceSource, PriceTableJson, PriceUpdateResult, SyncConflict, @@ -98,13 +99,17 @@ function buildManualPriceDataFromProviderPricing( /** * 价格表处理核心逻辑(内部函数,无权限检查) - * 用于系统初始化和 Web UI 上传 + * 用于系统初始化、云端自动同步和 Web UI 上传 * @param jsonContent - 价格表 JSON 内容 * @param overwriteManual - 可选,要覆盖的手动添加模型名称列表 + * @param source - 写入记录的来源。云端/自动同步为 'litellm'(默认); + * 用户在本地显式上传的价格表为 'manual',使其遵循“本地优先”原则、 + * 不被后续云端自动同步覆盖。 */ export async function processPriceTableInternal( jsonContent: string, - overwriteManual?: string[] + overwriteManual?: string[], + source: ModelPriceSource = "litellm" ): Promise> { try { // 解析JSON内容 @@ -156,7 +161,10 @@ export async function processPriceTableInternal( }; // 处理每个模型的价格 - for (const [modelName, priceData] of entries) { + for (const [rawModelName, priceData] of entries) { + // 与 manual 记录入库时(upsertModelPrice 使用 trim 后的名称)保持一致地归一化, + // 避免云端表里带空白的同名键绕过本地手动模型的保护检查。 + const modelName = typeof rawModelName === "string" ? rawModelName.trim() : rawModelName; try { // 验证价格数据基本类型 if (typeof priceData !== "object" || priceData === null) { @@ -172,9 +180,11 @@ export async function processPriceTableInternal( continue; } - // 检查是否存在手动添加的价格且不在覆盖列表中 + // 本地优先:仅当本次写入来自云端/自动同步(source='litellm')时, + // 才跳过用户手动维护的模型,除非显式列入覆盖列表。 + // 用户显式上传(source='manual')属于权威导入,不受此保护跳过,可正常覆盖。 const isManualPrice = manualPrices.has(modelName); - if (isManualPrice && !overwriteSet.has(modelName)) { + if (source === "litellm" && isManualPrice && !overwriteSet.has(modelName)) { // 跳过手动添加的模型,记录到 skippedConflicts result.skippedConflicts?.push(modelName); result.unchanged.push(modelName); @@ -186,15 +196,15 @@ export async function processPriceTableInternal( if (!existingPrice) { // 模型不存在,新增记录 - await createModelPrice(modelName, priceData, "litellm"); + await createModelPrice(modelName, priceData, source); result.added.push(modelName); - } else if (!isPriceDataEqual(existingPrice.priceData, priceData)) { - // 模型存在但价格发生变化 - // 如果是手动模型且在覆盖列表中,先删除旧记录 - if (isManualPrice && overwriteSet.has(modelName)) { - await deleteModelPriceByName(modelName); - } - await createModelPrice(modelName, priceData, "litellm"); + } else if ( + existingPrice.source !== source || + !isPriceDataEqual(existingPrice.priceData, priceData) + ) { + // 价格或来源发生变化:用事务原子地“删旧 + 插新”替换该模型的所有记录, + // 既保证不会在崩溃时丢失价格,又避免同名记录堆积(litellm 孤儿行,或 manual + litellm 并存)。 + await upsertModelPrice(modelName, priceData, source); result.updated.push(modelName); } else { // 价格未发生变化,不需要更新 @@ -261,7 +271,9 @@ export async function uploadPriceTable( jsonContent = JSON.stringify(parseResult.data.models); } - const result = await processPriceTableInternal(jsonContent, overwriteManual); + // 用户显式上传的价格表视为“本地优先”的权威来源,标记为 manual, + // 使其不会被后续云端自动同步静默覆盖。 + const result = await processPriceTableInternal(jsonContent, overwriteManual, "manual"); if (result.ok) { emitActionAudit({ diff --git a/src/actions/notifications.ts b/src/actions/notifications.ts index b066d2997..4cf314d10 100644 --- a/src/actions/notifications.ts +++ b/src/actions/notifications.ts @@ -3,7 +3,6 @@ import { emitActionAudit } from "@/lib/audit/emit"; import { getSession } from "@/lib/auth"; import type { NotificationJobType } from "@/lib/constants/notification.constants"; -import { logger } from "@/lib/logger"; import { resolveSystemTimezone } from "@/lib/utils/timezone"; import { WebhookNotifier } from "@/lib/webhook"; import { buildTestMessage } from "@/lib/webhook/templates/test-messages"; @@ -41,18 +40,10 @@ export async function updateNotificationSettingsAction( const before = await getNotificationSettings(); const updated = await updateNotificationSettings(payload); - // 重新调度通知任务(仅生产环境) - if (process.env.NODE_ENV === "production") { - // 动态导入避免 Turbopack 编译 Bull 模块 - const { scheduleNotifications } = await import("@/lib/notification/notification-queue"); - await scheduleNotifications(); - } else { - logger.warn({ - action: "schedule_notifications_skipped", - reason: "development_mode", - message: "Notification scheduling is disabled in development mode", - }); - } + // 重新调度通知任务,使总开关、子开关、时间/间隔等变更立即生效(添加/移除 repeatable 作业)。 + // 动态导入避免静态加载 Bull;scheduleNotifications 内部已 fail-open,缺少 REDIS_URL 时不会影响设置保存。 + const { scheduleNotifications } = await import("@/lib/notification/notification-queue"); + await scheduleNotifications(); emitActionAudit({ category: "notification", diff --git a/src/actions/request-filters.ts b/src/actions/request-filters.ts index 8f5ce7d71..4f8f62242 100644 --- a/src/actions/request-filters.ts +++ b/src/actions/request-filters.ts @@ -253,6 +253,14 @@ export async function createRequestFilterAction(data: { operations: data.operations ?? null, }); + // 立即同步内存缓存,确保新规则对代理请求即时生效,无需用户手动点"刷新缓存"。 + // 仓储层已在写入后触发一次本进程 reload,这里 reload(false) 复用它(不补跑第二轮); + // reload 仅为缓存同步,失败不应把已成功的写入误报为失败。 + try { + await requestFilterEngine.reload(false); + } catch (reloadError) { + logger.warn("[RequestFiltersAction] Failed to reload engine after create", { reloadError }); + } revalidatePath(SETTINGS_PATH); return { ok: true, data: created }; } catch (error) { @@ -378,6 +386,14 @@ export async function updateRequestFilterAction( return { ok: false, error: "记录不存在" }; } + // 立即同步内存缓存,确保规则改动对代理请求即时生效,无需用户手动点"刷新缓存"。 + // 仓储层已在写入后触发一次本进程 reload,这里 reload(false) 复用它(不补跑第二轮); + // reload 仅为缓存同步,失败不应把已成功的写入误报为失败。 + try { + await requestFilterEngine.reload(false); + } catch (reloadError) { + logger.warn("[RequestFiltersAction] Failed to reload engine after update", { reloadError }); + } revalidatePath(SETTINGS_PATH); return { ok: true, data: updated }; } catch (error) { @@ -393,6 +409,14 @@ export async function deleteRequestFilterAction(id: number): Promise>>; @@ -73,9 +56,18 @@ export interface UsageLogsExportStatus { processedRows: number; totalRows: number; progressPercent: number; + format: UsageLogsExportFormat; error?: string; } +export interface UsageLogsExportDownload { + /** CSV text (encoding "utf8") or base64-encoded XLSX bytes (encoding "base64"). */ + content: string; + encoding: "utf8" | "base64"; + format: UsageLogsExportFormat; + filename: string; +} + interface UsageLogsExportJobRecord extends UsageLogsExportStatus { ownerUserId: number; } @@ -85,13 +77,21 @@ const usageLogsExportStatusStore = new RedisKVStore({ defaultTtlSeconds: USAGE_LOGS_EXPORT_JOB_TTL_SECONDS, }); -const usageLogsExportCsvStore = new RedisKVStore({ - prefix: "cch:usage-logs:export:csv:", +const usageLogsExportResultStore = new RedisKVStore({ + prefix: "cch:usage-logs:export:result:", defaultTtlSeconds: USAGE_LOGS_EXPORT_JOB_TTL_SECONDS, }); -function usageLogsExportCsvKey(jobId: string): string { - return `${jobId}:csv`; +function usageLogsExportResultKey(jobId: string): string { + return `${jobId}:result`; +} + +function fileExtensionForFormat(format: UsageLogsExportFormat): string { + return format === "xlsx" ? "xlsx" : "csv"; +} + +function encodingForFormat(format: UsageLogsExportFormat): "utf8" | "base64" { + return format === "xlsx" ? "base64" : "utf8"; } function resolveUsageLogFiltersForSession( @@ -108,6 +108,7 @@ function toUsageLogsExportStatus(job: UsageLogsExportJobRecord): UsageLogsExport processedRows: job.processedRows, totalRows: job.totalRows, progressPercent: job.progressPercent, + format: job.format, error: job.error, }; } @@ -123,32 +124,6 @@ function getUsageLogsExportJob( return job; } -function buildCsvRows(logs: UsageLogRow[]): string[] { - return logs.map((log) => { - const retryCount = log.providerChain ? getRetryCount(log.providerChain) : 0; - return [ - log.createdAt ? new Date(log.createdAt).toISOString() : "", - escapeCsvField(log.userName), - escapeCsvField(log.keyName), - escapeCsvField(log.providerName ?? ""), - escapeCsvField(log.model ?? ""), - escapeCsvField(log.originalModel ?? ""), - escapeCsvField(log.endpoint ?? ""), - log.statusCode?.toString() ?? "", - log.inputTokens?.toString() ?? "0", - log.outputTokens?.toString() ?? "0", - log.cacheCreation5mInputTokens?.toString() ?? "0", - log.cacheCreation1hInputTokens?.toString() ?? "0", - log.cacheReadInputTokens?.toString() ?? "0", - log.totalTokens.toString(), - log.costUsd ?? "0", - log.durationMs?.toString() ?? "", - escapeCsvField(log.sessionId ?? ""), - retryCount.toString(), - ].join(","); - }); -} - function buildUsageLogsExportProgress( processedRows: number, totalRows: number, @@ -169,8 +144,15 @@ function buildUsageLogsExportProgress( }; } -async function buildUsageLogsExportCsv( +/** + * Stream every matching usage log (in batches) and assemble the requested + * export format. Timestamps are rendered in `timezone` and numeric columns are + * normalized so Excel parses them as numbers (see @/lib/usage-logs/export). + */ +async function buildUsageLogsExport( filters: Omit, + format: UsageLogsExportFormat, + timezone: string, onProgress?: ( progress: Pick ) => Promise | void @@ -183,7 +165,11 @@ async function buildUsageLogsExportCsv( estimatedTotalRows = stats.totalRequests; } - const csvLines = [CSV_HEADERS.join(",")]; + // Both formats stream batch-by-batch into string buffers (CSV lines / XLSX row + // XML + an incremental summary) so the full UsageLogRow[] is never retained. + const csvLines = format === "csv" ? [buildCsvHeaderLine(timezone)] : []; + const xlsxDetailRows: string[] = []; + const xlsxSummary = format === "xlsx" ? createSummaryAccumulator(timezone) : null; let cursor: UsageLogBatchFilters["cursor"] | undefined; let processedRows = 0; @@ -195,7 +181,14 @@ async function buildUsageLogsExportCsv( }); if (batch.logs.length > 0) { - csvLines.push(...buildCsvRows(batch.logs)); + if (format === "xlsx" && xlsxSummary) { + for (const log of batch.logs) { + xlsxDetailRows.push(buildDetailRowXml(log, xlsxDetailRows.length + 2, timezone)); + xlsxSummary.add(log); + } + } else { + csvLines.push(...buildCsvRows(batch.logs, timezone)); + } processedRows += batch.logs.length; } @@ -210,12 +203,22 @@ async function buildUsageLogsExportCsv( cursor = batch.nextCursor; } - return `\uFEFF${csvLines.join("\n")}`; + if (format === "xlsx" && xlsxSummary) { + const bytes = await assembleUsageLogsXlsx({ + detailRowsXml: xlsxDetailRows, + summary: xlsxSummary.finalize(), + timezone, + }); + return Buffer.from(bytes).toString("base64"); + } + + return `${CSV_BOM}${csvLines.join("\n")}`; } async function runUsageLogsExportJob( jobId: string, - filters: Omit + filters: Omit, + format: UsageLogsExportFormat ): Promise { const existingJob = await usageLogsExportStatusStore.get(jobId); if (!existingJob) { @@ -229,8 +232,9 @@ async function runUsageLogsExportJob( }); try { + const timezone = await resolveSystemTimezone(); let lastProgressUpdateAt = 0; - const csv = await buildUsageLogsExportCsv(filters, async (progress) => { + const content = await buildUsageLogsExport(filters, format, timezone, async (progress) => { const now = Date.now(); if ( progress.progressPercent < 100 && @@ -257,13 +261,16 @@ async function runUsageLogsExportJob( return; } - const csvStored = await usageLogsExportCsvStore.set(usageLogsExportCsvKey(jobId), csv); - if (!csvStored) { + const resultStored = await usageLogsExportResultStore.set( + usageLogsExportResultKey(jobId), + content + ); + if (!resultStored) { await usageLogsExportStatusStore.set(jobId, { ...currentJob, status: "failed", progressPercent: 0, - error: "Failed to persist CSV to Redis", + error: "Failed to persist export to Redis", }); return; } @@ -316,22 +323,34 @@ export async function getUsageLogs( } } +export type UsageLogsExportInput = Omit & { + format?: UsageLogsExportFormat; +}; + /** - * 导出使用日志为 CSV 格式 + * 同步导出使用日志为 CSV 格式(XLSX 仅支持异步任务) */ -export async function exportUsageLogs( - filters: Omit -): Promise> { +export async function exportUsageLogs(input: UsageLogsExportInput): Promise> { try { const session = await getSession(); if (!session) { return { ok: false, error: "未登录" }; } + const { format = "csv", ...filters } = input; + if (format !== "csv") { + // XLSX is assembled from every matching row in memory, so it is only + // offered via the async job flow. + return { + ok: false, + error: "Synchronous export only supports CSV; use the async job for XLSX.", + }; + } const finalFilters = resolveUsageLogFiltersForSession(session, filters); - const csv = await buildUsageLogsExportCsv(finalFilters); + const timezone = await resolveSystemTimezone(); + const content = await buildUsageLogsExport(finalFilters, "csv", timezone); - return { ok: true, data: csv }; + return { ok: true, data: content }; } catch (error) { logger.error("导出使用日志失败:", error); const message = error instanceof Error ? error.message : "导出使用日志失败"; @@ -340,7 +359,7 @@ export async function exportUsageLogs( } export async function startUsageLogsExport( - filters: Omit + input: UsageLogsExportInput ): Promise> { try { const session = await getSession(); @@ -348,6 +367,7 @@ export async function startUsageLogsExport( return { ok: false, error: "未登录" }; } + const { format = "csv", ...filters } = input; const jobId = crypto.randomUUID(); const finalFilters = resolveUsageLogFiltersForSession(session, filters); @@ -358,6 +378,7 @@ export async function startUsageLogsExport( processedRows: 0, totalRows: 0, progressPercent: 0, + format, }); if (!stored) { @@ -367,7 +388,7 @@ export async function startUsageLogsExport( // Defer to next tick so the action returns the jobId immediately. // Safe for self-hosted Bun server (long-lived process); NOT suitable for serverless. setTimeout(() => { - void runUsageLogsExportJob(jobId, finalFilters); + void runUsageLogsExportJob(jobId, finalFilters, format); }, 0); return { ok: true, data: { jobId } }; @@ -400,7 +421,9 @@ export async function getUsageLogsExportStatus( } } -export async function downloadUsageLogsExport(jobId: string): Promise> { +export async function downloadUsageLogsExport( + jobId: string +): Promise> { try { const session = await getSession(); if (!session) { @@ -420,12 +443,20 @@ export async function downloadUsageLogsExport(jobId: string): Promise trimmedField.startsWith(char))) { - safeField = `'${field}`; - } - - if ( - safeField.includes(",") || - safeField.includes('"') || - safeField.includes("\n") || - safeField.includes("\r") - ) { - return `"${safeField.replace(/"/g, '""')}"`; - } - return safeField; -} - /** * 获取模型列表(用于筛选器) */ diff --git a/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/SummaryTab.tsx b/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/SummaryTab.tsx index dd2f826db..44e21f887 100644 --- a/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/SummaryTab.tsx +++ b/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/SummaryTab.tsx @@ -313,10 +313,21 @@ export function SummaryTab({
{t("effort.label")}: - + + + + + + + + +

{t("effort.tooltip")}

+
+
+
{effortInfo.isOverridden && effortInfo.overriddenEffort && ( <> diff --git a/src/app/[locale]/dashboard/logs/_components/usage-logs-filters.tsx b/src/app/[locale]/dashboard/logs/_components/usage-logs-filters.tsx index dd515b41a..26a9b1a6f 100644 --- a/src/app/[locale]/dashboard/logs/_components/usage-logs-filters.tsx +++ b/src/app/[locale]/dashboard/logs/_components/usage-logs-filters.tsx @@ -1,11 +1,17 @@ "use client"; import { format, startOfDay, startOfWeek } from "date-fns"; -import { Clock, Download, Network, Server, User } from "lucide-react"; +import { ChevronDown, Clock, Download, Network, Server, User } from "lucide-react"; import { useTranslations } from "next-intl"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { Progress } from "@/components/ui/progress"; import { downloadUsageLogsExport, @@ -167,19 +173,18 @@ export function UsageLogsFilters({ onReset(); }, [onReset]); - const downloadCsv = useCallback((csv: string) => { - const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" }); + const downloadBlob = useCallback((blob: Blob, extension: string) => { const url = window.URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; - a.download = `usage-logs-${format(new Date(), "yyyy-MM-dd-HHmmss")}.csv`; + a.download = `usage-logs-${format(new Date(), "yyyy-MM-dd-HHmmss")}.${extension}`; document.body.appendChild(a); a.click(); document.body.removeChild(a); window.URL.revokeObjectURL(url); }, []); - const handleExport = async () => { + const handleExport = async (exportFormat: "csv" | "xlsx") => { const runId = exportRunIdRef.current + 1; exportRunIdRef.current = runId; setIsExporting(true); @@ -189,11 +194,12 @@ export function UsageLogsFilters({ processedRows: 0, totalRows: 0, progressPercent: 0, + format: exportFormat, }); try { const exportFilters = sanitizeFilters(localFilters); - const startResult = await startUsageLogsExport(exportFilters); + const startResult = await startUsageLogsExport({ ...exportFilters, format: exportFormat }); if (exportRunIdRef.current !== runId) { return; } @@ -259,7 +265,7 @@ export function UsageLogsFilters({ return; } - downloadCsv(downloadResult.data); + downloadBlob(downloadResult.data.blob, exportFormat === "xlsx" ? "xlsx" : "csv"); toast.success(t("logs.filters.exportSuccess")); } catch (error) { @@ -423,10 +429,23 @@ export function UsageLogsFilters({ - + + + + + + handleExport("csv")} disabled={isExporting}> + {t("logs.filters.exportAsCsv")} + + handleExport("xlsx")} disabled={isExporting}> + {t("logs.filters.exportAsXlsx")} + + + {isExporting && exportStatus ? (
diff --git a/src/app/[locale]/dashboard/logs/_components/usage-logs-stats-panel.test.tsx b/src/app/[locale]/dashboard/logs/_components/usage-logs-stats-panel.test.tsx new file mode 100644 index 000000000..70d17a6fc --- /dev/null +++ b/src/app/[locale]/dashboard/logs/_components/usage-logs-stats-panel.test.tsx @@ -0,0 +1,74 @@ +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// Stable translation function so loadStats identity stays stable across rerenders +// (mirrors real next-intl, where useTranslations returns a memoized fn). This ensures +// the only refetch triggers are filtersKey and refreshKey, matching production. +const i18nMock = vi.hoisted(() => ({ t: (key: string) => key })); + +const getUsageLogsStatsMock = vi.hoisted(() => vi.fn()); + +vi.mock("next-intl", () => ({ + useTranslations: () => i18nMock.t, +})); + +vi.mock("@/lib/api-client/v1/actions/usage-logs", () => ({ + getUsageLogsStats: getUsageLogsStatsMock, +})); + +import { UsageLogsStatsPanel } from "./usage-logs-stats-panel"; + +// Same object reference across rerenders so only refreshKey drives the refetch. +const FILTERS = { userId: 1 }; + +async function renderPanel(root: Root, refreshKey: number) { + await act(async () => { + root.render(); + }); +} + +describe("UsageLogsStatsPanel manual refresh", () => { + let container: HTMLElement; + let root: Root; + + beforeEach(() => { + getUsageLogsStatsMock.mockReset(); + // Error branch avoids rendering the full stats shape while still exercising the fetch path. + getUsageLogsStatsMock.mockResolvedValue({ ok: false, error: "stub" }); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(async () => { + await act(async () => { + root.unmount(); + }); + container.remove(); + }); + + it("fetches stats once on mount", async () => { + await renderPanel(root, 0); + expect(getUsageLogsStatsMock).toHaveBeenCalledTimes(1); + }); + + it("refetches when refreshKey is bumped (manual refresh)", async () => { + await renderPanel(root, 0); + expect(getUsageLogsStatsMock).toHaveBeenCalledTimes(1); + + await renderPanel(root, 1); + expect(getUsageLogsStatsMock).toHaveBeenCalledTimes(2); + + await renderPanel(root, 2); + expect(getUsageLogsStatsMock).toHaveBeenCalledTimes(3); + }); + + it("does not refetch when refreshKey is unchanged on rerender", async () => { + await renderPanel(root, 5); + expect(getUsageLogsStatsMock).toHaveBeenCalledTimes(1); + + await renderPanel(root, 5); + expect(getUsageLogsStatsMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/app/[locale]/dashboard/logs/_components/usage-logs-stats-panel.tsx b/src/app/[locale]/dashboard/logs/_components/usage-logs-stats-panel.tsx index d03b7a3a1..df9a39782 100644 --- a/src/app/[locale]/dashboard/logs/_components/usage-logs-stats-panel.tsx +++ b/src/app/[locale]/dashboard/logs/_components/usage-logs-stats-panel.tsx @@ -25,14 +25,23 @@ interface UsageLogsStatsPanelProps { minRetryCount?: number; }; currencyCode?: CurrencyCode; + /** + * 手动刷新计数器:每次递增都会触发一次统计汇总重新拉取(与列表的手动刷新联动)。 + * 仅在值变化时生效,不影响按 filters 变化的常规重拉。 + */ + refreshKey?: number; } /** * Stats panel component with glass morphism UI * Always expanded (not collapsible), loads data asynchronously - * Re-fetches when filters change + * Re-fetches when filters change or when refreshKey is bumped (manual refresh) */ -export function UsageLogsStatsPanel({ filters, currencyCode = "USD" }: UsageLogsStatsPanelProps) { +export function UsageLogsStatsPanel({ + filters, + currencyCode = "USD", + refreshKey = 0, +}: UsageLogsStatsPanelProps) { const t = useTranslations("dashboard"); const [stats, setStats] = useState(null); const [isLoading, setIsLoading] = useState(true); @@ -61,11 +70,11 @@ export function UsageLogsStatsPanel({ filters, currencyCode = "USD" }: UsageLogs } }, [filters, t]); - // Load data on mount and when filters change - // biome-ignore lint/correctness/useExhaustiveDependencies: filtersKey is used to detect filter changes + // Load data on mount, when filters change, and on manual refresh (refreshKey bump) + // biome-ignore lint/correctness/useExhaustiveDependencies: filtersKey/refreshKey are the refetch triggers useEffect(() => { loadStats(); - }, [filtersKey, loadStats]); + }, [filtersKey, refreshKey, loadStats]); return (
| null>(null); const fullscreen = useFullscreen(); @@ -182,6 +184,8 @@ function UsageLogsViewContent({ const handleManualRefresh = useCallback(async () => { setIsManualRefreshing(true); + // 同时刷新底部调用历史(react-query)与顶部统计汇总(refreshKey 触发)。 + setStatsRefreshKey((k) => k + 1); await queryClientInstance.invalidateQueries({ queryKey: ["usage-logs-batch"] }); if (refreshTimeoutRef.current) { clearTimeout(refreshTimeoutRef.current); @@ -272,7 +276,11 @@ function UsageLogsViewContent({
{/* Stats Summary */} {hasStatsFilters && ( - + )} {/* Toolbar + Filter */} diff --git a/src/app/[locale]/settings/error-rules/_components/add-rule-dialog.tsx b/src/app/[locale]/settings/error-rules/_components/add-rule-dialog.tsx index f10b6dad3..0c831ae48 100644 --- a/src/app/[locale]/settings/error-rules/_components/add-rule-dialog.tsx +++ b/src/app/[locale]/settings/error-rules/_components/add-rule-dialog.tsx @@ -1,6 +1,7 @@ "use client"; import { Plus } from "lucide-react"; +import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { useState } from "react"; import { toast } from "sonner"; @@ -30,6 +31,7 @@ import { RegexTester } from "./regex-tester"; export function AddRuleDialog() { const t = useTranslations("settings"); + const router = useRouter(); const [open, setOpen] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); const [pattern, setPattern] = useState(""); @@ -106,6 +108,7 @@ export function AddRuleDialog() { if (result.ok) { toast.success(t("errorRules.addSuccess")); setOpen(false); + router.refresh(); // Reset form setPattern(""); setCategory(""); diff --git a/src/app/[locale]/settings/error-rules/_components/edit-rule-dialog.tsx b/src/app/[locale]/settings/error-rules/_components/edit-rule-dialog.tsx index 05b03d083..f332f66ea 100644 --- a/src/app/[locale]/settings/error-rules/_components/edit-rule-dialog.tsx +++ b/src/app/[locale]/settings/error-rules/_components/edit-rule-dialog.tsx @@ -1,5 +1,6 @@ "use client"; +import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { useEffect, useState } from "react"; import { toast } from "sonner"; @@ -34,6 +35,7 @@ interface EditRuleDialogProps { export function EditRuleDialog({ rule, open, onOpenChange }: EditRuleDialogProps) { const t = useTranslations("settings"); + const router = useRouter(); const [isSubmitting, setIsSubmitting] = useState(false); const [pattern, setPattern] = useState(""); const [category, setCategory] = useState(""); @@ -127,6 +129,7 @@ export function EditRuleDialog({ rule, open, onOpenChange }: EditRuleDialogProps if (result.ok) { toast.success(t("errorRules.editSuccess")); onOpenChange(false); + router.refresh(); } else { toast.error(result.error); } diff --git a/src/app/[locale]/settings/error-rules/_components/refresh-cache-button.tsx b/src/app/[locale]/settings/error-rules/_components/refresh-cache-button.tsx index f2f0660cb..0c7acd50e 100644 --- a/src/app/[locale]/settings/error-rules/_components/refresh-cache-button.tsx +++ b/src/app/[locale]/settings/error-rules/_components/refresh-cache-button.tsx @@ -1,6 +1,7 @@ "use client"; import { RefreshCw } from "lucide-react"; +import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { useState } from "react"; import { toast } from "sonner"; @@ -21,6 +22,7 @@ interface RefreshCacheButtonProps { export function RefreshCacheButton({ stats }: RefreshCacheButtonProps) { const t = useTranslations("settings"); + const router = useRouter(); const [isRefreshing, setIsRefreshing] = useState(false); const handleRefresh = async () => { @@ -32,6 +34,7 @@ export function RefreshCacheButton({ stats }: RefreshCacheButtonProps) { if (result.ok) { const count = result.data.stats.totalCount; toast.success(t("errorRules.refreshCacheSuccess", { count })); + router.refresh(); } else { toast.error(result.error); } diff --git a/src/app/[locale]/settings/error-rules/_components/rule-list-table.tsx b/src/app/[locale]/settings/error-rules/_components/rule-list-table.tsx index 05976713b..a3581c3e0 100644 --- a/src/app/[locale]/settings/error-rules/_components/rule-list-table.tsx +++ b/src/app/[locale]/settings/error-rules/_components/rule-list-table.tsx @@ -2,6 +2,7 @@ import { formatInTimeZone } from "date-fns-tz"; import { AlertTriangle, Pencil, Trash2 } from "lucide-react"; +import { useRouter } from "next/navigation"; import { useTimeZone, useTranslations } from "next-intl"; import { useState } from "react"; import { toast } from "sonner"; @@ -33,6 +34,7 @@ const categoryColors: Record = { export function RuleListTable({ rules }: RuleListTableProps) { const t = useTranslations("settings"); + const router = useRouter(); const timeZone = useTimeZone() ?? "UTC"; const [selectedRule, setSelectedRule] = useState(null); const [isEditDialogOpen, setIsEditDialogOpen] = useState(false); @@ -42,6 +44,7 @@ export function RuleListTable({ rules }: RuleListTableProps) { if (result.ok) { toast.success(isEnabled ? t("errorRules.enable") : t("errorRules.disable")); + router.refresh(); } else { toast.error(result.error); } @@ -61,6 +64,7 @@ export function RuleListTable({ rules }: RuleListTableProps) { if (result.ok) { toast.success(t("errorRules.deleteSuccess")); + router.refresh(); } else { toast.error(result.error); } diff --git a/src/app/api/v1/resources/usage-logs/handlers.ts b/src/app/api/v1/resources/usage-logs/handlers.ts index f2dddc81c..2328342cc 100644 --- a/src/app/api/v1/resources/usage-logs/handlers.ts +++ b/src/app/api/v1/resources/usage-logs/handlers.ts @@ -1,5 +1,6 @@ import type { Context } from "hono"; import type { ActionResult } from "@/actions/types"; +import type { UsageLogsExportDownload } from "@/actions/usage-logs"; import { callAction } from "@/lib/api/v1/_shared/action-bridge"; import { createProblemResponse, @@ -86,6 +87,18 @@ export async function createUsageLogsExport(c: Context): Promise { if (!body.ok) return body.response; const actions = await import("@/actions/usage-logs"); const preferAsync = (c.req.header("prefer") ?? "").toLowerCase().includes("respond-async"); + + // XLSX is assembled in-memory from every matching row, so it is only offered + // via the async job flow (sync exports always return CSV). + if (!preferAsync && body.data.format === "xlsx") { + return createProblemResponse({ + status: 400, + instance: new URL(c.req.url).pathname, + errorCode: "usage_logs.xlsx_requires_async", + detail: "xlsx export requires asynchronous processing (set 'Prefer: respond-async').", + }); + } + const result = preferAsync ? await callAction(c, actions.startUsageLogsExport, [body.data] as never[], c.get("auth")) : await callAction(c, actions.exportUsageLogs, [body.data] as never[], c.get("auth")); @@ -123,10 +136,17 @@ export async function downloadUsageLogsExport(c: Context): Promise { c.get("auth") ); if (!result.ok) return actionError(c, result); - return new Response(String(result.data), { + const download = result.data as UsageLogsExportDownload; + const isXlsx = download.format === "xlsx"; + const body = + download.encoding === "base64" ? Buffer.from(download.content, "base64") : download.content; + const contentType = isXlsx + ? "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + : "text/csv; charset=utf-8"; + return new Response(body, { headers: { - "Content-Type": "text/csv; charset=utf-8", - "Content-Disposition": `attachment; filename="usage-logs-${params.jobId}.csv"`, + "Content-Type": contentType, + "Content-Disposition": `attachment; filename="${download.filename}"`, }, }); } diff --git a/src/app/v1/_lib/models/available-models.ts b/src/app/v1/_lib/models/available-models.ts index 2c62196ea..d1ea2aeba 100644 --- a/src/app/v1/_lib/models/available-models.ts +++ b/src/app/v1/_lib/models/available-models.ts @@ -370,16 +370,31 @@ export function formatOpenAIResponse(models: FetchedModel[]): OpenAIModelsRespon return { object: "list" as const, data }; } +/** + * 将时间戳归一化为 Anthropic API 规范格式(秒级精度,不含毫秒)。 + * + * 官方 Anthropic /v1/models 的 created_at 形如 `2026-05-29T09:22:44Z`,不含毫秒; + * 而 Date.toISOString() 始终输出 `.SSSZ`,部分上游也会带毫秒,因此统一去除。 + * 无法解析的上游时间戳原样返回,避免抛错。 + */ +function normalizeAnthropicTimestamp(value: string): string { + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) { + return value; + } + return parsed.toISOString().replace(/\.\d{3}Z$/, "Z"); +} + /** * 格式化为 Anthropic 响应 */ export function formatAnthropicResponse(models: FetchedModel[]): AnthropicModelsResponse { - const now = new Date().toISOString(); + const now = normalizeAnthropicTimestamp(new Date().toISOString()); const data = models.map((m) => ({ id: m.id, type: "model" as const, display_name: m.displayName || m.id, - created_at: m.createdAt || now, + created_at: m.createdAt ? normalizeAnthropicTimestamp(m.createdAt) : now, })); return { data, has_more: false }; diff --git a/src/app/v1/_lib/proxy/request-body-codec.ts b/src/app/v1/_lib/proxy/request-body-codec.ts new file mode 100644 index 000000000..e22093afd --- /dev/null +++ b/src/app/v1/_lib/proxy/request-body-codec.ts @@ -0,0 +1,261 @@ +/** + * 入站请求体解压(content-encoding)。 + * + * 背景:Codex 等客户端会用 `content-encoding: zstd`(也可能是 gzip/deflate/br) + * 压缩请求体后发往代理。代理需要解压才能解析出 model、做敏感词/过滤、计费与日志。 + * 解压后由上层剥离 `content-encoding` 头,并以明文转发给上游(content-length 会被 + * 出站黑名单重算)。 + * + * 运行时为 Node.js(route.ts: `runtime = "nodejs"`)。`node:zlib` 自 Node 22.15 起 + * 原生提供 zstd 同步解压(gzip/deflate/br 更早即有),无需第三方依赖。该最低版本要求 + * 由 package.json 的 `engines` 字段声明;生产镜像(deploy/Dockerfile 的 node:trixie-slim) + * 运行 Node 24+,满足要求。 + * + * 注意:解压在 `ProxySession.fromContext` 内、鉴权 guard 之前同步执行(与既有的请求体 + * `JSON.parse` 一致)。单层编码 + maxOutputBytes 输出上限将其最坏开销限定为单次有界解压。 + */ +import { + brotliDecompressSync, + gunzipSync, + inflateRawSync, + inflateSync, + zstdDecompressSync, +} from "node:zlib"; +import { logger } from "@/lib/logger"; +import { ProxyError } from "./errors"; + +/** 解析「字节数」环境变量;非法/缺省时回退到 fallback。 */ +function parseByteLimitEnv(name: string, fallback: number): number { + const raw = process.env[name]; + if (!raw) return fallback; + const n = Number(raw); + return Number.isFinite(n) && n > 0 ? Math.trunc(n) : fallback; +} + +/** + * 解压输出硬上限,防御解压炸弹(decompression bomb):很小的压缩体可能展开成数 GB + * 导致 OOM。这是一个独立的内存兜底阈值——注意 /v1、/v1beta 代理路径并不受 + * next.config.ts 的 proxyClientMaxBodySize 钳制(见 proxy.matcher.ts),故入站压缩体 + * 体积本身不另设限。逐层解压按此上限增量限制,超过即按 413 拒绝。 + * + * 默认 100MB(代理刻意支持大请求体,见 next.config.ts proxyClientMaxBodySize)。 + * 可经环境变量 MAX_DECOMPRESSED_REQUEST_BYTES 覆盖(字节数),供内存受限部署下调上限。 + */ +export const MAX_DECOMPRESSED_REQUEST_BYTES = parseByteLimitEnv( + "MAX_DECOMPRESSED_REQUEST_BYTES", + 100 * 1024 * 1024 +); + +/** + * 压缩输入(线上字节)硬上限。解压在 `ProxySession.fromContext` 内、鉴权 guard 之前同步执行, + * 且 /v1、/v1beta 路径不受 proxyClientMaxBodySize 钳制(见上)。该上限在解压前先按压缩体本身 + * 的字节数拒绝过大输入,作为鉴权前的结构性天花板(限制需读取+解压的输入量)。 + * + * 默认与 {@link MAX_DECOMPRESSED_REQUEST_BYTES} 一致:真实压缩比下合法请求的压缩体一定不超过其 + * 解压体,故该默认不会误拒既有的大上下文/图片压缩请求(避免「明文 100MB 放行、压缩体却被拒」的 + * 不对称)。内存受限部署下调 MAX_DECOMPRESSED_REQUEST_BYTES 时本上限随之收紧;也可经 + * MAX_COMPRESSED_REQUEST_BYTES 单独覆盖。超过按 413 拒绝。 + */ +export const MAX_COMPRESSED_REQUEST_BYTES = parseByteLimitEnv( + "MAX_COMPRESSED_REQUEST_BYTES", + MAX_DECOMPRESSED_REQUEST_BYTES +); + +/** + * content-encoding 编码链最大层数。真实客户端(含 Codex)只发单层编码;允许多层会让一个 + * 很小的压缩体经多次同步解压放大 CPU 开销与峰值内存(每层最多解到 maxOutputBytes,层间 + * 缓冲还会短暂共存),无正当用途。限制为单层后,峰值解压内存即由 maxOutputBytes 一档兜底; + * 超出按 400 拒绝。 + */ +export const MAX_CONTENT_ENCODING_LAYERS = 1; + +const SUPPORTED_ENCODINGS = new Set(["zstd", "gzip", "x-gzip", "deflate", "br"]); + +export interface DecodeRequestBodyOptions { + /** 解压输出字节上限,默认 {@link MAX_DECOMPRESSED_REQUEST_BYTES}。主要用于测试。 */ + maxOutputBytes?: number; + /** 压缩输入字节上限,默认 {@link MAX_COMPRESSED_REQUEST_BYTES}。主要用于测试。 */ + maxCompressedBytes?: number; +} + +export interface DecodedRequestBody { + /** + * 请求体明文字节。解压时为新分配的解压结果;未解压时即原始字节,可能与入参共享 + * 底层内存(仅供只读消费,调用方不得改写)。 + */ + buffer: ArrayBuffer; + /** 是否实际执行了解压。 */ + decoded: boolean; + /** 实际应用的编码链(按解码顺序,如 "zstd" 或 "br, gzip");未解压时为 null。 */ + encoding: string | null; + originalByteLength: number; + decodedByteLength: number; +} + +/** + * 将 `content-encoding` 头解析为编码 token 列表(小写、去空白、去除 identity)。 + * HTTP 语义为「按列出顺序逐层应用」,因此解码需反向进行。 + */ +export function parseContentEncoding(header: string | null | undefined): string[] { + if (!header) return []; + return header + .split(",") + .map((token) => token.trim().toLowerCase()) + .filter((token) => token.length > 0 && token !== "identity"); +} + +function isOutputTooLargeError(err: unknown): boolean { + return ( + err instanceof RangeError || + (err as NodeJS.ErrnoException | undefined)?.code === "ERR_BUFFER_TOO_LARGE" + ); +} + +function decodeOne(buffer: Buffer, encoding: string, maxOutputLength: number): Buffer { + switch (encoding) { + case "zstd": + return zstdDecompressSync(buffer, { maxOutputLength }); + case "gzip": + case "x-gzip": + return gunzipSync(buffer, { maxOutputLength }); + case "br": + return brotliDecompressSync(buffer, { maxOutputLength }); + case "deflate": + // HTTP `deflate` 名义上是 zlib 包装,但不少实现发送的是裸 deflate 流, + // 因此先按 zlib 解,失败再回退裸 deflate。解压炸弹错误不回退,直接抛出。 + try { + return inflateSync(buffer, { maxOutputLength }); + } catch (err) { + if (isOutputTooLargeError(err)) throw err; + return inflateRawSync(buffer, { maxOutputLength }); + } + default: + // parseContentEncoding + 支持集校验已保证不会走到这里。 + throw new Error(`Unsupported content-encoding: ${encoding}`); + } +} + +// 返回的 buffer 仅被下游只读消费(TextDecoder / JSON.parse / 透传转发),不会被改写, +// 故视图正好完整覆盖底层 ArrayBuffer 时直接复用、避免对最大 100MB 数据做无谓拷贝。 +function toArrayBuffer(input: ArrayBuffer | Uint8Array): ArrayBuffer { + if (input instanceof ArrayBuffer) return input; + if (input.byteOffset === 0 && input.byteLength === input.buffer.byteLength) { + return input.buffer as ArrayBuffer; + } + return input.buffer.slice(input.byteOffset, input.byteOffset + input.byteLength) as ArrayBuffer; +} + +function bufferToArrayBuffer(buf: Buffer): ArrayBuffer { + if (buf.byteOffset === 0 && buf.byteLength === buf.buffer.byteLength) { + return buf.buffer as ArrayBuffer; + } + return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) as ArrayBuffer; +} + +/** + * 按 `content-encoding` 解压入站请求体。 + * + * - 无编码 / identity / 空体:原样返回(decoded=false)。 + * - 含不支持的编码:不解压、原样返回(decoded=false)并告警,由上层透传给上游。 + * - 支持的编码:逐层反向解压;超过上限抛 413,损坏流抛 400。 + */ +export function decodeRequestBody( + input: ArrayBuffer | Uint8Array, + contentEncoding: string | null | undefined, + options?: DecodeRequestBodyOptions +): DecodedRequestBody { + const maxOutputBytes = options?.maxOutputBytes ?? MAX_DECOMPRESSED_REQUEST_BYTES; + const maxCompressedBytes = options?.maxCompressedBytes ?? MAX_COMPRESSED_REQUEST_BYTES; + const originalByteLength = input.byteLength; + + const encodings = parseContentEncoding(contentEncoding); + if (encodings.length === 0) { + const buffer = toArrayBuffer(input); + return { + buffer, + decoded: false, + encoding: null, + originalByteLength, + decodedByteLength: buffer.byteLength, + }; + } + + // 空体:不可能是有效压缩流,在层数/支持集校验之前直接透传,避免对安全的空请求误报 400。 + if (originalByteLength === 0) { + const buffer = toArrayBuffer(input); + return { + buffer, + decoded: false, + encoding: null, + originalByteLength, + decodedByteLength: buffer.byteLength, + }; + } + + if (encodings.length > MAX_CONTENT_ENCODING_LAYERS) { + // 防御多层编码放大:每层都是一次同步解压,过多层数纯属攻击/异常。 + throw new ProxyError( + `Too many content-encoding layers (${encodings.length}); at most ${MAX_CONTENT_ENCODING_LAYERS} are allowed.`, + 400 + ); + } + + const unsupported = encodings.filter((enc) => !SUPPORTED_ENCODINGS.has(enc)); + if (unsupported.length > 0) { + // 透传:不解压、保留原始字节与 content-encoding 头,交给上游处理。 + logger.warn("[decodeRequestBody] Unsupported content-encoding, passing through untouched", { + contentEncoding, + unsupported, + }); + const buffer = toArrayBuffer(input); + return { + buffer, + decoded: false, + encoding: null, + originalByteLength, + decodedByteLength: buffer.byteLength, + }; + } + + // 解压前先按压缩体本身字节数拒绝过大输入(鉴权前防放大,见 MAX_COMPRESSED_REQUEST_BYTES)。 + // 仅对「支持的单层编码」生效:上面已确保 encodings 非空、层数合法且全部受支持。 + if (originalByteLength > maxCompressedBytes) { + throw new ProxyError( + `Compressed request body exceeds the maximum allowed size (${maxCompressedBytes} bytes).`, + 413 + ); + } + + // 按 HTTP 语义反向逐层解码。 + const decodeOrder = [...encodings].reverse(); + let current: Buffer = Buffer.from(input instanceof Uint8Array ? input : new Uint8Array(input)); + for (const enc of decodeOrder) { + try { + current = decodeOne(current, enc, maxOutputBytes); + } catch (err) { + if (isOutputTooLargeError(err)) { + throw new ProxyError( + `Request body exceeds the maximum decompressed size (${maxOutputBytes} bytes).`, + 413 + ); + } + const message = err instanceof Error ? err.message : String(err); + throw new ProxyError(`Failed to decode '${enc}' request body: ${message}`, 400); + } + } + + const buffer = bufferToArrayBuffer(current); + logger.debug("[decodeRequestBody] Decompressed request body", { + encoding: decodeOrder.join(", "), + originalByteLength, + decodedByteLength: buffer.byteLength, + }); + + return { + buffer, + decoded: true, + encoding: decodeOrder.join(", "), + originalByteLength, + decodedByteLength: buffer.byteLength, + }; +} diff --git a/src/app/v1/_lib/proxy/session.ts b/src/app/v1/_lib/proxy/session.ts index b6dfda24e..6c2c1f36d 100644 --- a/src/app/v1/_lib/proxy/session.ts +++ b/src/app/v1/_lib/proxy/session.ts @@ -29,6 +29,7 @@ import { type OpenAIImageRequestMetadata, parseOpenAIImageMultipartMetadata, } from "./openai-image-compat"; +import { decodeRequestBody } from "./request-body-codec"; /** * Classification of an auth failure, used to decide whether to record the @@ -82,6 +83,12 @@ interface RequestBodyResult { contentLength?: number | null; actualBodyBytes?: number; imageRequestMetadata?: OpenAIImageRequestMetadata | null; + /** + * 入站请求体实际解压所用的 content-encoding(链)。 + * 非空表示代理已解压请求体,调用方需剥离出站 `content-encoding` 头, + * 避免上游对明文再次解码。未解压时为 undefined。 + */ + decodedContentEncoding?: string; } export class ProxySession { @@ -237,6 +244,12 @@ export class ProxySession { const headerLog = formatHeadersForLog(headers); const bodyResult = await parseRequestBody(c); + // 已在代理内解压请求体:剥离 content-encoding,避免上游对明文再次解码 + // (raw passthrough 也会转发解压后的字节;content-length 由出站黑名单重算)。 + if (bodyResult.decodedContentEncoding) { + headers.delete("content-encoding"); + } + // 提取 User-Agent const userAgent = headers.get("user-agent") || null; @@ -1157,27 +1170,29 @@ async function parseRequestBody(c: Context): Promise { const contentLength = parseContentLengthHeader(c.req.header("content-length")); const contentType = c.req.header("content-type") ?? null; + const contentEncoding = c.req.header("content-encoding") ?? null; const pathname = new URL(c.req.url).pathname; - const requestBodyBuffer = await c.req.raw.clone().arrayBuffer(); - const actualBodyBytes = requestBodyBuffer.byteLength; - const requestBodyText = new TextDecoder().decode(requestBodyBuffer); + // 原始(可能被压缩的)入站字节:用于截断检测与 multipart 透传。 + const rawBodyBuffer = await c.req.raw.clone().arrayBuffer(); + const receivedBodyBytes = rawBodyBuffer.byteLength; // Truncation detection: warn only when both conditions are met // 1. Absolute difference > 1MB (avoid false positives from minor discrepancies) // 2. Actual body < 80% of expected (significant truncation) + // 注意:基于「接收到的原始字节」与 content-length 比较(同为压缩域),不受解压影响。 const MIN_TRUNCATION_DIFF_BYTES = 1024 * 1024; // 1MB const TRUNCATION_RATIO_THRESHOLD = 0.8; if ( contentLength !== null && - contentLength - actualBodyBytes > MIN_TRUNCATION_DIFF_BYTES && - actualBodyBytes < contentLength * TRUNCATION_RATIO_THRESHOLD + contentLength - receivedBodyBytes > MIN_TRUNCATION_DIFF_BYTES && + receivedBodyBytes < contentLength * TRUNCATION_RATIO_THRESHOLD ) { logger.warn("[parseRequestBody] Possible body truncation detected", { - pathname: new URL(c.req.url).pathname, + pathname, method, contentLength, - actualBodyBytes, - ratio: (actualBodyBytes / contentLength).toFixed(2), + actualBodyBytes: receivedBodyBytes, + ratio: (receivedBodyBytes / contentLength).toFixed(2), }); } @@ -1188,6 +1203,7 @@ async function parseRequestBody(c: Context): Promise { if (getOpenAIImageEndpoint(pathname) && isOpenAIImageMultipartContentType(contentType)) { // 图片 multipart 请求保留 sidecar metadata,并为过滤/敏感词提供文本字段视图。 + // multipart 请求体不会被 content-encoding 压缩,按原始字节透传。 imageRequestMetadata = await parseOpenAIImageMultipartMetadata( c.req.raw, pathname, @@ -1203,13 +1219,19 @@ async function parseRequestBody(c: Context): Promise { requestMessage, requestBodyLog, requestBodyLogNote, - requestBodyBuffer, + requestBodyBuffer: rawBodyBuffer, contentLength, - actualBodyBytes, + actualBodyBytes: receivedBodyBytes, imageRequestMetadata, }; } + // 非 multipart:按 content-encoding(zstd/gzip/deflate/br)解压请求体, + // 使下游模型解析、过滤、计费、日志与转发都基于明文。 + const decodedBody = decodeRequestBody(rawBodyBuffer, contentEncoding); + const requestBodyBuffer = decodedBody.buffer; + const requestBodyText = new TextDecoder().decode(requestBodyBuffer); + try { const parsedMessage = JSON.parse(requestBodyText) as Record; requestMessage = parsedMessage; // 保留原始数据用于业务逻辑 @@ -1226,7 +1248,10 @@ async function parseRequestBody(c: Context): Promise { requestBodyLogNote, requestBodyBuffer, contentLength, - actualBodyBytes, + // 维持原语义:actualBodyBytes 表示「接收到的原始(线上)字节」,供 + // isLargeRequestBody 的截断提示判断使用,不受解压后体积影响。 + actualBodyBytes: receivedBodyBytes, imageRequestMetadata, + decodedContentEncoding: decodedBody.encoding ?? undefined, }; } diff --git a/src/components/customs/anthropic-effort-badge.tsx b/src/components/customs/anthropic-effort-badge.tsx index a93ea4c61..91577d22e 100644 --- a/src/components/customs/anthropic-effort-badge.tsx +++ b/src/components/customs/anthropic-effort-badge.tsx @@ -7,6 +7,9 @@ const ANTHROPIC_EFFORT_BADGE_STYLES: Record = { medium: "border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-800 dark:bg-amber-950/30 dark:text-amber-300", high: "border-rose-200 bg-rose-50 text-rose-700 dark:border-rose-800 dark:bg-rose-950/30 dark:text-rose-300", + // xhigh 介于 high 与 max 之间,需有独立样式,否则会落入灰色 DEFAULT 看起来比 high 还弱。 + xhigh: + "border-red-200 bg-red-50 text-red-700 dark:border-red-800 dark:bg-red-950/30 dark:text-red-300", max: "border-red-300 bg-red-100 text-red-800 dark:border-red-700 dark:bg-red-950/40 dark:text-red-200", }; diff --git a/src/components/ui/__tests__/provider-group-tag-input.test.tsx b/src/components/ui/__tests__/provider-group-tag-input.test.tsx index a2a73a2e7..d5de942a5 100644 --- a/src/components/ui/__tests__/provider-group-tag-input.test.tsx +++ b/src/components/ui/__tests__/provider-group-tag-input.test.tsx @@ -4,13 +4,15 @@ import type { ReactNode } from "react"; import { act } from "react"; -import { createRoot } from "react-dom/client"; +import { type Root, createRoot } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { ProviderGroupSelect } from "@/app/[locale]/dashboard/_components/user/forms/provider-group-select"; import { TagInput } from "@/components/ui/tag-input"; -const providerActionsMocks = vi.hoisted(() => ({ - getProviderGroupsWithCount: vi.fn(async () => ({ ok: true, data: [] })), +// 该组件从 v1 api-client 导入 getProviderGroupsWithCount,必须 mock 这个路径, +// 否则测试会命中真实的 REST 客户端(历史上 mock 错路径掩盖了真实数据流)。 +const providerApiMocks = vi.hoisted(() => ({ + getProviderGroupsWithCount: vi.fn(async () => ({ ok: true, data: [] as unknown[] })), })); const sonnerMocks = vi.hoisted(() => ({ @@ -19,27 +21,47 @@ const sonnerMocks = vi.hoisted(() => ({ }, })); -vi.mock("@/actions/providers", () => providerActionsMocks); +vi.mock("@/lib/api-client/v1/actions/providers", () => providerApiMocks); vi.mock("sonner", () => sonnerMocks); +// 追踪所有挂载的 React root,确保即使某个用例断言失败提前抛出,afterEach 也能卸载, +// 避免残留已挂载的 root 污染后续用例的 document.activeElement / DOM。 +const mountedRoots: Array<{ container: HTMLElement; root: Root; unmounted: boolean }> = []; + function render(node: ReactNode) { const container = document.createElement("div"); document.body.appendChild(container); const root = createRoot(container); - act(() => { root.render(node); }); - + const entry = { container, root, unmounted: false }; + mountedRoots.push(entry); return { container, unmount: () => { + if (entry.unmounted) return; + entry.unmounted = true; act(() => root.unmount()); container.remove(); }, }; } +async function flush() { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); +} + +function createDeferred() { + let resolve!: (value: T) => void; + const promise = new Promise((res) => { + resolve = res; + }); + return { promise, resolve }; +} + async function typeAndSubmit(input: HTMLInputElement, value: string) { await act(async () => { input.focus(); @@ -56,7 +78,37 @@ async function typeAndSubmit(input: HTMLInputElement, value: string) { }); } +/** 下拉建议项通过 Portal 渲染(无 Dialog 祖先时落到 document.body),按分组名匹配建议按钮。 */ +function suggestionButtonsFor(group: string) { + return Array.from(document.querySelectorAll("button")).filter((btn) => + (btn.textContent || "").includes(group) + ); +} + +const PROVIDER_GROUP_TRANSLATIONS = { + label: "Provider group", + placeholder: "Enter group", + description: "desc", + providersSuffix: "providers", + errors: { + loadFailed: "Load failed", + }, + tagInputErrors: { + empty: "empty", + duplicate: "duplicate", + too_long: "too long", + invalid_format: "invalid format", + max_tags: "max tags", + }, +}; + afterEach(() => { + for (const entry of mountedRoots.splice(0)) { + if (entry.unmounted) continue; + entry.unmounted = true; + act(() => entry.root.unmount()); + entry.container.remove(); + } while (document.body.firstChild) { document.body.removeChild(document.body.firstChild); } @@ -64,6 +116,7 @@ afterEach(() => { beforeEach(() => { vi.clearAllMocks(); + providerApiMocks.getProviderGroupsWithCount.mockResolvedValue({ ok: true, data: [] }); }); describe("provider-group tag inputs", () => { @@ -87,23 +140,12 @@ describe("provider-group tag inputs", () => { test("ProviderGroupSelect 应允许输入中文分组", async () => { const onChange = vi.fn(); - const translations = { - label: "Provider group", - placeholder: "Enter group", - description: "desc", - errors: { - loadFailed: "Load failed", - }, - tagInputErrors: { - empty: "empty", - duplicate: "duplicate", - too_long: "too long", - invalid_format: "invalid format", - max_tags: "max tags", - }, - }; const { container, unmount } = render( - + ); const input = container.querySelector("input"); @@ -116,4 +158,110 @@ describe("provider-group tag inputs", () => { unmount(); }); + + // 数据流回归:覆盖 mock 路径、ActionResult 解包与建议渲染链路(经 focus -> handleFocus 展开)。 + test("数据加载后点击输入框应展开下拉并列出已有的供应商分组", async () => { + providerApiMocks.getProviderGroupsWithCount.mockResolvedValue({ + ok: true, + data: [ + { group: "team-alpha", providerCount: 3 }, + { group: "team-beta", providerCount: 1 }, + ], + }); + const onChange = vi.fn(); + const { container, unmount } = render( + + ); + + // 等待异步加载完成 + await flush(); + + const input = container.querySelector("input") as HTMLInputElement; + await act(async () => { + // 点击容器会聚焦输入框,进而触发 onFocus -> handleFocus 展开下拉 + input.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + expect(suggestionButtonsFor("team-alpha").length).toBeGreaterThan(0); + expect(suggestionButtonsFor("team-beta").length).toBeGreaterThan(0); + + unmount(); + }); + + // #1212 核心回归:建议数据在用户聚焦「之后」才异步返回时,下拉应自动展开。 + // 这是 tag-input.tsx 中「首次加载自动展开」effect 的专属守护用例(移除该 effect 则失败)。 + test("回归 #1212:建议数据在聚焦之后异步返回时下拉应自动展开", async () => { + const deferred = createDeferred<{ + ok: true; + data: Array<{ group: string; providerCount: number }>; + }>(); + providerApiMocks.getProviderGroupsWithCount.mockReturnValue(deferred.promise); + + const onChange = vi.fn(); + const { container, unmount } = render( + + ); + + const input = container.querySelector("input") as HTMLInputElement; + + // 用户在数据返回前就聚焦了输入框:此时建议为空,下拉不应展开 + await act(async () => { + // happy-dom 下 input.focus() 即可触发 React 的 onFocus 并设置 document.activeElement + input.focus(); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + expect(suggestionButtonsFor("team-alpha").length).toBe(0); + + // 数据异步返回,输入框仍处于聚焦状态:下拉应自动展开 + await act(async () => { + deferred.resolve({ ok: true, data: [{ group: "team-alpha", providerCount: 2 }] }); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + expect(suggestionButtonsFor("team-alpha").length).toBeGreaterThan(0); + + unmount(); + }); + + // #1212 实际场景:字段位于创建用户的 Dialog 内,下拉通过 Portal 渲染进 dialog-content。 + test("在 Dialog 中点击输入框时下拉应渲染到 dialog-content 容器内", async () => { + providerApiMocks.getProviderGroupsWithCount.mockResolvedValue({ + ok: true, + data: [{ group: "team-alpha", providerCount: 5 }], + }); + const onChange = vi.fn(); + const { container, unmount } = render( +
+ +
+ ); + await flush(); + + const input = container.querySelector("input") as HTMLInputElement; + await act(async () => { + input.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + const dialogContent = container.querySelector('[data-slot="dialog-content"]') as HTMLElement; + const buttonsInDialog = Array.from(dialogContent.querySelectorAll("button")).filter((btn) => + (btn.textContent || "").includes("team-alpha") + ); + expect(buttonsInDialog.length).toBeGreaterThan(0); + + unmount(); + }); }); diff --git a/src/components/ui/tag-input.tsx b/src/components/ui/tag-input.tsx index f5c656a77..b013cf4f4 100644 --- a/src/components/ui/tag-input.tsx +++ b/src/components/ui/tag-input.tsx @@ -175,6 +175,18 @@ export function TagInput({ return () => document.removeEventListener("mousedown", handleClickOutside, true); }, [showSuggestions]); + // 建议列表「首次」异步加载完成时自动展开下拉。建议数据经网络请求获取时, + // 用户可能在数据返回前就已聚焦输入框;此时 focus 事件不会再次触发,下拉无法展开。 + // 只处理首次由空变为非空,避免后续刷新(清空再填充)覆盖用户已手动关闭(Escape/点击外部)的下拉。 + const didAutoOpenRef = React.useRef(false); + React.useEffect(() => { + if (didAutoOpenRef.current || suggestions.length === 0) return; + didAutoOpenRef.current = true; + if (!disabled && inputRef.current === document.activeElement) { + setShowSuggestions(true); + } + }, [disabled, suggestions.length]); + const inputMinWidthClass = normalizedMaxVisible === undefined ? "min-w-[120px]" : "min-w-[60px]"; // Normalize suggestions so callers can provide either strings or { value, label } objects. diff --git a/src/instrumentation.ts b/src/instrumentation.ts index 6f3fb4cf4..1767b1bfb 100644 --- a/src/instrumentation.ts +++ b/src/instrumentation.ts @@ -4,6 +4,7 @@ */ import { startCacheCleanup } from "@/lib/cache/session-cache"; +import { getBenignBrokenPipeCode } from "@/lib/lifecycle/benign-errors"; import { logger } from "@/lib/logger"; import { CHANNEL_API_KEYS_UPDATED, subscribeCacheInvalidation } from "@/lib/redis/pubsub"; import { apiKeyVacuumFilter } from "@/lib/security/api-key-vacuum-filter"; @@ -36,7 +37,7 @@ const instrumentationState = globalThis as unknown as { * * 这两个 process.on(...) 不会与现有的 SIGTERM / SIGINT 处理器冲突。 */ -function registerCrashDiagnostics(): void { +export function registerCrashDiagnostics(): void { if (instrumentationState.__CCH_CRASH_HANDLERS_REGISTERED__) { return; } @@ -80,6 +81,20 @@ function registerCrashDiagnostics(): void { }; process.on("uncaughtException", (err: Error) => { + // 良性断管(EPIPE,issue #1234):流式响应写入由 Next.js 持有,下游断开会让 socket + // write 在本地 try/catch 之外抛 EPIPE 并逃逸到此。这是请求级传输关闭,不应放大为整个 + // 进程退出/容器重启。仅抑制写侧、来源明确的 EPIPE;ECONNRESET 等来源不明的码仍 fail-fast。 + const benignCode = getBenignBrokenPipeCode(err); + if (benignCode) { + logger.warn("[Lifecycle] ignored uncaught client disconnect", { + error: err.message, + errorName: err.name, + errorCode: benignCode, + stack: err.stack, + }); + return; + } + const reportPath = writeReport("uncaughtException", err); writeFatalStderr("uncaughtException", err, reportPath); logger.fatal("[Lifecycle] uncaughtException", { @@ -97,6 +112,20 @@ function registerCrashDiagnostics(): void { // 否则一个未捕获的 promise 会让进程留在未定义状态、绕过 supervisor 重启。 // 因此:写诊断报告 + 同步落盘日志 + 主动 exit(1) 复现默认语义。 process.on("unhandledRejection", (reason: unknown) => { + // 同 uncaughtException:良性断管(EPIPE)以 rejection 形式逃逸时同样不应使进程退出。 + // 对原始 reason 判定(而非 wrap 后的 err),否则 { code: "EPIPE" } 之类非 Error 拒因在 + // new Error(String(reason)) 后会丢失 code,且 message 会变成 "[object Object]"。 + const benignCode = getBenignBrokenPipeCode(reason); + if (benignCode) { + logger.warn("[Lifecycle] ignored unhandled client disconnect", { + error: reason instanceof Error ? reason.message : `non-Error rejection (${benignCode})`, + errorName: reason instanceof Error ? reason.name : typeof reason, + errorCode: benignCode, + stack: reason instanceof Error ? reason.stack : undefined, + }); + return; + } + const err = reason instanceof Error ? reason : new Error(String(reason)); const reportPath = writeReport("unhandledRejection", err); writeFatalStderr("unhandledRejection", err, reportPath); diff --git a/src/lib/api-client/v1/actions/providers.ts b/src/lib/api-client/v1/actions/providers.ts index 44bfab515..7ceee8c4c 100644 --- a/src/lib/api-client/v1/actions/providers.ts +++ b/src/lib/api-client/v1/actions/providers.ts @@ -61,7 +61,12 @@ export function getAvailableProviderGroups(userId?: number): Promise { } export function getProviderGroupsWithCount() { - return apiGet(`/api/v1/providers/groups?include=count`, dashboardCompatOptions); + return toActionResult( + apiGet>( + `/api/v1/providers/groups?include=count`, + dashboardCompatOptions + ) + ); } export function addProvider(data: unknown) { @@ -219,9 +224,11 @@ export function fetchUpstreamModels(data: unknown) { } export function getModelSuggestionsByProviderGroup(providerGroup?: string | null) { - return apiGet( - `/api/v1/providers/model-suggestions${searchParams({ providerGroup })}`, - dashboardCompatOptions + return toActionResult( + apiGet( + `/api/v1/providers/model-suggestions${searchParams({ providerGroup })}`, + dashboardCompatOptions + ) ); } diff --git a/src/lib/api-client/v1/actions/usage-logs.ts b/src/lib/api-client/v1/actions/usage-logs.ts index ce9d10d22..483d5a02e 100644 --- a/src/lib/api-client/v1/actions/usage-logs.ts +++ b/src/lib/api-client/v1/actions/usage-logs.ts @@ -61,7 +61,11 @@ export function getUsageLogSessionIdSuggestions(params: object) { } export function exportUsageLogs(params?: object) { - return toActionResult(apiPost("/api/v1/usage-logs/exports", params)); + // Unwrap the REST `{ csv }` envelope so the result matches the server action + // contract (ActionResult). + return toActionResult( + apiPost<{ csv: string }>("/api/v1/usage-logs/exports", params).then((body) => body.csv) + ); } export function startUsageLogsExport(params?: object) { @@ -78,13 +82,17 @@ export function getUsageLogsExportStatus(jobId: string) { ); } +export interface UsageLogsExportDownloadFile { + blob: Blob; +} + export function downloadUsageLogsExport(jobId: string) { - return toActionResult( + return toActionResult( fetch(`/api/v1/usage-logs/exports/${encodeURIComponent(jobId)}/download`, { credentials: "include", }).then(async (response) => { if (!response.ok) throw new Error(response.statusText || "Export download failed"); - return response.text(); + return { blob: await response.blob() }; }) ); } diff --git a/src/lib/api-client/v1/openapi-types.gen.ts b/src/lib/api-client/v1/openapi-types.gen.ts index 81102bb70..0c521716a 100644 --- a/src/lib/api-client/v1/openapi-types.gen.ts +++ b/src/lib/api-client/v1/openapi-types.gen.ts @@ -36126,6 +36126,12 @@ export interface operations { startTime?: number | null; /** @description End timestamp in milliseconds. */ endTime?: number | null; + /** + * @description Export format. xlsx is only available asynchronously (Prefer: respond-async). + * @default csv + * @enum {string} + */ + format?: "csv" | "xlsx"; }; }; }; diff --git a/src/lib/api/v1/schemas/usage-logs.ts b/src/lib/api/v1/schemas/usage-logs.ts index f77712a97..be787ddef 100644 --- a/src/lib/api/v1/schemas/usage-logs.ts +++ b/src/lib/api/v1/schemas/usage-logs.ts @@ -36,7 +36,14 @@ export const UsageLogsExportCreateSchema = UsageLogsQuerySchema.omit({ limit: true, page: true, pageSize: true, -}).strict(); +}) + .extend({ + format: z + .enum(["csv", "xlsx"]) + .default("csv") + .describe("Export format. xlsx is only available asynchronously (Prefer: respond-async)."), + }) + .strict(); export const UsageLogExportJobParamSchema = z.object({ jobId: z.string().min(1).describe("Export job id."), diff --git a/src/lib/error-rule-detector.ts b/src/lib/error-rule-detector.ts index 30fc10c4c..8caa9d9e3 100644 --- a/src/lib/error-rule-detector.ts +++ b/src/lib/error-rule-detector.ts @@ -171,10 +171,11 @@ class ErrorRuleDetector { */ async reload(options: { queueIfRunning?: boolean } = {}): Promise { if (this.activeReloadPromise) { - // 无论是事件驱动还是显式调用,只要有 in-flight reload 就排队补跑一轮, - // 确保调用方写库后的显式 reload 不会拿到旧快照。 - this.reloadRequestedWhileLoading = true; + // queueIfRunning=true(事件驱动 / 手动刷新)排队补跑一轮,确保读到最新写入; + // 默认(action 在 emit 已触发 reload 之后调用)直接复用这次在途 reload, + // 避免对同一次写入做两次冗余 DB 读、并让保存响应少等一轮。 if (options.queueIfRunning) { + this.reloadRequestedWhileLoading = true; logger.info("[ErrorRuleDetector] Reload already in progress, queueing another pass"); } return this.activeReloadPromise; diff --git a/src/lib/lifecycle/benign-errors.ts b/src/lib/lifecycle/benign-errors.ts new file mode 100644 index 000000000..d059839c5 --- /dev/null +++ b/src/lib/lifecycle/benign-errors.ts @@ -0,0 +1,64 @@ +/** + * 进程级崩溃处理器使用的“良性断管/客户端断连”判定。 + * + * 背景(issue #1234):CCH 代理流式响应(如 Codex `/v1/responses`)时,下游客户端在 + * Next.js 仍向 socket 写入时断开,底层 socket write 抛出 `write EPIPE`。该错误发生在 + * 本地 try/catch 之外(最终 Response 写入由 Next.js 持有),逃逸到进程级 uncaughtException + * 处理器;若按致命错误 process.exit(1),会把单个请求的断管放大为整个容器重启。 + * + * 判定范围刻意仅限 EPIPE: + * - EPIPE 是“写侧”错误——只在“我们向已关闭的 socket 写入”时出现。代理中唯一未被本地 + * handler 兜住、且会逃逸到进程级的大写入路径,就是 Next.js 向客户端写流式响应;因此 + * 进程级的 EPIPE 几乎必然来自下游断连,抑制它是安全的。 + * - 不包含 ECONNRESET / ERR_STREAM_PREMATURE_CLOSE:这两者方向不确定,可能源自上游 + * provider / DB / Redis 等连接(读侧重置 / 提前关闭),而进程级处理器没有请求或连接 + * 上下文来区分“客户端断连”与“自身基础设施故障”。全局吞掉它们会把真正的故障降级为 + * warn 并让进程带病运行,破坏 #1147 引入的 fail-fast 语义。这类码在有上下文的代理层 + * (forwarder 的 stream error / isTransportError)单独处理。 + * + * 设计约束:保持零依赖,避免在崩溃处理路径引入副作用导入。 + */ + +/** 进程级可安全抑制的断管错误码(仅写侧、来源明确的 EPIPE)。 */ +const BENIGN_BROKEN_PIPE_CODES = new Set(["EPIPE"]); + +/** cause 链最大遍历深度,避免循环引用导致的死循环。 */ +const MAX_CAUSE_DEPTH = 5; + +/** + * 返回触发“良性断管”判定的错误码(沿 `cause` 链向下查找),未命中返回 undefined。 + * + * 供崩溃处理器记录“实际命中的 code”——即便它嵌套在 cause 链深处(undici/fetch 常把底层 + * socket 错误包在 cause 上),也能拿到准确的 code 而非顶层的 undefined。 + * + * 仅基于错误码(`code`)判定,刻意不做 message 模糊匹配,避免把携带 "EPIPE" 字样的上游 + * 错误文案误判为良性,从而错误地抑制真正应当退出的崩溃。 + * + * @param err - 待检查的错误(任意类型) + * @returns 命中的良性错误码;未命中返回 undefined + */ +export function getBenignBrokenPipeCode(err: unknown): string | undefined { + let current: unknown = err; + for (let depth = 0; depth <= MAX_CAUSE_DEPTH && current != null; depth++) { + if (typeof current === "object") { + const code = (current as { code?: unknown }).code; + if (typeof code === "string" && BENIGN_BROKEN_PIPE_CODES.has(code)) { + return code; + } + current = (current as { cause?: unknown }).cause; + continue; + } + break; + } + return undefined; +} + +/** + * 是否为“良性的断管/客户端断连”错误(仅 EPIPE,含 cause 链)。 + * + * @param err - 待检查的错误(任意类型) + * @returns 命中良性断管错误码时返回 true + */ +export function isBenignBrokenPipeError(err: unknown): boolean { + return getBenignBrokenPipeCode(err) !== undefined; +} diff --git a/src/lib/notification/notification-queue.ts b/src/lib/notification/notification-queue.ts index c4ce3db5b..54257beff 100644 --- a/src/lib/notification/notification-queue.ts +++ b/src/lib/notification/notification-queue.ts @@ -424,13 +424,30 @@ function setupQueueProcessor(queue: Queue.Queue): void { | undefined = data; let cooldownCommit: { keys: string[]; cooldownMinutes: number } | undefined; switch (type) { - case "circuit-breaker": + case "circuit-breaker": { + // 执行期再次校验开关:入队后若开关被关闭,遗留作业不应继续发送 + const { getNotificationSettings } = await import("@/repository/notifications"); + const settings = await getNotificationSettings(); + + if (!settings.enabled || !settings.circuitBreakerEnabled) { + logger.info({ action: "circuit_breaker_disabled", jobId: job.id }); + return { success: true, skipped: true }; + } + message = buildCircuitBreakerMessage(data as CircuitBreakerAlertData, timezone); break; + } case "daily-leaderboard": { // 动态生成排行榜数据 const { getNotificationSettings } = await import("@/repository/notifications"); const settings = await getNotificationSettings(); + + // 执行期再次校验开关:总开关或子开关关闭后,遗留的 repeatable 作业不应继续发送 + if (!settings.enabled || !settings.dailyLeaderboardEnabled) { + logger.info({ action: "daily_leaderboard_disabled", jobId: job.id }); + return { success: true, skipped: true }; + } + const leaderboardData = await generateDailyLeaderboard( settings.dailyLeaderboardTopN || 5 ); @@ -451,6 +468,13 @@ function setupQueueProcessor(queue: Queue.Queue): void { // 动态生成成本预警数据 const { getNotificationSettings } = await import("@/repository/notifications"); const settings = await getNotificationSettings(); + + // 执行期再次校验开关:总开关或子开关关闭后,遗留的 repeatable 作业不应继续发送 + if (!settings.enabled || !settings.costAlertEnabled) { + logger.info({ action: "cost_alert_disabled", jobId: job.id }); + return { success: true, skipped: true }; + } + const alerts = await generateCostAlerts( parseFloat(settings.costAlertThreshold || "0.80") ); @@ -705,6 +729,68 @@ export async function addNotificationJobForTarget( } } +/** + * 移除队列中所有 repeatable 定时任务。 + * 逐个尝试移除(单个 key 失败不影响其余 key),返回是否全部成功。 + * 调用方应在“仍要新增任务”的场景下检查返回值:若有旧任务未能移除, + * 继续新增会导致旧任务与新任务同时触发(重复发送),应中止本次重调度等待重试。 + */ +async function removeAllRepeatableJobs(queue: Queue.Queue): Promise { + let repeatableJobs: Awaited>; + try { + repeatableJobs = await queue.getRepeatableJobs(); + } catch (error) { + logger.warn({ + action: "notification_repeatable_list_failed", + error: error instanceof Error ? error.message : String(error), + }); + return false; + } + + let allRemoved = true; + for (const job of repeatableJobs) { + try { + await queue.removeRepeatableByKey(job.key); + } catch (error) { + allRemoved = false; + logger.warn({ + action: "notification_repeatable_remove_failed", + key: job.key, + error: error instanceof Error ? error.message : String(error), + }); + } + } + return allRemoved; +} + +/** 将“每 N 分钟”归一化到 [1, 1440] 分钟。 */ +function clampIntervalMinutes(rawMinutes: number): number { + return Math.min(Math.max(1, Math.trunc(rawMinutes)), 24 * 60); +} + +/** + * 将“每 N 分钟”间隔映射为 Bull 的 repeat 选项。 + * Bull cron 的分钟字段仅 0-59,且分钟步进表达式在 N 不整除 60 时(如 45)会在整点边界产生不均匀间隔, + * 因此仅当 N<=59 且能整除 60 时使用 cron(可携带时区);否则退化为固定毫秒间隔 + * (every 按固定节奏触发,不对齐整点、不支持时区,这是 Bull 的限制)。 + */ +function intervalToRepeat( + intervalMinutes: number, + tz?: string +): { cron: string; tz?: string } | { every: number } { + if (intervalMinutes <= 59 && 60 % intervalMinutes === 0) { + return tz + ? { cron: `*/${intervalMinutes} * * * *`, tz } + : { cron: `*/${intervalMinutes} * * * *` }; + } + return { every: intervalMinutes * 60 * 1000 }; +} + +/** 生成 repeat 选项的可读日志标签。 */ +function describeRepeat(repeat: { cron: string } | { every: number }): string { + return "cron" in repeat ? repeat.cron : `every:${Math.round(repeat.every / 60000)}m`; +} + /** * 调度定时通知任务 */ @@ -719,19 +805,21 @@ export async function scheduleNotifications() { if (!settings.enabled) { logger.info({ action: "notifications_disabled" }); - // 移除所有已存在的定时任务 - const repeatableJobs = await queue.getRepeatableJobs(); - for (const job of repeatableJobs) { - await queue.removeRepeatableByKey(job.key); - } + // 总开关关闭:移除所有已存在的定时任务(此处无需新增任务,移除失败不阻断) + await removeAllRepeatableJobs(queue); return; } - // 移除旧的定时任务 - const repeatableJobs = await queue.getRepeatableJobs(); - for (const job of repeatableJobs) { - await queue.removeRepeatableByKey(job.key); + // 移除旧的定时任务,避免改时间/改配置后旧任务残留导致重复或错误时间触发。 + // 若移除未全部成功,则不再新增任务——否则旧任务会与新任务同时触发(重复发送),等待下次重调度重试。 + const removedAll = await removeAllRepeatableJobs(queue); + if (!removedAll) { + logger.error({ + action: "schedule_notifications_aborted", + reason: "stale_repeatable_remove_failed", + }); + return; } if (settings.useLegacyMode) { @@ -764,8 +852,8 @@ export async function scheduleNotifications() { } if (settings.costAlertEnabled && settings.costAlertWebhook) { - const interval = settings.costAlertCheckInterval ?? 60; // 分钟 - const cron = `*/${interval} * * * *`; // 每 N 分钟 + const interval = clampIntervalMinutes(settings.costAlertCheckInterval ?? 60); + const repeat = intervalToRepeat(interval); await queue.add( { @@ -774,27 +862,22 @@ export async function scheduleNotifications() { // data 字段省略,任务执行时动态生成 }, { - repeat: { cron }, + repeat, jobId: "cost-alert-scheduled", } ); logger.info({ action: "cost_alert_scheduled", - schedule: cron, + schedule: describeRepeat(repeat), intervalMinutes: interval, mode: "legacy", }); } if (settings.cacheHitRateAlertEnabled && settings.cacheHitRateAlertWebhook) { - const intervalMinutesRaw = settings.cacheHitRateAlertCheckInterval ?? 5; - const intervalMinutes = Math.max(1, Math.trunc(intervalMinutesRaw)); - const clampedIntervalMinutes = Math.min(intervalMinutes, 24 * 60); - const repeat = - clampedIntervalMinutes <= 59 - ? { cron: `*/${clampedIntervalMinutes} * * * *` } - : { every: clampedIntervalMinutes * 60 * 1000 }; + const interval = clampIntervalMinutes(settings.cacheHitRateAlertCheckInterval ?? 5); + const repeat = intervalToRepeat(interval); await queue.add( { @@ -806,8 +889,8 @@ export async function scheduleNotifications() { logger.info({ action: "cache_hit_rate_alert_scheduled", - schedule: "cron" in repeat ? repeat.cron : `every:${clampedIntervalMinutes}m`, - intervalMinutes: clampedIntervalMinutes, + schedule: describeRepeat(repeat), + intervalMinutes: interval, mode: "legacy", }); } @@ -848,12 +931,14 @@ export async function scheduleNotifications() { if (settings.costAlertEnabled) { const bindings = await getEnabledBindingsByType("cost_alert"); - const interval = settings.costAlertCheckInterval ?? 60; - const defaultCron = `*/${interval} * * * *`; + const interval = clampIntervalMinutes(settings.costAlertCheckInterval ?? 60); for (const binding of bindings) { - const cron = binding.scheduleCron ?? defaultCron; const tz = binding.scheduleTimezone ?? systemTimezone; + // 优先级:绑定自定义 cron(支持 tz)> 默认间隔(按 N 是否整除 60 选择 cron 或固定 every)。 + const repeat = binding.scheduleCron + ? { cron: binding.scheduleCron, tz } + : intervalToRepeat(interval, tz); await queue.add( { @@ -862,7 +947,7 @@ export async function scheduleNotifications() { bindingId: binding.id, }, { - repeat: { cron, tz }, + repeat, jobId: `cost-alert:${binding.id}`, } ); @@ -870,7 +955,7 @@ export async function scheduleNotifications() { logger.info({ action: "cost_alert_scheduled", - schedule: defaultCron, + schedule: describeRepeat(intervalToRepeat(interval)), intervalMinutes: interval, targets: bindings.length, mode: "targets", @@ -879,19 +964,12 @@ export async function scheduleNotifications() { if (settings.cacheHitRateAlertEnabled) { const bindings = await getEnabledBindingsByType("cache_hit_rate_alert"); - const intervalMinutesRaw = settings.cacheHitRateAlertCheckInterval ?? 5; - const intervalMinutes = Math.max(1, Math.trunc(intervalMinutesRaw)); - const clampedIntervalMinutes = Math.min(intervalMinutes, 24 * 60); - const defaultCron = `*/${clampedIntervalMinutes} * * * *`; - const repeat = - clampedIntervalMinutes <= 59 - ? { cron: defaultCron, tz: systemTimezone } - : { every: clampedIntervalMinutes * 60 * 1000 }; + const interval = clampIntervalMinutes(settings.cacheHitRateAlertCheckInterval ?? 5); + const repeat = intervalToRepeat(interval, systemTimezone); if (bindings.length > 0) { // 注意:这里刻意只调度一个共享的 repeat 作业,然后在处理器内 fan-out 到所有 bindings。 // 这样可以避免对每个 binding 重复计算同一份 payload;代价是 binding 的 scheduleCron/scheduleTimezone 将被忽略。 - // 另外:interval > 59 分钟会使用 repeat.every(固定间隔,不对齐整点,也不支持 tz),这是 Bull cron 分钟字段的限制。 // 若未来需要支持 per-binding 的 cron/timezone,需要改为“每个 binding 一个 repeat 作业”或引入更细粒度的调度层。 await queue.add( { @@ -904,17 +982,16 @@ export async function scheduleNotifications() { ); logger.info({ action: "cache_hit_rate_alert_scheduled", - schedule: "cron" in repeat ? repeat.cron : `every:${clampedIntervalMinutes}m`, - intervalMinutes: clampedIntervalMinutes, + schedule: describeRepeat(repeat), + intervalMinutes: interval, targets: bindings.length, mode: "targets", }); } else { logger.info({ action: "cache_hit_rate_alert_schedule_skipped", - schedule: - clampedIntervalMinutes <= 59 ? defaultCron : `every:${clampedIntervalMinutes}m`, - intervalMinutes: clampedIntervalMinutes, + schedule: describeRepeat(repeat), + intervalMinutes: interval, reason: "no_bindings", mode: "targets", }); diff --git a/src/lib/request-filter-engine.ts b/src/lib/request-filter-engine.ts index c3974ace8..e9a9efcd7 100644 --- a/src/lib/request-filter-engine.ts +++ b/src/lib/request-filter-engine.ts @@ -278,6 +278,8 @@ export class RequestFilterEngine { private isLoading = false; private isInitialized = false; private initializationPromise: Promise | null = null; + private activeReloadPromise: Promise | null = null; // 合并并发 reload + private reloadRequestedWhileLoading = false; // reload 期间收到的补跑请求 private eventEmitterCleanup: (() => void) | null = null; private redisPubSubCleanup: (() => void) | null = null; @@ -336,19 +338,48 @@ export class RequestFilterEngine { } } - async reload(): Promise { - if (this.isLoading) return; - this.isLoading = true; - - try { - const { getActiveRequestFilters } = await import("@/repository/request-filters"); - const filters = await getActiveRequestFilters(); - this.loadFilters(filters); - } catch (error) { - logger.error("[RequestFilterEngine] Failed to reload filters", { error }); - } finally { - this.isLoading = false; + async reload(queue = true): Promise { + if (this.activeReloadPromise) { + // 已有 reload 在途: + // - queue=true(默认 / 事件驱动 / 手动刷新)排队补跑一轮,确保写库后的显式 reload + // 不被丢弃,否则代理仍命中旧快照、必须手动点"刷新缓存"才生效。 + // - queue=false(action 在 emit 已触发 reload 之后调用)直接复用这次在途 reload, + // 避免对同一次写入做两次冗余的 DB 读、并让保存响应少等一轮。 + if (queue) { + this.reloadRequestedWhileLoading = true; + } + return this.activeReloadPromise; } + + const reloadLoop = (async () => { + do { + // reload 期间若又收到补跑请求,本轮结束后立刻再跑一轮,避免新规则落库却没进缓存。 + this.reloadRequestedWhileLoading = false; + this.isLoading = true; + + try { + const { getActiveRequestFilters } = await import("@/repository/request-filters"); + const filters = await getActiveRequestFilters(); + this.loadFilters(filters); + } catch (error) { + logger.error("[RequestFilterEngine] Failed to reload filters", { error }); + } finally { + this.isLoading = false; + } + } while (this.reloadRequestedWhileLoading); + })(); + + this.activeReloadPromise = reloadLoop.finally(() => { + // 极窄窗口:do/while 已判定无需继续,但 finally 微任务执行前又来了新请求, + // 此处再检查一次,避免晚到的补跑被静默吞掉。 + const shouldRestart = this.reloadRequestedWhileLoading; + this.activeReloadPromise = null; + if (shouldRestart) { + return this.reload(); + } + }); + + return this.activeReloadPromise; } /** Shared filter loading logic (used by reload and setFiltersForTest) */ @@ -423,7 +454,15 @@ export class RequestFilterEngine { } private async ensureInitialized(): Promise { + // 已初始化后立即返回,绝不让代理热路径阻塞等待在途 reload: + // loadFilters() 对各 bucket 做同步整体替换,请求读到的恒为某个一致快照; + // 用户保存后的"即时生效"由 action 侧 await reload() 保证,无需并发代理请求陪跑一次 DB 读。 if (this.isInitialized) return; + // 仅在「尚未初始化」且已有在途 reload 时复用它,避免冷启动期间重复打库。 + if (this.activeReloadPromise) { + await this.activeReloadPromise; + return; + } if (!this.initializationPromise) { this.initializationPromise = this.reload().finally(() => { this.initializationPromise = null; diff --git a/src/lib/usage-logs/export/columns.ts b/src/lib/usage-logs/export/columns.ts new file mode 100644 index 000000000..f632ed2d6 --- /dev/null +++ b/src/lib/usage-logs/export/columns.ts @@ -0,0 +1,118 @@ +/** + * Single source of truth for the usage-logs export detail columns, shared by + * the CSV and XLSX renderers so they can never drift apart. + */ + +import { getRetryCount } from "@/lib/utils/provider-chain-formatter"; +import type { UsageLogRow } from "@/repository/usage-logs"; + +export type DetailColumnKind = "text" | "number" | "datetime"; + +export interface DetailColumn { + /** Stable English header (datetime columns get the timezone appended). */ + header: string; + kind: DetailColumnKind; + /** + * Raw extracted value: string for text columns, number|null for number + * columns, Date|null for datetime columns. + */ + get: (log: UsageLogRow) => string | number | Date | null; + /** number columns only: emit 0 (instead of blank) when the value is null. */ + zeroWhenNull?: boolean; + /** ExcelJS number/date format string. */ + numFmt?: string; +} + +export const COST_NUM_FMT = "0.00######"; +export const INT_NUM_FMT = "0"; +export const DATETIME_NUM_FMT = "yyyy-mm-dd hh:mm:ss"; + +function retryCountOf(log: UsageLogRow): number { + return log.providerChain ? getRetryCount(log.providerChain) : 0; +} + +export const DETAIL_COLUMNS: DetailColumn[] = [ + { header: "Time", kind: "datetime", numFmt: DATETIME_NUM_FMT, get: (log) => log.createdAt }, + { header: "User", kind: "text", get: (log) => log.userName }, + { header: "Key", kind: "text", get: (log) => log.keyName }, + { header: "Provider", kind: "text", get: (log) => log.providerName ?? "" }, + { header: "Model", kind: "text", get: (log) => log.model ?? "" }, + { header: "Original Model", kind: "text", get: (log) => log.originalModel ?? "" }, + { header: "Endpoint", kind: "text", get: (log) => log.endpoint ?? "" }, + { header: "Status Code", kind: "number", numFmt: INT_NUM_FMT, get: (log) => log.statusCode }, + { + header: "Input Tokens", + kind: "number", + numFmt: INT_NUM_FMT, + zeroWhenNull: true, + get: (log) => log.inputTokens, + }, + { + header: "Output Tokens", + kind: "number", + numFmt: INT_NUM_FMT, + zeroWhenNull: true, + get: (log) => log.outputTokens, + }, + { + header: "Cache Write 5m", + kind: "number", + numFmt: INT_NUM_FMT, + zeroWhenNull: true, + get: (log) => log.cacheCreation5mInputTokens, + }, + { + header: "Cache Write 1h", + kind: "number", + numFmt: INT_NUM_FMT, + zeroWhenNull: true, + get: (log) => log.cacheCreation1hInputTokens, + }, + { + header: "Cache Read", + kind: "number", + numFmt: INT_NUM_FMT, + zeroWhenNull: true, + get: (log) => log.cacheReadInputTokens, + }, + { + header: "Total Tokens", + kind: "number", + numFmt: INT_NUM_FMT, + zeroWhenNull: true, + get: (log) => log.totalTokens, + }, + { + header: "Cost (USD)", + kind: "number", + numFmt: COST_NUM_FMT, + zeroWhenNull: true, + get: (log) => log.costUsd, + }, + { header: "Duration (ms)", kind: "number", numFmt: INT_NUM_FMT, get: (log) => log.durationMs }, + { header: "Session ID", kind: "text", get: (log) => log.sessionId ?? "" }, + { + header: "Retry Count", + kind: "number", + numFmt: INT_NUM_FMT, + zeroWhenNull: true, + get: retryCountOf, + }, +]; + +/** + * Detail-sheet headers, with the timezone appended to datetime columns so the + * cells stay clean datetimes (e.g. "Time (Asia/Shanghai)"). + */ +export function buildDetailHeaders(timezone: string): string[] { + return DETAIL_COLUMNS.map((column) => + column.kind === "datetime" ? `${column.header} (${timezone})` : column.header + ); +} + +/** A cell value that should render blank (null, undefined, or whitespace-only). */ +export function isBlankValue(value: string | number | Date | null | undefined): boolean { + return ( + value === null || value === undefined || (typeof value === "string" && value.trim() === "") + ); +} diff --git a/src/lib/usage-logs/export/csv.ts b/src/lib/usage-logs/export/csv.ts new file mode 100644 index 000000000..64dd5735b --- /dev/null +++ b/src/lib/usage-logs/export/csv.ts @@ -0,0 +1,66 @@ +/** + * CSV rendering for usage-logs exports. Numeric columns are normalized so Excel + * parses them as numbers (see ./numeric), and timestamps are rendered in the + * resolved system timezone (see ./format). + */ + +import type { UsageLogRow } from "@/repository/usage-logs"; +import { buildDetailHeaders, DETAIL_COLUMNS, isBlankValue } from "./columns"; +import { formatExportTimestamp, isValidDate } from "./format"; +import { normalizeDecimalForSpreadsheet } from "./numeric"; + +export const CSV_BOM = ""; + +/** + * Escape a CSV field, neutralizing spreadsheet formula injection. Mirrors the + * historical behaviour: fields whose first non-whitespace char is one of + * = + - @ are prefixed with a single quote. + */ +export function escapeCsvField(field: string): string { + const dangerousChars = ["=", "+", "-", "@"]; + const trimmedField = field.trimStart(); + let safeField = field; + if (trimmedField && dangerousChars.some((char) => trimmedField.startsWith(char))) { + safeField = `'${field}`; + } + + if ( + safeField.includes(",") || + safeField.includes('"') || + safeField.includes("\n") || + safeField.includes("\r") + ) { + return `"${safeField.replace(/"/g, '""')}"`; + } + return safeField; +} + +function renderCsvCell( + value: string | number | Date | null, + column: (typeof DETAIL_COLUMNS)[number], + timezone: string +): string { + switch (column.kind) { + case "datetime": + return isValidDate(value) ? formatExportTimestamp(value, timezone) : ""; + case "number": + if (isBlankValue(value) && !column.zeroWhenNull) { + return ""; + } + return normalizeDecimalForSpreadsheet(value as string | number | null); + default: + return escapeCsvField(typeof value === "string" ? value : String(value ?? "")); + } +} + +/** The CSV header row (comma-joined), with the timezone annotation. */ +export function buildCsvHeaderLine(timezone: string): string { + return buildDetailHeaders(timezone).map(escapeCsvField).join(","); +} + +/** Render usage log rows as CSV data lines (no header, no BOM). */ +export function buildCsvRows(logs: UsageLogRow[], timezone: string): string[] { + return logs.map((log) => + DETAIL_COLUMNS.map((column) => renderCsvCell(column.get(log), column, timezone)).join(",") + ); +} diff --git a/src/lib/usage-logs/export/format.ts b/src/lib/usage-logs/export/format.ts new file mode 100644 index 000000000..dbe4d27db --- /dev/null +++ b/src/lib/usage-logs/export/format.ts @@ -0,0 +1,38 @@ +/** + * Timezone-aware formatting for spreadsheet exports. + * + * Timestamps are rendered in the system timezone (resolved by the caller via + * resolveSystemTimezone) instead of UTC, and the timezone is surfaced once in + * the column header so each cell stays a clean, Excel-parseable datetime. + */ + +import { formatInTimeZone } from "date-fns-tz"; + +/** Excel-friendly local datetime, e.g. "2026-06-03 20:34:56". */ +export const EXPORT_DATETIME_FORMAT = "yyyy-MM-dd HH:mm:ss"; + +/** + * Narrow to a usable Date. `new Date(NaN)` is still `instanceof Date`, so an + * `instanceof` check alone would let an invalid date reach `formatInTimeZone` + * and throw a RangeError mid-export. + */ +export function isValidDate(value: unknown): value is Date { + return value instanceof Date && !Number.isNaN(value.getTime()); +} + +/** Render an instant as a wall-clock string in the given IANA timezone. */ +export function formatExportTimestamp(date: Date, timezone: string): string { + return formatInTimeZone(date, timezone, EXPORT_DATETIME_FORMAT); +} + +/** + * Convert a UTC instant into a Date whose UTC fields equal the wall-clock time + * in `timezone`. The XLSX writer derives the Excel serial from this Date's UTC + * epoch value (see ./xlsx excelSerial), so the cell displays the intended local + * time while remaining a real (sortable, computable) Excel date. + */ +export function toExcelZonedDate(date: Date, timezone: string): Date { + const parts = formatInTimeZone(date, timezone, "yyyy-MM-dd-HH-mm-ss").split("-").map(Number); + const [year, month, day, hour, minute, second] = parts; + return new Date(Date.UTC(year, month - 1, day, hour, minute, second)); +} diff --git a/src/lib/usage-logs/export/numeric.ts b/src/lib/usage-logs/export/numeric.ts new file mode 100644 index 000000000..6047e9769 --- /dev/null +++ b/src/lib/usage-logs/export/numeric.ts @@ -0,0 +1,51 @@ +/** + * Numeric normalization for spreadsheet exports. + * + * Excel only keeps 15 significant digits. A `numeric(21, 15)` cost such as + * `1.234567890123456` (16 significant digits) is therefore imported as *text*, + * which breaks SUM() and other math. Values < 1 (e.g. `0.000123...`) have fewer + * significant digits and slip under the ceiling, which is why only some rows + * misbehaved. Normalizing every numeric value to <=15 significant digits, plain + * decimal notation, with trailing zeros trimmed keeps Excel treating them as + * numbers. + */ + +const SPREADSHEET_NUMBER_FORMATTER = new Intl.NumberFormat("en-US", { + maximumSignificantDigits: 15, + useGrouping: false, +}); + +/** + * Coerce a DB numeric string (or number) into a finite number, or null when the + * input is empty, nullish, or not a finite number. + */ +export function toFiniteNumber(value: string | number | null | undefined): number | null { + if (value === null || value === undefined) { + return null; + } + if (typeof value === "number") { + return Number.isFinite(value) ? value : null; + } + if (typeof value !== "string") { + return null; + } + const trimmed = value.trim(); + if (trimmed === "") { + return null; + } + const parsed = Number(trimmed); + return Number.isFinite(parsed) ? parsed : null; +} + +/** + * Render a decimal value as an Excel-safe numeric literal: at most 15 + * significant digits, plain decimal notation (never scientific), trailing zeros + * stripped. Non-finite / empty / nullish inputs collapse to "0". + */ +export function normalizeDecimalForSpreadsheet(value: string | number | null | undefined): string { + const parsed = toFiniteNumber(value); + if (parsed === null) { + return "0"; + } + return SPREADSHEET_NUMBER_FORMATTER.format(parsed); +} diff --git a/src/lib/usage-logs/export/summary.ts b/src/lib/usage-logs/export/summary.ts new file mode 100644 index 000000000..66ca5ba97 --- /dev/null +++ b/src/lib/usage-logs/export/summary.ts @@ -0,0 +1,163 @@ +/** + * Aggregated summary for the XLSX export's second worksheet. + * + * - Multi-day exports are summarized per calendar day. + * - Single-day exports are summarized per hour. + * + * Calendar boundaries are evaluated in the resolved system timezone so the + * buckets line up with what the user sees in the dashboard. + */ + +import { formatInTimeZone } from "date-fns-tz"; +import type { UsageLogRow } from "@/repository/usage-logs"; +import { isValidDate } from "./format"; +import { toFiniteNumber } from "./numeric"; + +export type SummaryGranularity = "daily" | "hourly"; + +export interface SummaryRow { + period: string; + requests: number; + inputTokens: number; + outputTokens: number; + cacheWrite5m: number; + cacheWrite1h: number; + cacheRead: number; + totalTokens: number; + cost: number; +} + +export interface UsageLogsSummary { + granularity: SummaryGranularity; + rows: SummaryRow[]; + total: SummaryRow; +} + +export const SUMMARY_HEADERS = [ + "Period", + "Requests", + "Input Tokens", + "Output Tokens", + "Cache Write 5m", + "Cache Write 1h", + "Cache Read", + "Total Tokens", + "Cost (USD)", +] as const; + +const UNKNOWN_PERIOD = "Unknown"; + +function emptyRow(period: string): SummaryRow { + return { + period, + requests: 0, + inputTokens: 0, + outputTokens: 0, + cacheWrite5m: 0, + cacheWrite1h: 0, + cacheRead: 0, + totalTokens: 0, + cost: 0, + }; +} + +function accumulate(row: SummaryRow, log: UsageLogRow): void { + row.requests += 1; + row.inputTokens += log.inputTokens ?? 0; + row.outputTokens += log.outputTokens ?? 0; + row.cacheWrite5m += log.cacheCreation5mInputTokens ?? 0; + row.cacheWrite1h += log.cacheCreation1hInputTokens ?? 0; + row.cacheRead += log.cacheReadInputTokens ?? 0; + row.totalTokens += log.totalTokens ?? 0; + row.cost += toFiniteNumber(log.costUsd) ?? 0; +} + +function merge(target: SummaryRow, source: SummaryRow): void { + target.requests += source.requests; + target.inputTokens += source.inputTokens; + target.outputTokens += source.outputTokens; + target.cacheWrite5m += source.cacheWrite5m; + target.cacheWrite1h += source.cacheWrite1h; + target.cacheRead += source.cacheRead; + target.totalTokens += source.totalTokens; + target.cost += source.cost; +} + +function byPeriod(a: SummaryRow, b: SummaryRow): number { + return a.period < b.period ? -1 : a.period > b.period ? 1 : 0; +} + +/** + * Incremental summary builder. Logs are folded in one at a time (each timestamp + * formatted exactly once) so callers can stream batches without retaining the + * rows. Buckets are kept at hour granularity; `finalize` chooses per-hour vs + * per-day output from the distinct-day count and rolls hours up when needed. + * Period labels are zero-padded ISO, so the later lexicographic sort is also + * chronological. + */ +export interface SummaryAccumulator { + add(log: UsageLogRow): void; + finalize(): UsageLogsSummary; +} + +export function createSummaryAccumulator(timezone: string): SummaryAccumulator { + const hourBuckets = new Map(); + const days = new Set(); + const total = emptyRow("Total"); + let unknown: SummaryRow | null = null; + + return { + add(log) { + accumulate(total, log); + if (!isValidDate(log.createdAt)) { + unknown ??= emptyRow(UNKNOWN_PERIOD); + accumulate(unknown, log); + return; + } + const hourKey = `${formatInTimeZone(log.createdAt, timezone, "yyyy-MM-dd HH")}:00`; + days.add(hourKey.slice(0, 10)); + let row = hourBuckets.get(hourKey); + if (!row) { + row = emptyRow(hourKey); + hourBuckets.set(hourKey, row); + } + accumulate(row, log); + }, + finalize() { + const granularity: SummaryGranularity = days.size <= 1 ? "hourly" : "daily"; + let rows: SummaryRow[]; + if (granularity === "hourly") { + rows = [...hourBuckets.values()]; + } else { + const dayBuckets = new Map(); + for (const hour of hourBuckets.values()) { + const dayKey = hour.period.slice(0, 10); + let day = dayBuckets.get(dayKey); + if (!day) { + day = emptyRow(dayKey); + dayBuckets.set(dayKey, day); + } + merge(day, hour); + } + rows = [...dayBuckets.values()]; + } + if (unknown) { + rows.push(unknown); + } + rows.sort(byPeriod); + return { granularity, rows, total }; + }, + }; +} + +/** + * Build the per-day or per-hour summary for the given logs (convenience wrapper + * around {@link createSummaryAccumulator} for callers that already hold all rows). + */ +export function buildUsageLogsSummary(logs: UsageLogRow[], timezone: string): UsageLogsSummary { + const accumulator = createSummaryAccumulator(timezone); + for (const log of logs) { + accumulator.add(log); + } + return accumulator.finalize(); +} diff --git a/src/lib/usage-logs/export/xlsx.ts b/src/lib/usage-logs/export/xlsx.ts new file mode 100644 index 000000000..398ecc2c8 --- /dev/null +++ b/src/lib/usage-logs/export/xlsx.ts @@ -0,0 +1,282 @@ +/** + * Minimal, dependency-light XLSX writer for usage-logs exports. + * + * Why hand-rolled: we only need to emit two simple worksheets with correctly + * typed numeric / date cells. A purpose-built writer (on top of the already + * present `fflate` zip codec) keeps the cells genuinely numeric so Excel SUM() + * works, renders timestamps as real Excel dates in the system timezone, and + * avoids pulling in a heavy spreadsheet dependency tree. + * + * Workbook layout: + * Sheet 1 "Usage Logs" - one row per request (mirrors the CSV) + * Sheet 2 "Daily/Hourly Summary" - aggregates (see ./summary) + */ + +import { strToU8, zip } from "fflate"; +import type { UsageLogRow } from "@/repository/usage-logs"; +import { + buildDetailHeaders, + COST_NUM_FMT, + DETAIL_COLUMNS, + type DetailColumn, + isBlankValue, +} from "./columns"; +import { isValidDate, toExcelZonedDate } from "./format"; +import { normalizeDecimalForSpreadsheet } from "./numeric"; +import { + createSummaryAccumulator, + SUMMARY_HEADERS, + type SummaryRow, + type UsageLogsSummary, +} from "./summary"; + +// Cell style indices, matched 1:1 to the entries in STYLES_XML below. +const STYLE = { text: 0, header: 1, datetime: 2, integer: 3, cost: 4 } as const; + +// Spreadsheet column letters are invariant per column index, so precompute them +// once instead of recomputing inside every row. +const DETAIL_COLUMN_REFS = DETAIL_COLUMNS.map((_column, index) => columnRef(index)); +const SUMMARY_COLUMN_REFS = SUMMARY_HEADERS.map((_header, index) => columnRef(index)); + +// Days between the Unix epoch (1970-01-01) and the Excel epoch (1899-12-30). +const EXCEL_EPOCH_OFFSET_DAYS = 25569; +const MS_PER_DAY = 86_400_000; +const SECONDS_PER_DAY = 86_400; + +function escapeXml(value: string): string { + return value.replace(/[&<>"']/g, (char) => { + switch (char) { + case "&": + return "&"; + case "<": + return "<"; + case ">": + return ">"; + case '"': + return """; + default: + return "'"; + } + }); +} + +// Keep only characters allowed by the XML 1.0 Char production, so a stray byte +// in a model/endpoint string (control bytes, unpaired surrogates, the U+FFFE / +// U+FFFF non-characters) cannot corrupt the whole workbook. +function stripIllegalXmlChars(value: string): string { + let out = ""; + for (const char of value) { + const code = char.codePointAt(0) ?? 0; + if ( + code === 0x09 || + code === 0x0a || + code === 0x0d || + (code >= 0x20 && code <= 0xd7ff) || + (code >= 0xe000 && code <= 0xfffd) || + (code >= 0x10000 && code <= 0x10ffff) + ) { + out += char; + } + } + return out; +} + +function sanitizeXmlText(value: string): string { + return escapeXml(stripIllegalXmlChars(value)); +} + +/** Zero-based column index -> spreadsheet column letters (0 -> A, 26 -> AA). */ +export function columnRef(index: number): string { + let remaining = index + 1; + let ref = ""; + while (remaining > 0) { + const mod = (remaining - 1) % 26; + ref = String.fromCharCode(65 + mod) + ref; + remaining = Math.floor((remaining - 1) / 26); + } + return ref; +} + +function excelSerial(date: Date): number { + const serial = date.getTime() / MS_PER_DAY + EXCEL_EPOCH_OFFSET_DAYS; + // Timestamps are whole seconds; snap to the second grid so binary-float + // artifacts in the division cannot make Excel display the wrong second. + return Math.round(serial * SECONDS_PER_DAY) / SECONDS_PER_DAY; +} + +function textCell(ref: string, value: string, style: number): string { + if (value === "") { + return ``; + } + return `${sanitizeXmlText(value)}`; +} + +function numberCell(ref: string, value: string | null, style: number): string { + if (value === null) { + return ``; + } + return `${value}`; +} + +function dateCell(ref: string, date: Date): string { + return `${excelSerial(date)}`; +} + +// Only reached for number columns; cost gets the decimal format, the rest are integers. +function detailNumberStyle(column: DetailColumn): number { + return column.numFmt === COST_NUM_FMT ? STYLE.cost : STYLE.integer; +} + +function detailCell(column: DetailColumn, log: UsageLogRow, ref: string, timezone: string): string { + const raw = column.get(log); + if (column.kind === "datetime") { + return isValidDate(raw) ? dateCell(ref, toExcelZonedDate(raw, timezone)) : ``; + } + if (column.kind === "number") { + if (isBlankValue(raw) && !column.zeroWhenNull) { + return numberCell(ref, null, STYLE.integer); + } + return numberCell( + ref, + normalizeDecimalForSpreadsheet(raw as string | number | null), + detailNumberStyle(column) + ); + } + return textCell(ref, typeof raw === "string" ? raw : String(raw ?? ""), STYLE.text); +} + +function rowXml(rowNumber: number, cells: string[]): string { + return `${cells.join("")}`; +} + +function headerRowXml(headers: string[], rowNumber: number, refs: string[]): string { + const cells = headers.map((header, index) => + textCell(`${refs[index]}${rowNumber}`, header, STYLE.header) + ); + return rowXml(rowNumber, cells); +} + +function worksheetXml(rows: string[], columnCount: number): string { + const lastCol = columnRef(columnCount - 1); + const lastRow = Math.max(rows.length, 1); + return ` +${rows.join("")}`; +} + +/** + * Render a single detail row's XML. `rowNumber` is 1-based (the header occupies + * row 1). Exposed so callers can stream batches into the sheet without retaining + * the whole result set in memory. + */ +export function buildDetailRowXml(log: UsageLogRow, rowNumber: number, timezone: string): string { + const cells = DETAIL_COLUMNS.map((column, columnIndex) => + detailCell(column, log, `${DETAIL_COLUMN_REFS[columnIndex]}${rowNumber}`, timezone) + ); + return rowXml(rowNumber, cells); +} + +function detailSheetXml(detailRowsXml: string[], timezone: string): string { + const rows = [ + headerRowXml(buildDetailHeaders(timezone), 1, DETAIL_COLUMN_REFS), + ...detailRowsXml, + ]; + return worksheetXml(rows, DETAIL_COLUMNS.length); +} + +function summaryRowCells(row: SummaryRow, rowNumber: number, periodStyle: number): string[] { + const integers = [ + row.requests, + row.inputTokens, + row.outputTokens, + row.cacheWrite5m, + row.cacheWrite1h, + row.cacheRead, + row.totalTokens, + ]; + const cells = [textCell(`${SUMMARY_COLUMN_REFS[0]}${rowNumber}`, row.period, periodStyle)]; + integers.forEach((value, index) => { + cells.push( + numberCell(`${SUMMARY_COLUMN_REFS[index + 1]}${rowNumber}`, String(value), STYLE.integer) + ); + }); + cells.push( + numberCell( + `${SUMMARY_COLUMN_REFS[8]}${rowNumber}`, + normalizeDecimalForSpreadsheet(row.cost), + STYLE.cost + ) + ); + return cells; +} + +function buildSummarySheet(summary: UsageLogsSummary): string { + const rows = [headerRowXml([...SUMMARY_HEADERS], 1, SUMMARY_COLUMN_REFS)]; + summary.rows.forEach((row, index) => { + rows.push(rowXml(index + 2, summaryRowCells(row, index + 2, STYLE.text))); + }); + const totalRowNumber = summary.rows.length + 2; + rows.push(rowXml(totalRowNumber, summaryRowCells(summary.total, totalRowNumber, STYLE.header))); + return worksheetXml(rows, SUMMARY_HEADERS.length); +} + +function summarySheetName(summary: UsageLogsSummary): string { + return summary.granularity === "daily" ? "Daily Summary" : "Hourly Summary"; +} + +const STYLES_XML = ` +`; + +const CONTENT_TYPES_XML = ` +`; + +const ROOT_RELS_XML = ` +`; + +const WORKBOOK_RELS_XML = ` +`; + +function workbookXml(summaryName: string): string { + return ` +`; +} + +export interface XlsxParts { + /** Pre-rendered detail row XML (one entry per data row, row numbers from 2). */ + detailRowsXml: string[]; + summary: UsageLogsSummary; + timezone: string; +} + +/** + * Assemble an XLSX workbook (detail sheet + daily/hourly summary sheet) from + * pre-rendered detail rows and an aggregated summary. Compression runs via + * fflate's async zip so a large export does not block the event loop. + */ +export function assembleUsageLogsXlsx(parts: XlsxParts): Promise { + const files: Record = { + "[Content_Types].xml": strToU8(CONTENT_TYPES_XML), + "_rels/.rels": strToU8(ROOT_RELS_XML), + "xl/workbook.xml": strToU8(workbookXml(summarySheetName(parts.summary))), + "xl/_rels/workbook.xml.rels": strToU8(WORKBOOK_RELS_XML), + "xl/styles.xml": strToU8(STYLES_XML), + "xl/worksheets/sheet1.xml": strToU8(detailSheetXml(parts.detailRowsXml, parts.timezone)), + "xl/worksheets/sheet2.xml": strToU8(buildSummarySheet(parts.summary)), + }; + return new Promise((resolve, reject) => { + zip(files, { level: 6 }, (error, data) => (error ? reject(error) : resolve(data))); + }); +} + +/** + * Build an XLSX workbook for the given logs (convenience wrapper that holds all + * rows in memory; the streaming export path uses buildDetailRowXml + + * createSummaryAccumulator + assembleUsageLogsXlsx instead). + */ +export function buildUsageLogsXlsx(logs: UsageLogRow[], timezone: string): Promise { + const accumulator = createSummaryAccumulator(timezone); + const detailRowsXml = logs.map((log, index) => { + accumulator.add(log); + return buildDetailRowXml(log, index + 2, timezone); + }); + return assembleUsageLogsXlsx({ detailRowsXml, summary: accumulator.finalize(), timezone }); +} diff --git a/src/repository/model-price.ts b/src/repository/model-price.ts index 0dfbb8ed9..c21c0d918 100644 --- a/src/repository/model-price.ts +++ b/src/repository/model-price.ts @@ -267,24 +267,24 @@ export async function createModelPrice( /** * 更新或插入模型价格(先删除旧记录,再插入新记录) - * 用于手动维护单个模型价格,source 固定为 'manual' + * 用于手动维护单个模型价格或批量替换;source 默认为 'manual'。 */ export async function upsertModelPrice( modelName: string, - priceData: ModelPriceData + priceData: ModelPriceData, + source: ModelPriceSource = "manual" ): Promise { // 使用事务确保删除和插入的原子性 return await db.transaction(async (tx) => { // 先删除该模型的所有旧记录 await tx.delete(modelPrices).where(eq(modelPrices.modelName, modelName)); - // 插入新记录,source 固定为 'manual' const [price] = await tx .insert(modelPrices) .values({ modelName: modelName, priceData: priceData, - source: "manual", + source: source, }) .returning(); return toModelPrice(price); diff --git a/tests/api/v1/usage-logs/usage-logs.test.ts b/tests/api/v1/usage-logs/usage-logs.test.ts index 65ca4fa30..6245aa868 100644 --- a/tests/api/v1/usage-logs/usage-logs.test.ts +++ b/tests/api/v1/usage-logs/usage-logs.test.ts @@ -85,7 +85,15 @@ describe("v1 usage log endpoints", () => { progressPercent: 100, }, }); - downloadUsageLogsExportMock.mockResolvedValue({ ok: true, data: "Time,Model\nnow,claude" }); + downloadUsageLogsExportMock.mockResolvedValue({ + ok: true, + data: { + content: "Time,Model\nnow,claude", + encoding: "utf8", + format: "csv", + filename: "usage-logs-job-1.csv", + }, + }); }); test("lists usage logs with offset and cursor filters", async () => { @@ -247,7 +255,7 @@ describe("v1 usage log endpoints", () => { }); expect(sync.response.status).toBe(200); expect(sync.json).toEqual({ csv: "Time,Model\nnow,claude" }); - expect(exportUsageLogsMock).toHaveBeenCalledWith({ model: "claude" }); + expect(exportUsageLogsMock).toHaveBeenCalledWith({ model: "claude", format: "csv" }); const asyncJob = await callV1Route({ method: "POST", @@ -257,7 +265,7 @@ describe("v1 usage log endpoints", () => { }); expect(asyncJob.response.status).toBe(202); expect(asyncJob.response.headers.get("Location")).toBe("/api/v1/usage-logs/exports/job-1"); - expect(startUsageLogsExportMock).toHaveBeenCalledWith({ model: "claude" }); + expect(startUsageLogsExportMock).toHaveBeenCalledWith({ model: "claude", format: "csv" }); const status = await callV1Route({ method: "GET", @@ -277,6 +285,46 @@ describe("v1 usage log endpoints", () => { expect(download.text).toContain("Time,Model"); }); + test("xlsx export requires async and downloads as a spreadsheet", async () => { + const syncXlsx = await callV1Route({ + method: "POST", + pathname: "/api/v1/usage-logs/exports", + headers, + body: { model: "claude", format: "xlsx" }, + }); + expect(syncXlsx.response.status).toBe(400); + expect(startUsageLogsExportMock).not.toHaveBeenCalledWith( + expect.objectContaining({ format: "xlsx" }) + ); + + const asyncXlsx = await callV1Route({ + method: "POST", + pathname: "/api/v1/usage-logs/exports", + headers: { ...headers, Prefer: "respond-async" }, + body: { model: "claude", format: "xlsx" }, + }); + expect(asyncXlsx.response.status).toBe(202); + expect(startUsageLogsExportMock).toHaveBeenCalledWith({ model: "claude", format: "xlsx" }); + + downloadUsageLogsExportMock.mockResolvedValueOnce({ + ok: true, + data: { + content: Buffer.from("PK-xlsx-bytes").toString("base64"), + encoding: "base64", + format: "xlsx", + filename: "usage-logs-job-1.xlsx", + }, + }); + const download = await callV1Route({ + method: "GET", + pathname: "/api/v1/usage-logs/exports/job-1/download", + headers, + }); + expect(download.response.status).toBe(200); + expect(download.response.headers.get("content-type")).toContain("spreadsheetml.sheet"); + expect(download.response.headers.get("content-disposition")).toContain(".xlsx"); + }); + test("returns problem+json for action failures and documents paths", async () => { getUsageLogsStatsMock.mockResolvedValueOnce({ ok: false, diff --git a/tests/unit/actions/error-rules-cache-reload.test.ts b/tests/unit/actions/error-rules-cache-reload.test.ts new file mode 100644 index 000000000..2084230c7 --- /dev/null +++ b/tests/unit/actions/error-rules-cache-reload.test.ts @@ -0,0 +1,135 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const getSessionMock = vi.fn(); +const reloadMock = vi.fn(async () => {}); +const emitErrorRulesUpdatedMock = vi.fn(async () => {}); +const createErrorRuleMock = vi.fn(); +const updateErrorRuleMock = vi.fn(); +const deleteErrorRuleMock = vi.fn(); +const getErrorRuleByIdMock = vi.fn(); + +vi.mock("@/lib/auth", () => ({ + getSession: getSessionMock, +})); + +vi.mock("@/lib/logger", () => ({ + logger: { + error: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + }, +})); + +vi.mock("@/lib/emit-event", () => ({ + emitErrorRulesUpdated: emitErrorRulesUpdatedMock, +})); + +vi.mock("@/lib/error-override-validator", () => ({ + validateErrorOverrideResponse: vi.fn(() => null), +})); + +vi.mock("@/lib/error-rule-detector", () => ({ + errorRuleDetector: { + reload: reloadMock, + getStats: vi.fn(() => ({ totalCount: 0 })), + ensureInitialized: vi.fn(async () => {}), + detectAsync: vi.fn(async () => ({ matched: false })), + }, +})); + +vi.mock("@/repository/error-rules", () => ({ + createErrorRule: createErrorRuleMock, + updateErrorRule: updateErrorRuleMock, + deleteErrorRule: deleteErrorRuleMock, + getErrorRuleById: getErrorRuleByIdMock, + getAllErrorRules: vi.fn(async () => []), + syncDefaultErrorRules: vi.fn(async () => ({ inserted: 0, updated: 0, skipped: 0, deleted: 0 })), +})); + +vi.mock("next/cache", () => ({ + revalidatePath: vi.fn(), +})); + +const baseRule = { + id: 1, + pattern: "boom", + category: "prompt_limit" as const, + matchType: "contains" as const, + description: null, + overrideResponse: null, + overrideStatusCode: null, + isEnabled: true, + isDefault: false, + priority: 0, + createdAt: new Date("2026-06-01T00:00:00.000Z"), + updatedAt: new Date("2026-06-01T00:00:00.000Z"), +}; + +describe("error-rules actions reload the detector on mutation", () => { + beforeEach(() => { + vi.clearAllMocks(); + getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } }); + }); + + it("createErrorRuleAction reloads the detector after a successful create", async () => { + createErrorRuleMock.mockResolvedValue(baseRule); + + const { createErrorRuleAction } = await import("@/actions/error-rules"); + const res = await createErrorRuleAction({ + pattern: "boom", + category: "prompt_limit", + matchType: "contains", + }); + + expect(res.ok).toBe(true); + expect(reloadMock).toHaveBeenCalledTimes(1); + }); + + it("updateErrorRuleAction reloads the detector after a successful update", async () => { + getErrorRuleByIdMock.mockResolvedValue(baseRule); + updateErrorRuleMock.mockResolvedValue({ ...baseRule, isEnabled: false }); + + const { updateErrorRuleAction } = await import("@/actions/error-rules"); + const res = await updateErrorRuleAction(1, { isEnabled: false }); + + expect(res.ok).toBe(true); + expect(reloadMock).toHaveBeenCalledTimes(1); + }); + + it("deleteErrorRuleAction reloads the detector after a successful delete", async () => { + deleteErrorRuleMock.mockResolvedValue(true); + + const { deleteErrorRuleAction } = await import("@/actions/error-rules"); + const res = await deleteErrorRuleAction(1); + + expect(res.ok).toBe(true); + expect(reloadMock).toHaveBeenCalledTimes(1); + }); + + it("does not reload the detector when the delete target does not exist", async () => { + deleteErrorRuleMock.mockResolvedValue(false); + + const { deleteErrorRuleAction } = await import("@/actions/error-rules"); + const res = await deleteErrorRuleAction(999); + + expect(res.ok).toBe(false); + expect(reloadMock).not.toHaveBeenCalled(); + }); + + it("still returns ok:true when the detector reload throws (cache sync is best-effort)", async () => { + createErrorRuleMock.mockResolvedValue(baseRule); + reloadMock.mockRejectedValueOnce(new Error("boom")); + + const { createErrorRuleAction } = await import("@/actions/error-rules"); + const res = await createErrorRuleAction({ + pattern: "boom", + category: "prompt_limit", + matchType: "contains", + }); + + // The DB write succeeded; a failed best-effort cache reload must NOT flip the + // action to failed (which would prompt the user to retry and double-create). + expect(res.ok).toBe(true); + expect(reloadMock).toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/actions/model-prices.test.ts b/tests/unit/actions/model-prices.test.ts index c99973e63..f64a86eb0 100644 --- a/tests/unit/actions/model-prices.test.ts +++ b/tests/unit/actions/model-prices.test.ts @@ -488,8 +488,7 @@ describe("Model Price Actions", () => { findAllManualPricesMock.mockResolvedValue(new Map([["custom-model", manualPrice]])); findAllLatestPricesMock.mockResolvedValue([manualPrice]); - deleteModelPriceByNameMock.mockResolvedValue(undefined); - createModelPriceMock.mockResolvedValue( + upsertModelPriceMock.mockResolvedValue( makeMockPrice( "custom-model", { @@ -513,8 +512,12 @@ describe("Model Price Actions", () => { expect(result.ok).toBe(true); expect(result.data?.updated).toContain("custom-model"); - expect(deleteModelPriceByNameMock).toHaveBeenCalledWith("custom-model"); - expect(createModelPriceMock).toHaveBeenCalled(); + // The overwrite is an atomic replace (delete + insert in one transaction). + expect(upsertModelPriceMock).toHaveBeenCalledWith( + "custom-model", + expect.any(Object), + "litellm" + ); }); it("should add new models with litellm source", async () => { @@ -608,6 +611,173 @@ describe("Model Price Actions", () => { expect(result.data?.unchanged).toContain("safe-model"); expect(createModelPriceMock).not.toHaveBeenCalled(); }); + + it("should persist models with the manual source when source='manual' (local upload)", async () => { + findAllManualPricesMock.mockResolvedValue(new Map()); + findAllLatestPricesMock.mockResolvedValue([]); + createModelPriceMock.mockResolvedValue( + makeMockPrice("new-model", { mode: "chat" }, "manual") + ); + + const { processPriceTableInternal } = await import("@/actions/model-prices"); + const result = await processPriceTableInternal( + JSON.stringify({ "new-model": { mode: "chat", input_cost_per_token: 0.000001 } }), + undefined, + "manual" + ); + + expect(result.ok).toBe(true); + expect(result.data?.added).toContain("new-model"); + expect(createModelPriceMock).toHaveBeenCalledWith("new-model", expect.any(Object), "manual"); + }); + + it("should protect a locally-uploaded (manual) model from later auto-sync overwrite", async () => { + // Regression: a user-created local model must survive an unattended cloud sync. + const manualPrice = makeMockPrice("my-custom-model", { + mode: "chat", + input_cost_per_token: 0.123, + }); + findAllManualPricesMock.mockResolvedValue(new Map([["my-custom-model", manualPrice]])); + findAllLatestPricesMock.mockResolvedValue([manualPrice]); + + const { processPriceTableInternal } = await import("@/actions/model-prices"); + // Auto-sync writes 'litellm' (default) with a different cloud price and no overwrite list. + const result = await processPriceTableInternal( + JSON.stringify({ "my-custom-model": { mode: "chat", input_cost_per_token: 0.999 } }) + ); + + expect(result.ok).toBe(true); + expect(result.data?.skippedConflicts).toContain("my-custom-model"); + expect(createModelPriceMock).not.toHaveBeenCalled(); + expect(deleteModelPriceByNameMock).not.toHaveBeenCalled(); + }); + + it("should atomically replace a changed cloud price via upsert (no orphan rows)", async () => { + const existing = makeMockPrice( + "cloud-model", + { mode: "chat", input_cost_per_token: 0.001 }, + "litellm" + ); + findAllManualPricesMock.mockResolvedValue(new Map()); + findAllLatestPricesMock.mockResolvedValue([existing]); + upsertModelPriceMock.mockResolvedValue( + makeMockPrice("cloud-model", { mode: "chat", input_cost_per_token: 0.002 }, "litellm") + ); + + const { processPriceTableInternal } = await import("@/actions/model-prices"); + const result = await processPriceTableInternal( + JSON.stringify({ "cloud-model": { mode: "chat", input_cost_per_token: 0.002 } }) + ); + + expect(result.ok).toBe(true); + expect(result.data?.updated).toContain("cloud-model"); + // Transactional replace, not a separate delete + insert. + expect(upsertModelPriceMock).toHaveBeenCalledWith( + "cloud-model", + expect.any(Object), + "litellm" + ); + expect(createModelPriceMock).not.toHaveBeenCalled(); + }); + + it("should convert an existing cloud (litellm) model to manual on local upload", async () => { + const existing = makeMockPrice( + "shared-model", + { mode: "chat", input_cost_per_token: 0.001 }, + "litellm" + ); + findAllManualPricesMock.mockResolvedValue(new Map()); + findAllLatestPricesMock.mockResolvedValue([existing]); + upsertModelPriceMock.mockResolvedValue( + makeMockPrice("shared-model", { mode: "chat", input_cost_per_token: 0.001 }, "manual") + ); + + const { processPriceTableInternal } = await import("@/actions/model-prices"); + const result = await processPriceTableInternal( + JSON.stringify({ "shared-model": { mode: "chat", input_cost_per_token: 0.001 } }), + undefined, + "manual" + ); + + expect(result.ok).toBe(true); + expect(result.data?.updated).toContain("shared-model"); + expect(upsertModelPriceMock).toHaveBeenCalledWith( + "shared-model", + expect.any(Object), + "manual" + ); + }); + + it("should update an existing manual model on local re-upload (manual source bypasses skip)", async () => { + // Regression: re-uploading a price file to revise a user's own model must apply, + // not be silently skipped as a conflict. + const manualPrice = makeMockPrice("my-model", { mode: "chat", input_cost_per_token: 0.1 }); + findAllManualPricesMock.mockResolvedValue(new Map([["my-model", manualPrice]])); + findAllLatestPricesMock.mockResolvedValue([manualPrice]); + upsertModelPriceMock.mockResolvedValue( + makeMockPrice("my-model", { mode: "chat", input_cost_per_token: 0.2 }, "manual") + ); + + const { processPriceTableInternal } = await import("@/actions/model-prices"); + const result = await processPriceTableInternal( + JSON.stringify({ "my-model": { mode: "chat", input_cost_per_token: 0.2 } }), + undefined, + "manual" + ); + + expect(result.ok).toBe(true); + expect(result.data?.updated).toContain("my-model"); + expect(result.data?.skippedConflicts).not.toContain("my-model"); + expect(upsertModelPriceMock).toHaveBeenCalledWith("my-model", expect.any(Object), "manual"); + }); + + it("should normalize whitespace in cloud model names before manual protection", async () => { + const manualPrice = makeMockPrice("claude-3", { mode: "chat", input_cost_per_token: 0.123 }); + findAllManualPricesMock.mockResolvedValue(new Map([["claude-3", manualPrice]])); + findAllLatestPricesMock.mockResolvedValue([manualPrice]); + + const { processPriceTableInternal } = await import("@/actions/model-prices"); + const result = await processPriceTableInternal( + JSON.stringify({ " claude-3 ": { mode: "chat", input_cost_per_token: 0.999 } }) + ); + + expect(result.ok).toBe(true); + expect(result.data?.skippedConflicts).toContain("claude-3"); + expect(createModelPriceMock).not.toHaveBeenCalled(); + }); + }); + + describe("uploadPriceTable - local-first", () => { + it("should store uploaded models with manual source so auto-sync cannot overwrite them", async () => { + findAllManualPricesMock.mockResolvedValue(new Map()); + findAllLatestPricesMock.mockResolvedValue([]); + createModelPriceMock.mockResolvedValue( + makeMockPrice("my-custom-model", { mode: "chat", input_cost_per_token: 0.001 }, "manual") + ); + + const { uploadPriceTable } = await import("@/actions/model-prices"); + const result = await uploadPriceTable( + JSON.stringify({ "my-custom-model": { mode: "chat", input_cost_per_token: 0.001 } }) + ); + + expect(result.ok).toBe(true); + expect(createModelPriceMock).toHaveBeenCalledWith( + "my-custom-model", + expect.any(Object), + "manual" + ); + }); + + it("should reject non-admin users", async () => { + getSessionMock.mockResolvedValue({ user: { id: 2, role: "user" } }); + + const { uploadPriceTable } = await import("@/actions/model-prices"); + const result = await uploadPriceTable(JSON.stringify({ x: { mode: "chat" } })); + + expect(result.ok).toBe(false); + expect(result.error).toContain("无权限"); + expect(createModelPriceMock).not.toHaveBeenCalled(); + }); }); describe("pinModelPricingProviderAsManual", () => { diff --git a/tests/unit/actions/request-filters-cache-reload.test.ts b/tests/unit/actions/request-filters-cache-reload.test.ts new file mode 100644 index 000000000..b7a3e4bb8 --- /dev/null +++ b/tests/unit/actions/request-filters-cache-reload.test.ts @@ -0,0 +1,144 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const getSessionMock = vi.fn(); +const reloadMock = vi.fn(async () => {}); +const createRequestFilterMock = vi.fn(); +const updateRequestFilterMock = vi.fn(); +const deleteRequestFilterMock = vi.fn(); + +vi.mock("@/lib/auth", () => ({ + getSession: getSessionMock, +})); + +vi.mock("@/lib/logger", () => ({ + logger: { + error: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + }, +})); + +vi.mock("@/lib/request-filter-engine", () => ({ + requestFilterEngine: { + reload: reloadMock, + getStats: vi.fn(() => ({ count: 0 })), + }, +})); + +vi.mock("@/repository/request-filters", () => ({ + createRequestFilter: createRequestFilterMock, + deleteRequestFilter: deleteRequestFilterMock, + getAllRequestFilters: vi.fn(async () => []), + getRequestFilterById: vi.fn(async () => null), + updateRequestFilter: updateRequestFilterMock, +})); + +vi.mock("next/cache", () => ({ + revalidatePath: vi.fn(), +})); + +const baseFilter = { + id: 1, + name: "f", + description: null, + scope: "header" as const, + action: "remove" as const, + matchType: null, + target: "x-test", + replacement: null, + priority: 0, + isEnabled: true, + bindingType: "global" as const, + providerIds: null, + groupTags: null, + ruleMode: "simple" as const, + executionPhase: "guard" as const, + operations: null, + createdAt: new Date("2026-06-01T00:00:00.000Z"), + updatedAt: new Date("2026-06-01T00:00:00.000Z"), +}; + +describe("request-filters actions reload the engine on mutation", () => { + beforeEach(() => { + vi.clearAllMocks(); + getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } }); + }); + + it("createRequestFilterAction reloads the engine after a successful create", async () => { + createRequestFilterMock.mockResolvedValue(baseFilter); + + const { createRequestFilterAction } = await import("@/actions/request-filters"); + const res = await createRequestFilterAction({ + name: "f", + scope: "header", + action: "remove", + target: "x-test", + bindingType: "global", + }); + + expect(res.ok).toBe(true); + expect(reloadMock).toHaveBeenCalledTimes(1); + // reload(false): the repository emit already kicked off a fresh reload; the + // action reuses it instead of forcing a redundant second DB read. + expect(reloadMock).toHaveBeenCalledWith(false); + }); + + it("still returns ok:true when the engine reload throws (cache sync is best-effort)", async () => { + createRequestFilterMock.mockResolvedValue(baseFilter); + reloadMock.mockRejectedValueOnce(new Error("boom")); + + const { createRequestFilterAction } = await import("@/actions/request-filters"); + const res = await createRequestFilterAction({ + name: "f", + scope: "header", + action: "remove", + target: "x-test", + bindingType: "global", + }); + + // The DB write succeeded; a failed best-effort cache reload must NOT flip the + // action to failed (which would prompt the user to retry and double-create). + expect(res.ok).toBe(true); + expect(reloadMock).toHaveBeenCalled(); + }); + + it("updateRequestFilterAction reloads the engine after a successful update", async () => { + updateRequestFilterMock.mockResolvedValue(baseFilter); + + const { updateRequestFilterAction } = await import("@/actions/request-filters"); + const res = await updateRequestFilterAction(1, { isEnabled: false }); + + expect(res.ok).toBe(true); + expect(reloadMock).toHaveBeenCalledTimes(1); + }); + + it("deleteRequestFilterAction reloads the engine after a successful delete", async () => { + deleteRequestFilterMock.mockResolvedValue(true); + + const { deleteRequestFilterAction } = await import("@/actions/request-filters"); + const res = await deleteRequestFilterAction(1); + + expect(res.ok).toBe(true); + expect(reloadMock).toHaveBeenCalledTimes(1); + }); + + it("does not reload the engine when the update target does not exist", async () => { + updateRequestFilterMock.mockResolvedValue(null); + + const { updateRequestFilterAction } = await import("@/actions/request-filters"); + const res = await updateRequestFilterAction(999, { isEnabled: false }); + + expect(res.ok).toBe(false); + expect(reloadMock).not.toHaveBeenCalled(); + }); + + it("does not reload the engine when the delete target does not exist", async () => { + deleteRequestFilterMock.mockResolvedValue(false); + + const { deleteRequestFilterAction } = await import("@/actions/request-filters"); + const res = await deleteRequestFilterAction(999); + + expect(res.ok).toBe(false); + expect(reloadMock).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/actions/usage-logs-export-retry-count.test.ts b/tests/unit/actions/usage-logs-export-retry-count.test.ts index 69dd3f06c..0c97c2587 100644 --- a/tests/unit/actions/usage-logs-export-retry-count.test.ts +++ b/tests/unit/actions/usage-logs-export-retry-count.test.ts @@ -13,6 +13,10 @@ vi.mock("@/lib/auth", () => { }; }); +vi.mock("@/lib/utils/timezone", () => ({ + resolveSystemTimezone: vi.fn(async () => "UTC"), +})); + vi.mock("@/lib/redis/redis-kv-store", () => ({ RedisKVStore: class MockRedisKVStore { private readonly prefix: string; @@ -361,7 +365,11 @@ describe("Usage logs CSV export retryCount", () => { const downloadResult = await downloadUsageLogsExport(jobId); expect(downloadResult.ok).toBe(true); - expect(downloadResult.data).toContain("Session ID"); - expect(downloadResult.data).toContain("job-session"); + if (!downloadResult.ok) throw new Error("expected ok download"); + expect(downloadResult.data.format).toBe("csv"); + expect(downloadResult.data.encoding).toBe("utf8"); + expect(downloadResult.data.filename).toMatch(/\.csv$/); + expect(downloadResult.data.content).toContain("Session ID"); + expect(downloadResult.data.content).toContain("job-session"); }); }); diff --git a/tests/unit/actions/usage-logs-export-xlsx.test.ts b/tests/unit/actions/usage-logs-export-xlsx.test.ts new file mode 100644 index 000000000..9c9e89f17 --- /dev/null +++ b/tests/unit/actions/usage-logs-export-xlsx.test.ts @@ -0,0 +1,181 @@ +import { strFromU8, unzipSync } from "fflate"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +const getSessionMock = vi.fn(); +const findUsageLogsWithDetailsMock = vi.fn(); +const findUsageLogsBatchMock = vi.fn(); +const findUsageLogsStatsMock = vi.fn(); +const exportStatusStore = new Map(); +const exportResultStore = new Map(); + +vi.mock("@/lib/auth", () => ({ getSession: getSessionMock })); + +vi.mock("@/lib/utils/timezone", () => ({ + resolveSystemTimezone: vi.fn(async () => "Asia/Shanghai"), +})); + +vi.mock("@/lib/redis/redis-kv-store", () => ({ + RedisKVStore: class MockRedisKVStore { + private readonly prefix: string; + constructor(options: { prefix: string }) { + this.prefix = options.prefix; + } + async set(key: string, value: T) { + if (this.prefix.includes(":status:")) { + exportStatusStore.set(key, value); + } else { + exportResultStore.set(key, value as string); + } + return true; + } + async get(key: string) { + if (this.prefix.includes(":status:")) { + return (exportStatusStore.get(key) as T | undefined) ?? null; + } + return ((exportResultStore.get(key) as T | undefined) ?? null) as T | null; + } + async delete(key: string) { + if (this.prefix.includes(":status:")) { + return exportStatusStore.delete(key); + } + return exportResultStore.delete(key); + } + }, +})); + +vi.mock("@/repository/usage-logs", () => ({ + findUsageLogSessionIdSuggestions: vi.fn(async () => []), + findUsageLogsBatch: findUsageLogsBatchMock, + findUsageLogsStats: findUsageLogsStatsMock, + findUsageLogsWithDetails: findUsageLogsWithDetailsMock, + getUsedEndpoints: vi.fn(async () => []), + getUsedModels: vi.fn(async () => []), + getUsedStatusCodes: vi.fn(async () => []), +})); + +function summary(totalRequests = 0) { + return { + totalRequests, + totalCost: 0, + totalTokens: 0, + totalInputTokens: 0, + totalOutputTokens: 0, + totalCacheCreationTokens: 0, + totalCacheReadTokens: 0, + totalCacheCreation5mTokens: 0, + totalCacheCreation1hTokens: 0, + }; +} + +function log(overrides: Record = {}) { + return { + createdAt: new Date("2026-03-16T01:00:00.000Z"), + userName: "u", + keyName: "k", + providerName: "p", + model: "m", + originalModel: "om", + endpoint: "/v1/messages", + statusCode: 200, + inputTokens: 1, + outputTokens: 2, + cacheCreation5mInputTokens: 0, + cacheCreation1hInputTokens: 0, + cacheReadInputTokens: 0, + totalTokens: 3, + costUsd: "1.500000000000000", + durationMs: 10, + sessionId: "s1", + providerChain: null, + ...overrides, + }; +} + +describe("Usage logs XLSX export", () => { + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + vi.useRealTimers(); + exportStatusStore.clear(); + exportResultStore.clear(); + getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } }); + findUsageLogsWithDetailsMock.mockResolvedValue({ logs: [], total: 1, summary: summary(1) }); + findUsageLogsBatchMock.mockResolvedValue({ logs: [], nextCursor: null, hasMore: false }); + findUsageLogsStatsMock.mockResolvedValue(summary()); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + test("async xlsx job completes and downloads a base64 workbook with two sheets", async () => { + vi.useFakeTimers(); + findUsageLogsBatchMock.mockResolvedValueOnce({ + logs: [log({ sessionId: "job-session" })], + nextCursor: null, + hasMore: false, + }); + + const { downloadUsageLogsExport, getUsageLogsExportStatus, startUsageLogsExport } = + await import("@/actions/usage-logs"); + + const startResult = await startUsageLogsExport({ format: "xlsx" }); + expect(startResult.ok).toBe(true); + if (!startResult.ok) throw new Error("start failed"); + const jobId = startResult.data.jobId; + + const queued = await getUsageLogsExportStatus(jobId); + expect(queued.ok).toBe(true); + if (!queued.ok) throw new Error("status failed"); + expect(queued.data.format).toBe("xlsx"); + + await vi.runAllTimersAsync(); + + const completed = await getUsageLogsExportStatus(jobId); + expect(completed.ok && completed.data.status).toBe("completed"); + + const download = await downloadUsageLogsExport(jobId); + expect(download.ok).toBe(true); + if (!download.ok) throw new Error("download failed"); + expect(download.data.format).toBe("xlsx"); + expect(download.data.encoding).toBe("base64"); + expect(download.data.filename).toMatch(/\.xlsx$/); + + const bytes = Buffer.from(download.data.content, "base64"); + // PK zip signature + expect(bytes[0]).toBe(0x50); + expect(bytes[1]).toBe(0x4b); + + const files = unzipSync(new Uint8Array(bytes)); + expect(Object.keys(files)).toEqual( + expect.arrayContaining(["xl/worksheets/sheet1.xml", "xl/worksheets/sheet2.xml"]) + ); + const sheet1 = strFromU8(files["xl/worksheets/sheet1.xml"]); + // 01:00 UTC -> 09:00 Asia/Shanghai, header carries the timezone + expect(sheet1).toContain("Time (Asia/Shanghai)"); + // cost rendered as a numeric cell, normalized + expect(sheet1).toContain("1.5"); + }); + + test("sync export rejects xlsx (async job only)", async () => { + const { exportUsageLogs } = await import("@/actions/usage-logs"); + const result = await exportUsageLogs({ format: "xlsx" }); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("expected rejection"); + expect(result.error).toMatch(/XLSX/); + }); + + test("sync csv export returns CSV text", async () => { + findUsageLogsBatchMock.mockResolvedValueOnce({ + logs: [log()], + nextCursor: null, + hasMore: false, + }); + const { exportUsageLogs } = await import("@/actions/usage-logs"); + const result = await exportUsageLogs({ format: "csv" }); + expect(result.ok).toBe(true); + if (!result.ok) throw new Error("export failed"); + expect(result.data).toContain("Session ID"); + expect(result.data.startsWith("")).toBe(true); + }); +}); diff --git a/tests/unit/api/v1/api-client-actions.test.ts b/tests/unit/api/v1/api-client-actions.test.ts index 5b8f29564..74c285d9f 100644 --- a/tests/unit/api/v1/api-client-actions.test.ts +++ b/tests/unit/api/v1/api-client-actions.test.ts @@ -1,5 +1,5 @@ import { Buffer } from "node:buffer"; -import { beforeEach, describe, expect, test, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { DASHBOARD_COMPAT_HEADER } from "@/lib/api/v1/_shared/constants"; import { ApiError } from "@/lib/api-client/v1/errors"; @@ -39,6 +39,11 @@ describe("v1 action compatibility client", () => { vi.clearAllMocks(); }); + // Always restore globals, even if a stubbed-fetch test throws mid-assertion. + afterEach(() => { + vi.unstubAllGlobals(); + }); + test("preserves provider edit undo metadata from response headers", async () => { patchMock.mockImplementation( async ( @@ -373,4 +378,91 @@ describe("v1 action compatibility client", () => { expect(result).not.toHaveProperty("data"); expect(result[1]?.circuitState).toBe("open"); }); + + test("wraps provider group counts in ActionResult for the dashboard consumer", async () => { + // 后端 listProviderGroups 经 actionJson() 解包,直接返回裸数组; + // provider-group-select.tsx 却按 `{ ok, data }` 消费。若 api-client 不再包一层, + // `res.ok` 永远 undefined,每次展开用户编辑面板都会误报 "获取供应商分组统计失败"。 + const groupCounts = [ + { group: "default", providerCount: 2 }, + { group: "prod", providerCount: 1 }, + ]; + getMock.mockResolvedValue(groupCounts); + + const result = await providers.getProviderGroupsWithCount(); + + expect(getMock).toHaveBeenCalledWith("/api/v1/providers/groups?include=count", { + headers: { [DASHBOARD_COMPAT_HEADER]: "1" }, + }); + expect(result).toEqual({ ok: true, data: groupCounts }); + }); + + test("maps a failed provider group counts request to a failed ActionResult", async () => { + getMock.mockRejectedValue( + new ApiError({ + status: 403, + errorCode: "auth.forbidden", + detail: "Admin access is required.", + }) + ); + + const result = await providers.getProviderGroupsWithCount(); + + expect(result).toEqual({ + ok: false, + error: "Admin access is required.", + errorCode: "auth.forbidden", + errorParams: undefined, + }); + }); + + test("wraps model suggestions in ActionResult for the autocomplete consumer", async () => { + // 与分组统计同源:use-model-suggestions.ts 检查 `res.ok && res.data`, + // 裸数组会让自动补全静默失效(不报错但永远拿不到建议模型)。 + const suggestions = ["claude-3-opus", "claude-3-sonnet"]; + getMock.mockResolvedValue(suggestions); + + const result = await providers.getModelSuggestionsByProviderGroup("default"); + + expect(getMock).toHaveBeenCalledWith( + "/api/v1/providers/model-suggestions?providerGroup=default", + { headers: { [DASHBOARD_COMPAT_HEADER]: "1" } } + ); + expect(result).toEqual({ ok: true, data: suggestions }); + }); + + test("downloadUsageLogsExport returns the response body as a Blob", async () => { + const fetchMock = vi.fn( + async () => + new Response(new Blob(["PKxlsx-bytes"]), { + status: 200, + headers: { + "Content-Type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "Content-Disposition": 'attachment; filename="usage-logs-job-9.xlsx"', + }, + }) + ); + vi.stubGlobal("fetch", fetchMock); + + const result = await usageLogs.downloadUsageLogsExport("job-9"); + + expect(fetchMock).toHaveBeenCalledWith("/api/v1/usage-logs/exports/job-9/download", { + credentials: "include", + }); + expect(result.ok).toBe(true); + if (!result.ok) throw new Error("expected ok"); + expect(result.data.blob).toBeInstanceOf(Blob); + expect(await result.data.blob.text()).toBe("PKxlsx-bytes"); + }); + + test("downloadUsageLogsExport surfaces a non-2xx download as an error result", async () => { + const fetchMock = vi.fn( + async () => new Response("nope", { status: 404, statusText: "Not Found" }) + ); + vi.stubGlobal("fetch", fetchMock); + + const result = await usageLogs.downloadUsageLogsExport("missing"); + + expect(result.ok).toBe(false); + }); }); diff --git a/tests/unit/benign-broken-pipe-error.test.ts b/tests/unit/benign-broken-pipe-error.test.ts new file mode 100644 index 000000000..6fb4a754f --- /dev/null +++ b/tests/unit/benign-broken-pipe-error.test.ts @@ -0,0 +1,124 @@ +/** + * Benign broken-pipe error detection tests (issue #1234) + * + * 验证进程级崩溃处理器使用的判定:仅把写侧、来源明确的 EPIPE 视为良性断管, + * 而 ECONNRESET / ERR_STREAM_PREMATURE_CLOSE 等来源不明的码必须保持 fail-fast, + * 避免在进程级(无请求上下文)误吞上游基础设施故障。 + */ +import { describe, expect, it } from "vitest"; +import { getBenignBrokenPipeCode, isBenignBrokenPipeError } from "@/lib/lifecycle/benign-errors"; + +describe("isBenignBrokenPipeError", () => { + describe("benign (EPIPE only — write-side, unambiguous downstream disconnect)", () => { + it("detects EPIPE (the issue #1234 write EPIPE case)", () => { + const err = new Error("write EPIPE"); + (err as NodeJS.ErrnoException).code = "EPIPE"; + expect(isBenignBrokenPipeError(err)).toBe(true); + }); + }); + + describe("cause chain", () => { + it("detects EPIPE wrapped on cause", () => { + const cause = new Error("write EPIPE"); + (cause as NodeJS.ErrnoException).code = "EPIPE"; + const err = new Error("request failed"); + (err as Error & { cause: Error }).cause = cause; + expect(isBenignBrokenPipeError(err)).toBe(true); + }); + + it("detects EPIPE nested two levels deep", () => { + const root = new Error("write EPIPE"); + (root as NodeJS.ErrnoException).code = "EPIPE"; + const mid = new Error("socket write failed"); + (mid as Error & { cause: Error }).cause = root; + const top = new Error("stream error"); + (top as Error & { cause: Error }).cause = mid; + expect(isBenignBrokenPipeError(top)).toBe(true); + }); + + it("does not loop forever on a cyclic cause chain", () => { + const a = new Error("a"); + const b = new Error("b"); + (a as Error & { cause: Error }).cause = b; + (b as Error & { cause: Error }).cause = a; + expect(isBenignBrokenPipeError(a)).toBe(false); + }); + }); + + describe("ambiguous codes are deliberately NOT benign (preserve fail-fast)", () => { + it("does NOT treat ECONNRESET as benign (may originate upstream: DB/Redis/provider)", () => { + const err = new Error("read ECONNRESET"); + (err as NodeJS.ErrnoException).code = "ECONNRESET"; + expect(isBenignBrokenPipeError(err)).toBe(false); + }); + + it("does NOT treat ERR_STREAM_PREMATURE_CLOSE as benign", () => { + const err = new Error("Premature close"); + (err as NodeJS.ErrnoException).code = "ERR_STREAM_PREMATURE_CLOSE"; + expect(isBenignBrokenPipeError(err)).toBe(false); + }); + + it("does NOT treat an upstream-nested ECONNRESET as benign", () => { + const root = new Error("read ECONNRESET"); + (root as NodeJS.ErrnoException).code = "ECONNRESET"; + const top = new Error("provider request failed"); + (top as Error & { cause: Error }).cause = root; + expect(isBenignBrokenPipeError(top)).toBe(false); + }); + }); + + describe("non-benign errors (must still fail-fast)", () => { + it("does not match a generic error without a code", () => { + expect(isBenignBrokenPipeError(new Error("Something went wrong"))).toBe(false); + }); + + it("does not match unrelated transport codes", () => { + const err = new Error("connect ECONNREFUSED"); + (err as NodeJS.ErrnoException).code = "ECONNREFUSED"; + expect(isBenignBrokenPipeError(err)).toBe(false); + }); + + it("does not match on message text alone (avoids false-benign suppression)", () => { + // 上游错误文案可能包含 "EPIPE" 字样,但没有真实的 code, + // 必须按非良性处理,确保真正的崩溃仍会退出。 + expect(isBenignBrokenPipeError(new Error("upstream said: write EPIPE"))).toBe(false); + }); + + it("handles non-Error values safely", () => { + expect(isBenignBrokenPipeError(null)).toBe(false); + expect(isBenignBrokenPipeError(undefined)).toBe(false); + expect(isBenignBrokenPipeError("EPIPE")).toBe(false); + expect(isBenignBrokenPipeError(42)).toBe(false); + }); + + it("matches a plain object carrying EPIPE but not other codes", () => { + // 非 Error 但带 code 的对象(某些 stream 错误事件 / 非 Error 拒因)也应识别。 + expect(isBenignBrokenPipeError({ code: "EPIPE" })).toBe(true); + expect(isBenignBrokenPipeError({ code: "ECONNRESET" })).toBe(false); + }); + }); +}); + +describe("getBenignBrokenPipeCode", () => { + it("returns the matched code for a top-level EPIPE", () => { + const err = new Error("write EPIPE"); + (err as NodeJS.ErrnoException).code = "EPIPE"; + expect(getBenignBrokenPipeCode(err)).toBe("EPIPE"); + }); + + it("returns the nested code so logging is accurate even when wrapped", () => { + const cause = new Error("write EPIPE"); + (cause as NodeJS.ErrnoException).code = "EPIPE"; + const err = new Error("request failed"); + (err as Error & { cause: Error }).cause = cause; + expect(getBenignBrokenPipeCode(err)).toBe("EPIPE"); + }); + + it("returns undefined for ambiguous and unrelated codes", () => { + const econn = new Error("read ECONNRESET"); + (econn as NodeJS.ErrnoException).code = "ECONNRESET"; + expect(getBenignBrokenPipeCode(econn)).toBeUndefined(); + expect(getBenignBrokenPipeCode(new Error("no code"))).toBeUndefined(); + expect(getBenignBrokenPipeCode(null)).toBeUndefined(); + }); +}); diff --git a/tests/unit/dashboard-logs-export-progress-ui.test.tsx b/tests/unit/dashboard-logs-export-progress-ui.test.tsx index 016071b68..143440c5c 100644 --- a/tests/unit/dashboard-logs-export-progress-ui.test.tsx +++ b/tests/unit/dashboard-logs-export-progress-ui.test.tsx @@ -41,6 +41,39 @@ vi.mock("sonner", () => ({ }, })); +// Render the dropdown menu inline so its items are directly clickable in happy-dom. +vi.mock("@/components/ui/dropdown-menu", () => ({ + DropdownMenu: ({ children }: { children: ReactNode }) =>
{children}
, + DropdownMenuTrigger: ({ children }: { children: ReactNode }) => <>{children}, + DropdownMenuContent: ({ children }: { children: ReactNode }) =>
{children}
, + DropdownMenuItem: ({ + children, + onSelect, + disabled, + }: { + children: ReactNode; + onSelect?: () => void; + disabled?: boolean; + }) => ( + + ), +})); + +function csvBlobResult() { + return { + ok: true as const, + data: { blob: new Blob(["Time,User\n"], { type: "text/csv" }), filename: "usage-logs.csv" }, + }; +} + +function findButtonByText(container: Element, text: string): Element | undefined { + return Array.from(container.querySelectorAll("button")).find( + (button) => (button.textContent || "").trim() === text + ); +} + vi.mock("@/app/[locale]/dashboard/logs/_components/filters/active-filters-display", () => ({ ActiveFiltersDisplay: () =>
, })); @@ -162,7 +195,7 @@ describe("UsageLogsFilters export progress UI", () => { progressPercent: 100, }, }); - downloadUsageLogsExportMock.mockResolvedValue({ ok: true, data: "\uFEFFTime,User\n" }); + downloadUsageLogsExportMock.mockResolvedValue(csvBlobResult()); const { container, unmount } = renderWithIntl( { /> ); - const exportButton = Array.from(container.querySelectorAll("button")).find( - (button) => (button.textContent || "").trim() === "Export" - ); - - await actClick(exportButton ?? null); + await actClick(findButtonByText(container, "Export as CSV") ?? null); await flushPromises(); expect(container.textContent).toContain("Exported 50 / 200"); @@ -190,6 +219,7 @@ describe("UsageLogsFilters export progress UI", () => { }); await flushPromises(); + expect(startUsageLogsExportMock).toHaveBeenCalledWith({ format: "csv" }); expect(downloadUsageLogsExportMock).toHaveBeenCalledWith("job-1"); expect(toastSuccessMock).toHaveBeenCalledWith("Export completed successfully"); expect(toastErrorMock).not.toHaveBeenCalled(); @@ -209,7 +239,7 @@ describe("UsageLogsFilters export progress UI", () => { progressPercent: 100, }, }); - downloadUsageLogsExportMock.mockResolvedValue({ ok: true, data: "\uFEFFTime,User\n" }); + downloadUsageLogsExportMock.mockResolvedValue(csvBlobResult()); const { container, unmount } = renderWithIntl( { await actClick(container.querySelector("[data-testid='request-filters']")); - const exportButton = Array.from(container.querySelectorAll("button")).find( - (button) => (button.textContent || "").trim() === "Export" + await actClick(findButtonByText(container, "Export as CSV") ?? null); + await flushPromises(); + + expect(startUsageLogsExportMock).toHaveBeenCalledWith({ + sessionId: "draft-session", + format: "csv", + }); + + unmount(); + }); + + test("exports as XLSX when the XLSX option is selected", async () => { + startUsageLogsExportMock.mockResolvedValue({ ok: true, data: { jobId: "job-3" } }); + getUsageLogsExportStatusMock.mockResolvedValueOnce({ + ok: true, + data: { + jobId: "job-3", + status: "completed", + processedRows: 1, + totalRows: 1, + progressPercent: 100, + format: "xlsx", + }, + }); + downloadUsageLogsExportMock.mockResolvedValue({ + ok: true, + data: { blob: new Blob(["PK"], { type: "application/octet-stream" }), filename: "u.xlsx" }, + }); + + const { container, unmount } = renderWithIntl( + {}} + onReset={() => {}} + /> ); - await actClick(exportButton ?? null); + await actClick(findButtonByText(container, "Export as XLSX (with summary)") ?? null); await flushPromises(); - expect(startUsageLogsExportMock).toHaveBeenCalledWith({ sessionId: "draft-session" }); + expect(startUsageLogsExportMock).toHaveBeenCalledWith({ format: "xlsx" }); unmount(); }); diff --git a/tests/unit/error-rules-list-refresh-ui.test.tsx b/tests/unit/error-rules-list-refresh-ui.test.tsx new file mode 100644 index 000000000..5a1d41a8b --- /dev/null +++ b/tests/unit/error-rules-list-refresh-ui.test.tsx @@ -0,0 +1,304 @@ +/** + * @vitest-environment happy-dom + * + * Regression: editing/toggling/deleting an error rule must refresh the list + * immediately (router.refresh) instead of leaving stale data until a manual + * page/cache refresh. Mirrors the behavior request-filters already has. + */ + +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import type { ErrorRule } from "@/repository/error-rules"; + +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +const refreshMock = vi.fn(); +const updateErrorRuleActionMock = vi.fn(async () => ({ ok: true })); +const deleteErrorRuleActionMock = vi.fn(async () => ({ ok: true })); +const createErrorRuleActionMock = vi.fn(async () => ({ ok: true })); +const refreshCacheActionMock = vi.fn(async () => ({ + ok: true, + data: { stats: { totalCount: 3 } }, +})); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ refresh: refreshMock, push: vi.fn(), replace: vi.fn() }), +})); + +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string) => key, + useTimeZone: () => "UTC", +})); + +vi.mock("sonner", () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})); + +vi.mock("@/lib/api-client/v1/actions/error-rules", () => ({ + updateErrorRuleAction: updateErrorRuleActionMock, + deleteErrorRuleAction: deleteErrorRuleActionMock, + createErrorRuleAction: createErrorRuleActionMock, + refreshCacheAction: refreshCacheActionMock, +})); + +// --- UI primitive stubs (Radix portals/providers are noise for this test) --- +vi.mock("@/components/ui/switch", () => ({ + Switch: ({ checked, onCheckedChange, "aria-label": ariaLabel }: any) => ( + + ), +})); + +vi.mock("@/components/ui/badge", () => ({ + Badge: ({ children }: any) => {children}, +})); + +vi.mock("@/components/ui/tooltip", () => ({ + Tooltip: ({ children }: any) => <>{children}, + TooltipTrigger: ({ children }: any) => <>{children}, + TooltipContent: () => null, + TooltipProvider: ({ children }: any) => <>{children}, +})); + +vi.mock("@/components/ui/dialog", () => ({ + Dialog: ({ children }: any) =>
{children}
, + DialogContent: ({ children }: any) =>
{children}
, + DialogHeader: ({ children }: any) =>
{children}
, + DialogFooter: ({ children }: any) =>
{children}
, + DialogTitle: ({ children }: any) =>
{children}
, + DialogDescription: ({ children }: any) =>
{children}
, + DialogTrigger: ({ children }: any) =>
{children}
, +})); + +vi.mock("@/components/ui/select", () => ({ + // Drivable native onValueChange?.(e.target.value)} + > + + ))} + + ), + SelectContent: () => null, + SelectItem: () => null, + SelectTrigger: () => null, + SelectValue: () => null, +})); + +vi.mock("@/components/ui/label", () => ({ + Label: ({ children }: any) => , +})); + +vi.mock("@/app/[locale]/settings/error-rules/_components/override-section", () => ({ + OverrideSection: () => null, +})); + +vi.mock("@/app/[locale]/settings/error-rules/_components/regex-tester", () => ({ + RegexTester: () => null, +})); + +const rule: ErrorRule = { + id: 42, + pattern: "boom", + category: "prompt_limit", + matchType: "contains", + description: "test rule", + overrideResponse: null, + overrideStatusCode: null, + isEnabled: true, + isDefault: false, + priority: 0, + createdAt: new Date("2026-06-01T00:00:00.000Z"), + updatedAt: new Date("2026-06-01T00:00:00.000Z"), +}; + +let container: HTMLDivElement; +let root: Root; + +async function mount(element: React.ReactNode) { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + await act(async () => { + root.render(element); + }); +} + +async function flush() { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); +} + +// Sets a React-controlled element's value via the native setter, then fires the +// event React listens to (input for text, change for select) so state updates. +function setControlledValue( + el: HTMLInputElement | HTMLSelectElement, + proto: { prototype: object }, + value: string, + eventName: "input" | "change" +) { + const setter = Object.getOwnPropertyDescriptor(proto.prototype, "value")?.set; + setter?.call(el, value); + el.dispatchEvent(new Event(eventName, { bubbles: true })); +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal( + "confirm", + vi.fn(() => true) + ); +}); + +afterEach(async () => { + await act(async () => { + root?.unmount(); + }); + container?.remove(); + vi.unstubAllGlobals(); +}); + +describe("error rules list refresh after mutation", () => { + test("toggling a rule refreshes the list", async () => { + const { RuleListTable } = await import( + "@/app/[locale]/settings/error-rules/_components/rule-list-table" + ); + + await mount(); + + const toggle = container.querySelector('button[role="switch"]') as HTMLButtonElement; + expect(toggle).toBeTruthy(); + + await act(async () => { + toggle.click(); + }); + await flush(); + + expect(updateErrorRuleActionMock).toHaveBeenCalledWith(42, { isEnabled: false }); + expect(refreshMock).toHaveBeenCalled(); + }); + + test("deleting a rule refreshes the list", async () => { + const { RuleListTable } = await import( + "@/app/[locale]/settings/error-rules/_components/rule-list-table" + ); + + await mount(); + + const deleteButton = container + .querySelector(".lucide-trash-2") + ?.closest("button") as HTMLButtonElement; + expect(deleteButton).toBeTruthy(); + + await act(async () => { + deleteButton.click(); + }); + await flush(); + + expect(deleteErrorRuleActionMock).toHaveBeenCalledWith(42); + expect(refreshMock).toHaveBeenCalled(); + }); + + test("saving an edited rule refreshes the list", async () => { + const { EditRuleDialog } = await import( + "@/app/[locale]/settings/error-rules/_components/edit-rule-dialog" + ); + + await mount(); + + const form = container.querySelector("form") as HTMLFormElement; + expect(form).toBeTruthy(); + + await act(async () => { + form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })); + }); + await flush(); + + expect(updateErrorRuleActionMock).toHaveBeenCalled(); + expect(refreshMock).toHaveBeenCalled(); + }); + + test("creating a rule refreshes the list", async () => { + const { AddRuleDialog } = await import( + "@/app/[locale]/settings/error-rules/_components/add-rule-dialog" + ); + + await mount(); + + // Drive the controlled pattern input and category select, then submit. + await act(async () => { + setControlledValue( + container.querySelector("#pattern") as HTMLInputElement, + window.HTMLInputElement, + "boom", + "input" + ); + setControlledValue( + container.querySelector('[data-testid="category-select"]') as HTMLSelectElement, + window.HTMLSelectElement, + "prompt_limit", + "change" + ); + }); + + const form = container.querySelector("form") as HTMLFormElement; + expect(form).toBeTruthy(); + + await act(async () => { + form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })); + }); + await flush(); + + expect(createErrorRuleActionMock).toHaveBeenCalled(); + expect(refreshMock).toHaveBeenCalled(); + }); + + test("refreshing the cache refreshes the list", async () => { + const { RefreshCacheButton } = await import( + "@/app/[locale]/settings/error-rules/_components/refresh-cache-button" + ); + + await mount(); + + const button = container.querySelector("button") as HTMLButtonElement; + expect(button).toBeTruthy(); + + await act(async () => { + button.click(); + }); + await flush(); + + expect(refreshCacheActionMock).toHaveBeenCalled(); + expect(refreshMock).toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/instrumentation-crash-handler.test.ts b/tests/unit/instrumentation-crash-handler.test.ts new file mode 100644 index 000000000..bda70367c --- /dev/null +++ b/tests/unit/instrumentation-crash-handler.test.ts @@ -0,0 +1,163 @@ +/** + * Instrumentation crash-handler behavior tests (issue #1234) + * + * 锁定核心回归:进程级 uncaughtException / unhandledRejection 处理器在遇到良性断管 + * (仅 EPIPE,写侧断连)时必须仅记录 warn 而不调用 process.exit(1);遇到真正的错误 + * (含来源不明的 ECONNRESET / ERR_STREAM_PREMATURE_CLOSE)时仍必须 fail-fast 退出。 + * + * 谓词 isBenignBrokenPipeError 已单独单测,这里验证 registerCrashDiagnostics 的实际接线, + * 防止未来重构(删掉早返回、反转判断、移动谓词调用)在谓词测试全绿的情况下重新引入崩溃。 + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/logger", () => ({ + logger: { + fatal: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + debug: vi.fn(), + trace: vi.fn(), + }, +})); + +import { logger } from "@/lib/logger"; +import { registerCrashDiagnostics } from "@/instrumentation"; + +type CrashHandler = (arg: unknown) => void; + +/** + * 通过 spy process.on 捕获 registerCrashDiagnostics 实际注册的处理器, + * 避免用 process.emit 触发真实进程事件(会干扰 vitest 自身的监听器)。 + */ +function captureHandlers(): { uncaughtException: CrashHandler; unhandledRejection: CrashHandler } { + const handlers: Record = {}; + const onSpy = vi.spyOn(process, "on").mockImplementation((( + event: string, + handler: CrashHandler + ) => { + if (event === "uncaughtException" || event === "unhandledRejection") { + handlers[event] = handler; + } + return process; + }) as never); + + // 重置去重标志,确保本次调用真正执行 process.on 注册 + ( + globalThis as { __CCH_CRASH_HANDLERS_REGISTERED__?: boolean } + ).__CCH_CRASH_HANDLERS_REGISTERED__ = false; + registerCrashDiagnostics(); + onSpy.mockRestore(); + + if (!handlers.uncaughtException || !handlers.unhandledRejection) { + throw new Error("crash handlers were not registered"); + } + return { + uncaughtException: handlers.uncaughtException, + unhandledRejection: handlers.unhandledRejection, + }; +} + +function makeError(code: string, message = code): NodeJS.ErrnoException { + const err = new Error(message) as NodeJS.ErrnoException; + err.code = code; + return err; +} + +describe("registerCrashDiagnostics", () => { + let exitSpy: ReturnType; + let stderrSpy: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + exitSpy = vi + .spyOn(process, "exit") + .mockImplementation(((_code?: number) => undefined) as never); + stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + // 避免在 fatal 路径写出 Node 诊断报告文件 + if (process.report && typeof process.report.writeReport === "function") { + vi.spyOn(process.report, "writeReport").mockReturnValue(""); + } + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("benign broken-pipe errors (must NOT exit)", () => { + it("uncaughtException: EPIPE is logged at warn and does not exit", () => { + const { uncaughtException } = captureHandlers(); + uncaughtException(makeError("EPIPE", "write EPIPE")); + + expect(exitSpy).not.toHaveBeenCalled(); + expect(logger.warn).toHaveBeenCalledTimes(1); + expect(logger.fatal).not.toHaveBeenCalled(); + expect(stderrSpy).not.toHaveBeenCalled(); + }); + + it("unhandledRejection: rejected Error with EPIPE does not exit", () => { + const { unhandledRejection } = captureHandlers(); + unhandledRejection(makeError("EPIPE", "write EPIPE")); + + expect(exitSpy).not.toHaveBeenCalled(); + expect(logger.warn).toHaveBeenCalledTimes(1); + expect(logger.fatal).not.toHaveBeenCalled(); + }); + + it("unhandledRejection: non-Error rejection { code: EPIPE } does not exit and logs a clean message", () => { + // 回归:reason 在 wrap 成 Error 之前判定,否则 code 会丢失,且 error 字段会变成 + // "[object Object]"(gemini / greptile 评审发现)。 + const { unhandledRejection } = captureHandlers(); + unhandledRejection({ code: "EPIPE" }); + + expect(exitSpy).not.toHaveBeenCalled(); + expect(logger.warn).toHaveBeenCalledTimes(1); + const [, meta] = (logger.warn as unknown as ReturnType).mock.calls[0]; + expect(meta.errorCode).toBe("EPIPE"); + expect(meta.error).not.toBe("[object Object]"); + }); + }); + + describe("genuine errors (must still fail-fast)", () => { + it("uncaughtException: a generic Error exits with code 1 and writes diagnostics", () => { + const { uncaughtException } = captureHandlers(); + uncaughtException(new Error("real bug")); + + expect(exitSpy).toHaveBeenCalledWith(1); + expect(logger.fatal).toHaveBeenCalledTimes(1); + expect(logger.warn).not.toHaveBeenCalled(); + // fatal 路径必须写出同步 stderr 兜底诊断,防止回归静默吞掉致命错误 + expect(stderrSpy).toHaveBeenCalled(); + }); + + it("uncaughtException: a non-benign transport code (ECONNREFUSED) exits with code 1", () => { + const { uncaughtException } = captureHandlers(); + uncaughtException(makeError("ECONNREFUSED", "connect ECONNREFUSED")); + + expect(exitSpy).toHaveBeenCalledWith(1); + expect(logger.fatal).toHaveBeenCalledTimes(1); + }); + + it.each([ + "ECONNRESET", + "ERR_STREAM_PREMATURE_CLOSE", + ])("uncaughtException: ambiguous code %s is NOT suppressed and still exits with code 1", (code) => { + // 这些码方向不明(可能来自上游 DB/Redis/provider),进程级无上下文区分, + // 必须保持 fail-fast,避免误吞真正的基础设施故障。 + const { uncaughtException } = captureHandlers(); + uncaughtException(makeError(code)); + + expect(exitSpy).toHaveBeenCalledWith(1); + expect(logger.fatal).toHaveBeenCalledTimes(1); + expect(logger.warn).not.toHaveBeenCalled(); + }); + + it("unhandledRejection: a generic rejection exits with code 1", () => { + const { unhandledRejection } = captureHandlers(); + unhandledRejection(new Error("real rejection")); + + expect(exitSpy).toHaveBeenCalledWith(1); + expect(logger.fatal).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/tests/unit/lib/request-filter-engine-reload-queue.test.ts b/tests/unit/lib/request-filter-engine-reload-queue.test.ts new file mode 100644 index 000000000..408ac3cf7 --- /dev/null +++ b/tests/unit/lib/request-filter-engine-reload-queue.test.ts @@ -0,0 +1,216 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; +import type { RequestFilter } from "@/repository/request-filters"; + +const mocks = vi.hoisted(() => { + const listeners = new Map void>>(); + + return { + getActiveRequestFilters: vi.fn(), + subscribeCacheInvalidation: vi.fn(async () => undefined), + eventEmitter: { + on(event: string, handler: (...args: unknown[]) => void) { + const current = listeners.get(event) ?? new Set<(...args: unknown[]) => void>(); + current.add(handler); + listeners.set(event, current); + }, + off(event: string, handler: (...args: unknown[]) => void) { + listeners.get(event)?.delete(handler); + }, + emit(event: string, ...args: unknown[]) { + for (const handler of listeners.get(event) ?? []) { + handler(...args); + } + }, + removeAllListeners() { + listeners.clear(); + }, + }, + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + trace: vi.fn(), + error: vi.fn(), + fatal: vi.fn(), + }, + }; +}); + +vi.mock("@/repository/request-filters", () => ({ + getActiveRequestFilters: mocks.getActiveRequestFilters, +})); + +vi.mock("@/lib/event-emitter", () => ({ + eventEmitter: mocks.eventEmitter, +})); + +vi.mock("@/lib/redis/pubsub", () => ({ + CHANNEL_REQUEST_FILTERS_UPDATED: "requestFiltersUpdated", + subscribeCacheInvalidation: mocks.subscribeCacheInvalidation, +})); + +vi.mock("@/lib/logger", () => ({ + logger: mocks.logger, +})); + +let nextId = 1; +function buildFilter(overrides?: Partial): RequestFilter { + return { + id: nextId++, + name: "filter", + description: null, + scope: "header", + action: "remove", + matchType: null, + target: "x-test-header", + replacement: null, + priority: 0, + isEnabled: true, + bindingType: "global", + providerIds: null, + groupTags: null, + ruleMode: "simple", + executionPhase: "guard", + operations: null, + createdAt: new Date("2026-06-01T00:00:00.000Z"), + updatedAt: new Date("2026-06-01T00:00:00.000Z"), + ...overrides, + }; +} + +/** Returns an array of N distinct global-guard filters. */ +function filters(n: number): RequestFilter[] { + return Array.from({ length: n }, () => buildFilter()); +} + +describe("RequestFilterEngine reload queue", () => { + afterEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + mocks.eventEmitter.removeAllListeners(); + // The engine is a globalThis singleton that survives resetModules, so its + // event listener only registers on first construction. Drop it so the next + // test re-imports a fresh engine that re-subscribes to mocks.eventEmitter. + delete (globalThis as Record).__CCH_REQUEST_FILTER_ENGINE__; + nextId = 1; + }); + + test("applies a reload requested while another reload is in-flight (not dropped)", async () => { + let resolveFirstLoad: ((value: RequestFilter[]) => void) | undefined; + + // First load is slow and returns 1 filter (the "old" snapshot). + // Second load returns 2 filters (the "new" snapshot saved by the user). + mocks.getActiveRequestFilters + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFirstLoad = resolve; + }) + ) + .mockResolvedValueOnce(filters(2)); + + const { requestFilterEngine } = await import("@/lib/request-filter-engine"); + // Allow the constructor's async event-listener wiring to settle. + await new Promise((resolve) => setTimeout(resolve, 0)); + + const firstReload = requestFilterEngine.reload(); // starts load #1 (pending) + const secondReload = requestFilterEngine.reload(); // requested mid-flight -> must queue + + // Let the dynamic import inside reload() settle so load #1 actually calls + // getActiveRequestFilters (assigning resolveFirstLoad) before we resolve it. + await new Promise((resolve) => setTimeout(resolve, 0)); + resolveFirstLoad?.(filters(1)); + await Promise.all([firstReload, secondReload]); + + // The concurrent reload must NOT be silently dropped: a second DB read runs + // and the engine ends up reflecting the newest snapshot (2 filters), not the + // stale one (1 filter). + expect(mocks.getActiveRequestFilters).toHaveBeenCalledTimes(2); + expect(requestFilterEngine.getStats().count).toBe(2); + }); + + test("a requestFiltersUpdated event during a reload triggers a queued rerun", async () => { + let resolveFirstLoad: ((value: RequestFilter[]) => void) | undefined; + + mocks.getActiveRequestFilters + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFirstLoad = resolve; + }) + ) + .mockResolvedValueOnce(filters(3)); + + const { requestFilterEngine } = await import("@/lib/request-filter-engine"); + await new Promise((resolve) => setTimeout(resolve, 0)); + + const firstReload = requestFilterEngine.reload(); + mocks.eventEmitter.emit("requestFiltersUpdated"); + + await new Promise((resolve) => setTimeout(resolve, 0)); + resolveFirstLoad?.(filters(1)); + await firstReload; + // Let the queued rerun (kicked by the event handler) settle. + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(mocks.getActiveRequestFilters).toHaveBeenCalledTimes(2); + expect(requestFilterEngine.getStats().count).toBe(3); + }); + + test("an awaited reload after an in-flight reload observes the freshest snapshot", async () => { + // Models the save path: the repository emits an event (fire-and-forget reload), + // then the action awaits its own reload. The awaited reload must resolve only + // after a pass that reflects the just-written rows. + let resolveFirstLoad: ((value: RequestFilter[]) => void) | undefined; + + mocks.getActiveRequestFilters + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFirstLoad = resolve; + }) + ) + .mockResolvedValueOnce(filters(5)); + + const { requestFilterEngine } = await import("@/lib/request-filter-engine"); + await new Promise((resolve) => setTimeout(resolve, 0)); + + // Event-driven (fire-and-forget) reload starts first. + void requestFilterEngine.reload(); + // Action's awaited reload races in while the first is still loading. + const awaitedReload = requestFilterEngine.reload(); + + await new Promise((resolve) => setTimeout(resolve, 0)); + resolveFirstLoad?.(filters(1)); + await awaitedReload; + + expect(mocks.getActiveRequestFilters).toHaveBeenCalledTimes(2); + expect(requestFilterEngine.getStats().count).toBe(5); + }); + + test("reload(false) reuses an in-flight reload without forcing a redundant rerun", async () => { + let resolveLoad: ((value: RequestFilter[]) => void) | undefined; + + // Only ONE DB read should happen: the second reload(false) must reuse the + // in-flight load instead of queueing a second pass. + mocks.getActiveRequestFilters.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveLoad = resolve; + }) + ); + + const { requestFilterEngine } = await import("@/lib/request-filter-engine"); + await new Promise((resolve) => setTimeout(resolve, 0)); + + const first = requestFilterEngine.reload(false); // starts load #1 + const second = requestFilterEngine.reload(false); // in-flight + queue=false -> reuse + + await new Promise((resolve) => setTimeout(resolve, 0)); + resolveLoad?.(filters(2)); + await Promise.all([first, second]); + + expect(mocks.getActiveRequestFilters).toHaveBeenCalledTimes(1); + expect(requestFilterEngine.getStats().count).toBe(2); + }); +}); diff --git a/tests/unit/notification/notification-queue.test.ts b/tests/unit/notification/notification-queue.test.ts new file mode 100644 index 000000000..2a8ccb03a --- /dev/null +++ b/tests/unit/notification/notification-queue.test.ts @@ -0,0 +1,456 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// 受控的依赖 mock(在 beforeEach 中通过 vi.doMock 装配,跨 resetModules 复用同一组 spy) +const mockGetNotificationSettings = vi.fn(); +const mockGenerateDailyLeaderboard = vi.fn(); +const mockGenerateCostAlerts = vi.fn(); +const mockSendWebhookMessage = vi.fn(); +const mockGetEnabledBindingsByType = vi.fn(async () => []); + +const queueAdd = vi.fn(async () => ({})); +const queueGetRepeatableJobs = vi.fn(async () => [] as Array<{ key: string }>); +const queueRemoveRepeatableByKey = vi.fn(async () => {}); + +type MockJob = { + id: string; + timestamp: number; + data: Record; + update: (data: unknown) => Promise; +}; + +class MockQueue { + processHandler: ((job: MockJob) => Promise) | null = null; + add = queueAdd; + getRepeatableJobs = queueGetRepeatableJobs; + removeRepeatableByKey = queueRemoveRepeatableByKey; + process = vi.fn((fn: (job: MockJob) => Promise) => { + this.processHandler = fn; + }); + on = vi.fn(); + close = vi.fn(async () => {}); + + constructor() { + capturedQueue = this; + } +} + +let capturedQueue: MockQueue | null = null; + +function makeSettings(overrides: Record = {}) { + return { + id: 1, + enabled: false, + useLegacyMode: true, + circuitBreakerEnabled: false, + circuitBreakerWebhook: null, + dailyLeaderboardEnabled: false, + dailyLeaderboardWebhook: null, + dailyLeaderboardTime: "18:00", + dailyLeaderboardTopN: 5, + costAlertEnabled: false, + costAlertWebhook: null, + costAlertThreshold: "0.80", + costAlertCheckInterval: 60, + cacheHitRateAlertEnabled: false, + cacheHitRateAlertWebhook: null, + cacheHitRateAlertWindowMode: "auto", + cacheHitRateAlertCheckInterval: 5, + cacheHitRateAlertHistoricalLookbackDays: 7, + cacheHitRateAlertMinEligibleRequests: 20, + cacheHitRateAlertMinEligibleTokens: 0, + cacheHitRateAlertAbsMin: "0.05", + cacheHitRateAlertDropRel: "0.3", + cacheHitRateAlertDropAbs: "0.1", + cacheHitRateAlertCooldownMinutes: 30, + cacheHitRateAlertTopN: 10, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + }; +} + +beforeEach(() => { + vi.resetModules(); + capturedQueue = null; + process.env.REDIS_URL = "redis://localhost:6379"; + + vi.doMock("bull", () => ({ default: MockQueue })); + + vi.doMock("@/repository/notifications", () => ({ + getNotificationSettings: mockGetNotificationSettings, + })); + + vi.doMock("@/repository/notification-bindings", () => ({ + getEnabledBindingsByType: mockGetEnabledBindingsByType, + getBindingById: vi.fn(async () => null), + })); + + vi.doMock("@/repository/webhook-targets", () => ({ + getWebhookTargetById: vi.fn(async () => ({ isEnabled: true })), + })); + + vi.doMock("@/lib/notification/tasks/daily-leaderboard", () => ({ + generateDailyLeaderboard: mockGenerateDailyLeaderboard, + })); + + vi.doMock("@/lib/notification/tasks/cost-alert", () => ({ + generateCostAlerts: mockGenerateCostAlerts, + })); + + vi.doMock("@/lib/notification/tasks/cache-hit-rate-alert", () => ({ + applyCacheHitRateAlertCooldownToPayload: vi.fn(), + buildCacheHitRateAlertCooldownKey: vi.fn(), + commitCacheHitRateAlertCooldown: vi.fn(), + generateCacheHitRateAlertPayload: vi.fn(), + })); + + vi.doMock("@/lib/webhook", () => ({ + buildCacheHitRateAlertMessage: vi.fn(() => ({})), + buildCircuitBreakerMessage: vi.fn(() => ({})), + buildCostAlertMessage: vi.fn(() => ({})), + buildDailyLeaderboardMessage: vi.fn(() => ({})), + sendWebhookMessage: mockSendWebhookMessage, + })); + + vi.doMock("@/lib/utils/timezone", () => ({ + resolveSystemTimezone: vi.fn(async () => "UTC"), + })); + + vi.doMock("@/lib/logger", () => ({ + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + trace: vi.fn(), + fatal: vi.fn(), + }, + })); + + mockSendWebhookMessage.mockResolvedValue({ success: true }); + mockGenerateDailyLeaderboard.mockResolvedValue({ date: "2026-06-02", entries: [] }); + mockGenerateCostAlerts.mockResolvedValue([{ providerName: "p" }]); + mockGetEnabledBindingsByType.mockResolvedValue([]); + queueGetRepeatableJobs.mockResolvedValue([]); +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +/** 初始化队列并返回捕获的 process 处理器 */ +async function loadProcessor() { + const mod = await import("@/lib/notification/notification-queue"); + // addNotificationJob 内部触发 getNotificationQueue(),注册并捕获 process 处理器 + await mod.addNotificationJob("daily-leaderboard", "https://example.com/hook", { + date: "2026-06-02", + entries: [], + } as never); + if (!capturedQueue?.processHandler) { + throw new Error("process handler not captured"); + } + return capturedQueue.processHandler; +} + +function makeJob(data: Record): MockJob { + return { id: "job-1", timestamp: 1000, data, update: vi.fn(async () => {}) }; +} + +describe("notification queue processor - daily-leaderboard", () => { + it("skips sending when the master switch is off (issue #1236)", async () => { + mockGetNotificationSettings.mockResolvedValue( + makeSettings({ enabled: false, dailyLeaderboardEnabled: true }) + ); + + const handler = await loadProcessor(); + const result = await handler( + makeJob({ type: "daily-leaderboard", webhookUrl: "https://example.com/hook" }) + ); + + expect(result).toEqual({ success: true, skipped: true }); + expect(mockGenerateDailyLeaderboard).not.toHaveBeenCalled(); + expect(mockSendWebhookMessage).not.toHaveBeenCalled(); + }); + + it("skips sending when the daily-leaderboard sub-switch is off", async () => { + mockGetNotificationSettings.mockResolvedValue( + makeSettings({ enabled: true, dailyLeaderboardEnabled: false }) + ); + + const handler = await loadProcessor(); + const result = await handler( + makeJob({ type: "daily-leaderboard", webhookUrl: "https://example.com/hook" }) + ); + + expect(result).toEqual({ success: true, skipped: true }); + expect(mockSendWebhookMessage).not.toHaveBeenCalled(); + }); + + it("sends when both master and sub-switch are enabled", async () => { + mockGetNotificationSettings.mockResolvedValue( + makeSettings({ enabled: true, dailyLeaderboardEnabled: true }) + ); + + const handler = await loadProcessor(); + const result = await handler( + makeJob({ type: "daily-leaderboard", webhookUrl: "https://example.com/hook" }) + ); + + expect(result).toEqual({ success: true }); + expect(mockGenerateDailyLeaderboard).toHaveBeenCalledTimes(1); + expect(mockSendWebhookMessage).toHaveBeenCalledTimes(1); + }); +}); + +describe("notification queue processor - cost-alert", () => { + it("skips sending when the master switch is off", async () => { + mockGetNotificationSettings.mockResolvedValue( + makeSettings({ enabled: false, costAlertEnabled: true }) + ); + + const handler = await loadProcessor(); + const result = await handler( + makeJob({ type: "cost-alert", webhookUrl: "https://example.com/hook" }) + ); + + expect(result).toEqual({ success: true, skipped: true }); + expect(mockGenerateCostAlerts).not.toHaveBeenCalled(); + expect(mockSendWebhookMessage).not.toHaveBeenCalled(); + }); + + it("skips sending when the cost-alert sub-switch is off", async () => { + mockGetNotificationSettings.mockResolvedValue( + makeSettings({ enabled: true, costAlertEnabled: false }) + ); + + const handler = await loadProcessor(); + const result = await handler( + makeJob({ type: "cost-alert", webhookUrl: "https://example.com/hook" }) + ); + + expect(result).toEqual({ success: true, skipped: true }); + expect(mockSendWebhookMessage).not.toHaveBeenCalled(); + }); + + it("sends when both master and sub-switch are enabled", async () => { + mockGetNotificationSettings.mockResolvedValue( + makeSettings({ enabled: true, costAlertEnabled: true }) + ); + + const handler = await loadProcessor(); + const result = await handler( + makeJob({ type: "cost-alert", webhookUrl: "https://example.com/hook" }) + ); + + expect(result).toEqual({ success: true }); + expect(mockGenerateCostAlerts).toHaveBeenCalledTimes(1); + expect(mockSendWebhookMessage).toHaveBeenCalledTimes(1); + }); +}); + +describe("notification queue processor - circuit-breaker", () => { + const data = { + providerName: "OpenAI", + providerId: 1, + failureCount: 5, + retryAt: "2026-06-02T12:30:00Z", + }; + + it("skips sending when the master switch is off", async () => { + mockGetNotificationSettings.mockResolvedValue( + makeSettings({ enabled: false, circuitBreakerEnabled: true }) + ); + + const handler = await loadProcessor(); + const result = await handler( + makeJob({ type: "circuit-breaker", webhookUrl: "https://example.com/hook", data }) + ); + + expect(result).toEqual({ success: true, skipped: true }); + expect(mockSendWebhookMessage).not.toHaveBeenCalled(); + }); + + it("skips sending when the circuit-breaker sub-switch is off", async () => { + mockGetNotificationSettings.mockResolvedValue( + makeSettings({ enabled: true, circuitBreakerEnabled: false }) + ); + + const handler = await loadProcessor(); + const result = await handler( + makeJob({ type: "circuit-breaker", webhookUrl: "https://example.com/hook", data }) + ); + + expect(result).toEqual({ success: true, skipped: true }); + expect(mockSendWebhookMessage).not.toHaveBeenCalled(); + }); + + it("sends when both master and sub-switch are enabled", async () => { + mockGetNotificationSettings.mockResolvedValue( + makeSettings({ enabled: true, circuitBreakerEnabled: true }) + ); + + const handler = await loadProcessor(); + const result = await handler( + makeJob({ type: "circuit-breaker", webhookUrl: "https://example.com/hook", data }) + ); + + expect(result).toEqual({ success: true }); + expect(mockSendWebhookMessage).toHaveBeenCalledTimes(1); + }); +}); + +describe("scheduleNotifications", () => { + it("removes all repeatable jobs when the master switch is off", async () => { + mockGetNotificationSettings.mockResolvedValue(makeSettings({ enabled: false })); + queueGetRepeatableJobs.mockResolvedValue([{ key: "k1" }, { key: "k2" }]); + + const { scheduleNotifications } = await import("@/lib/notification/notification-queue"); + await scheduleNotifications(); + + expect(queueRemoveRepeatableByKey).toHaveBeenCalledWith("k1"); + expect(queueRemoveRepeatableByKey).toHaveBeenCalledWith("k2"); + expect(queueAdd).not.toHaveBeenCalled(); + }); + + it("attempts to remove every repeatable job even when one removal fails (master off)", async () => { + mockGetNotificationSettings.mockResolvedValue(makeSettings({ enabled: false })); + queueGetRepeatableJobs.mockResolvedValue([{ key: "k1" }, { key: "k2" }]); + queueRemoveRepeatableByKey.mockRejectedValueOnce(new Error("redis down")); + + const { scheduleNotifications } = await import("@/lib/notification/notification-queue"); + await expect(scheduleNotifications()).resolves.toBeUndefined(); + + expect(queueRemoveRepeatableByKey).toHaveBeenCalledTimes(2); + }); + + it("aborts adding new jobs when an old repeatable cannot be removed (avoids double-firing)", async () => { + mockGetNotificationSettings.mockResolvedValue( + makeSettings({ + enabled: true, + useLegacyMode: true, + costAlertEnabled: true, + costAlertWebhook: "https://example.com/hook", + }) + ); + queueGetRepeatableJobs.mockResolvedValue([{ key: "stale" }]); + queueRemoveRepeatableByKey.mockRejectedValueOnce(new Error("redis down")); + + const { scheduleNotifications } = await import("@/lib/notification/notification-queue"); + await scheduleNotifications(); + + // 旧任务未能移除时不得新增任务,否则新旧任务会同时触发 + expect(queueAdd).not.toHaveBeenCalled(); + }); + + it("uses {every} for an interval that does not divide 60 (legacy)", async () => { + mockGetNotificationSettings.mockResolvedValue( + makeSettings({ + enabled: true, + useLegacyMode: true, + costAlertEnabled: true, + costAlertWebhook: "https://example.com/hook", + costAlertCheckInterval: 45, + }) + ); + + const { scheduleNotifications } = await import("@/lib/notification/notification-queue"); + await scheduleNotifications(); + + const costCall = queueAdd.mock.calls.find( + (c) => (c[0] as { type?: string })?.type === "cost-alert" + ); + expect((costCall?.[1] as { repeat?: unknown })?.repeat).toEqual({ every: 45 * 60 * 1000 }); + }); + + it("schedules targets-mode cost-alert with binding jobId and tz (cron path)", async () => { + mockGetNotificationSettings.mockResolvedValue( + makeSettings({ + enabled: true, + useLegacyMode: false, + costAlertEnabled: true, + costAlertCheckInterval: 30, + }) + ); + mockGetEnabledBindingsByType.mockImplementation(async (type: string) => + type === "cost_alert" + ? [{ id: 7, targetId: 3, scheduleCron: null, scheduleTimezone: "Asia/Tokyo" }] + : [] + ); + + const { scheduleNotifications } = await import("@/lib/notification/notification-queue"); + await scheduleNotifications(); + + const costCall = queueAdd.mock.calls.find( + (c) => (c[0] as { type?: string })?.type === "cost-alert" + ); + expect(costCall?.[0]).toMatchObject({ type: "cost-alert", targetId: 3, bindingId: 7 }); + expect(costCall?.[1]).toMatchObject({ + repeat: { cron: "*/30 * * * *", tz: "Asia/Tokyo" }, + jobId: "cost-alert:7", + }); + }); + + it("schedules targets-mode cost-alert with {every} for interval >= 60 (drops tz)", async () => { + mockGetNotificationSettings.mockResolvedValue( + makeSettings({ + enabled: true, + useLegacyMode: false, + costAlertEnabled: true, + costAlertCheckInterval: 120, + }) + ); + mockGetEnabledBindingsByType.mockImplementation(async (type: string) => + type === "cost_alert" + ? [{ id: 9, targetId: 4, scheduleCron: null, scheduleTimezone: "Asia/Tokyo" }] + : [] + ); + + const { scheduleNotifications } = await import("@/lib/notification/notification-queue"); + await scheduleNotifications(); + + const costCall = queueAdd.mock.calls.find( + (c) => (c[0] as { type?: string })?.type === "cost-alert" + ); + expect((costCall?.[1] as { repeat?: unknown })?.repeat).toEqual({ every: 120 * 60 * 1000 }); + }); + + it("uses {every} instead of */60 cron for cost-alert interval >= 60 (legacy)", async () => { + mockGetNotificationSettings.mockResolvedValue( + makeSettings({ + enabled: true, + useLegacyMode: true, + costAlertEnabled: true, + costAlertWebhook: "https://example.com/hook", + costAlertCheckInterval: 60, + }) + ); + + const { scheduleNotifications } = await import("@/lib/notification/notification-queue"); + await scheduleNotifications(); + + const costCall = queueAdd.mock.calls.find( + (c) => (c[0] as { type?: string })?.type === "cost-alert" + ); + expect((costCall?.[1] as { repeat?: unknown })?.repeat).toEqual({ every: 60 * 60 * 1000 }); + }); + + it("uses a step cron for cost-alert interval < 60 (legacy)", async () => { + mockGetNotificationSettings.mockResolvedValue( + makeSettings({ + enabled: true, + useLegacyMode: true, + costAlertEnabled: true, + costAlertWebhook: "https://example.com/hook", + costAlertCheckInterval: 30, + }) + ); + + const { scheduleNotifications } = await import("@/lib/notification/notification-queue"); + await scheduleNotifications(); + + const costCall = queueAdd.mock.calls.find( + (c) => (c[0] as { type?: string })?.type === "cost-alert" + ); + expect((costCall?.[1] as { repeat?: unknown })?.repeat).toEqual({ cron: "*/30 * * * *" }); + }); +}); diff --git a/tests/unit/proxy/available-models.test.ts b/tests/unit/proxy/available-models.test.ts index 86ce62ab2..d6d6fc627 100644 --- a/tests/unit/proxy/available-models.test.ts +++ b/tests/unit/proxy/available-models.test.ts @@ -204,6 +204,39 @@ describe("formatAnthropicResponse - Anthropic 格式响应", () => { const result: AnthropicModelsResponse = formatAnthropicResponse([{ id: "test-model" }]); expect(result.data[0].display_name).toBe("test-model"); }); + + test("fallback created_at 不应包含毫秒(符合官方 API 规范)", () => { + const result: AnthropicModelsResponse = formatAnthropicResponse([{ id: "test-model" }]); + expect(result.data[0].created_at).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/); + }); + + test("上游带毫秒的 created_at 应被归一化为秒级精度", () => { + const result: AnthropicModelsResponse = formatAnthropicResponse([ + { id: "claude-3-opus", createdAt: "2026-05-29T09:22:44.350Z" }, + ]); + expect(result.data[0].created_at).toBe("2026-05-29T09:22:44Z"); + }); + + test("上游已是秒级精度的 created_at 应保持不变", () => { + const result: AnthropicModelsResponse = formatAnthropicResponse([ + { id: "claude-3-opus", createdAt: "2024-02-29T00:00:00Z" }, + ]); + expect(result.data[0].created_at).toBe("2024-02-29T00:00:00Z"); + }); + + test("非 Z 时区偏移的 created_at 应被归一化为 UTC 秒级精度", () => { + const result: AnthropicModelsResponse = formatAnthropicResponse([ + { id: "claude-3-opus", createdAt: "2026-05-29T17:22:44.350+08:00" }, + ]); + expect(result.data[0].created_at).toBe("2026-05-29T09:22:44Z"); + }); + + test("无法解析的 created_at 应原样返回,不抛错", () => { + const result: AnthropicModelsResponse = formatAnthropicResponse([ + { id: "claude-3-opus", createdAt: "not-a-valid-date" }, + ]); + expect(result.data[0].created_at).toBe("not-a-valid-date"); + }); }); describe("formatGeminiResponse - Gemini 格式响应", () => { diff --git a/tests/unit/proxy/request-body-codec.test.ts b/tests/unit/proxy/request-body-codec.test.ts new file mode 100644 index 000000000..e25683d61 --- /dev/null +++ b/tests/unit/proxy/request-body-codec.test.ts @@ -0,0 +1,246 @@ +import { + brotliCompressSync, + deflateRawSync, + deflateSync, + gzipSync, + zstdCompressSync, +} from "node:zlib"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { ProxyError } from "@/app/v1/_lib/proxy/errors"; +import { + decodeRequestBody, + MAX_COMPRESSED_REQUEST_BYTES, + MAX_CONTENT_ENCODING_LAYERS, + MAX_DECOMPRESSED_REQUEST_BYTES, + parseContentEncoding, +} from "@/app/v1/_lib/proxy/request-body-codec"; + +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); + +const SAMPLE = JSON.stringify({ + model: "gpt-5-codex", + stream: true, + input: [{ role: "user", content: "hello zstd" }], +}); + +function raw(text = SAMPLE): Uint8Array { + return encoder.encode(text); +} + +function decodedText(result: { buffer: ArrayBuffer }): string { + return decoder.decode(result.buffer); +} + +describe("parseContentEncoding", () => { + it("returns empty for null/undefined/empty", () => { + expect(parseContentEncoding(null)).toEqual([]); + expect(parseContentEncoding(undefined)).toEqual([]); + expect(parseContentEncoding("")).toEqual([]); + }); + + it("lowercases, trims, and drops identity", () => { + expect(parseContentEncoding(" ZSTD ")).toEqual(["zstd"]); + expect(parseContentEncoding("identity")).toEqual([]); + expect(parseContentEncoding("gzip, identity, BR")).toEqual(["gzip", "br"]); + }); +}); + +describe("decodeRequestBody", () => { + it("round-trips zstd", () => { + const result = decodeRequestBody(zstdCompressSync(raw()), "zstd"); + expect(result.decoded).toBe(true); + expect(result.encoding).toBe("zstd"); + expect(decodedText(result)).toBe(SAMPLE); + }); + + it("round-trips gzip and x-gzip", () => { + const gz = decodeRequestBody(gzipSync(raw()), "gzip"); + expect(gz.decoded).toBe(true); + expect(decodedText(gz)).toBe(SAMPLE); + + const xgz = decodeRequestBody(gzipSync(raw()), "x-gzip"); + expect(xgz.decoded).toBe(true); + expect(decodedText(xgz)).toBe(SAMPLE); + }); + + it("round-trips brotli", () => { + const result = decodeRequestBody(brotliCompressSync(raw()), "br"); + expect(result.decoded).toBe(true); + expect(decodedText(result)).toBe(SAMPLE); + }); + + it("round-trips zlib-wrapped deflate", () => { + const result = decodeRequestBody(deflateSync(raw()), "deflate"); + expect(result.decoded).toBe(true); + expect(decodedText(result)).toBe(SAMPLE); + }); + + it("round-trips raw (headerless) deflate via fallback", () => { + const result = decodeRequestBody(deflateRawSync(raw()), "deflate"); + expect(result.decoded).toBe(true); + expect(decodedText(result)).toBe(SAMPLE); + }); + + it("is case-insensitive", () => { + const result = decodeRequestBody(gzipSync(raw()), "GZip"); + expect(result.decoded).toBe(true); + expect(decodedText(result)).toBe(SAMPLE); + }); + + it("caps content-encoding to a single layer", () => { + expect(MAX_CONTENT_ENCODING_LAYERS).toBe(1); + }); + + it("rejects multi-layer content-encoding chains with ProxyError(400)", () => { + // Even all-supported multi-layer chains are rejected: real clients never send them + // and they amplify synchronous decompression cost. + const layered = gzipSync(gzipSync(raw())); + try { + decodeRequestBody(layered, "gzip, gzip"); + throw new Error("expected decodeRequestBody to throw"); + } catch (err) { + expect(err).toBeInstanceOf(ProxyError); + expect((err as ProxyError).statusCode).toBe(400); + } + }); + + it("passes through when no content-encoding", () => { + const result = decodeRequestBody(raw(), null); + expect(result.decoded).toBe(false); + expect(result.encoding).toBeNull(); + expect(decodedText(result)).toBe(SAMPLE); + }); + + it("passes through identity", () => { + const result = decodeRequestBody(raw(), "identity"); + expect(result.decoded).toBe(false); + expect(decodedText(result)).toBe(SAMPLE); + }); + + it("passes through an empty body even with an encoding header", () => { + const result = decodeRequestBody(new Uint8Array(0), "zstd"); + expect(result.decoded).toBe(false); + expect(result.decodedByteLength).toBe(0); + }); + + it("passes through unsupported encodings untouched", () => { + const result = decodeRequestBody(raw(), "snappy"); + expect(result.decoded).toBe(false); + expect(result.encoding).toBeNull(); + expect(decodedText(result)).toBe(SAMPLE); + }); + + it("accepts ArrayBuffer input and returns an independent ArrayBuffer", () => { + const gz = gzipSync(raw()); + const ab = gz.buffer.slice(gz.byteOffset, gz.byteOffset + gz.byteLength); + const result = decodeRequestBody(ab, "gzip"); + expect(result.decoded).toBe(true); + expect(result.buffer).toBeInstanceOf(ArrayBuffer); + // Decoded output is freshly allocated, never the input compressed buffer. + expect(result.buffer).not.toBe(ab); + expect(decodedText(result)).toBe(SAMPLE); + }); + + it("throws ProxyError(413) when decompressed output exceeds the cap (bomb guard)", () => { + const bomb = gzipSync(Buffer.alloc(1024 * 1024, 0)); // 1MB of zeros -> tiny gzip + expect(bomb.byteLength).toBeLessThan(1024 * 1024); + try { + decodeRequestBody(bomb, "gzip", { maxOutputBytes: 1024 }); + throw new Error("expected decodeRequestBody to throw"); + } catch (err) { + expect(err).toBeInstanceOf(ProxyError); + expect((err as ProxyError).statusCode).toBe(413); + } + }); + + it("throws ProxyError(400) on a corrupt compressed stream", () => { + const garbage = encoder.encode("this is definitely not a gzip stream"); + try { + decodeRequestBody(garbage, "gzip"); + throw new Error("expected decodeRequestBody to throw"); + } catch (err) { + expect(err).toBeInstanceOf(ProxyError); + expect((err as ProxyError).statusCode).toBe(400); + } + }); + + it("exposes a sane default decompression cap", () => { + expect(MAX_DECOMPRESSED_REQUEST_BYTES).toBe(100 * 1024 * 1024); + }); + + it("defaults the compressed-input cap to the decompressed ceiling (regression-free)", () => { + // Real compression never grows the body, so a legitimate compressed request is always + // <= its decompressed size; matching the output ceiling avoids rejecting large compressed + // requests that the plaintext/output path would accept. + expect(MAX_COMPRESSED_REQUEST_BYTES).toBe(MAX_DECOMPRESSED_REQUEST_BYTES); + expect(MAX_COMPRESSED_REQUEST_BYTES).toBe(100 * 1024 * 1024); + }); + + it("throws ProxyError(413) when the compressed input exceeds the cap, before decompressing", () => { + // A valid gzip body that decodes fine, but whose compressed size exceeds the input cap. + const body = gzipSync(raw()); + try { + decodeRequestBody(body, "gzip", { maxCompressedBytes: 1 }); + throw new Error("expected decodeRequestBody to throw"); + } catch (err) { + expect(err).toBeInstanceOf(ProxyError); + expect((err as ProxyError).statusCode).toBe(413); + expect((err as ProxyError).message).toContain("Compressed request body"); + } + }); + + it("does not apply the compressed cap to unsupported encodings (still passes through)", () => { + // Unsupported encodings are passed through untouched even if larger than the compressed cap. + const result = decodeRequestBody(raw(), "snappy", { maxCompressedBytes: 1 }); + expect(result.decoded).toBe(false); + expect(result.encoding).toBeNull(); + expect(decodedText(result)).toBe(SAMPLE); + }); + + it("does not apply the compressed cap to an empty body", () => { + const result = decodeRequestBody(new Uint8Array(0), "zstd", { maxCompressedBytes: 1 }); + expect(result.decoded).toBe(false); + expect(result.decodedByteLength).toBe(0); + }); + + it("allows a supported body at or under the compressed cap", () => { + const body = gzipSync(raw()); + const result = decodeRequestBody(body, "gzip", { maxCompressedBytes: body.byteLength }); + expect(result.decoded).toBe(true); + expect(decodedText(result)).toBe(SAMPLE); + }); +}); + +describe("decodeRequestBody env-configurable limits", () => { + const ORIGINAL_ENV = { ...process.env }; + + afterEach(() => { + process.env = { ...ORIGINAL_ENV }; + vi.resetModules(); + }); + + it("honors MAX_DECOMPRESSED_REQUEST_BYTES override", async () => { + process.env.MAX_DECOMPRESSED_REQUEST_BYTES = String(4 * 1024 * 1024); + vi.resetModules(); + const mod = await import("@/app/v1/_lib/proxy/request-body-codec"); + expect(mod.MAX_DECOMPRESSED_REQUEST_BYTES).toBe(4 * 1024 * 1024); + }); + + it("honors MAX_COMPRESSED_REQUEST_BYTES override", async () => { + process.env.MAX_COMPRESSED_REQUEST_BYTES = String(2 * 1024 * 1024); + vi.resetModules(); + const mod = await import("@/app/v1/_lib/proxy/request-body-codec"); + expect(mod.MAX_COMPRESSED_REQUEST_BYTES).toBe(2 * 1024 * 1024); + }); + + it("falls back to defaults for invalid/non-positive env values", async () => { + process.env.MAX_DECOMPRESSED_REQUEST_BYTES = "not-a-number"; + process.env.MAX_COMPRESSED_REQUEST_BYTES = "-5"; + vi.resetModules(); + const mod = await import("@/app/v1/_lib/proxy/request-body-codec"); + expect(mod.MAX_DECOMPRESSED_REQUEST_BYTES).toBe(100 * 1024 * 1024); + // Falls back to the decompressed default (100MB) since the compressed default tracks it. + expect(mod.MAX_COMPRESSED_REQUEST_BYTES).toBe(100 * 1024 * 1024); + }); +}); diff --git a/tests/unit/proxy/session-request-decode.test.ts b/tests/unit/proxy/session-request-decode.test.ts new file mode 100644 index 000000000..9523dd3eb --- /dev/null +++ b/tests/unit/proxy/session-request-decode.test.ts @@ -0,0 +1,152 @@ +import type { Context } from "hono"; +import { gzipSync, zstdCompressSync } from "node:zlib"; +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@/repository/model-price", () => ({ + findLatestPriceByModel: vi.fn(), +})); + +vi.mock("@/repository/system-config", () => ({ + getSystemSettings: vi.fn(), +})); + +import { ProxySession } from "@/app/v1/_lib/proxy/session"; + +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); + +/** + * Minimal Hono Context stub covering the surface `ProxySession.fromContext` + * touches: method, url, header() (all + by-name), and raw Request. + */ +function makeContext( + url: string, + headers: Record, + body: Uint8Array | string +): Context { + const req = new Request(url, { method: "POST", headers, body }); + return { + req: { + method: "POST", + url, + raw: req, + header: (name?: string) => { + if (name === undefined) { + const all: Record = {}; + req.headers.forEach((value, key) => { + all[key] = value; + }); + return all; + } + return req.headers.get(name) ?? undefined; + }, + }, + } as unknown as Context; +} + +describe("ProxySession.fromContext request body decompression", () => { + it("decompresses a zstd codex /v1/responses body and strips content-encoding", async () => { + const payload = JSON.stringify({ + model: "gpt-5-codex", + stream: true, + input: [{ role: "user", content: "ping" }], + }); + const ctx = makeContext( + "https://hub.test/v1/responses", + { "content-type": "application/json", "content-encoding": "zstd" }, + zstdCompressSync(encoder.encode(payload)) + ); + + const session = await ProxySession.fromContext(ctx); + + expect(session.request.message.model).toBe("gpt-5-codex"); + expect(session.request.message.stream).toBe(true); + expect(session.request.buffer).toBeDefined(); + expect(decoder.decode(session.request.buffer)).toBe(payload); + // Upstream must not be told the (now plaintext) body is still zstd-encoded. + expect(session.headers.get("content-encoding")).toBeNull(); + }); + + it("decompresses a gzip /v1/messages body", async () => { + const payload = JSON.stringify({ + model: "claude-sonnet-4-5", + messages: [{ role: "user", content: "hi" }], + }); + const ctx = makeContext( + "https://hub.test/v1/messages", + { "content-type": "application/json", "content-encoding": "gzip" }, + gzipSync(encoder.encode(payload)) + ); + + const session = await ProxySession.fromContext(ctx); + + expect(session.request.message.model).toBe("claude-sonnet-4-5"); + expect(decoder.decode(session.request.buffer)).toBe(payload); + expect(session.headers.get("content-encoding")).toBeNull(); + }); + + it("decompresses for the raw-passthrough /v1/responses/compact endpoint", async () => { + const payload = JSON.stringify({ model: "gpt-5-codex", input: "compact me" }); + const ctx = makeContext( + "https://hub.test/v1/responses/compact", + { "content-type": "application/json", "content-encoding": "zstd" }, + zstdCompressSync(encoder.encode(payload)) + ); + + const session = await ProxySession.fromContext(ctx); + + // Raw passthrough forwards session.request.buffer verbatim -> must be plaintext. + expect(decoder.decode(session.request.buffer)).toBe(payload); + expect(session.headers.get("content-encoding")).toBeNull(); + }); + + it("leaves uncompressed requests untouched", async () => { + const payload = JSON.stringify({ model: "gpt-5-codex", input: "plain" }); + const ctx = makeContext( + "https://hub.test/v1/responses", + { "content-type": "application/json" }, + encoder.encode(payload) + ); + + const session = await ProxySession.fromContext(ctx); + + expect(session.request.message.model).toBe("gpt-5-codex"); + expect(decoder.decode(session.request.buffer)).toBe(payload); + expect(session.headers.get("content-encoding")).toBeNull(); + }); + + it("preserves content-encoding for unsupported encodings (transparent passthrough)", async () => { + const payload = JSON.stringify({ model: "gpt-5-codex", input: "exotic" }); + const ctx = makeContext( + "https://hub.test/v1/responses", + { "content-type": "application/json", "content-encoding": "snappy" }, + encoder.encode(payload) + ); + + const session = await ProxySession.fromContext(ctx); + + // We could not decode it, so we must not strip the header: forward as-is. + expect(session.headers.get("content-encoding")).toBe("snappy"); + }); + + it("surfaces a ProxyError(400) when a declared-compressed body is corrupt", async () => { + const ctx = makeContext( + "https://hub.test/v1/responses", + { "content-type": "application/json", "content-encoding": "gzip" }, + encoder.encode("this is not a valid gzip stream") + ); + + await expect(ProxySession.fromContext(ctx)).rejects.toMatchObject({ statusCode: 400 }); + }); + + it("surfaces a ProxyError(400) when the content-encoding chain has too many layers", async () => { + const payload = JSON.stringify({ model: "gpt-5-codex", input: "x" }); + const ctx = makeContext( + "https://hub.test/v1/responses", + { "content-type": "application/json", "content-encoding": "gzip, gzip, gzip, gzip" }, + gzipSync(encoder.encode(payload)) + ); + + await expect(ProxySession.fromContext(ctx)).rejects.toMatchObject({ statusCode: 400 }); + }); +}); diff --git a/tests/unit/settings/providers/provider-form-endpoint-pool.test.tsx b/tests/unit/settings/providers/provider-form-endpoint-pool.test.tsx index ac6ea6b6e..1a1602b0f 100644 --- a/tests/unit/settings/providers/provider-form-endpoint-pool.test.tsx +++ b/tests/unit/settings/providers/provider-form-endpoint-pool.test.tsx @@ -31,7 +31,7 @@ const providersActionMocks = vi.hoisted(() => ({ removeProvider: vi.fn(async () => ({ ok: true })), getUnmaskedProviderKey: vi.fn(async () => ({ ok: true, data: { key: "test-key" } })), getProviderTestPresets: vi.fn(async () => ({ ok: true, data: [] })), - getModelSuggestionsByProviderGroup: vi.fn(async () => []), + getModelSuggestionsByProviderGroup: vi.fn(async () => ({ ok: true, data: [] })), fetchUpstreamModels: vi.fn(async () => ({ ok: true, data: { models: [] } })), })); vi.mock("@/actions/providers", () => providersActionMocks); diff --git a/tests/unit/settings/providers/provider-form-total-limit-ui.test.tsx b/tests/unit/settings/providers/provider-form-total-limit-ui.test.tsx index 8fd565a2b..571affb1c 100644 --- a/tests/unit/settings/providers/provider-form-total-limit-ui.test.tsx +++ b/tests/unit/settings/providers/provider-form-total-limit-ui.test.tsx @@ -42,7 +42,7 @@ const providersActionMocks = vi.hoisted(() => ({ removeProvider: vi.fn(async (_providerId: number) => ({ ok: true })), getUnmaskedProviderKey: vi.fn(async () => ({ ok: true, data: { key: "test-key" } })), getProviderTestPresets: vi.fn(async () => ({ ok: true, data: [] })), - getModelSuggestionsByProviderGroup: vi.fn(async () => []), + getModelSuggestionsByProviderGroup: vi.fn(async () => ({ ok: true, data: [] })), fetchUpstreamModels: vi.fn(async () => ({ ok: true, data: { models: [] } })), })); vi.mock("@/actions/providers", () => providersActionMocks); diff --git a/tests/unit/usage-logs/export-csv.test.ts b/tests/unit/usage-logs/export-csv.test.ts new file mode 100644 index 000000000..6c5dccfb3 --- /dev/null +++ b/tests/unit/usage-logs/export-csv.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, test } from "vitest"; +import type { UsageLogRow } from "@/repository/usage-logs"; +import { buildCsvHeaderLine, buildCsvRows, escapeCsvField } from "@/lib/usage-logs/export/csv"; +import { buildDetailHeaders } from "@/lib/usage-logs/export/columns"; + +function makeLog(overrides: Partial = {}): UsageLogRow { + return { + id: 1, + createdAt: new Date("2026-06-03T12:34:56.000Z"), + sessionId: "s1", + requestSequence: 1, + userName: "alice", + keyName: "key-1", + providerName: "anthropic", + model: "claude", + originalModel: "claude-orig", + actualResponseModel: null, + endpoint: "/v1/messages", + statusCode: 200, + inputTokens: 10, + outputTokens: 20, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 5, + cacheCreation5mInputTokens: 1, + cacheCreation1hInputTokens: 2, + cacheTtlApplied: null, + totalTokens: 38, + costUsd: "1.500000000000000", + costMultiplier: null, + groupCostMultiplier: null, + costBreakdown: null, + durationMs: 123, + ttfbMs: null, + errorMessage: null, + providerChain: null, + blockedBy: null, + blockedReason: null, + userAgent: null, + clientIp: null, + messagesCount: null, + context1mApplied: null, + swapCacheTtlApplied: null, + specialSettings: null, + ...overrides, + }; +} + +const HEADER = buildDetailHeaders("UTC"); +const TIME_IDX = 0; +const STATUS_IDX = HEADER.indexOf("Status Code"); +const COST_IDX = HEADER.indexOf("Cost (USD)"); +const DURATION_IDX = HEADER.indexOf("Duration (ms)"); + +describe("buildCsvHeaderLine", () => { + test("annotates the time column with the timezone", () => { + expect(buildCsvHeaderLine("Asia/Shanghai").split(",")[TIME_IDX]).toBe("Time (Asia/Shanghai)"); + expect(buildCsvHeaderLine("UTC").split(",")[TIME_IDX]).toBe("Time (UTC)"); + }); +}); + +describe("buildCsvRows", () => { + test("renders the timestamp in the requested timezone (no UTC Z suffix)", () => { + const [row] = buildCsvRows([makeLog()], "Asia/Shanghai"); + const cells = row.split(","); + // 12:34:56 UTC -> 20:34:56 in Asia/Shanghai (+08:00) + expect(cells[TIME_IDX]).toBe("2026-06-03 20:34:56"); + expect(cells[TIME_IDX]).not.toContain("Z"); + }); + + test("normalizes the cost so Excel reads it as a number (trailing zeros gone)", () => { + const [row] = buildCsvRows([makeLog({ costUsd: "1.500000000000000" })], "UTC"); + expect(row.split(",")[COST_IDX]).toBe("1.5"); + }); + + test("caps 16-significant-digit costs to Excel's 15-digit ceiling", () => { + const [row] = buildCsvRows([makeLog({ costUsd: "1.234567890123456" })], "UTC"); + expect(row.split(",")[COST_IDX]).toBe("1.23456789012346"); + }); + + test("blank status code / duration stay blank; null cost becomes 0", () => { + const [row] = buildCsvRows( + [makeLog({ statusCode: null, durationMs: null, costUsd: null })], + "UTC" + ); + const cells = row.split(","); + expect(cells[STATUS_IDX]).toBe(""); + expect(cells[DURATION_IDX]).toBe(""); + expect(cells[COST_IDX]).toBe("0"); + }); + + test("null timestamp renders as an empty cell", () => { + const [row] = buildCsvRows([makeLog({ createdAt: null })], "UTC"); + expect(row.split(",")[TIME_IDX]).toBe(""); + }); + + test("invalid Date timestamp renders empty (no RangeError crash)", () => { + const [row] = buildCsvRows([makeLog({ createdAt: new Date(Number.NaN) })], "UTC"); + expect(row.split(",")[TIME_IDX]).toBe(""); + }); + + test("retry count is derived from the provider chain", () => { + const retryIdx = HEADER.indexOf("Retry Count"); + const [row] = buildCsvRows( + [ + makeLog({ + providerChain: [ + { reason: "initial_selection" }, + { reason: "retry_failed", attemptNumber: 1 }, + { reason: "retry_success", statusCode: 200, attemptNumber: 1 }, + ] as UsageLogRow["providerChain"], + }), + ], + "UTC" + ); + expect(row.split(",")[retryIdx]).toBe("1"); + }); +}); + +describe("escapeCsvField", () => { + test("neutralizes formula injection regardless of leading whitespace", () => { + expect(escapeCsvField("=1+1")).toBe("'=1+1"); + // a tab does not trigger CSV quoting, so only the leading-quote guard applies + expect(escapeCsvField(" \t@SUM(A1:A2)")).toBe("' \t@SUM(A1:A2)"); + expect(escapeCsvField("+2+2")).toBe("'+2+2"); + }); + + test("quotes fields containing commas or quotes", () => { + expect(escapeCsvField("a,b")).toBe('"a,b"'); + expect(escapeCsvField('a"b')).toBe('"a""b"'); + expect(escapeCsvField("plain")).toBe("plain"); + }); +}); diff --git a/tests/unit/usage-logs/export-numeric.test.ts b/tests/unit/usage-logs/export-numeric.test.ts new file mode 100644 index 000000000..82725218a --- /dev/null +++ b/tests/unit/usage-logs/export-numeric.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, test } from "vitest"; +import { normalizeDecimalForSpreadsheet, toFiniteNumber } from "@/lib/usage-logs/export/numeric"; + +describe("toFiniteNumber", () => { + test("parses numeric strings", () => { + expect(toFiniteNumber("1.5")).toBe(1.5); + expect(toFiniteNumber("0")).toBe(0); + expect(toFiniteNumber(42)).toBe(42); + }); + + test("returns null for empty / nullish / non-numeric", () => { + expect(toFiniteNumber("")).toBeNull(); + expect(toFiniteNumber(" ")).toBeNull(); + expect(toFiniteNumber(null)).toBeNull(); + expect(toFiniteNumber(undefined)).toBeNull(); + expect(toFiniteNumber("abc")).toBeNull(); + expect(toFiniteNumber(Number.NaN)).toBeNull(); + expect(toFiniteNumber(Number.POSITIVE_INFINITY)).toBeNull(); + }); + + test("returns null for unexpected non-string/number types (no .trim() crash)", () => { + expect(toFiniteNumber(true as unknown as string)).toBeNull(); + expect(toFiniteNumber({} as unknown as string)).toBeNull(); + expect(toFiniteNumber([] as unknown as string)).toBeNull(); + }); +}); + +describe("normalizeDecimalForSpreadsheet", () => { + test("strips trailing zeros so Excel parses the value as a number", () => { + // numeric(21,15) always pads to 15 decimals -> Excel sees 16 significant + // digits and falls back to text. Trimming makes it a clean number again. + expect(normalizeDecimalForSpreadsheet("1.500000000000000")).toBe("1.5"); + expect(normalizeDecimalForSpreadsheet("0.001000000000000")).toBe("0.001"); + }); + + test("caps to 15 significant digits (Excel's precision ceiling)", () => { + expect(normalizeDecimalForSpreadsheet("1.234567890123456")).toBe("1.23456789012346"); + expect(normalizeDecimalForSpreadsheet("12.3456789012345678")).toBe("12.3456789012346"); + }); + + test("preserves small values whose leading digit is 0", () => { + expect(normalizeDecimalForSpreadsheet("0.000123456789012345")).toBe("0.000123456789012345"); + }); + + test("never emits scientific notation", () => { + expect(normalizeDecimalForSpreadsheet(1e-12)).toBe("0.000000000001"); + expect(normalizeDecimalForSpreadsheet("0.000000000123456")).toBe("0.000000000123456"); + expect(normalizeDecimalForSpreadsheet(1e-12)).not.toContain("e"); + }); + + test("nullish / empty / non-finite collapse to 0", () => { + expect(normalizeDecimalForSpreadsheet(null)).toBe("0"); + expect(normalizeDecimalForSpreadsheet(undefined)).toBe("0"); + expect(normalizeDecimalForSpreadsheet("")).toBe("0"); + expect(normalizeDecimalForSpreadsheet("not-a-number")).toBe("0"); + expect(normalizeDecimalForSpreadsheet("0")).toBe("0"); + expect(normalizeDecimalForSpreadsheet(0)).toBe("0"); + }); + + test("passes through plain integers and decimals unchanged", () => { + expect(normalizeDecimalForSpreadsheet("123456.789")).toBe("123456.789"); + expect(normalizeDecimalForSpreadsheet("42")).toBe("42"); + }); +}); diff --git a/tests/unit/usage-logs/export-summary.test.ts b/tests/unit/usage-logs/export-summary.test.ts new file mode 100644 index 000000000..b0ac2309a --- /dev/null +++ b/tests/unit/usage-logs/export-summary.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, test } from "vitest"; +import type { UsageLogRow } from "@/repository/usage-logs"; +import { buildUsageLogsSummary } from "@/lib/usage-logs/export/summary"; + +function makeLog(overrides: Partial = {}): UsageLogRow { + return { + id: 1, + createdAt: new Date("2026-06-03T12:00:00.000Z"), + sessionId: "s1", + requestSequence: 1, + userName: "alice", + keyName: "key-1", + providerName: "anthropic", + model: "claude", + originalModel: null, + actualResponseModel: null, + endpoint: "/v1/messages", + statusCode: 200, + inputTokens: 10, + outputTokens: 20, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 5, + cacheCreation5mInputTokens: 1, + cacheCreation1hInputTokens: 2, + cacheTtlApplied: null, + totalTokens: 38, + costUsd: "0.5", + costMultiplier: null, + groupCostMultiplier: null, + costBreakdown: null, + durationMs: 100, + ttfbMs: null, + errorMessage: null, + providerChain: null, + blockedBy: null, + blockedReason: null, + userAgent: null, + clientIp: null, + messagesCount: null, + context1mApplied: null, + swapCacheTtlApplied: null, + specialSettings: null, + ...overrides, + }; +} + +describe("buildUsageLogsSummary", () => { + test("single-day data is bucketed by hour (in the system timezone)", () => { + const logs = [ + // 12:00 UTC -> 20:00 Asia/Shanghai + makeLog({ createdAt: new Date("2026-06-03T12:00:00.000Z"), costUsd: "0.5" }), + makeLog({ createdAt: new Date("2026-06-03T12:30:00.000Z"), costUsd: "0.5" }), + // 13:10 UTC -> 21:10 Asia/Shanghai + makeLog({ createdAt: new Date("2026-06-03T13:10:00.000Z"), costUsd: "1" }), + ]; + const summary = buildUsageLogsSummary(logs, "Asia/Shanghai"); + + expect(summary.granularity).toBe("hourly"); + expect(summary.rows.map((r) => r.period)).toEqual(["2026-06-03 20:00", "2026-06-03 21:00"]); + expect(summary.rows[0].requests).toBe(2); + expect(summary.rows[0].cost).toBeCloseTo(1, 10); + expect(summary.rows[1].requests).toBe(1); + expect(summary.total.requests).toBe(3); + expect(summary.total.cost).toBeCloseTo(2, 10); + expect(summary.total.inputTokens).toBe(30); + expect(summary.total.totalTokens).toBe(114); + }); + + test("multi-day data is bucketed by day", () => { + const logs = [ + makeLog({ createdAt: new Date("2026-06-03T12:00:00.000Z") }), + makeLog({ createdAt: new Date("2026-06-04T12:00:00.000Z") }), + makeLog({ createdAt: new Date("2026-06-04T18:00:00.000Z") }), + ]; + const summary = buildUsageLogsSummary(logs, "UTC"); + + expect(summary.granularity).toBe("daily"); + expect(summary.rows.map((r) => r.period)).toEqual(["2026-06-03", "2026-06-04"]); + expect(summary.rows[1].requests).toBe(2); + expect(summary.total.requests).toBe(3); + }); + + test("day boundaries follow the timezone, not UTC", () => { + // 23:30 UTC on 06-03 is 07:30 on 06-04 in Asia/Shanghai -> two distinct days + const logs = [ + makeLog({ createdAt: new Date("2026-06-03T12:00:00.000Z") }), + makeLog({ createdAt: new Date("2026-06-03T23:30:00.000Z") }), + ]; + const summary = buildUsageLogsSummary(logs, "Asia/Shanghai"); + expect(summary.granularity).toBe("daily"); + expect(summary.rows.map((r) => r.period)).toEqual(["2026-06-03", "2026-06-04"]); + }); + + test("empty input yields a zeroed total and no rows", () => { + const summary = buildUsageLogsSummary([], "UTC"); + expect(summary.granularity).toBe("hourly"); + expect(summary.rows).toEqual([]); + expect(summary.total.requests).toBe(0); + expect(summary.total.cost).toBe(0); + }); + + test("invalid Date rows fall into the Unknown bucket without crashing", () => { + const summary = buildUsageLogsSummary( + [ + makeLog({ createdAt: new Date("2026-06-03T12:00:00.000Z") }), + makeLog({ createdAt: new Date(Number.NaN) }), + makeLog({ createdAt: null }), + ], + "UTC" + ); + expect(summary.total.requests).toBe(3); + expect(summary.rows.some((r) => r.period === "Unknown")).toBe(true); + const unknown = summary.rows.find((r) => r.period === "Unknown"); + expect(unknown?.requests).toBe(2); + }); +}); diff --git a/tests/unit/usage-logs/export-xlsx.test.ts b/tests/unit/usage-logs/export-xlsx.test.ts new file mode 100644 index 000000000..a2178a69e --- /dev/null +++ b/tests/unit/usage-logs/export-xlsx.test.ts @@ -0,0 +1,199 @@ +import { strFromU8, unzipSync } from "fflate"; +import { describe, expect, test } from "vitest"; +import type { UsageLogRow } from "@/repository/usage-logs"; +import { buildUsageLogsXlsx, columnRef } from "@/lib/usage-logs/export/xlsx"; + +function makeLog(overrides: Partial = {}): UsageLogRow { + return { + id: 1, + createdAt: new Date("2026-06-03T12:34:56.000Z"), + sessionId: "s1", + requestSequence: 1, + userName: "alice", + keyName: "key-1", + providerName: "anthropic", + model: "claude", + originalModel: "claude-orig", + actualResponseModel: null, + endpoint: "/v1/messages", + statusCode: 200, + inputTokens: 10, + outputTokens: 20, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 5, + cacheCreation5mInputTokens: 1, + cacheCreation1hInputTokens: 2, + cacheTtlApplied: null, + totalTokens: 38, + costUsd: "1.500000000000000", + costMultiplier: null, + groupCostMultiplier: null, + costBreakdown: null, + durationMs: 123, + ttfbMs: null, + errorMessage: null, + providerChain: null, + blockedBy: null, + blockedReason: null, + userAgent: null, + clientIp: null, + messagesCount: null, + context1mApplied: null, + swapCacheTtlApplied: null, + specialSettings: null, + ...overrides, + }; +} + +function unzip(bytes: Uint8Array): Record { + const files = unzipSync(bytes); + const out: Record = {}; + for (const [name, content] of Object.entries(files)) { + out[name] = strFromU8(content); + } + return out; +} + +/** Extract the inner XML of a cell by its A1 reference. */ +function cell(sheetXml: string, ref: string): string | null { + const match = sheetXml.match(new RegExp(`]*?(?:/>|>(.*?))`)); + if (!match) return null; + return match[0]; +} + +const COST_COL = columnRef(14); // O +const TIME_COL = columnRef(0); // A +const MODEL_COL = columnRef(4); // E +const STATUS_COL = columnRef(7); // H + +describe("buildUsageLogsXlsx", () => { + test("produces a valid two-sheet workbook package", async () => { + const files = unzip(await buildUsageLogsXlsx([makeLog()], "UTC")); + expect(Object.keys(files)).toEqual( + expect.arrayContaining([ + "[Content_Types].xml", + "_rels/.rels", + "xl/workbook.xml", + "xl/_rels/workbook.xml.rels", + "xl/styles.xml", + "xl/worksheets/sheet1.xml", + "xl/worksheets/sheet2.xml", + ]) + ); + expect(files["xl/workbook.xml"]).toContain('name="Usage Logs"'); + }); + + test("cost is a numeric cell (not text) and normalized for Excel", async () => { + const files = unzip( + await buildUsageLogsXlsx([makeLog({ costUsd: "1.500000000000000" })], "UTC") + ); + const costCell = cell(files["xl/worksheets/sheet1.xml"], `${COST_COL}2`) ?? ""; + expect(costCell).toContain("1.5"); + expect(costCell).not.toContain("inlineStr"); + }); + + test("16-significant-digit cost is capped to 15 digits", async () => { + const files = unzip( + await buildUsageLogsXlsx([makeLog({ costUsd: "1.234567890123456" })], "UTC") + ); + const costCell = cell(files["xl/worksheets/sheet1.xml"], `${COST_COL}2`) ?? ""; + expect(costCell).toContain("1.23456789012346"); + }); + + test("model name is a text (inlineStr) cell, not interpreted as a formula", async () => { + const files = unzip(await buildUsageLogsXlsx([makeLog({ model: "=1+1" })], "UTC")); + const modelCell = cell(files["xl/worksheets/sheet1.xml"], `${MODEL_COL}2`) ?? ""; + expect(modelCell).toContain("inlineStr"); + expect(modelCell).toContain("=1+1"); + }); + + test("status code is an integer numeric cell", async () => { + const files = unzip(await buildUsageLogsXlsx([makeLog({ statusCode: 200 })], "UTC")); + const statusCell = cell(files["xl/worksheets/sheet1.xml"], `${STATUS_COL}2`) ?? ""; + expect(statusCell).toContain("200"); + expect(statusCell).not.toContain("inlineStr"); + }); + + test("timestamp is a real Excel date serial reflecting the system timezone", async () => { + const files = unzip(await buildUsageLogsXlsx([makeLog()], "Asia/Shanghai")); + const sheet1 = files["xl/worksheets/sheet1.xml"]; + // header carries the timezone + expect(sheet1).toContain("Time (Asia/Shanghai)"); + + const timeCell = cell(sheet1, `${TIME_COL}2`) ?? ""; + const serial = Number(timeCell.match(/([^<]+)<\/v>/)?.[1]); + expect(Number.isFinite(serial)).toBe(true); + + // serial -> wall clock; 12:34:56 UTC is 20:34:56 in Asia/Shanghai (+08:00) + const ms = Math.round(((serial - 25569) * 86_400_000) / 1000) * 1000; + const wall = new Date(ms); + expect(wall.getUTCFullYear()).toBe(2026); + expect(wall.getUTCMonth()).toBe(5); // June + expect(wall.getUTCDate()).toBe(3); + expect(wall.getUTCHours()).toBe(20); + expect(wall.getUTCMinutes()).toBe(34); + expect(wall.getUTCSeconds()).toBe(56); + }); + + test("single-day data yields an hourly summary sheet", async () => { + const files = unzip( + await buildUsageLogsXlsx( + [ + makeLog({ createdAt: new Date("2026-06-03T12:00:00.000Z"), costUsd: "0.5" }), + makeLog({ createdAt: new Date("2026-06-03T12:30:00.000Z"), costUsd: "0.5" }), + ], + "UTC" + ) + ); + expect(files["xl/workbook.xml"]).toContain('name="Hourly Summary"'); + const summary = files["xl/worksheets/sheet2.xml"]; + expect(summary).toContain("Period"); + expect(summary).toContain("2026-06-03 12:00"); + expect(summary).toContain("Total"); + // total cost cell at column I (index 8), last data row + total row + }); + + test("multi-day data yields a daily summary sheet", async () => { + const files = unzip( + await buildUsageLogsXlsx( + [ + makeLog({ createdAt: new Date("2026-06-03T12:00:00.000Z") }), + makeLog({ createdAt: new Date("2026-06-04T12:00:00.000Z") }), + ], + "UTC" + ) + ); + expect(files["xl/workbook.xml"]).toContain('name="Daily Summary"'); + const summary = files["xl/worksheets/sheet2.xml"]; + expect(summary).toContain("2026-06-03"); + expect(summary).toContain("2026-06-04"); + }); + + test("does not crash on empty input", async () => { + const files = unzip(await buildUsageLogsXlsx([], "UTC")); + expect(files["xl/worksheets/sheet1.xml"]).toContain("Time (UTC)"); + expect(files["xl/worksheets/sheet2.xml"]).toContain("Total"); + }); + + test("invalid Date timestamp yields an empty cell (no crash)", async () => { + const files = unzip( + await buildUsageLogsXlsx([makeLog({ createdAt: new Date(Number.NaN) })], "UTC") + ); + const timeCell = cell(files["xl/worksheets/sheet1.xml"], `${TIME_COL}2`) ?? ""; + expect(timeCell).toBe(``); + }); + + test("strips illegal XML characters from text cells", async () => { + const files = unzip(await buildUsageLogsXlsx([makeLog({ model: "gpt\uFFFE\uFFFF-x" })], "UTC")); + const modelCell = cell(files["xl/worksheets/sheet1.xml"], `${MODEL_COL}2`) ?? ""; + expect(modelCell).toContain("gpt-x"); + expect(modelCell).not.toContain("\uFFFE"); + expect(modelCell).not.toContain("\uFFFF"); + }); + + test("styles.xml declares the two OOXML-reserved fills", async () => { + const files = unzip(await buildUsageLogsXlsx([makeLog()], "UTC")); + expect(files["xl/styles.xml"]).toContain(''); + expect(files["xl/styles.xml"]).toContain('patternType="gray125"'); + }); +});