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);
+ });
+});