diff --git a/messages/en/dashboard.json b/messages/en/dashboard.json index abaf67948..eb8ec252f 100644 --- a/messages/en/dashboard.json +++ b/messages/en/dashboard.json @@ -69,6 +69,7 @@ "noProviderFound": "No matching providers found", "noSessionFound": "No matching session IDs found", "model": "Model", + "actualResponseModelMismatch": "Billing/actual model mismatch", "endpoint": "Endpoint", "status": "Status", "timeRange": "Time Range", @@ -86,6 +87,7 @@ "statusCode": "Status Code", "minRetryCount": "Retry Count ≥", "minRetryCountPlaceholder": "Enter minimum retries", + "enabled": "On", "apply": "Apply Filter", "reset": "Reset", "last7days": "7d", diff --git a/messages/ja/dashboard.json b/messages/ja/dashboard.json index 94d02459b..921d03678 100644 --- a/messages/ja/dashboard.json +++ b/messages/ja/dashboard.json @@ -69,6 +69,7 @@ "noProviderFound": "一致するプロバイダーが見つかりません", "noSessionFound": "一致するセッションIDが見つかりません", "model": "モデル", + "actualResponseModelMismatch": "課金/実応答モデル不一致", "endpoint": "エンドポイント", "status": "ステータス", "timeRange": "時間範囲", @@ -86,6 +87,7 @@ "statusCode": "ステータスコード", "minRetryCount": "リトライ回数≥", "minRetryCountPlaceholder": "回数を入力(0 で制限なし)", + "enabled": "オン", "apply": "フィルターを適用", "reset": "リセット", "last7days": "7日", diff --git a/messages/ru/dashboard.json b/messages/ru/dashboard.json index 6aaec6552..5f5ad60dc 100644 --- a/messages/ru/dashboard.json +++ b/messages/ru/dashboard.json @@ -69,6 +69,7 @@ "noProviderFound": "Провайдеры не найдены", "noSessionFound": "ID сессии не найдены", "model": "Модель", + "actualResponseModelMismatch": "Модель тарификации отличается от фактической", "endpoint": "Эндпоинт", "status": "Статус", "timeRange": "Диапазон времени", @@ -86,6 +87,7 @@ "statusCode": "Код состояния", "minRetryCount": "Количество ретраев ≥", "minRetryCountPlaceholder": "Введите минимум (0 — без ограничения)", + "enabled": "Вкл.", "apply": "Применить фильтр", "reset": "Сброс", "last7days": "7д", diff --git a/messages/zh-CN/dashboard.json b/messages/zh-CN/dashboard.json index 56751f603..9ccd3be60 100644 --- a/messages/zh-CN/dashboard.json +++ b/messages/zh-CN/dashboard.json @@ -69,6 +69,7 @@ "noProviderFound": "未找到匹配的供应商", "noSessionFound": "未找到匹配的 Session ID", "model": "模型", + "actualResponseModelMismatch": "计费/实际模型不一致", "endpoint": "端点", "status": "状态", "timeRange": "时间范围", @@ -86,6 +87,7 @@ "statusCode": "状态码", "minRetryCount": "重试次数≥", "minRetryCountPlaceholder": "输入次数(0 表示不限)", + "enabled": "已开启", "apply": "应用筛选", "reset": "重置", "last7days": "近7天", diff --git a/messages/zh-TW/dashboard.json b/messages/zh-TW/dashboard.json index b07887864..51e10ed36 100644 --- a/messages/zh-TW/dashboard.json +++ b/messages/zh-TW/dashboard.json @@ -69,6 +69,7 @@ "noProviderFound": "未找到匹配的供應商", "noSessionFound": "未找到符合的 Session ID", "model": "Model", + "actualResponseModelMismatch": "計費/實際模型不一致", "endpoint": "端點", "status": "狀態", "timeRange": "時間範圍", @@ -86,6 +87,7 @@ "statusCode": "狀態碼", "minRetryCount": "重試次數≥", "minRetryCountPlaceholder": "輸入次數(0 表示不限)", + "enabled": "已開啟", "apply": "套用篩選", "reset": "重設", "last7days": "近 7 天", diff --git a/src/actions/my-usage.ts b/src/actions/my-usage.ts index a1536f2dc..55eee4b7f 100644 --- a/src/actions/my-usage.ts +++ b/src/actions/my-usage.ts @@ -268,6 +268,7 @@ export interface MyUsageLogsFilters { endTime?: number; sessionId?: string; model?: string; + actualResponseModelMismatch?: boolean; statusCode?: number; excludeStatusCode200?: boolean; endpoint?: string; @@ -615,6 +616,7 @@ export interface MyUsageLogsBatchFilters { /** Session ID(精确匹配;空字符串/空白视为不筛选) */ sessionId?: string; model?: string; + actualResponseModelMismatch?: boolean; statusCode?: number; excludeStatusCode200?: boolean; endpoint?: string; @@ -684,6 +686,7 @@ export async function getMyUsageLogs( startTime: dateRange.startTime, endTime: dateRange.endTime, model: filters.model, + actualResponseModelMismatch: filters.actualResponseModelMismatch, statusCode: filters.statusCode, excludeStatusCode200: filters.excludeStatusCode200, endpoint: filters.endpoint, @@ -729,6 +732,7 @@ export async function getMyUsageLogsBatch( startTime: dateRange.startTime, endTime: dateRange.endTime, model: filters.model, + actualResponseModelMismatch: filters.actualResponseModelMismatch, statusCode: filters.statusCode, excludeStatusCode200: filters.excludeStatusCode200, endpoint: filters.endpoint, @@ -777,6 +781,7 @@ export async function getMyUsageLogsBatchFull( const result = await findReadonlyUsageLogsBatchForKey({ sessionId: params.sessionId, model: params.model, + actualResponseModelMismatch: params.actualResponseModelMismatch, statusCode: params.statusCode, excludeStatusCode200: params.excludeStatusCode200, endpoint: params.endpoint, diff --git a/src/app/[locale]/dashboard/logs/_components/filters/active-filters-display.tsx b/src/app/[locale]/dashboard/logs/_components/filters/active-filters-display.tsx index fe1c73026..4e9045659 100644 --- a/src/app/[locale]/dashboard/logs/_components/filters/active-filters-display.tsx +++ b/src/app/[locale]/dashboard/logs/_components/filters/active-filters-display.tsx @@ -99,6 +99,14 @@ export function ActiveFiltersDisplay({ }); } + if (filters.actualResponseModelMismatch) { + result.push({ + key: "actualResponseModelMismatch", + label: t("actualResponseModelMismatch"), + value: t("enabled"), + }); + } + // Endpoint filter if (filters.endpoint) { result.push({ diff --git a/src/app/[locale]/dashboard/logs/_components/filters/request-filters.tsx b/src/app/[locale]/dashboard/logs/_components/filters/request-filters.tsx index 6af778544..523d84646 100644 --- a/src/app/[locale]/dashboard/logs/_components/filters/request-filters.tsx +++ b/src/app/[locale]/dashboard/logs/_components/filters/request-filters.tsx @@ -22,6 +22,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; import { getUsageLogSessionIdSuggestions } from "@/lib/api-client/v1/actions/usage-logs"; import { SESSION_ID_SUGGESTION_MIN_LEN } from "@/lib/constants/usage-logs.constants"; import { useDebounce } from "@/lib/hooks/use-debounce"; @@ -303,6 +304,23 @@ export function RequestFilters({ +
+ + + onFiltersChange({ + ...filters, + actualResponseModelMismatch: checked ? true : undefined, + }) + } + aria-label={t("logs.filters.actualResponseModelMismatch")} + /> +
+ {/* Session ID with suggestions */}
diff --git a/src/app/[locale]/dashboard/logs/_components/filters/types.ts b/src/app/[locale]/dashboard/logs/_components/filters/types.ts index dabef5119..ffde30de0 100644 --- a/src/app/[locale]/dashboard/logs/_components/filters/types.ts +++ b/src/app/[locale]/dashboard/logs/_components/filters/types.ts @@ -16,6 +16,7 @@ export interface UsageLogFilters { statusCode?: number; excludeStatusCode200?: boolean; model?: string; + actualResponseModelMismatch?: boolean; endpoint?: string; minRetryCount?: number; } 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 26a9b1a6f..cdf75b30d 100644 --- a/src/app/[locale]/dashboard/logs/_components/usage-logs-filters.tsx +++ b/src/app/[locale]/dashboard/logs/_components/usage-logs-filters.tsx @@ -42,6 +42,7 @@ const VALID_FILTER_KEYS: (keyof UsageLogFilters)[] = [ "statusCode", "excludeStatusCode200", "model", + "actualResponseModelMismatch", "endpoint", "minRetryCount", ]; @@ -131,6 +132,7 @@ export function UsageLogsFilters({ let count = 0; if (isAdmin && localFilters.providerId !== undefined) count++; if (localFilters.model) count++; + if (localFilters.actualResponseModelMismatch) count++; if (localFilters.endpoint) count++; if (localFilters.sessionId) count++; return count; @@ -138,6 +140,7 @@ export function UsageLogsFilters({ isAdmin, localFilters.providerId, localFilters.model, + localFilters.actualResponseModelMismatch, localFilters.endpoint, localFilters.sessionId, ]); diff --git a/src/app/[locale]/dashboard/logs/_components/usage-logs-view-virtualized.test.tsx b/src/app/[locale]/dashboard/logs/_components/usage-logs-view-virtualized.test.tsx index bee5ce204..ce3e68cbb 100644 --- a/src/app/[locale]/dashboard/logs/_components/usage-logs-view-virtualized.test.tsx +++ b/src/app/[locale]/dashboard/logs/_components/usage-logs-view-virtualized.test.tsx @@ -105,6 +105,7 @@ vi.mock("./usage-logs-filters", () => ({ endTime: 2000, statusCode: 500, model: "claude-sonnet", + actualResponseModelMismatch: true, endpoint: "/v1/messages", minRetryCount: 1, }) @@ -185,7 +186,7 @@ describe("UsageLogsViewVirtualized filter navigation", () => { clickButton(container, "apply all filters"); expect(routerMocks.pushedHref).toBe( - "/zh-CN/dashboard/logs?userId=2&keyId=3&providerId=4&sessionId=session-abc&startTime=1000&endTime=2000&statusCode=500&model=claude-sonnet&endpoint=%2Fv1%2Fmessages&minRetry=1" + "/zh-CN/dashboard/logs?userId=2&keyId=3&providerId=4&sessionId=session-abc&startTime=1000&endTime=2000&statusCode=500&model=claude-sonnet&actualResponseModelMismatch=true&endpoint=%2Fv1%2Fmessages&minRetry=1" ); unmount(); diff --git a/src/app/[locale]/dashboard/logs/_components/usage-logs-view-virtualized.tsx b/src/app/[locale]/dashboard/logs/_components/usage-logs-view-virtualized.tsx index f27e63c74..20c6363e6 100644 --- a/src/app/[locale]/dashboard/logs/_components/usage-logs-view-virtualized.tsx +++ b/src/app/[locale]/dashboard/logs/_components/usage-logs-view-virtualized.tsx @@ -166,6 +166,7 @@ function UsageLogsViewContent({ endTime: _params.get("endTime") ?? undefined, statusCode: _params.get("statusCode") ?? undefined, model: _params.get("model") ?? undefined, + actualResponseModelMismatch: _params.get("actualResponseModelMismatch") ?? undefined, endpoint: _params.get("endpoint") ?? undefined, minRetry: _params.get("minRetry") ?? undefined, page: _params.get("page") ?? undefined, @@ -265,6 +266,7 @@ function UsageLogsViewContent({ if (statsFilters.sessionId) count++; if (statsFilters.statusCode !== undefined || statsFilters.excludeStatusCode200) count++; if (statsFilters.model) count++; + if (statsFilters.actualResponseModelMismatch) count++; if (statsFilters.endpoint) count++; if (statsFilters.minRetryCount !== undefined && statsFilters.minRetryCount > 0) count++; return count; diff --git a/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx b/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx index fc5ab5491..5df89c878 100644 --- a/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx +++ b/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx @@ -66,6 +66,7 @@ export interface VirtualizedLogsTableFilters { statusCode?: number; excludeStatusCode200?: boolean; model?: string; + actualResponseModelMismatch?: boolean; endpoint?: string; minRetryCount?: number; } diff --git a/src/app/[locale]/dashboard/logs/_utils/logs-query.ts b/src/app/[locale]/dashboard/logs/_utils/logs-query.ts index 4430f3854..8e068e995 100644 --- a/src/app/[locale]/dashboard/logs/_utils/logs-query.ts +++ b/src/app/[locale]/dashboard/logs/_utils/logs-query.ts @@ -8,6 +8,7 @@ export interface LogsUrlFilters { statusCode?: number; excludeStatusCode200?: boolean; model?: string; + actualResponseModelMismatch?: boolean; endpoint?: string; minRetryCount?: number; page?: number; @@ -43,6 +44,9 @@ export function parseLogsUrlFilters(searchParams: { ? Number.parseInt(statusCodeParam, 10) : undefined; + const actualResponseModelMismatch = + parseStringParam(searchParams.actualResponseModelMismatch) === "true" ? true : undefined; + return { userId: parseIntParam(searchParams.userId), keyId: parseIntParam(searchParams.keyId), @@ -53,6 +57,7 @@ export function parseLogsUrlFilters(searchParams: { statusCode: Number.isFinite(statusCode) ? statusCode : undefined, excludeStatusCode200: statusCodeParam === "!200", model: parseStringParam(searchParams.model), + actualResponseModelMismatch, endpoint: parseStringParam(searchParams.endpoint), minRetryCount: parseIntParam(searchParams.minRetry), page, @@ -79,6 +84,7 @@ export function buildLogsUrlQuery(filters: LogsUrlFilters): URLSearchParams { } if (filters.model) query.set("model", filters.model); + if (filters.actualResponseModelMismatch) query.set("actualResponseModelMismatch", "true"); if (filters.endpoint) query.set("endpoint", filters.endpoint); if (filters.minRetryCount !== undefined) { diff --git a/src/app/api/v1/resources/me/handlers.ts b/src/app/api/v1/resources/me/handlers.ts index 6ca2c8193..2cf40af0a 100644 --- a/src/app/api/v1/resources/me/handlers.ts +++ b/src/app/api/v1/resources/me/handlers.ts @@ -109,6 +109,7 @@ function parseMeUsageLogsQuery(c: Context): MeUsageLogsActionQueryInput | Respon endTime: c.req.query("endTime"), sessionId: c.req.query("sessionId"), model: c.req.query("model"), + actualResponseModelMismatch: c.req.query("actualResponseModelMismatch"), statusCode: c.req.query("statusCode"), excludeStatusCode200: c.req.query("excludeStatusCode200"), endpoint: c.req.query("endpoint"), diff --git a/src/app/api/v1/resources/usage-logs/handlers.ts b/src/app/api/v1/resources/usage-logs/handlers.ts index 2328342cc..0526ea3f1 100644 --- a/src/app/api/v1/resources/usage-logs/handlers.ts +++ b/src/app/api/v1/resources/usage-logs/handlers.ts @@ -163,6 +163,7 @@ function parseUsageLogsQuery(c: Context): UsageLogsActionQueryInput | Response { keyId: c.req.query("keyId"), providerId: c.req.query("providerId"), model: c.req.query("model"), + actualResponseModelMismatch: c.req.query("actualResponseModelMismatch"), statusCode: c.req.query("statusCode"), excludeStatusCode200: c.req.query("excludeStatusCode200"), endpoint: c.req.query("endpoint"), diff --git a/src/lib/api-client/v1/openapi-types.gen.ts b/src/lib/api-client/v1/openapi-types.gen.ts index 5306ae919..842cedc3c 100644 --- a/src/lib/api-client/v1/openapi-types.gen.ts +++ b/src/lib/api-client/v1/openapi-types.gen.ts @@ -35295,6 +35295,8 @@ export interface operations { providerId?: number | null; /** @description Model filter. */ model?: string; + /** @description Only include records whose requested model differs from the actual response model. */ + actualResponseModelMismatch?: "true" | "false" | boolean; /** @description HTTP status code filter. */ statusCode?: number | null; /** @description Exclude successful responses. */ @@ -35498,6 +35500,8 @@ export interface operations { providerId?: number | null; /** @description Model filter. */ model?: string; + /** @description Only include records whose requested model differs from the actual response model. */ + actualResponseModelMismatch?: "true" | "false" | boolean; /** @description HTTP status code filter. */ statusCode?: number | null; /** @description Exclude successful responses. */ @@ -36566,6 +36570,8 @@ export interface operations { providerId?: number | null; /** @description Model filter. */ model?: string; + /** @description Only include records whose requested model differs from the actual response model. */ + actualResponseModelMismatch?: "true" | "false" | boolean; /** @description HTTP status code filter. */ statusCode?: number | null; /** @description Exclude successful responses. */ @@ -37642,6 +37648,8 @@ export interface operations { sessionId?: string; /** @description Model filter. */ model?: string; + /** @description Only include records whose requested model differs from the actual response model. */ + actualResponseModelMismatch?: "true" | "false" | boolean; /** @description HTTP status code filter. */ statusCode?: number | null; /** @description Exclude successful responses. */ @@ -37843,6 +37851,8 @@ export interface operations { sessionId?: string; /** @description Model filter. */ model?: string; + /** @description Only include records whose requested model differs from the actual response model. */ + actualResponseModelMismatch?: "true" | "false" | boolean; /** @description HTTP status code filter. */ statusCode?: number | null; /** @description Exclude successful responses. */ diff --git a/src/lib/api/v1/schemas/me.ts b/src/lib/api/v1/schemas/me.ts index d95c40358..83643be33 100644 --- a/src/lib/api/v1/schemas/me.ts +++ b/src/lib/api/v1/schemas/me.ts @@ -18,6 +18,9 @@ export const MeUsageLogsQuerySchema = z.object({ endTime: NumberQuerySchema.describe("End timestamp in milliseconds."), sessionId: z.string().optional().describe("Session id filter."), model: z.string().optional().describe("Model filter."), + actualResponseModelMismatch: BooleanQuerySchema.describe( + "Only include records whose requested model differs from the actual response model." + ), statusCode: z.coerce.number().int().optional().describe("HTTP status code filter."), excludeStatusCode200: BooleanQuerySchema.describe("Exclude successful responses."), endpoint: z.string().optional().describe("Endpoint filter."), diff --git a/src/lib/api/v1/schemas/usage-logs.ts b/src/lib/api/v1/schemas/usage-logs.ts index be787ddef..a54303089 100644 --- a/src/lib/api/v1/schemas/usage-logs.ts +++ b/src/lib/api/v1/schemas/usage-logs.ts @@ -22,6 +22,9 @@ export const UsageLogsQuerySchema = z.object({ keyId: NumberQuerySchema.describe("Key id filter."), providerId: NumberQuerySchema.describe("Provider id filter."), model: z.string().optional().describe("Model filter."), + actualResponseModelMismatch: BooleanQuerySchema.describe( + "Only include records whose requested model differs from the actual response model." + ), statusCode: z.coerce.number().int().optional().describe("HTTP status code filter."), excludeStatusCode200: BooleanQuerySchema.describe("Exclude successful responses."), endpoint: z.string().optional().describe("Endpoint filter."), diff --git a/src/repository/_shared/usage-log-filters.ts b/src/repository/_shared/usage-log-filters.ts index 692008793..12358f20a 100644 --- a/src/repository/_shared/usage-log-filters.ts +++ b/src/repository/_shared/usage-log-filters.ts @@ -10,6 +10,7 @@ export interface UsageLogFilterParams { statusCode?: number; excludeStatusCode200?: boolean; model?: string; + actualResponseModelMismatch?: boolean; endpoint?: string; minRetryCount?: number; } @@ -63,6 +64,23 @@ export function buildDefaultHiddenUsageLogEndpointCondition( )`; } +export function buildActualResponseModelMismatchCondition( + modelColumn: SQLWrapper, + actualResponseModelColumn: SQLWrapper, + fallbackModelColumn?: SQLWrapper +): SQL { + const effectiveRequestModel = fallbackModelColumn + ? sql`COALESCE(NULLIF(btrim(${modelColumn}), ''), NULLIF(btrim(${fallbackModelColumn}), ''))` + : sql`NULLIF(btrim(${modelColumn}), '')`; + const actualResponseModel = sql`NULLIF(btrim(${actualResponseModelColumn}), '')`; + + return sql`( + ${effectiveRequestModel} IS NOT NULL + AND ${actualResponseModel} IS NOT NULL + AND ${effectiveRequestModel} <> ${actualResponseModel} + )`; +} + // 重试次数计算: // - 对齐前端 getRetryCount/isActualRequest:只统计“实际请求”的次数,再 - 1 得到重试次数 // - Hedge Race(并发尝试)按 0 处理(并发不算顺序重试,且 UI 优先展示 Hedge Race) @@ -145,6 +163,16 @@ export function buildUsageLogConditions(filters: UsageLogFilterParams): SQL[] { conditions.push(eq(messageRequest.model, filters.model)); } + if (filters.actualResponseModelMismatch) { + conditions.push( + buildActualResponseModelMismatchCondition( + messageRequest.model, + messageRequest.actualResponseModel, + messageRequest.originalModel + ) + ); + } + const hiddenEndpointCondition = buildDefaultHiddenUsageLogEndpointCondition( messageRequest.endpoint, filters.endpoint diff --git a/src/repository/usage-logs.ts b/src/repository/usage-logs.ts index fa789060c..1a3c00b52 100644 --- a/src/repository/usage-logs.ts +++ b/src/repository/usage-logs.ts @@ -15,6 +15,7 @@ import { LEDGER_BILLING_CONDITION } from "./_shared/ledger-conditions"; import { escapeLike } from "./_shared/like"; import { EXCLUDE_WARMUP_CONDITION } from "./_shared/message-request-conditions"; import { + buildActualResponseModelMismatchCondition, buildDefaultHiddenUsageLogEndpointCondition, buildUsageLogConditions, buildUsageLogEndpointMatchCondition, @@ -35,6 +36,8 @@ export interface UsageLogFilters { /** 排除 200 状态码(筛选所有非 200 的请求,包括 NULL) */ excludeStatusCode200?: boolean; model?: string; + /** 仅筛选请求模型与实际响应模型不一致的记录(不按 originalModel/模型重定向判断) */ + actualResponseModelMismatch?: boolean; endpoint?: string; /** 最低重试次数(按 provider_chain 中“实际请求”数量 - 1 计算;<= 0 视为不筛选) */ minRetryCount?: number; @@ -323,6 +326,16 @@ export async function findUsageLogsBatch( ledgerConditions.push(eq(usageLedger.model, filters.model)); } + if (filters.actualResponseModelMismatch) { + ledgerConditions.push( + buildActualResponseModelMismatchCondition( + usageLedger.model, + usageLedger.actualResponseModel, + usageLedger.originalModel + ) + ); + } + const hiddenLedgerEndpointCondition = buildDefaultHiddenUsageLogEndpointCondition( usageLedger.endpoint, filters.endpoint @@ -459,6 +472,7 @@ interface UsageLogSlimFilters { /** 排除 200 状态码(筛选所有非 200 的请求,包括 NULL) */ excludeStatusCode200?: boolean; model?: string; + actualResponseModelMismatch?: boolean; endpoint?: string; /** 最低重试次数(按 provider_chain 中“实际请求”数量 - 1 计算;<= 0 视为不筛选) */ minRetryCount?: number; @@ -512,6 +526,7 @@ export async function findUsageLogsForKeySlim( filters.statusCode ?? "", filters.excludeStatusCode200 ? "1" : "0", filters.model ?? "", + filters.actualResponseModelMismatch ? "1" : "0", filters.endpoint ?? "", filters.minRetryCount ?? "", ].join("\u0001"); @@ -730,6 +745,16 @@ function buildKeyLedgerConditions( conditions.push(eq(usageLedger.model, filters.model)); } + if (filters.actualResponseModelMismatch) { + conditions.push( + buildActualResponseModelMismatchCondition( + usageLedger.model, + usageLedger.actualResponseModel, + usageLedger.originalModel + ) + ); + } + const hiddenKeyLedgerEndpointCondition = buildDefaultHiddenUsageLogEndpointCondition( usageLedger.endpoint, filters.endpoint @@ -1677,6 +1702,16 @@ export async function findUsageLogsStats( conditions.push(eq(usageLedger.model, filters.model)); } + if (filters.actualResponseModelMismatch) { + conditions.push( + buildActualResponseModelMismatchCondition( + usageLedger.model, + usageLedger.actualResponseModel, + usageLedger.originalModel + ) + ); + } + const hiddenStatsLedgerEndpointCondition = buildDefaultHiddenUsageLogEndpointCondition( usageLedger.endpoint, filters.endpoint diff --git a/tests/api/v1/me/me.test.ts b/tests/api/v1/me/me.test.ts index a57f0e3fe..093e45cbd 100644 --- a/tests/api/v1/me/me.test.ts +++ b/tests/api/v1/me/me.test.ts @@ -171,6 +171,34 @@ describe("v1 me endpoints", () => { expect(getMyUsageLogsBatchFullMock).toHaveBeenCalledWith({ limit: 20, model: "claude" }); }); + test("passes actual response model mismatch filter through self usage-log request params", async () => { + const list = await callV1Route({ + method: "GET", + pathname: "/api/v1/me/usage-logs?limit=15&actualResponseModelMismatch=true", + headers, + }); + expect(list.response.status).toBe(200); + expect(getMyUsageLogsBatchMock).toHaveBeenCalledWith( + expect.objectContaining({ + limit: 15, + actualResponseModelMismatch: true, + }) + ); + + const full = await callV1Route({ + method: "GET", + pathname: "/api/v1/me/usage-logs/full?actualResponseModelMismatch=true", + headers, + }); + expect(full.response.status).toBe(200); + expect(getMyUsageLogsBatchFullMock).toHaveBeenCalledWith( + expect.objectContaining({ + limit: 20, + actualResponseModelMismatch: true, + }) + ); + }); + test("reads filter options stats summary and scoped ip geo", async () => { const models = await callV1Route({ method: "GET", diff --git a/tests/api/v1/usage-logs/usage-logs.test.ts b/tests/api/v1/usage-logs/usage-logs.test.ts index 6245aa868..68638988f 100644 --- a/tests/api/v1/usage-logs/usage-logs.test.ts +++ b/tests/api/v1/usage-logs/usage-logs.test.ts @@ -222,6 +222,46 @@ describe("v1 usage log endpoints", () => { }); }); + test("passes actual response model mismatch filter through usage-log request params", async () => { + const list = await callV1Route({ + method: "GET", + pathname: "/api/v1/usage-logs?limit=15&actualResponseModelMismatch=true", + headers, + }); + expect(list.response.status).toBe(200); + expect(getUsageLogsBatchMock).toHaveBeenCalledWith( + expect.objectContaining({ + limit: 15, + actualResponseModelMismatch: true, + }) + ); + + const stats = await callV1Route({ + method: "GET", + pathname: "/api/v1/usage-logs/stats?actualResponseModelMismatch=true", + headers, + }); + expect(stats.response.status).toBe(200); + expect(getUsageLogsStatsMock).toHaveBeenCalledWith( + expect.objectContaining({ + limit: 20, + actualResponseModelMismatch: true, + }) + ); + + const asyncExport = await callV1Route({ + method: "POST", + pathname: "/api/v1/usage-logs/exports", + headers: { ...headers, Prefer: "respond-async" }, + body: { actualResponseModelMismatch: true }, + }); + expect(asyncExport.response.status).toBe(202); + expect(startUsageLogsExportMock).toHaveBeenCalledWith({ + actualResponseModelMismatch: true, + format: "csv", + }); + }); + test("keeps global usage-log metadata admin-only", async () => { validateAuthTokenMock.mockResolvedValue(userSession); diff --git a/tests/unit/repository/usage-logs-actual-model-mismatch-filter.test.ts b/tests/unit/repository/usage-logs-actual-model-mismatch-filter.test.ts new file mode 100644 index 000000000..0bb3d0b37 --- /dev/null +++ b/tests/unit/repository/usage-logs-actual-model-mismatch-filter.test.ts @@ -0,0 +1,54 @@ +import type { SQL } from "drizzle-orm"; +import { CasingCache } from "drizzle-orm/casing"; +import { describe, expect, test } from "vitest"; + +import { buildUsageLogConditions } from "@/repository/_shared/usage-log-filters"; + +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; +} + +describe("Usage logs actual response model mismatch filter", () => { + test("compares requested billing model with actual response model, not redirected model", () => { + const conditions = buildUsageLogConditions({ actualResponseModelMismatch: true }); + const mismatchCondition = conditions.find((condition) => + sqlToString(condition).toLowerCase().includes("actual_response_model") + ); + + expect(mismatchCondition).toBeDefined(); + if (!mismatchCondition) { + throw new Error("Expected actual response model mismatch SQL condition to be present"); + } + + const whereSql = sqlToString(mismatchCondition).toLowerCase(); + expect(whereSql).toContain("coalesce"); + expect(whereSql).toContain('"message_request"."model"'); + expect(whereSql).toContain('"message_request"."original_model"'); + expect(whereSql).toContain('"message_request"."actual_response_model"'); + expect(whereSql).toContain("<>"); + expect(whereSql).not.toContain( + '"message_request"."original_model" <> "message_request"."model"' + ); + expect(whereSql).not.toContain( + '"message_request"."model" <> "message_request"."original_model"' + ); + }); + + test("does not add mismatch condition when the optional flag is absent or false", () => { + const baseSql = buildUsageLogConditions({}) + .map((condition) => sqlToString(condition).toLowerCase()) + .join("\n"); + const disabledSql = buildUsageLogConditions({ actualResponseModelMismatch: false }) + .map((condition) => sqlToString(condition).toLowerCase()) + .join("\n"); + + expect(baseSql).not.toContain("actual_response_model"); + expect(disabledSql).toBe(baseSql); + }); +});