diff --git a/changelog.d/fixes/6812-request-count-by-provider-date.md b/changelog.d/fixes/6812-request-count-by-provider-date.md new file mode 100644 index 0000000000..503897954e --- /dev/null +++ b/changelog.d/fixes/6812-request-count-by-provider-date.md @@ -0,0 +1 @@ +- **Request count by provider & date**: Dashboard → Analytics now shows a dedicated table of request counts grouped by provider and calendar date (plus token totals), for providers that bill per-request rather than per-token — sortable columns and a single-date filter. New `getProviderDailyUsageRows()` query (`src/lib/db/usageAnalytics.ts`) and its own `GET /api/usage/requests-by-provider-date` route (kept separate from the frozen `/api/usage/analytics` route). Regression guard: `tests/unit/db-provider-daily-usage-4009.test.ts`. (#4009 — thanks @tjengbudi) diff --git a/src/app/api/usage/requests-by-provider-date/route.ts b/src/app/api/usage/requests-by-provider-date/route.ts new file mode 100644 index 0000000000..0f1527b1d1 --- /dev/null +++ b/src/app/api/usage/requests-by-provider-date/route.ts @@ -0,0 +1,115 @@ +import { NextResponse } from "next/server"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { buildUnifiedSource, getProviderDailyUsageRows } from "@/lib/db/usageAnalytics"; + +/** + * GET /api/usage/requests-by-provider-date — #4009 + * + * Per-day, per-provider request counts (some providers bill by request, not + * token, so operators need a plain count broken down by date). Sliced into + * its own route (rather than folding into /api/usage/analytics) because that + * route's file size is frozen at the quality-gate baseline. + * + * Query params (all optional, mirror /api/usage/analytics): + * - range: "1d" | "7d" | "30d" | "90d" | "ytd" | "all" (default "30d") + * - startDate / endDate: ISO-8601, overrides `range` when both are set + * - date: YYYY-MM-DD, narrows the result to a single calendar date + */ + +function getRangeStartIso(range: string): string | null { + const end = new Date(); + const start = new Date(end); + + switch (range) { + case "1d": + start.setDate(start.getDate() - 1); + break; + case "7d": + start.setDate(start.getDate() - 7); + break; + case "30d": + start.setDate(start.getDate() - 30); + break; + case "90d": + start.setDate(start.getDate() - 90); + break; + case "ytd": + start.setMonth(0, 1); + start.setHours(0, 0, 0, 0); + break; + case "all": + default: + return null; + } + + return start.toISOString(); +} + +interface DateWindow { + sinceIso: string | null; + untilIso: string | null; +} + +/** + * Resolves the query window from the request's params: an explicit single + * `date` wins, then an explicit `startDate`/`endDate` pair, then falls back + * to the named `range` preset. + */ +function resolveDateWindow(searchParams: URLSearchParams, range: string): DateWindow { + const singleDate = searchParams.get("date") || undefined; + if (singleDate) { + return { sinceIso: `${singleDate}T00:00:00.000Z`, untilIso: `${singleDate}T23:59:59.999Z` }; + } + const startDate = searchParams.get("startDate") || undefined; + const endDate = searchParams.get("endDate") || undefined; + return { + sinceIso: startDate || getRangeStartIso(range), + untilIso: endDate || null, + }; +} + +async function resolveRawCutoffDate(): Promise { + const { getUserDatabaseSettings } = await import("@/lib/db/databaseSettings"); + const dbSettings = getUserDatabaseSettings(); + const rawRetentionDays = dbSettings.aggregation?.rawDataRetentionDays ?? 30; + const rawCutoff = new Date(); + rawCutoff.setDate(rawCutoff.getDate() - rawRetentionDays); + return rawCutoff.toISOString().split("T")[0]; +} + +function errorResponse(error: unknown): Promise { + console.error("Error computing requests-by-provider-date:", error); + const message = error instanceof Error ? error.message : String(error); + return import("@omniroute/open-sse/utils/error").then(({ buildErrorBody }) => + NextResponse.json(buildErrorBody(500, message || "Failed to compute requests-by-provider-date"), { + status: 500, + }) + ); +} + +export async function GET(request: Request) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + + try { + const { searchParams } = new URL(request.url); + const range = searchParams.get("range") || "30d"; + const singleDate = searchParams.get("date") || undefined; + const { sinceIso, untilIso } = resolveDateWindow(searchParams, range); + const rawCutoffDate = await resolveRawCutoffDate(); + + const { unifiedSource, unifiedParams } = buildUnifiedSource({ + sinceIso, + untilIso, + rawCutoffDate, + apiKeyWhere: "", + apiKeyParams: {}, + }); + + const rows = getProviderDailyUsageRows(unifiedSource, unifiedParams); + + return NextResponse.json({ rows, range, date: singleDate ?? null }); + } catch (error) { + return errorResponse(error); + } +} diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index 5d6ea3410f..dceeb74e18 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -1459,6 +1459,8 @@ "chartModel": "__MISSING__:Model", "chartProvider": "__MISSING__:Provider", "chartProviderBreakdown": "__MISSING__:Provider Breakdown", + "chartDate": "__MISSING__:Date", + "chartRequestsByProviderDate": "__MISSING__:Requests by Provider & Date", "filterAllKeys": "__MISSING__:All Keys", "filterSearchKeys": "__MISSING__:Search keys…", "filterNoKeysMatch": "__MISSING__:No keys match", diff --git a/src/i18n/messages/az.json b/src/i18n/messages/az.json index 8a0692310b..fd076d8198 100644 --- a/src/i18n/messages/az.json +++ b/src/i18n/messages/az.json @@ -1459,6 +1459,8 @@ "chartModel": "__MISSING__:Model", "chartProvider": "__MISSING__:Provider", "chartProviderBreakdown": "__MISSING__:Provider Breakdown", + "chartDate": "__MISSING__:Date", + "chartRequestsByProviderDate": "__MISSING__:Requests by Provider & Date", "filterAllKeys": "__MISSING__:All Keys", "filterSearchKeys": "__MISSING__:Search keys…", "filterNoKeysMatch": "__MISSING__:No keys match", diff --git a/src/i18n/messages/bg.json b/src/i18n/messages/bg.json index 2d4ed81e3e..5e37dde230 100644 --- a/src/i18n/messages/bg.json +++ b/src/i18n/messages/bg.json @@ -1459,6 +1459,8 @@ "chartModel": "__MISSING__:Model", "chartProvider": "__MISSING__:Provider", "chartProviderBreakdown": "__MISSING__:Provider Breakdown", + "chartDate": "__MISSING__:Date", + "chartRequestsByProviderDate": "__MISSING__:Requests by Provider & Date", "filterAllKeys": "__MISSING__:All Keys", "filterSearchKeys": "__MISSING__:Search keys…", "filterNoKeysMatch": "__MISSING__:No keys match", diff --git a/src/i18n/messages/bn.json b/src/i18n/messages/bn.json index b430f74ef1..2dfa459023 100644 --- a/src/i18n/messages/bn.json +++ b/src/i18n/messages/bn.json @@ -1459,6 +1459,8 @@ "chartModel": "__MISSING__:Model", "chartProvider": "__MISSING__:Provider", "chartProviderBreakdown": "__MISSING__:Provider Breakdown", + "chartDate": "__MISSING__:Date", + "chartRequestsByProviderDate": "__MISSING__:Requests by Provider & Date", "filterAllKeys": "__MISSING__:All Keys", "filterSearchKeys": "__MISSING__:Search keys…", "filterNoKeysMatch": "__MISSING__:No keys match", diff --git a/src/i18n/messages/cs.json b/src/i18n/messages/cs.json index d717be3d47..939546c185 100644 --- a/src/i18n/messages/cs.json +++ b/src/i18n/messages/cs.json @@ -1459,6 +1459,8 @@ "chartModel": "__MISSING__:Model", "chartProvider": "__MISSING__:Provider", "chartProviderBreakdown": "__MISSING__:Provider Breakdown", + "chartDate": "__MISSING__:Date", + "chartRequestsByProviderDate": "__MISSING__:Requests by Provider & Date", "filterAllKeys": "__MISSING__:All Keys", "filterSearchKeys": "__MISSING__:Search keys…", "filterNoKeysMatch": "__MISSING__:No keys match", diff --git a/src/i18n/messages/da.json b/src/i18n/messages/da.json index f14b7ca1be..6bee3d61ad 100644 --- a/src/i18n/messages/da.json +++ b/src/i18n/messages/da.json @@ -1459,6 +1459,8 @@ "chartModel": "__MISSING__:Model", "chartProvider": "__MISSING__:Provider", "chartProviderBreakdown": "__MISSING__:Provider Breakdown", + "chartDate": "__MISSING__:Date", + "chartRequestsByProviderDate": "__MISSING__:Requests by Provider & Date", "filterAllKeys": "__MISSING__:All Keys", "filterSearchKeys": "__MISSING__:Search keys…", "filterNoKeysMatch": "__MISSING__:No keys match", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index aafe0413ed..a891f51f50 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -1459,6 +1459,8 @@ "chartModel": "__MISSING__:Model", "chartProvider": "__MISSING__:Provider", "chartProviderBreakdown": "__MISSING__:Provider Breakdown", + "chartDate": "__MISSING__:Date", + "chartRequestsByProviderDate": "__MISSING__:Requests by Provider & Date", "filterAllKeys": "__MISSING__:All Keys", "filterSearchKeys": "__MISSING__:Search keys…", "filterNoKeysMatch": "__MISSING__:No keys match", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 5039def7d0..1685759606 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -1548,6 +1548,8 @@ "chartModel": "Model", "chartProvider": "Provider", "chartProviderBreakdown": "Provider Breakdown", + "chartDate": "Date", + "chartRequestsByProviderDate": "Requests by Provider & Date", "filterAllKeys": "All Keys", "filterSearchKeys": "Search keys…", "filterNoKeysMatch": "No keys match", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 540e3560fb..d167c6f699 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -1459,6 +1459,8 @@ "chartModel": "__MISSING__:Model", "chartProvider": "__MISSING__:Provider", "chartProviderBreakdown": "__MISSING__:Provider Breakdown", + "chartDate": "__MISSING__:Date", + "chartRequestsByProviderDate": "__MISSING__:Requests by Provider & Date", "filterAllKeys": "__MISSING__:All Keys", "filterSearchKeys": "__MISSING__:Search keys…", "filterNoKeysMatch": "__MISSING__:No keys match", diff --git a/src/i18n/messages/fa.json b/src/i18n/messages/fa.json index 0621085dd3..5ab6808535 100644 --- a/src/i18n/messages/fa.json +++ b/src/i18n/messages/fa.json @@ -1459,6 +1459,8 @@ "chartModel": "__MISSING__:Model", "chartProvider": "__MISSING__:Provider", "chartProviderBreakdown": "__MISSING__:Provider Breakdown", + "chartDate": "__MISSING__:Date", + "chartRequestsByProviderDate": "__MISSING__:Requests by Provider & Date", "filterAllKeys": "__MISSING__:All Keys", "filterSearchKeys": "__MISSING__:Search keys…", "filterNoKeysMatch": "__MISSING__:No keys match", diff --git a/src/i18n/messages/fi.json b/src/i18n/messages/fi.json index b734538f15..cc3bfc90c1 100644 --- a/src/i18n/messages/fi.json +++ b/src/i18n/messages/fi.json @@ -1459,6 +1459,8 @@ "chartModel": "__MISSING__:Model", "chartProvider": "__MISSING__:Provider", "chartProviderBreakdown": "__MISSING__:Provider Breakdown", + "chartDate": "__MISSING__:Date", + "chartRequestsByProviderDate": "__MISSING__:Requests by Provider & Date", "filterAllKeys": "__MISSING__:All Keys", "filterSearchKeys": "__MISSING__:Search keys…", "filterNoKeysMatch": "__MISSING__:No keys match", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 131a1d0ca9..c73f1f7324 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -1459,6 +1459,8 @@ "chartModel": "__MISSING__:Model", "chartProvider": "__MISSING__:Provider", "chartProviderBreakdown": "__MISSING__:Provider Breakdown", + "chartDate": "__MISSING__:Date", + "chartRequestsByProviderDate": "__MISSING__:Requests by Provider & Date", "filterAllKeys": "__MISSING__:All Keys", "filterSearchKeys": "__MISSING__:Search keys…", "filterNoKeysMatch": "__MISSING__:No keys match", diff --git a/src/i18n/messages/gu.json b/src/i18n/messages/gu.json index d579e59c6b..a5c544ea78 100644 --- a/src/i18n/messages/gu.json +++ b/src/i18n/messages/gu.json @@ -1459,6 +1459,8 @@ "chartModel": "__MISSING__:Model", "chartProvider": "__MISSING__:Provider", "chartProviderBreakdown": "__MISSING__:Provider Breakdown", + "chartDate": "__MISSING__:Date", + "chartRequestsByProviderDate": "__MISSING__:Requests by Provider & Date", "filterAllKeys": "__MISSING__:All Keys", "filterSearchKeys": "__MISSING__:Search keys…", "filterNoKeysMatch": "__MISSING__:No keys match", diff --git a/src/i18n/messages/he.json b/src/i18n/messages/he.json index 93adcb6f09..7e645fade7 100644 --- a/src/i18n/messages/he.json +++ b/src/i18n/messages/he.json @@ -1459,6 +1459,8 @@ "chartModel": "__MISSING__:Model", "chartProvider": "__MISSING__:Provider", "chartProviderBreakdown": "__MISSING__:Provider Breakdown", + "chartDate": "__MISSING__:Date", + "chartRequestsByProviderDate": "__MISSING__:Requests by Provider & Date", "filterAllKeys": "__MISSING__:All Keys", "filterSearchKeys": "__MISSING__:Search keys…", "filterNoKeysMatch": "__MISSING__:No keys match", diff --git a/src/i18n/messages/hi.json b/src/i18n/messages/hi.json index 6d9c14c382..eea9fc002b 100644 --- a/src/i18n/messages/hi.json +++ b/src/i18n/messages/hi.json @@ -1459,6 +1459,8 @@ "chartModel": "__MISSING__:Model", "chartProvider": "__MISSING__:Provider", "chartProviderBreakdown": "__MISSING__:Provider Breakdown", + "chartDate": "__MISSING__:Date", + "chartRequestsByProviderDate": "__MISSING__:Requests by Provider & Date", "filterAllKeys": "__MISSING__:All Keys", "filterSearchKeys": "__MISSING__:Search keys…", "filterNoKeysMatch": "__MISSING__:No keys match", diff --git a/src/i18n/messages/hu.json b/src/i18n/messages/hu.json index 0bf85411ec..bb31037deb 100644 --- a/src/i18n/messages/hu.json +++ b/src/i18n/messages/hu.json @@ -1459,6 +1459,8 @@ "chartModel": "__MISSING__:Model", "chartProvider": "__MISSING__:Provider", "chartProviderBreakdown": "__MISSING__:Provider Breakdown", + "chartDate": "__MISSING__:Date", + "chartRequestsByProviderDate": "__MISSING__:Requests by Provider & Date", "filterAllKeys": "__MISSING__:All Keys", "filterSearchKeys": "__MISSING__:Search keys…", "filterNoKeysMatch": "__MISSING__:No keys match", diff --git a/src/i18n/messages/id.json b/src/i18n/messages/id.json index bfc6435007..5f8f86b9d9 100644 --- a/src/i18n/messages/id.json +++ b/src/i18n/messages/id.json @@ -1459,6 +1459,8 @@ "chartModel": "__MISSING__:Model", "chartProvider": "__MISSING__:Provider", "chartProviderBreakdown": "__MISSING__:Provider Breakdown", + "chartDate": "__MISSING__:Date", + "chartRequestsByProviderDate": "__MISSING__:Requests by Provider & Date", "filterAllKeys": "__MISSING__:All Keys", "filterSearchKeys": "__MISSING__:Search keys…", "filterNoKeysMatch": "__MISSING__:No keys match", diff --git a/src/i18n/messages/in.json b/src/i18n/messages/in.json index a98515a1b7..cac01f0870 100644 --- a/src/i18n/messages/in.json +++ b/src/i18n/messages/in.json @@ -1459,6 +1459,8 @@ "chartModel": "__MISSING__:Model", "chartProvider": "__MISSING__:Provider", "chartProviderBreakdown": "__MISSING__:Provider Breakdown", + "chartDate": "__MISSING__:Date", + "chartRequestsByProviderDate": "__MISSING__:Requests by Provider & Date", "filterAllKeys": "__MISSING__:All Keys", "filterSearchKeys": "__MISSING__:Search keys…", "filterNoKeysMatch": "__MISSING__:No keys match", diff --git a/src/i18n/messages/it.json b/src/i18n/messages/it.json index 19f5df65a3..b277686fac 100644 --- a/src/i18n/messages/it.json +++ b/src/i18n/messages/it.json @@ -1546,6 +1546,8 @@ "chartModel": "__MISSING__:Model", "chartProvider": "__MISSING__:Provider", "chartProviderBreakdown": "__MISSING__:Provider Breakdown", + "chartDate": "__MISSING__:Date", + "chartRequestsByProviderDate": "__MISSING__:Requests by Provider & Date", "filterAllKeys": "__MISSING__:All Keys", "filterSearchKeys": "__MISSING__:Search keys…", "filterNoKeysMatch": "__MISSING__:No keys match", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index 84a4b2e88c..ebf23e8c47 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -1542,6 +1542,8 @@ "chartModel": "__MISSING__:Model", "chartProvider": "__MISSING__:Provider", "chartProviderBreakdown": "__MISSING__:Provider Breakdown", + "chartDate": "__MISSING__:Date", + "chartRequestsByProviderDate": "__MISSING__:Requests by Provider & Date", "filterAllKeys": "__MISSING__:All Keys", "filterSearchKeys": "__MISSING__:Search keys…", "filterNoKeysMatch": "__MISSING__:No keys match", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index 0fd5de9cd5..f7daabcef8 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -1542,6 +1542,8 @@ "chartModel": "__MISSING__:Model", "chartProvider": "__MISSING__:Provider", "chartProviderBreakdown": "__MISSING__:Provider Breakdown", + "chartDate": "__MISSING__:Date", + "chartRequestsByProviderDate": "__MISSING__:Requests by Provider & Date", "filterAllKeys": "__MISSING__:All Keys", "filterSearchKeys": "__MISSING__:Search keys…", "filterNoKeysMatch": "__MISSING__:No keys match", diff --git a/src/i18n/messages/mr.json b/src/i18n/messages/mr.json index 54c1008d39..bde1c1f9db 100644 --- a/src/i18n/messages/mr.json +++ b/src/i18n/messages/mr.json @@ -1542,6 +1542,8 @@ "chartModel": "__MISSING__:Model", "chartProvider": "__MISSING__:Provider", "chartProviderBreakdown": "__MISSING__:Provider Breakdown", + "chartDate": "__MISSING__:Date", + "chartRequestsByProviderDate": "__MISSING__:Requests by Provider & Date", "filterAllKeys": "__MISSING__:All Keys", "filterSearchKeys": "__MISSING__:Search keys…", "filterNoKeysMatch": "__MISSING__:No keys match", diff --git a/src/i18n/messages/ms.json b/src/i18n/messages/ms.json index f62546e33c..96ac1d12ea 100644 --- a/src/i18n/messages/ms.json +++ b/src/i18n/messages/ms.json @@ -1542,6 +1542,8 @@ "chartModel": "__MISSING__:Model", "chartProvider": "__MISSING__:Provider", "chartProviderBreakdown": "__MISSING__:Provider Breakdown", + "chartDate": "__MISSING__:Date", + "chartRequestsByProviderDate": "__MISSING__:Requests by Provider & Date", "filterAllKeys": "__MISSING__:All Keys", "filterSearchKeys": "__MISSING__:Search keys…", "filterNoKeysMatch": "__MISSING__:No keys match", diff --git a/src/i18n/messages/nl.json b/src/i18n/messages/nl.json index fba50a4dcb..fc8bb6c44d 100644 --- a/src/i18n/messages/nl.json +++ b/src/i18n/messages/nl.json @@ -1542,6 +1542,8 @@ "chartModel": "__MISSING__:Model", "chartProvider": "__MISSING__:Provider", "chartProviderBreakdown": "__MISSING__:Provider Breakdown", + "chartDate": "__MISSING__:Date", + "chartRequestsByProviderDate": "__MISSING__:Requests by Provider & Date", "filterAllKeys": "__MISSING__:All Keys", "filterSearchKeys": "__MISSING__:Search keys…", "filterNoKeysMatch": "__MISSING__:No keys match", diff --git a/src/i18n/messages/no.json b/src/i18n/messages/no.json index 3c9b0a24b0..e91d1b5d20 100644 --- a/src/i18n/messages/no.json +++ b/src/i18n/messages/no.json @@ -1542,6 +1542,8 @@ "chartModel": "__MISSING__:Model", "chartProvider": "__MISSING__:Provider", "chartProviderBreakdown": "__MISSING__:Provider Breakdown", + "chartDate": "__MISSING__:Date", + "chartRequestsByProviderDate": "__MISSING__:Requests by Provider & Date", "filterAllKeys": "__MISSING__:All Keys", "filterSearchKeys": "__MISSING__:Search keys…", "filterNoKeysMatch": "__MISSING__:No keys match", diff --git a/src/i18n/messages/phi.json b/src/i18n/messages/phi.json index 19369618fd..f621e04ee8 100644 --- a/src/i18n/messages/phi.json +++ b/src/i18n/messages/phi.json @@ -1542,6 +1542,8 @@ "chartModel": "__MISSING__:Model", "chartProvider": "__MISSING__:Provider", "chartProviderBreakdown": "__MISSING__:Provider Breakdown", + "chartDate": "__MISSING__:Date", + "chartRequestsByProviderDate": "__MISSING__:Requests by Provider & Date", "filterAllKeys": "__MISSING__:All Keys", "filterSearchKeys": "__MISSING__:Search keys…", "filterNoKeysMatch": "__MISSING__:No keys match", diff --git a/src/i18n/messages/pl.json b/src/i18n/messages/pl.json index 6ef4a7676e..ae0829d5c3 100644 --- a/src/i18n/messages/pl.json +++ b/src/i18n/messages/pl.json @@ -1542,6 +1542,8 @@ "chartModel": "__MISSING__:Model", "chartProvider": "__MISSING__:Provider", "chartProviderBreakdown": "__MISSING__:Provider Breakdown", + "chartDate": "__MISSING__:Date", + "chartRequestsByProviderDate": "__MISSING__:Requests by Provider & Date", "filterAllKeys": "__MISSING__:All Keys", "filterSearchKeys": "__MISSING__:Search keys…", "filterNoKeysMatch": "__MISSING__:No keys match", diff --git a/src/i18n/messages/pt-BR.json b/src/i18n/messages/pt-BR.json index eb7666a1aa..91f0197be3 100644 --- a/src/i18n/messages/pt-BR.json +++ b/src/i18n/messages/pt-BR.json @@ -1542,6 +1542,8 @@ "chartModel": "Modelo", "chartProvider": "Provedor", "chartProviderBreakdown": "Detalhamento por Provedor", + "chartDate": "Data", + "chartRequestsByProviderDate": "Requisições por Provedor e Data", "filterAllKeys": "Todas as Chaves", "filterSearchKeys": "Buscar chaves…", "filterNoKeysMatch": "Nenhuma chave corresponde", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index b529e56379..068a7309db 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -1542,6 +1542,8 @@ "chartModel": "__MISSING__:Model", "chartProvider": "__MISSING__:Provider", "chartProviderBreakdown": "__MISSING__:Provider Breakdown", + "chartDate": "Data", + "chartRequestsByProviderDate": "Requisições por Provedor e Data", "filterAllKeys": "__MISSING__:All Keys", "filterSearchKeys": "__MISSING__:Search keys…", "filterNoKeysMatch": "__MISSING__:No keys match", diff --git a/src/i18n/messages/ro.json b/src/i18n/messages/ro.json index 9010ed140e..4e7d0c2dbe 100644 --- a/src/i18n/messages/ro.json +++ b/src/i18n/messages/ro.json @@ -1542,6 +1542,8 @@ "chartModel": "__MISSING__:Model", "chartProvider": "__MISSING__:Provider", "chartProviderBreakdown": "__MISSING__:Provider Breakdown", + "chartDate": "__MISSING__:Date", + "chartRequestsByProviderDate": "__MISSING__:Requests by Provider & Date", "filterAllKeys": "__MISSING__:All Keys", "filterSearchKeys": "__MISSING__:Search keys…", "filterNoKeysMatch": "__MISSING__:No keys match", diff --git a/src/i18n/messages/ru.json b/src/i18n/messages/ru.json index 9ebc7f3a5e..7557b3449c 100644 --- a/src/i18n/messages/ru.json +++ b/src/i18n/messages/ru.json @@ -1542,6 +1542,8 @@ "chartModel": "Модель", "chartProvider": "Поставщик", "chartProviderBreakdown": "Разбивка по провайдерам", + "chartDate": "__MISSING__:Date", + "chartRequestsByProviderDate": "__MISSING__:Requests by Provider & Date", "filterAllKeys": "Все ключи", "filterSearchKeys": "Поиск ключей…", "filterNoKeysMatch": "Нет совпадающих ключей", diff --git a/src/i18n/messages/sk.json b/src/i18n/messages/sk.json index fa886993d1..771fca2545 100644 --- a/src/i18n/messages/sk.json +++ b/src/i18n/messages/sk.json @@ -1542,6 +1542,8 @@ "chartModel": "__MISSING__:Model", "chartProvider": "__MISSING__:Provider", "chartProviderBreakdown": "__MISSING__:Provider Breakdown", + "chartDate": "__MISSING__:Date", + "chartRequestsByProviderDate": "__MISSING__:Requests by Provider & Date", "filterAllKeys": "__MISSING__:All Keys", "filterSearchKeys": "__MISSING__:Search keys…", "filterNoKeysMatch": "__MISSING__:No keys match", diff --git a/src/i18n/messages/sv.json b/src/i18n/messages/sv.json index a6d1eb0895..223f535b2a 100644 --- a/src/i18n/messages/sv.json +++ b/src/i18n/messages/sv.json @@ -1542,6 +1542,8 @@ "chartModel": "__MISSING__:Model", "chartProvider": "__MISSING__:Provider", "chartProviderBreakdown": "__MISSING__:Provider Breakdown", + "chartDate": "__MISSING__:Date", + "chartRequestsByProviderDate": "__MISSING__:Requests by Provider & Date", "filterAllKeys": "__MISSING__:All Keys", "filterSearchKeys": "__MISSING__:Search keys…", "filterNoKeysMatch": "__MISSING__:No keys match", diff --git a/src/i18n/messages/sw.json b/src/i18n/messages/sw.json index 1b3252984e..f5bf31ee4f 100644 --- a/src/i18n/messages/sw.json +++ b/src/i18n/messages/sw.json @@ -1459,6 +1459,8 @@ "chartModel": "__MISSING__:Model", "chartProvider": "__MISSING__:Provider", "chartProviderBreakdown": "__MISSING__:Provider Breakdown", + "chartDate": "__MISSING__:Date", + "chartRequestsByProviderDate": "__MISSING__:Requests by Provider & Date", "filterAllKeys": "__MISSING__:All Keys", "filterSearchKeys": "__MISSING__:Search keys…", "filterNoKeysMatch": "__MISSING__:No keys match", diff --git a/src/i18n/messages/ta.json b/src/i18n/messages/ta.json index 31be24816b..196ceef133 100644 --- a/src/i18n/messages/ta.json +++ b/src/i18n/messages/ta.json @@ -1459,6 +1459,8 @@ "chartModel": "__MISSING__:Model", "chartProvider": "__MISSING__:Provider", "chartProviderBreakdown": "__MISSING__:Provider Breakdown", + "chartDate": "__MISSING__:Date", + "chartRequestsByProviderDate": "__MISSING__:Requests by Provider & Date", "filterAllKeys": "__MISSING__:All Keys", "filterSearchKeys": "__MISSING__:Search keys…", "filterNoKeysMatch": "__MISSING__:No keys match", diff --git a/src/i18n/messages/te.json b/src/i18n/messages/te.json index f0515a3cf7..d4881a7878 100644 --- a/src/i18n/messages/te.json +++ b/src/i18n/messages/te.json @@ -1459,6 +1459,8 @@ "chartModel": "__MISSING__:Model", "chartProvider": "__MISSING__:Provider", "chartProviderBreakdown": "__MISSING__:Provider Breakdown", + "chartDate": "__MISSING__:Date", + "chartRequestsByProviderDate": "__MISSING__:Requests by Provider & Date", "filterAllKeys": "__MISSING__:All Keys", "filterSearchKeys": "__MISSING__:Search keys…", "filterNoKeysMatch": "__MISSING__:No keys match", diff --git a/src/i18n/messages/th.json b/src/i18n/messages/th.json index fa472aa81c..ff66525a28 100644 --- a/src/i18n/messages/th.json +++ b/src/i18n/messages/th.json @@ -1459,6 +1459,8 @@ "chartModel": "__MISSING__:Model", "chartProvider": "__MISSING__:Provider", "chartProviderBreakdown": "__MISSING__:Provider Breakdown", + "chartDate": "__MISSING__:Date", + "chartRequestsByProviderDate": "__MISSING__:Requests by Provider & Date", "filterAllKeys": "__MISSING__:All Keys", "filterSearchKeys": "__MISSING__:Search keys…", "filterNoKeysMatch": "__MISSING__:No keys match", diff --git a/src/i18n/messages/tr.json b/src/i18n/messages/tr.json index 04f02ebf9e..ceee5afaee 100644 --- a/src/i18n/messages/tr.json +++ b/src/i18n/messages/tr.json @@ -1459,6 +1459,8 @@ "chartModel": "__MISSING__:Model", "chartProvider": "__MISSING__:Provider", "chartProviderBreakdown": "__MISSING__:Provider Breakdown", + "chartDate": "__MISSING__:Date", + "chartRequestsByProviderDate": "__MISSING__:Requests by Provider & Date", "filterAllKeys": "__MISSING__:All Keys", "filterSearchKeys": "__MISSING__:Search keys…", "filterNoKeysMatch": "__MISSING__:No keys match", diff --git a/src/i18n/messages/uk-UA.json b/src/i18n/messages/uk-UA.json index adf4dc286f..ec688d1f99 100644 --- a/src/i18n/messages/uk-UA.json +++ b/src/i18n/messages/uk-UA.json @@ -1459,6 +1459,8 @@ "chartModel": "Модель", "chartProvider": "Провайдер", "chartProviderBreakdown": "Розподіл за провайдерами", + "chartDate": "__MISSING__:Date", + "chartRequestsByProviderDate": "__MISSING__:Requests by Provider & Date", "filterAllKeys": "Усі ключі", "filterSearchKeys": "Пошук ключів…", "filterNoKeysMatch": "Жоден ключ не відповідає", diff --git a/src/i18n/messages/ur.json b/src/i18n/messages/ur.json index 705f7e2d48..7d404dc24f 100644 --- a/src/i18n/messages/ur.json +++ b/src/i18n/messages/ur.json @@ -1459,6 +1459,8 @@ "chartModel": "__MISSING__:Model", "chartProvider": "__MISSING__:Provider", "chartProviderBreakdown": "__MISSING__:Provider Breakdown", + "chartDate": "__MISSING__:Date", + "chartRequestsByProviderDate": "__MISSING__:Requests by Provider & Date", "filterAllKeys": "__MISSING__:All Keys", "filterSearchKeys": "__MISSING__:Search keys…", "filterNoKeysMatch": "__MISSING__:No keys match", diff --git a/src/i18n/messages/vi.json b/src/i18n/messages/vi.json index 995090c263..2270e1f044 100644 --- a/src/i18n/messages/vi.json +++ b/src/i18n/messages/vi.json @@ -1459,6 +1459,8 @@ "chartModel": "__MISSING__:Model", "chartProvider": "__MISSING__:Provider", "chartProviderBreakdown": "__MISSING__:Provider Breakdown", + "chartDate": "__MISSING__:Date", + "chartRequestsByProviderDate": "__MISSING__:Requests by Provider & Date", "filterAllKeys": "__MISSING__:All Keys", "filterSearchKeys": "__MISSING__:Search keys…", "filterNoKeysMatch": "__MISSING__:No keys match", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index dddac4abd5..8f31fa9dd0 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -1532,6 +1532,8 @@ "chartModel": "模型", "chartProvider": "提供商", "chartProviderBreakdown": "按提供商拆分", + "chartDate": "__MISSING__:Date", + "chartRequestsByProviderDate": "__MISSING__:Requests by Provider & Date", "filterAllKeys": "全部密钥", "filterSearchKeys": "搜索密钥…", "filterNoKeysMatch": "没有匹配的密钥", diff --git a/src/lib/db/usageAnalytics.ts b/src/lib/db/usageAnalytics.ts index 59c627a441..e37fc5875c 100644 --- a/src/lib/db/usageAnalytics.ts +++ b/src/lib/db/usageAnalytics.ts @@ -693,6 +693,49 @@ export function getEndpointUsageRows(params: EndpointUsageParams = {}): Endpoint .all(bind) as EndpointUsageRow[]; } +// --------------------------------------------------------------------------- +// Request count per provider, per date — #4009 +// --------------------------------------------------------------------------- + +export interface ProviderDailyUsageRow { + date: string; + provider: string; + requests: number; + promptTokens: number; + completionTokens: number; + totalTokens: number; +} + +/** + * Per-day, per-provider request counts + token totals from the unified source CTE. + * Answers "how many requests did provider X get on date Y" (#4009) — providers that + * bill per-request rather than per-token need this breakdown, not just the + * per-provider aggregate (`getProviderUsageRows`) or the per-day aggregate + * (`getDailyUsage`). + */ +export function getProviderDailyUsageRows( + unifiedSource: string, + params: AnalyticsParams +): ProviderDailyUsageRow[] { + const db = getDbInstance(); + return db + .prepare( + ` + SELECT + DATE(timestamp) as date, + LOWER(provider) as provider, + COUNT(*) as requests, + COALESCE(SUM(tokens_input), 0) as promptTokens, + COALESCE(SUM(tokens_output), 0) as completionTokens, + COALESCE(SUM(tokens_input + tokens_output), 0) as totalTokens + FROM ${unifiedSource} AS _u + GROUP BY DATE(timestamp), LOWER(provider) + ORDER BY date DESC, requests DESC + ` + ) + .all(params) as ProviderDailyUsageRow[]; +} + // --------------------------------------------------------------------------- // Export-JSON backup — /api/settings/export-json // --------------------------------------------------------------------------- diff --git a/src/shared/components/UsageAnalytics.tsx b/src/shared/components/UsageAnalytics.tsx index c9282d03cc..02c5a62b26 100644 --- a/src/shared/components/UsageAnalytics.tsx +++ b/src/shared/components/UsageAnalytics.tsx @@ -23,6 +23,7 @@ import { ServiceTierBreakdown, ApiKeyFilterDropdown, CustomRangePicker, + RequestCountByProviderDateTable, } from "./analytics"; // ============================================================================ @@ -404,6 +405,9 @@ export default function UsageAnalytics() { {/* Provider Breakdown Table */} + {/* Request Count by Provider & Date — #4009 (some providers bill per-request) */} + + {/* API Key Table */} diff --git a/src/shared/components/analytics/RequestCountByProviderDateTable.tsx b/src/shared/components/analytics/RequestCountByProviderDateTable.tsx new file mode 100644 index 0000000000..e63eaa552c --- /dev/null +++ b/src/shared/components/analytics/RequestCountByProviderDateTable.tsx @@ -0,0 +1,80 @@ +"use client"; + +/** + * RequestCountByProviderDateTable — #4009 + * + * Some providers bill by request rather than by token, so operators need a + * plain per-provider, per-date request count (not just token aggregates). + * Self-contained: fetches its own data (via useProviderDailyUsage) from a + * dedicated endpoint so the main analytics route (frozen at the file-size + * baseline) stays untouched. Header + table markup are split into + * RequestCountDateFilter / RequestCountTable to keep this container under + * the max-lines-per-function complexity gate. + */ + +import { useCallback, useMemo, useState } from "react"; +import { useTranslations } from "next-intl"; +import Card from "../Card"; +import RequestCountDateFilter from "./RequestCountDateFilter"; +import RequestCountTable, { type RequestCountSortField } from "./RequestCountTable"; +import { useProviderDailyUsage } from "./useProviderDailyUsage"; +import { sortProviderDailyUsageRows } from "./requestCountSort"; + +export default function RequestCountByProviderDateTable({ range }: { range: string }) { + const t = useTranslations("analytics"); + const tCommon = useTranslations("common"); + const [dateFilter, setDateFilter] = useState(""); + const [sortBy, setSortBy] = useState("date"); + const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc"); + + const { rows, loading, error } = useProviderDailyUsage(range, dateFilter); + + const toggleSort = useCallback( + (field: RequestCountSortField) => { + if (sortBy === field) { + setSortOrder((prev) => (prev === "asc" ? "desc" : "asc")); + } else { + setSortBy(field); + setSortOrder("desc"); + } + }, + [sortBy] + ); + + const sorted = useMemo( + () => sortProviderDailyUsageRows(rows, sortBy, sortOrder), + [rows, sortBy, sortOrder] + ); + + return ( + + + + {loading && rows.length === 0 ? ( +
{tCommon("loading")}
+ ) : error ? ( +
+ {tCommon("errorShort")}: {error} +
+ ) : sorted.length === 0 ? ( +
{t("chartNoData")}
+ ) : ( + + )} +
+ ); +} diff --git a/src/shared/components/analytics/RequestCountDateFilter.tsx b/src/shared/components/analytics/RequestCountDateFilter.tsx new file mode 100644 index 0000000000..908b4af1ba --- /dev/null +++ b/src/shared/components/analytics/RequestCountDateFilter.tsx @@ -0,0 +1,41 @@ +"use client"; + +/** + * RequestCountDateFilter — title + single-date filter row for #4009's + * request-count-by-provider-date table. Split out to keep the container + * component under the max-lines-per-function complexity gate. + */ + +interface RequestCountDateFilterProps { + title: string; + dateLabel: string; + value: string; + onChange: (value: string) => void; +} + +export default function RequestCountDateFilter({ + title, + dateLabel, + value, + onChange, +}: RequestCountDateFilterProps) { + return ( +
+

{title}

+
+ onChange(e.target.value)} + aria-label={dateLabel} + className="px-2 py-1 rounded-md text-xs bg-black/[0.03] dark:bg-white/[0.03] border border-black/5 dark:border-white/5" + /> + {value && ( + + )} +
+
+ ); +} diff --git a/src/shared/components/analytics/RequestCountTable.tsx b/src/shared/components/analytics/RequestCountTable.tsx new file mode 100644 index 0000000000..71419a7d75 --- /dev/null +++ b/src/shared/components/analytics/RequestCountTable.tsx @@ -0,0 +1,104 @@ +"use client"; + +/** + * RequestCountTable — pure presentational table for #4009. + * Split out of RequestCountByProviderDateTable to keep that container under + * the max-lines-per-function complexity gate. + */ + +import { PROVIDER_COLORS } from "./chartColors"; +import { fmtCompact as fmt, fmtFull } from "@/shared/utils/formatting"; +import { SortIndicator } from "./charts"; + +export interface ProviderDailyUsageRow { + date: string; + provider: string; + requests: number; + promptTokens: number; + completionTokens: number; + totalTokens: number; +} + +export type RequestCountSortField = "date" | "provider" | "requests" | "totalTokens"; + +interface RequestCountTableProps { + rows: ProviderDailyUsageRow[]; + sortBy: RequestCountSortField; + sortOrder: "asc" | "desc"; + onToggleSort: (field: RequestCountSortField) => void; + dateLabel: string; + providerLabel: string; + requestsLabel: string; + totalLabel: string; +} + +export default function RequestCountTable({ + rows, + sortBy, + sortOrder, + onToggleSort, + dateLabel, + providerLabel, + requestsLabel, + totalLabel, +}: RequestCountTableProps) { + return ( +
+ + + + + + + + + + + {rows.map((row, i) => ( + + + + + + + ))} + +
onToggleSort("date")} + > + {dateLabel} + onToggleSort("provider")} + > + {providerLabel} + onToggleSort("requests")} + > + {requestsLabel} + onToggleSort("totalTokens")} + > + {totalLabel} +
{row.date} +
+ + {row.provider} +
+
+ {fmtFull(row.requests)} + + {fmt(row.totalTokens)} +
+
+ ); +} diff --git a/src/shared/components/analytics/index.tsx b/src/shared/components/analytics/index.tsx index 41e82ca084..a9eb9d34ee 100644 --- a/src/shared/components/analytics/index.tsx +++ b/src/shared/components/analytics/index.tsx @@ -26,3 +26,4 @@ export { DailyTrendChart, ModelOverTimeChart } from "./rechartsUsageCharts"; export { default as ApiKeyFilterDropdown } from "./ApiKeyFilterDropdown"; export { default as CustomRangePicker } from "./CustomRangePicker"; +export { default as RequestCountByProviderDateTable } from "./RequestCountByProviderDateTable"; diff --git a/src/shared/components/analytics/requestCountSort.ts b/src/shared/components/analytics/requestCountSort.ts new file mode 100644 index 0000000000..fa83588f9d --- /dev/null +++ b/src/shared/components/analytics/requestCountSort.ts @@ -0,0 +1,24 @@ +/** + * requestCountSort — pure sort helper for #4009's request-count-by-provider-date table. + * Split out of RequestCountByProviderDateTable to keep that container under the + * max-lines-per-function complexity gate. + */ + +import type { ProviderDailyUsageRow, RequestCountSortField } from "./RequestCountTable"; + +export function sortProviderDailyUsageRows( + rows: ProviderDailyUsageRow[], + sortBy: RequestCountSortField, + sortOrder: "asc" | "desc" +): ProviderDailyUsageRow[] { + const arr = [...rows]; + arr.sort((a, b) => { + const va = a[sortBy]; + const vb = b[sortBy]; + if (typeof va === "string" && typeof vb === "string") { + return sortOrder === "asc" ? va.localeCompare(vb) : vb.localeCompare(va); + } + return sortOrder === "asc" ? Number(va) - Number(vb) : Number(vb) - Number(va); + }); + return arr; +} diff --git a/src/shared/components/analytics/useProviderDailyUsage.ts b/src/shared/components/analytics/useProviderDailyUsage.ts new file mode 100644 index 0000000000..da2a5265f3 --- /dev/null +++ b/src/shared/components/analytics/useProviderDailyUsage.ts @@ -0,0 +1,46 @@ +"use client"; + +/** + * useProviderDailyUsage — data hook for #4009's request-count-by-provider-date table. + * Split out of RequestCountByProviderDateTable to keep that container under the + * max-lines-per-function complexity gate. + */ + +import { useCallback, useEffect, useState } from "react"; +import { useTranslations } from "next-intl"; +import { readFetchErrorMessage } from "@/shared/utils/fetchError"; +import type { ProviderDailyUsageRow } from "./RequestCountTable"; + +export function useProviderDailyUsage(range: string, dateFilter: string) { + const tCommon = useTranslations("common"); + const [rows, setRows] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchRows = useCallback(async () => { + try { + setLoading(true); + const params = new URLSearchParams(); + if (dateFilter) { + params.set("date", dateFilter); + } else { + params.set("range", range); + } + const res = await fetch(`/api/usage/requests-by-provider-date?${params.toString()}`); + if (!res.ok) throw new Error(await readFetchErrorMessage(res, tCommon("error"))); + const data = await res.json(); + setRows(Array.isArray(data.rows) ? data.rows : []); + setError(null); + } catch (err) { + setError((err as Error).message); + } finally { + setLoading(false); + } + }, [range, dateFilter, tCommon]); + + useEffect(() => { + fetchRows(); + }, [fetchRows]); + + return { rows, loading, error }; +} diff --git a/tests/unit/db-provider-daily-usage-4009.test.ts b/tests/unit/db-provider-daily-usage-4009.test.ts new file mode 100644 index 0000000000..9c19f747a3 --- /dev/null +++ b/tests/unit/db-provider-daily-usage-4009.test.ts @@ -0,0 +1,137 @@ +/** + * #4009 — Request count log per provider, per date. + * + * Some providers bill by request rather than by token, so operators need a + * plain per-provider, per-date request count breakdown. Verifies + * `getProviderDailyUsageRows` (src/lib/db/usageAnalytics.ts) groups + * `usage_history` rows correctly by DATE(timestamp) + provider. + * + * Seeds an in-memory temp SQLite DB and releases the handle in test.after + * (CLAUDE.md PII/Stream Learnings #3 — otherwise node:test hangs). + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omni-db-provider-daily-4009-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const mod = await import("../../src/lib/db/usageAnalytics.ts"); + +function insertUsageHistory(row: Record) { + const db = core.getDbInstance(); + const full = { + provider: "openai", + model: "gpt-4.1", + tokens_input: 10, + tokens_output: 20, + tokens_cache_read: 0, + tokens_cache_creation: 0, + tokens_reasoning: 0, + service_tier: "standard", + success: 1, + latency_ms: 100, + connection_id: null, + api_key_id: null, + api_key_name: null, + ...row, + timestamp: row.timestamp ?? new Date().toISOString(), + }; + db.prepare( + `INSERT INTO usage_history ( + timestamp, provider, model, + tokens_input, tokens_output, tokens_cache_read, tokens_cache_creation, tokens_reasoning, + service_tier, success, latency_ms, connection_id, api_key_id, api_key_name + ) VALUES ( + @timestamp, @provider, @model, + @tokens_input, @tokens_output, @tokens_cache_read, @tokens_cache_creation, @tokens_reasoning, + @service_tier, @success, @latency_ms, @connection_id, @api_key_id, @api_key_name + )` + ).run(full); +} + +test.before(() => { + core.resetDbInstance(); +}); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("#4009 getProviderDailyUsageRows is exported as a function", () => { + assert.equal(typeof mod.getProviderDailyUsageRows, "function"); +}); + +test("#4009 getProviderDailyUsageRows — groups requests by date + provider", () => { + const rawCutoffDate = "2020-01-01"; + const day1 = "2026-02-10T09:00:00.000Z"; + const day2 = "2026-02-11T09:00:00.000Z"; + + // 3 requests for openai on day1, 1 for anthropic on day1, 2 for openai on day2 + insertUsageHistory({ timestamp: day1, provider: "openai", tokens_input: 10, tokens_output: 20 }); + insertUsageHistory({ timestamp: day1, provider: "openai", tokens_input: 5, tokens_output: 15 }); + insertUsageHistory({ timestamp: day1, provider: "openai", tokens_input: 8, tokens_output: 12 }); + insertUsageHistory({ + timestamp: day1, + provider: "anthropic", + tokens_input: 100, + tokens_output: 50, + }); + insertUsageHistory({ timestamp: day2, provider: "openai", tokens_input: 1, tokens_output: 1 }); + insertUsageHistory({ timestamp: day2, provider: "openai", tokens_input: 2, tokens_output: 2 }); + + const { unifiedSource, unifiedParams } = mod.buildUnifiedSource({ + sinceIso: "2026-02-10T00:00:00.000Z", + untilIso: "2026-02-11T23:59:59.000Z", + rawCutoffDate, + apiKeyWhere: "", + apiKeyParams: {}, + }); + + const rows = mod.getProviderDailyUsageRows(unifiedSource, unifiedParams); + + const openaiDay1 = rows.find((r) => r.date === "2026-02-10" && r.provider === "openai"); + const anthropicDay1 = rows.find((r) => r.date === "2026-02-10" && r.provider === "anthropic"); + const openaiDay2 = rows.find((r) => r.date === "2026-02-11" && r.provider === "openai"); + + assert.ok(openaiDay1, "openai/day1 row present"); + assert.equal(openaiDay1!.requests, 3, "3 openai requests on day1"); + assert.equal(openaiDay1!.promptTokens, 23, "10+5+8 input tokens summed"); + assert.equal(openaiDay1!.completionTokens, 47, "20+15+12 output tokens summed"); + assert.equal(openaiDay1!.totalTokens, 70, "23+47 total tokens"); + + assert.ok(anthropicDay1, "anthropic/day1 row present"); + assert.equal(anthropicDay1!.requests, 1, "1 anthropic request on day1"); + + assert.ok(openaiDay2, "openai/day2 row present"); + assert.equal(openaiDay2!.requests, 2, "2 openai requests on day2 (separate from day1)"); + + // provider+date pairing must not conflate different dates for the same provider + assert.notEqual(openaiDay1!.requests, openaiDay2!.requests); +}); + +test("#4009 getProviderDailyUsageRows — lowercases provider for consistent grouping", () => { + const rawCutoffDate = "2020-01-01"; + const ts = "2026-03-01T09:00:00.000Z"; + + insertUsageHistory({ timestamp: ts, provider: "OpenAI", tokens_input: 1, tokens_output: 1 }); + insertUsageHistory({ timestamp: ts, provider: "openai", tokens_input: 1, tokens_output: 1 }); + + const { unifiedSource, unifiedParams } = mod.buildUnifiedSource({ + sinceIso: "2026-03-01T00:00:00.000Z", + untilIso: "2026-03-01T23:59:59.000Z", + rawCutoffDate, + apiKeyWhere: "", + apiKeyParams: {}, + }); + + const rows = mod.getProviderDailyUsageRows(unifiedSource, unifiedParams); + const openaiRows = rows.filter((r) => r.date === "2026-03-01" && r.provider === "openai"); + + assert.equal(openaiRows.length, 1, "mixed-case provider values fold into one group"); + assert.equal(openaiRows[0].requests, 2, "both rows counted in the single lowercase group"); +});