Просмотр исходного кода

fix: 修正 Retry Count 过滤与导出(#925) (#926)

* fix(usage-logs): 修正重试次数计算(#925)

* fix(usage-logs): 修复 ledger-only 下 minRetryCount 统计

* chore: format code (fix-issue925-retry-count-filter-f6a47b7)

* test(usage-logs): 稳定化 minRetryCount SQL 断言

* fix(usage-logs): hedge race 重试数按0处理

* chore: format code (fix-issue925-retry-count-filter-ad66120)

* test(usage-logs): 收紧 minRetryCount SQL 断言

* fix(usage-logs): retryCount SQL 对齐 getRetryCount 语义

---------

Co-authored-by: tesgth032 <[email protected]>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
tesgth032 3 недель назад
Родитель
Сommit
42e2e49b6d

+ 2 - 1
src/actions/usage-logs.ts

@@ -8,6 +8,7 @@ import {
 } from "@/lib/constants/usage-logs.constants";
 import { logger } from "@/lib/logger";
 import { readLiveChainBatch } from "@/lib/redis/live-chain-store";
+import { getRetryCount } from "@/lib/utils/provider-chain-formatter";
 import { isProviderFinalized } from "@/lib/utils/provider-display";
 import {
   findUsageLogSessionIdSuggestions,
@@ -121,7 +122,7 @@ function generateCsv(logs: UsageLogRow[]): string {
   ];
 
   const rows = logs.map((log) => {
-    const retryCount = log.providerChain ? Math.max(0, log.providerChain.length - 1) : 0;
+    const retryCount = log.providerChain ? getRetryCount(log.providerChain) : 0;
     return [
       log.createdAt ? new Date(log.createdAt).toISOString() : "",
       escapeCsvField(log.userName),

+ 55 - 4
src/repository/_shared/usage-log-filters.ts

@@ -13,6 +13,58 @@ export interface UsageLogFilterParams {
   minRetryCount?: number;
 }
 
+// 重试次数计算:
+// - 对齐前端 getRetryCount/isActualRequest:只统计“实际请求”的次数,再 - 1 得到重试次数
+// - Hedge Race(并发尝试)按 0 处理(并发不算顺序重试,且 UI 优先展示 Hedge Race)
+// - provider_chain 为空/NULL 时按 0 处理
+export const RETRY_COUNT_EXPR: SQL = sql`(
+  SELECT
+    CASE
+      WHEN COALESCE(
+        bool_or(
+          (elem->>'reason') IN (
+            'hedge_triggered',
+            'hedge_launched',
+            'hedge_winner',
+            'hedge_loser_cancelled'
+          )
+        ),
+        false
+      )
+      THEN 0
+      ELSE GREATEST(
+        COALESCE(
+          sum(
+            CASE
+              WHEN (
+                (elem->>'reason') IN (
+                  'concurrent_limit_failed',
+                  'retry_failed',
+                  'system_error',
+                  'resource_not_found',
+                  'client_error_non_retryable',
+                  'endpoint_pool_exhausted',
+                  'vendor_type_all_timeout',
+                  'client_abort',
+                  'http2_fallback'
+                )
+                OR (
+                  (elem->>'reason') IN ('request_success', 'retry_success')
+                  AND (elem->>'statusCode') IS NOT NULL
+                )
+              )
+              THEN 1
+              ELSE 0
+            END
+          ),
+          0
+        ) - 1,
+        0
+      )
+    END
+  FROM jsonb_array_elements(COALESCE(${messageRequest.providerChain}, '[]'::jsonb)) AS elem
+)`;
+
 export function buildUsageLogConditions(filters: UsageLogFilterParams): SQL[] {
   const conditions: SQL[] = [];
 
@@ -47,10 +99,9 @@ export function buildUsageLogConditions(filters: UsageLogFilterParams): SQL[] {
     conditions.push(eq(messageRequest.endpoint, filters.endpoint));
   }
 
-  if (filters.minRetryCount !== undefined) {
-    conditions.push(
-      sql`GREATEST(COALESCE(jsonb_array_length(${messageRequest.providerChain}) - 1, 0), 0) >= ${filters.minRetryCount}`
-    );
+  const minRetryCount = filters.minRetryCount ?? 0;
+  if (minRetryCount > 0) {
+    conditions.push(sql`${RETRY_COUNT_EXPR} >= ${minRetryCount}`);
   }
 
   return conditions;

+ 25 - 10
src/repository/usage-logs.ts

@@ -12,7 +12,7 @@ import type { SpecialSetting } from "@/types/special-settings";
 import { LEDGER_BILLING_CONDITION } from "./_shared/ledger-conditions";
 import { escapeLike } from "./_shared/like";
 import { EXCLUDE_WARMUP_CONDITION } from "./_shared/message-request-conditions";
-import { buildUsageLogConditions } from "./_shared/usage-log-filters";
+import { buildUsageLogConditions, RETRY_COUNT_EXPR } from "./_shared/usage-log-filters";
 
 export interface UsageLogFilters {
   userId?: number;
@@ -29,7 +29,7 @@ export interface UsageLogFilters {
   excludeStatusCode200?: boolean;
   model?: string;
   endpoint?: string;
-  /** 最低重试次数(provider_chain 长度 - 1) */
+  /** 最低重试次数(按 provider_chain 中“实际请求”数量 - 1 计算;<= 0 视为不筛选) */
   minRetryCount?: number;
   page?: number;
   pageSize?: number;
@@ -84,6 +84,18 @@ export interface UsageLogSummary {
   totalCacheCreation1hTokens: number;
 }
 
+const EMPTY_USAGE_LOG_SUMMARY: UsageLogSummary = {
+  totalRequests: 0,
+  totalCost: 0,
+  totalTokens: 0,
+  totalInputTokens: 0,
+  totalOutputTokens: 0,
+  totalCacheCreationTokens: 0,
+  totalCacheReadTokens: 0,
+  totalCacheCreation5mTokens: 0,
+  totalCacheCreation1hTokens: 0,
+};
+
 export interface UsageLogsResult {
   logs: UsageLogRow[];
   total: number;
@@ -405,7 +417,7 @@ interface UsageLogSlimFilters {
   excludeStatusCode200?: boolean;
   model?: string;
   endpoint?: string;
-  /** 最低重试次数(provider_chain 长度 - 1) */
+  /** 最低重试次数(按 provider_chain 中“实际请求”数量 - 1 计算;<= 0 视为不筛选) */
   minRetryCount?: number;
   page?: number;
   pageSize?: number;
@@ -1011,6 +1023,13 @@ export async function findUsageLogsStats(
 ): Promise<UsageLogSummary> {
   const { userId, keyId, providerId } = filters;
 
+  // 在 ledger-only 模式下,message_request 为空 —— 依赖它的筛选条件必须短路处理。
+  const ledgerOnly = await isLedgerOnlyMode();
+  const minRetryCount = filters.minRetryCount ?? 0;
+  if (ledgerOnly && minRetryCount > 0) {
+    return EMPTY_USAGE_LOG_SUMMARY;
+  }
+
   const conditions = [LEDGER_BILLING_CONDITION];
 
   if (userId !== undefined) {
@@ -1052,10 +1071,8 @@ export async function findUsageLogsStats(
     conditions.push(eq(usageLedger.endpoint, filters.endpoint));
   }
 
-  if (filters.minRetryCount !== undefined) {
-    conditions.push(
-      sql`GREATEST(COALESCE(jsonb_array_length(${messageRequest.providerChain}) - 1, 0), 0) >= ${filters.minRetryCount}`
-    );
+  if (minRetryCount > 0 && !ledgerOnly) {
+    conditions.push(sql`${RETRY_COUNT_EXPR} >= ${minRetryCount}`);
   }
 
   const baseQuery = db
@@ -1076,10 +1093,8 @@ export async function findUsageLogsStats(
       ? baseQuery.innerJoin(keysTable, eq(usageLedger.key, keysTable.key))
       : baseQuery;
 
-  // In ledger-only mode, message_request is empty — skip the innerJoin to avoid zeroing all results
-  const ledgerOnly = await isLedgerOnlyMode();
   const query =
-    filters.minRetryCount !== undefined && !ledgerOnly
+    minRetryCount > 0 && !ledgerOnly
       ? queryByKey.innerJoin(messageRequest, eq(usageLedger.requestId, messageRequest.id))
       : queryByKey;
 

+ 197 - 0
tests/unit/actions/usage-logs-export-retry-count.test.ts

@@ -0,0 +1,197 @@
+import { beforeEach, describe, expect, test, vi } from "vitest";
+
+const getSessionMock = vi.fn();
+const findUsageLogsWithDetailsMock = vi.fn();
+
+vi.mock("@/lib/auth", () => {
+  return {
+    getSession: getSessionMock,
+  };
+});
+
+vi.mock("@/repository/usage-logs", () => {
+  return {
+    findUsageLogSessionIdSuggestions: vi.fn(async () => []),
+    findUsageLogsBatch: vi.fn(async () => ({ logs: [], nextCursor: null, hasMore: false })),
+    findUsageLogsStats: vi.fn(async () => ({
+      totalRequests: 0,
+      totalCost: 0,
+      totalTokens: 0,
+      totalInputTokens: 0,
+      totalOutputTokens: 0,
+      totalCacheCreationTokens: 0,
+      totalCacheReadTokens: 0,
+      totalCacheCreation5mTokens: 0,
+      totalCacheCreation1hTokens: 0,
+    })),
+    findUsageLogsWithDetails: findUsageLogsWithDetailsMock,
+    getUsedEndpoints: vi.fn(async () => []),
+    getUsedModels: vi.fn(async () => []),
+    getUsedStatusCodes: vi.fn(async () => []),
+  };
+});
+
+function parseCsvLine(line: string): string[] {
+  const fields: string[] = [];
+  let current = "";
+  let inQuotes = false;
+
+  for (let i = 0; i < line.length; i++) {
+    const char = line[i];
+    if (!char) continue;
+
+    if (inQuotes) {
+      if (char === '"') {
+        const next = line[i + 1];
+        if (next === '"') {
+          current += '"';
+          i += 1;
+          continue;
+        }
+        inQuotes = false;
+        continue;
+      }
+
+      current += char;
+      continue;
+    }
+
+    if (char === ",") {
+      fields.push(current);
+      current = "";
+      continue;
+    }
+
+    if (char === '"') {
+      inQuotes = true;
+      continue;
+    }
+
+    current += char;
+  }
+
+  fields.push(current);
+  return fields;
+}
+
+describe("Usage logs CSV export retryCount", () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+    getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } });
+  });
+
+  test("exportUsageLogs: Retry Count 应对齐 getRetryCount(hedge race 为 0)", async () => {
+    findUsageLogsWithDetailsMock.mockResolvedValue({
+      logs: [
+        {
+          createdAt: new Date("2026-03-16T00: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: "0",
+          durationMs: 10,
+          sessionId: "s1",
+          providerChain: [
+            { reason: "initial_selection" },
+            { reason: "request_success", statusCode: 200 },
+          ],
+        },
+        {
+          createdAt: new Date("2026-03-16T00:00:01.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: "0",
+          durationMs: 10,
+          sessionId: "s2",
+          providerChain: [
+            { reason: "initial_selection" },
+            { reason: "retry_failed", attemptNumber: 1 },
+            { reason: "retry_success", statusCode: 200, attemptNumber: 1 },
+          ],
+        },
+        {
+          createdAt: new Date("2026-03-16T00:00:02.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: "0",
+          durationMs: 10,
+          sessionId: "s3",
+          providerChain: [
+            { reason: "initial_selection" },
+            { reason: "hedge_triggered" },
+            { reason: "hedge_launched" },
+            { reason: "hedge_winner", statusCode: 200 },
+            { reason: "hedge_loser_cancelled" },
+          ],
+        },
+      ],
+      total: 3,
+      summary: {
+        totalRequests: 3,
+        totalCost: 0,
+        totalTokens: 9,
+        totalInputTokens: 3,
+        totalOutputTokens: 6,
+        totalCacheCreationTokens: 0,
+        totalCacheReadTokens: 0,
+        totalCacheCreation5mTokens: 0,
+        totalCacheCreation1hTokens: 0,
+      },
+    });
+
+    const { exportUsageLogs } = await import("@/actions/usage-logs");
+    const result = await exportUsageLogs({});
+
+    expect(result.ok).toBe(true);
+    const csv = result.data;
+    const csvNoBom = csv.replace(/^\uFEFF/, "");
+    const lines = csvNoBom
+      .trim()
+      .split("\n")
+      .map((line) => line.replace(/\r$/, ""));
+
+    expect(lines).toHaveLength(4);
+    const header = parseCsvLine(lines[0] ?? "");
+    const retryCountIndex = header.indexOf("Retry Count");
+    expect(retryCountIndex).toBeGreaterThanOrEqual(0);
+
+    const row1 = parseCsvLine(lines[1] ?? "");
+    const row2 = parseCsvLine(lines[2] ?? "");
+    const row3 = parseCsvLine(lines[3] ?? "");
+    expect(row1[retryCountIndex]).toBe("0");
+    expect(row2[retryCountIndex]).toBe("1");
+    expect(row3[retryCountIndex]).toBe("0");
+  });
+});

+ 182 - 0
tests/unit/repository/usage-logs-min-retry-count-filter.test.ts

@@ -0,0 +1,182 @@
+import { describe, expect, test, vi } from "vitest";
+
+import { buildUsageLogConditions } from "@/repository/_shared/usage-log-filters";
+import type { SQL } from "drizzle-orm";
+import { CasingCache } from "drizzle-orm/casing";
+
+// 注意:CasingCache 来自 drizzle-orm/casing 子路径导出;若未来 drizzle-orm 升级导致接口调整,
+// 这里的 SQL 渲染 helper 需要同步更新。
+function sqlToString(sqlObj: SQL): string {
+  return sqlObj.toQuery({
+    escapeName: (name: string) => `"${name}"`,
+    escapeParam: (num: number, _value: unknown) => `$${num}`,
+    escapeString: (value: string) => `'${value}'`,
+    casing: new CasingCache(),
+    paramStartIndex: { value: 1 },
+  }).sql;
+}
+
+function createThenableQuery<T>(result: T, whereArgs?: unknown[]) {
+  const query: any = Promise.resolve(result);
+
+  query.from = vi.fn(() => query);
+  query.innerJoin = vi.fn(() => query);
+  query.leftJoin = vi.fn(() => query);
+  query.orderBy = vi.fn(() => query);
+  query.limit = vi.fn(() => query);
+  query.offset = vi.fn(() => query);
+  query.groupBy = vi.fn(() => query);
+  query.where = vi.fn((arg: unknown) => {
+    whereArgs?.push(arg);
+    return query;
+  });
+
+  return query;
+}
+
+describe("Usage logs minRetryCount filter", () => {
+  test("buildUsageLogConditions: minRetryCount <= 0 视为不筛选", () => {
+    expect(buildUsageLogConditions({})).toHaveLength(0);
+    expect(buildUsageLogConditions({ minRetryCount: 0 })).toHaveLength(0);
+    expect(buildUsageLogConditions({ minRetryCount: -1 })).toHaveLength(0);
+  });
+
+  test("buildUsageLogConditions: 重试次数表达式应对齐 getRetryCount/isActualRequest", () => {
+    const [condition] = buildUsageLogConditions({ minRetryCount: 1 });
+    const whereSql = sqlToString(condition).toLowerCase();
+    expect(whereSql).toContain("jsonb_array_elements");
+    expect(whereSql).toContain("bool_or");
+    expect(whereSql).toContain("sum");
+    expect(whereSql).toMatch(/-\s*1\b/);
+    expect(whereSql).not.toMatch(/-\s*2\b/);
+    expect(whereSql).toContain("greatest");
+    expect(whereSql).toContain("coalesce");
+    expect(whereSql).toContain("request_success");
+    expect(whereSql).toContain("retry_success");
+    expect(whereSql).toContain("retry_failed");
+    expect(whereSql).toContain("statuscode");
+    expect(whereSql).toContain("hedge_triggered");
+    expect(whereSql).not.toContain("jsonb_array_length");
+  });
+
+  test("findUsageLogsStats: 重试次数表达式应对齐 getRetryCount/isActualRequest", async () => {
+    vi.resetModules();
+
+    const whereArgs: unknown[] = [];
+    let query: any;
+    const selectMock = vi.fn(
+      () =>
+        (query = createThenableQuery(
+          [
+            {
+              totalRequests: 0,
+              totalCost: "0",
+              totalInputTokens: 0,
+              totalOutputTokens: 0,
+              totalCacheCreationTokens: 0,
+              totalCacheReadTokens: 0,
+              totalCacheCreation5mTokens: 0,
+              totalCacheCreation1hTokens: 0,
+            },
+          ],
+          whereArgs
+        ))
+    );
+
+    vi.doMock("@/drizzle/db", () => ({
+      db: {
+        select: selectMock,
+      },
+    }));
+    vi.doMock("@/lib/ledger-fallback", () => ({
+      isLedgerOnlyMode: vi.fn(async () => false),
+    }));
+
+    const { findUsageLogsStats } = await import("@/repository/usage-logs");
+    await findUsageLogsStats({ minRetryCount: 1 });
+
+    expect(whereArgs).toHaveLength(1);
+    const whereSql = sqlToString(whereArgs[0] as SQL).toLowerCase();
+    expect(whereSql).toContain("jsonb_array_elements");
+    expect(whereSql).toContain("bool_or");
+    expect(whereSql).toContain("sum");
+    expect(whereSql).toMatch(/-\s*1\b/);
+    expect(whereSql).not.toMatch(/-\s*2\b/);
+    expect(whereSql).toContain("greatest");
+    expect(whereSql).toContain("coalesce");
+    expect(whereSql).toContain("request_success");
+    expect(whereSql).toContain("retry_success");
+    expect(whereSql).toContain("retry_failed");
+    expect(whereSql).toContain("statuscode");
+    expect(whereSql).toContain("hedge_triggered");
+    expect(query?.innerJoin).toHaveBeenCalled();
+  });
+
+  test("findUsageLogsStats: minRetryCount <= 0 时不应 join messageRequest", async () => {
+    vi.resetModules();
+
+    let query: any;
+    const selectMock = vi.fn(
+      () =>
+        (query = createThenableQuery([
+          {
+            totalRequests: 0,
+            totalCost: "0",
+            totalInputTokens: 0,
+            totalOutputTokens: 0,
+            totalCacheCreationTokens: 0,
+            totalCacheReadTokens: 0,
+            totalCacheCreation5mTokens: 0,
+            totalCacheCreation1hTokens: 0,
+          },
+        ]))
+    );
+
+    vi.doMock("@/drizzle/db", () => ({
+      db: {
+        select: selectMock,
+      },
+    }));
+    vi.doMock("@/lib/ledger-fallback", () => ({
+      isLedgerOnlyMode: vi.fn(async () => false),
+    }));
+
+    const { findUsageLogsStats } = await import("@/repository/usage-logs");
+    await findUsageLogsStats({ minRetryCount: 0 });
+
+    expect(query?.innerJoin).not.toHaveBeenCalled();
+  });
+
+  test("findUsageLogsStats: ledger-only 且 minRetryCount > 0 时应短路返回 0", async () => {
+    vi.resetModules();
+
+    const selectMock = vi.fn(() => {
+      throw new Error("ledger-only 且 minRetryCount > 0 时不应触发 db 查询");
+    });
+
+    vi.doMock("@/drizzle/db", () => ({
+      db: {
+        select: selectMock,
+      },
+    }));
+    vi.doMock("@/lib/ledger-fallback", () => ({
+      isLedgerOnlyMode: vi.fn(async () => true),
+    }));
+
+    const { findUsageLogsStats } = await import("@/repository/usage-logs");
+    const summary = await findUsageLogsStats({ minRetryCount: 1 });
+
+    expect(selectMock).not.toHaveBeenCalled();
+    expect(summary).toEqual({
+      totalRequests: 0,
+      totalCost: 0,
+      totalTokens: 0,
+      totalInputTokens: 0,
+      totalOutputTokens: 0,
+      totalCacheCreationTokens: 0,
+      totalCacheReadTokens: 0,
+      totalCacheCreation5mTokens: 0,
+      totalCacheCreation1hTokens: 0,
+    });
+  });
+});