From d43be179a5241d2b94855499b557daf94d348eb1 Mon Sep 17 00:00:00 2001 From: chrarnoldus <12196001+chrarnoldus@users.noreply.github.com> Date: Fri, 4 Sep 2026 12:20:46 +0000 Subject: [PATCH] feat(admin): show progressive hourly gateway usage Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com> --- .../src/app/admin/gateway/UsageContent.tsx | 84 ++- .../gateway/gateway-usage-report.test.ts | 516 +++++++++++++----- .../app/admin/gateway/gateway-usage-report.ts | 99 +++- .../admin/gateway-usage-router.test.ts | 377 ++++++++++--- .../src/routers/admin/gateway-usage-router.ts | 20 +- 5 files changed, 807 insertions(+), 289 deletions(-) diff --git a/apps/web/src/app/admin/gateway/UsageContent.tsx b/apps/web/src/app/admin/gateway/UsageContent.tsx index cba7e25683..1dce643fa0 100644 --- a/apps/web/src/app/admin/gateway/UsageContent.tsx +++ b/apps/web/src/app/admin/gateway/UsageContent.tsx @@ -21,8 +21,7 @@ import { GATEWAY_USAGE_COLUMNS, GatewayUsageRangeSchema, gatewayUsageToTsv, - queryGatewayUsageRange, - type GatewayUsageProgress, + gatewayUsageRangeQueryOptions, type GatewayUsageRangeInput, } from './gateway-usage-report'; @@ -33,31 +32,19 @@ export function UsageContent() { const [endDate, setEndDate] = useState(() => new Date().toISOString().slice(0, 10)); const [model, setModel] = useState(''); const [submitted, setSubmitted] = useState(null); - const [progress, setProgress] = useState(null); const [copying, setCopying] = useState(false); const models = useQuery(trpc.models.list.queryOptions()); - const report = useQuery({ - queryKey: ['admin-gateway-usage-range', submitted], - queryFn: ({ signal }) => { - if (!submitted) throw new Error('Choose a date range and model ID.'); - return queryGatewayUsageRange(submitted, { + const report = useQuery( + gatewayUsageRangeQueryOptions(submitted, (input, signal) => + client.admin.gatewayUsage.getHourlyUsage.query(input, { signal, - onProgress: setProgress, - fetchDay: (input, signal) => - client.admin.gatewayUsage.getDailyUsage.query(input, { - signal, - context: { skipBatch: true }, - }), - }); - }, - enabled: submitted !== null, - staleTime: 0, - retry: false, - refetchOnWindowFocus: false, - refetchOnReconnect: false, - refetchOnMount: false, - }); - const tsv = report.data ? gatewayUsageToTsv(report.data) : ''; + context: { skipBatch: true }, + }) + ) + ); + const progress = report.data?.progress; + const isPartial = progress !== undefined && progress.completedHours < progress.totalHours; + const tsv = report.data ? gatewayUsageToTsv(report.data.rows) : ''; function runReport(event: FormEvent) { event.preventDefault(); @@ -70,7 +57,6 @@ export function UsageContent() { return; } const input = parsed.data; - setProgress(null); if ( submitted?.startDate === input.startDate && submitted.endDate === input.endDate && @@ -86,7 +72,11 @@ export function UsageContent() { setCopying(true); try { await navigator.clipboard.writeText(tsv); - toast.success('Report copied. Paste it into your spreadsheet.'); + toast.success( + isPartial + ? 'Partial results copied. Only completed hours are included.' + : 'Report copied. Paste it into your spreadsheet.' + ); } catch { toast.error('Could not copy. Select and copy the tab-separated text below.'); } finally { @@ -99,9 +89,10 @@ export function UsageContent() {

Model usage

- PostgreSQL read-replica usage grouped by date, provider, and gateway BYOK. Excludes user - BYOK and counts logged-in users separately from anonymous users. Each UTC day is queried - separately, with a 10-minute timeout per day. Both selected dates are included. + PostgreSQL read-replica usage grouped by UTC hour, provider, and gateway BYOK. Excludes + user BYOK and counts logged-in users separately from anonymous users. Queries run one hour + at a time, with a 10-minute timeout per hour. Results appear as each hour completes. Both + selected dates are included.

@@ -164,18 +155,19 @@ export function UsageContent() { {report.isFetching && (

{progress - ? `Querying ${progress.date}. ${progress.completedDays} of ${progress.totalDays} days completed. ` - : 'Starting daily queries. '} + ? `Querying ${progress.hourStart}. ${progress.completedHours} of ${progress.totalHours} hours completed. ` + : 'Starting hourly queries. '} Keep this tab open while the report runs.

)} {report.error && (

- {report.error.message}. Run the report again to retry. + {report.error.message}. Completed hours remain available below. Run the report again to + restart the range.

)} - {submitted && report.data && !report.isFetching && !report.error && ( -
+ {submitted && report.data && ( +

@@ -183,22 +175,28 @@ export function UsageContent() {

Costs are in microdollars (1 USD = 1,000,000 microdollars). Null values are shown as - NULL and copied as empty cells. User counts are distinct within each daily row and - must not be summed across days or providers. + NULL and copied as empty cells. User counts are distinct within each hourly row and + must not be summed across hours or providers. +

+

+ {isPartial ? 'Partial results' : 'Complete'} · {report.data.progress.completedHours}{' '} + of {report.data.progress.totalHours} hours completed

- {report.data.length === 0 ? ( + {report.data.rows.length === 0 ? (

- No usage found for this model and date range. + {isPartial + ? 'No usage found in the completed hours yet.' + : 'No usage found for this model and date range.'}

) : ( <> @@ -212,8 +210,8 @@ export function UsageContent() { - {report.data.map(row => ( - + {report.data.rows.map(row => ( + {GATEWAY_USAGE_COLUMNS.map(column => ( {row[column] === null ? 'NULL' : String(row[column])} @@ -230,7 +228,7 @@ export function UsageContent() { id="gateway-usage-tsv" value={tsv} readOnly - rows={Math.min(report.data.length + 1, 10)} + rows={Math.min(report.data.rows.length + 1, 10)} onFocus={event => event.currentTarget.select()} className="font-mono text-xs whitespace-pre" aria-label="Tab-separated results ready to copy into a spreadsheet" diff --git a/apps/web/src/app/admin/gateway/gateway-usage-report.test.ts b/apps/web/src/app/admin/gateway/gateway-usage-report.test.ts index 510139eb29..fadb46d7d0 100644 --- a/apps/web/src/app/admin/gateway/gateway-usage-report.test.ts +++ b/apps/web/src/app/admin/gateway/gateway-usage-report.test.ts @@ -1,16 +1,18 @@ +import { QueryClient, QueryObserver, type QueryObserverResult } from '@tanstack/react-query'; import * as z from 'zod'; import { GATEWAY_USAGE_COLUMNS, GatewayUsageRangeSchema, + gatewayUsageRangeQueryOptions, gatewayUsageToTsv, queryGatewayUsageRange, - type GatewayUsageProgress, type GatewayUsageRangeInput, + type GatewayUsageReport, type GatewayUsageRow, } from './gateway-usage-report'; const row: GatewayUsageRow = { - date: '2024-02-28', + hour_start: '2024-02-28T00:00:00.000Z', provider: 'openrouter', is_byok: false, users: '123', @@ -28,16 +30,35 @@ const range: GatewayUsageRangeInput = { endDate: '2024-03-01', model: 'test-model', }; +const singleDay = { ...range, endDate: range.startDate }; + +function hourStart(date: string, hour: number): string { + return `${date}T${String(hour).padStart(2, '0')}:00:00.000Z`; +} function createOptions() { const controller = new AbortController(); return { controller, signal: controller.signal, - fetchDay: jest - .fn, [{ date: string; model: string }, AbortSignal]>() + fetchHour: jest + .fn< + Promise, + [{ date: string; hour: number; model: string }, AbortSignal] + >() .mockResolvedValue([]), - onProgress: jest.fn(), + onProgress: jest.fn(), + }; +} + +function snapshot( + hour: number, + completedHours: number, + rows: GatewayUsageRow[] = [] +): GatewayUsageReport { + return { + rows, + progress: { hourStart: hourStart(singleDay.startDate, hour), completedHours, totalHours: 24 }, }; } @@ -81,7 +102,7 @@ describe('GatewayUsageRangeSchema', () => { const options = createOptions(); expect(GatewayUsageRangeSchema.safeParse(input).success).toBe(false); await expect(queryGatewayUsageRange(input, options)).rejects.toBeInstanceOf(z.ZodError); - expect(options.fetchDay).not.toHaveBeenCalled(); + expect(options.fetchHour).not.toHaveBeenCalled(); expect(options.onProgress).not.toHaveBeenCalled(); }); }); @@ -98,7 +119,7 @@ describe('GatewayUsageRangeSchema', () => { }), ]); await expect(queryGatewayUsageRange(input, options)).rejects.toBeInstanceOf(z.ZodError); - expect(options.fetchDay).not.toHaveBeenCalled(); + expect(options.fetchHour).not.toHaveBeenCalled(); expect(options.onProgress).not.toHaveBeenCalled(); }); @@ -109,7 +130,7 @@ describe('GatewayUsageRangeSchema', () => { await expect(queryGatewayUsageRange({ ...range, model }, options)).rejects.toBeInstanceOf( z.ZodError ); - expect(options.fetchDay).not.toHaveBeenCalled(); + expect(options.fetchHour).not.toHaveBeenCalled(); expect(options.onProgress).not.toHaveBeenCalled(); } ); @@ -128,110 +149,138 @@ describe('queryGatewayUsageRange', () => { { name: 'autumn DST boundary', dates: ['2026-10-31', '2026-11-01', '2026-11-02'] }, { name: 'minimum date', dates: ['2000-01-01', '2000-01-02'] }, { name: 'maximum date', dates: ['9999-12-30', '9999-12-31'] }, - ])('queries each UTC day inclusively across $name', async ({ dates }) => { + ])('queries every UTC hour inclusively across $name', async ({ dates }) => { const options = createOptions(); const input = { startDate: dates[0], endDate: dates[dates.length - 1], model: ' test-model ' }; - await expect(queryGatewayUsageRange(input, options)).resolves.toEqual([]); - expect(options.fetchDay.mock.calls).toEqual( - dates.map(date => [{ date, model: 'test-model' }, options.signal]) + const hours = dates.flatMap(date => Array.from({ length: 24 }, (_, hour) => ({ date, hour }))); + const totalHours = hours.length; + await expect(queryGatewayUsageRange(input, options)).resolves.toEqual({ + rows: [], + progress: { hourStart: hourStart(input.endDate, 23), completedHours: totalHours, totalHours }, + }); + expect(options.fetchHour.mock.calls).toEqual( + hours.map(hour => [{ ...hour, model: 'test-model' }, options.signal]) ); - expect(options.onProgress.mock.calls.map(([progress]) => progress)).toEqual( - dates.flatMap((date, index) => [ - { date, completedDays: index, totalDays: dates.length }, - { date, completedDays: index + 1, totalDays: dates.length }, + expect(options.onProgress.mock.calls.map(([report]) => report)).toEqual( + hours.flatMap(({ date, hour }, index) => [ + { + rows: [], + progress: { hourStart: hourStart(date, hour), completedHours: index, totalHours }, + }, + { + rows: [], + progress: { hourStart: hourStart(date, hour), completedHours: index + 1, totalHours }, + }, ]) ); }); - it('fully awaits each day before scheduling the next and preserves row ordering', async () => { + it('returns rows from hour 23 of the maximum supported date without advancing into year 10000', async () => { + const options = createOptions(); + const lastRow = { ...row, hour_start: '9999-12-31T23:00:00.000Z' }; + options.fetchHour.mockImplementation(async ({ hour }) => (hour === 23 ? [lastRow] : [])); + + await expect( + queryGatewayUsageRange({ ...range, startDate: '9999-12-31', endDate: '9999-12-31' }, options) + ).resolves.toEqual({ + rows: [lastRow], + progress: { hourStart: lastRow.hour_start, completedHours: 24, totalHours: 24 }, + }); + expect(options.fetchHour).toHaveBeenCalledTimes(24); + expect(options.fetchHour).toHaveBeenLastCalledWith( + { date: '9999-12-31', hour: 23, model: range.model }, + options.signal + ); + }); + + it('publishes the first hour before the second completes and awaits each hour sequentially', async () => { const first = Promise.withResolvers(); const second = Promise.withResolvers(); const third = Promise.withResolvers(); const options = createOptions(); - options.fetchDay + options.fetchHour .mockReturnValueOnce(first.promise) .mockReturnValueOnce(second.promise) .mockReturnValueOnce(third.promise); - const secondRow = { ...row, date: '2024-02-29' }; - const thirdRow = { ...row, date: '2024-03-01' }; + const secondRow = { ...row, hour_start: hourStart(singleDay.startDate, 1) }; + const thirdRow = { ...row, hour_start: hourStart(singleDay.startDate, 2) }; const otherProvider = { ...thirdRow, provider: 'other-provider' }; - const result = queryGatewayUsageRange(range, options); + const result = queryGatewayUsageRange(singleDay, options); - await Promise.resolve(); - expect(options.fetchDay).toHaveBeenCalledTimes(1); - expect(options.onProgress.mock.calls).toEqual([ - [{ date: '2024-02-28', completedDays: 0, totalDays: 3 }], - ]); + expect(options.fetchHour).toHaveBeenCalledTimes(1); + expect(options.onProgress.mock.calls).toEqual([[snapshot(0, 0)]]); first.resolve([row]); await first.promise; - expect(options.fetchDay).toHaveBeenCalledTimes(2); - expect(options.fetchDay).toHaveBeenNthCalledWith( + expect(options.fetchHour).toHaveBeenCalledTimes(2); + expect(options.fetchHour).toHaveBeenNthCalledWith( 2, - { date: '2024-02-29', model: range.model }, + { date: singleDay.startDate, hour: 1, model: range.model }, options.signal ); - expect(options.onProgress).toHaveBeenLastCalledWith({ - date: '2024-02-29', - completedDays: 1, - totalDays: 3, - }); + expect(options.onProgress.mock.calls).toEqual([ + [snapshot(0, 0)], + [snapshot(0, 1, [row])], + [snapshot(1, 1, [row])], + ]); + const firstCompletedSnapshot = options.onProgress.mock.calls[1][0]; second.resolve([secondRow]); await second.promise; - expect(options.fetchDay).toHaveBeenCalledTimes(3); - expect(options.fetchDay).toHaveBeenNthCalledWith( + expect(options.fetchHour).toHaveBeenCalledTimes(3); + expect(options.fetchHour).toHaveBeenNthCalledWith( 3, - { date: '2024-03-01', model: range.model }, + { date: singleDay.startDate, hour: 2, model: range.model }, options.signal ); - expect(options.onProgress).toHaveBeenLastCalledWith({ - date: '2024-03-01', - completedDays: 2, - totalDays: 3, - }); + expect(options.onProgress).toHaveBeenLastCalledWith(snapshot(2, 2, [row, secondRow])); + expect(firstCompletedSnapshot).toEqual(snapshot(0, 1, [row])); third.resolve([thirdRow, otherProvider]); - await expect(result).resolves.toEqual([row, secondRow, thirdRow, otherProvider]); - expect(options.fetchDay).toHaveBeenCalledTimes(3); - expect(options.onProgress).toHaveBeenLastCalledWith({ - date: '2024-03-01', - completedDays: 3, - totalDays: 3, - }); + await expect(result).resolves.toEqual( + snapshot(23, 24, [row, secondRow, thirdRow, otherProvider]) + ); + expect(options.fetchHour).toHaveBeenCalledTimes(24); + expect(options.onProgress).toHaveBeenCalledTimes(48); + expect(options.onProgress).toHaveBeenLastCalledWith( + snapshot(23, 24, [row, secondRow, thirdRow, otherProvider]) + ); + expect(firstCompletedSnapshot).toEqual(snapshot(0, 1, [row])); }); - it('reports progress before each fetch and after completion, including empty days', async () => { + it('publishes before each fetch and after completion, including empty hours', async () => { const options = createOptions(); - const events: (GatewayUsageProgress | string)[] = []; - options.onProgress.mockImplementation(progress => { - events.push(progress); + const events: (GatewayUsageReport | string)[] = []; + options.onProgress.mockImplementation(report => { + events.push(report); }); - options.fetchDay.mockImplementation(async ({ date }) => { - events.push(`fetch ${date}`); - return date === row.date ? [row] : []; + options.fetchHour.mockImplementation(async ({ date, hour }) => { + events.push(`fetch ${hourStart(date, hour)}`); + return hour === 0 ? [row] : []; }); - await expect(queryGatewayUsageRange(range, options)).resolves.toEqual([row]); - expect(events).toEqual([ - { date: '2024-02-28', completedDays: 0, totalDays: 3 }, - 'fetch 2024-02-28', - { date: '2024-02-28', completedDays: 1, totalDays: 3 }, - { date: '2024-02-29', completedDays: 1, totalDays: 3 }, - 'fetch 2024-02-29', - { date: '2024-02-29', completedDays: 2, totalDays: 3 }, - { date: '2024-03-01', completedDays: 2, totalDays: 3 }, - 'fetch 2024-03-01', - { date: '2024-03-01', completedDays: 3, totalDays: 3 }, - ]); + await expect(queryGatewayUsageRange(singleDay, options)).resolves.toEqual( + snapshot(23, 24, [row]) + ); + expect(events).toEqual( + Array.from({ length: 24 }, (_, hour) => [ + snapshot(hour, hour, hour === 0 ? [] : [row]), + `fetch ${hourStart(singleDay.startDate, hour)}`, + snapshot(hour, hour + 1, [row]), + ]).flat() + ); }); - it('keeps daily distinct user counts separate and preserves numeric string precision', async () => { + it('keeps hourly distinct counts separate and preserves numeric string precision', async () => { const options = createOptions(); - const secondRow = { ...row, date: '2024-02-29', cost: '0.000000000000000001' }; - options.fetchDay.mockResolvedValueOnce([row]).mockResolvedValueOnce([secondRow]); - - const rows = await queryGatewayUsageRange({ ...range, endDate: secondRow.date }, options); + const secondRow = { + ...row, + hour_start: hourStart(singleDay.startDate, 1), + cost: '0.000000000000000001', + }; + options.fetchHour.mockResolvedValueOnce([row]).mockResolvedValueOnce([secondRow]); + + const { rows } = await queryGatewayUsageRange(singleDay, options); expect(rows).toEqual([row, secondRow]); expect(rows[0]).toBe(row); expect(rows[1]).toBe(secondRow); @@ -242,40 +291,64 @@ describe('queryGatewayUsageRange', () => { ]); expect(gatewayUsageToTsv(rows)).toBe( `${GATEWAY_USAGE_COLUMNS.join('\t')}\n` + - '2024-02-28\topenrouter\tfalse\t123\t100\t9007199254740993\t200\t300\t400\t1234567.890123\t7654321.123456\n' + - '2024-02-29\topenrouter\tfalse\t123\t100\t9007199254740993\t200\t300\t400\t0.000000000000000001\t7654321.123456' + '2024-02-28T00:00:00.000Z\topenrouter\tfalse\t123\t100\t9007199254740993\t200\t300\t400\t1234567.890123\t7654321.123456\n' + + '2024-02-28T01:00:00.000Z\topenrouter\tfalse\t123\t100\t9007199254740993\t200\t300\t400\t0.000000000000000001\t7654321.123456' ); }); - it.each([new Error('daily query failed'), 'daily query failed'])( - 'includes the failed date and stops scheduling after %p', + it('does not mutate snapshots or reuse accumulated rows across repeated runs', async () => { + const options = createOptions(); + options.onProgress.mockImplementation(report => { + Object.freeze(report.rows); + Object.freeze(report.progress); + Object.freeze(report); + }); + options.fetchHour.mockResolvedValueOnce([row]); + const first = await queryGatewayUsageRange(singleDay, options); + const firstSnapshots = options.onProgress.mock.calls.map(([report]) => report); + const nextRow = { ...row, provider: 'another-provider' }; + options.fetchHour.mockResolvedValueOnce([nextRow]); + const second = await queryGatewayUsageRange(singleDay, options); + + expect(firstSnapshots[0]).toEqual(snapshot(0, 0)); + expect(firstSnapshots[1]).toEqual(snapshot(0, 1, [row])); + expect(first).toEqual(snapshot(23, 24, [row])); + expect(first).toBe(firstSnapshots[47]); + expect(options.onProgress.mock.calls[48][0]).toEqual(snapshot(0, 0)); + expect(second).toEqual(snapshot(23, 24, [nextRow])); + expect(second.rows).not.toBe(first.rows); + expect(options.onProgress).toHaveBeenCalledTimes(96); + }); + + it.each([new Error('hourly query failed'), 'hourly query failed'])( + 'includes the failed UTC hour and preserves earlier snapshots after %p', async failure => { const options = createOptions(); - options.fetchDay.mockResolvedValueOnce([row]).mockRejectedValueOnce(failure); + options.fetchHour.mockResolvedValueOnce([row]).mockRejectedValueOnce(failure); - await expect(queryGatewayUsageRange(range, options)).rejects.toMatchObject({ - message: 'Failed to fetch gateway usage for 2024-02-29: daily query failed', + await expect(queryGatewayUsageRange(singleDay, options)).rejects.toMatchObject({ + message: 'Failed to fetch gateway usage for 2024-02-28T01:00:00.000Z: hourly query failed', cause: failure, }); - expect(options.fetchDay).toHaveBeenCalledTimes(2); + expect(options.fetchHour).toHaveBeenCalledTimes(2); expect(options.onProgress.mock.calls).toEqual([ - [{ date: '2024-02-28', completedDays: 0, totalDays: 3 }], - [{ date: '2024-02-28', completedDays: 1, totalDays: 3 }], - [{ date: '2024-02-29', completedDays: 1, totalDays: 3 }], + [snapshot(0, 0)], + [snapshot(0, 1, [row])], + [snapshot(1, 1, [row])], ]); } ); - it('includes the date when fetchDay throws synchronously', async () => { + it('includes the UTC hour when fetchHour throws synchronously', async () => { const options = createOptions(); - options.fetchDay.mockImplementation(() => { + options.fetchHour.mockImplementation(() => { throw new Error('synchronous failure'); }); - await expect(queryGatewayUsageRange(range, options)).rejects.toThrow( - 'Failed to fetch gateway usage for 2024-02-28: synchronous failure' + await expect(queryGatewayUsageRange(singleDay, options)).rejects.toThrow( + 'Failed to fetch gateway usage for 2024-02-28T00:00:00.000Z: synchronous failure' ); - expect(options.fetchDay).toHaveBeenCalledTimes(1); + expect(options.fetchHour).toHaveBeenCalledTimes(1); }); it.each([undefined, new Error('cancelled'), 'custom cancellation'])( @@ -284,28 +357,33 @@ describe('queryGatewayUsageRange', () => { const options = createOptions(); options.controller.abort(reason); - await expect(queryGatewayUsageRange(range, options)).rejects.toBe(options.signal.reason); - expect(options.fetchDay).not.toHaveBeenCalled(); + await expect(queryGatewayUsageRange(singleDay, options)).rejects.toBe(options.signal.reason); + expect(options.fetchHour).not.toHaveBeenCalled(); expect(options.onProgress).not.toHaveBeenCalled(); } ); it.each(['resolve', 'reject'] as const)( - 'does not complete or schedule another day when an aborted fetch later %ss', + 'does not publish or schedule another hour when an aborted fetch later %ss', async outcome => { const pending = Promise.withResolvers(); const options = createOptions(); - options.fetchDay.mockReturnValueOnce(pending.promise); - const result = queryGatewayUsageRange(range, options); + options.fetchHour.mockResolvedValueOnce([row]).mockReturnValueOnce(pending.promise); + const result = queryGatewayUsageRange(singleDay, options); + await Promise.resolve(); + expect(options.fetchHour).toHaveBeenCalledTimes(2); options.controller.abort(); const assertion = expect(result).rejects.toBe(options.signal.reason); - if (outcome === 'resolve') pending.resolve([row]); + if (outcome === 'resolve') + pending.resolve([{ ...row, hour_start: hourStart(singleDay.startDate, 1) }]); else pending.reject(new Error('transport error after cancellation')); await assertion; - expect(options.fetchDay).toHaveBeenCalledTimes(1); + expect(options.fetchHour).toHaveBeenCalledTimes(2); expect(options.onProgress.mock.calls).toEqual([ - [{ date: '2024-02-28', completedDays: 0, totalDays: 3 }], + [snapshot(0, 0)], + [snapshot(0, 1, [row])], + [snapshot(1, 1, [row])], ]); } ); @@ -316,37 +394,40 @@ describe('queryGatewayUsageRange', () => { { name: 'AbortError', message: 'Request aborted' }, ])('preserves fetch AbortError %p even if the signal is not aborted', async failure => { const options = createOptions(); - options.fetchDay.mockRejectedValueOnce(failure); + options.fetchHour.mockRejectedValueOnce(failure); - await expect(queryGatewayUsageRange(range, options)).rejects.toBe(failure); + await expect(queryGatewayUsageRange(singleDay, options)).rejects.toBe(failure); expect(options.signal.aborted).toBe(false); - expect(options.fetchDay).toHaveBeenCalledTimes(1); + expect(options.fetchHour).toHaveBeenCalledTimes(1); expect(options.onProgress).toHaveBeenCalledTimes(1); }); - it('does not fetch if progress cancels the current day before it starts', async () => { + it.each([0, 1])('does not fetch if progress cancels hour %s before it starts', async hour => { const options = createOptions(); - options.onProgress.mockImplementation(() => { - options.controller.abort(); + const reason = new Error('Cancelled before the hour starts'); + options.onProgress.mockImplementation(({ progress }) => { + if (progress.hourStart === hourStart(singleDay.startDate, hour)) + options.controller.abort(reason); }); - await expect(queryGatewayUsageRange(range, options)).rejects.toBe(options.signal.reason); - expect(options.fetchDay).not.toHaveBeenCalled(); - expect(options.onProgress).toHaveBeenCalledTimes(1); + await expect(queryGatewayUsageRange(singleDay, options)).rejects.toBe(reason); + expect(options.fetchHour).toHaveBeenCalledTimes(hour); + expect(options.onProgress).toHaveBeenCalledTimes(hour * 2 + 1); }); - it.each([range.endDate, range.startDate])( - 'honors cancellation after a day completes even when it is the final day (%s)', - async endDate => { + it.each([1, 24])( + 'honors cancellation after %s completed hours, including the final hour', + async count => { const options = createOptions(); - options.onProgress.mockImplementation(({ completedDays }) => { - if (completedDays === 1) options.controller.abort(); + options.onProgress.mockImplementation(({ progress }) => { + if (progress.completedHours === count) options.controller.abort(); }); - const result = queryGatewayUsageRange({ ...range, endDate }, options); - await expect(result).rejects.toMatchObject({ name: 'AbortError' }); - expect(options.fetchDay).toHaveBeenCalledTimes(1); - expect(options.onProgress).toHaveBeenCalledTimes(2); + await expect(queryGatewayUsageRange(singleDay, options)).rejects.toMatchObject({ + name: 'AbortError', + }); + expect(options.fetchHour).toHaveBeenCalledTimes(count); + expect(options.onProgress).toHaveBeenCalledTimes(count * 2); } ); @@ -360,18 +441,203 @@ describe('queryGatewayUsageRange', () => { queryGatewayUsageRange({ ...range, startDate: '2000-01-01', endDate: '9999-12-31' }, options) ).rejects.toBe(options.signal.reason); expect(options.onProgress).toHaveBeenCalledWith({ - date: '2000-01-01', - completedDays: 0, - totalDays: 2_921_940, + rows: [], + progress: { + hourStart: '2000-01-01T00:00:00.000Z', + completedHours: 0, + totalHours: 2_921_940 * 24, + }, }); - expect(options.fetchDay).not.toHaveBeenCalled(); + expect(options.fetchHour).not.toHaveBeenCalled(); + }); +}); + +describe('gatewayUsageRangeQueryOptions', () => { + let client: QueryClient; + + beforeEach(() => { + client = new QueryClient(); + }); + + afterEach(() => { + client.clear(); }); + + it('disables an unsubmitted range and opts out of automatic refetches and retries', async () => { + const { fetchHour } = createOptions(); + const options = gatewayUsageRangeQueryOptions(null, fetchHour); + const observer = new QueryObserver(client, options); + const unsubscribe = observer.subscribe(() => {}); + try { + expect(options).toMatchObject({ + queryKey: ['admin-gateway-usage-hourly-range', null], + enabled: false, + staleTime: 0, + retry: false, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + refetchOnMount: false, + }); + expect(observer.getCurrentResult().isFetching).toBe(false); + expect(observer.getCurrentResult().data).toBeUndefined(); + expect(fetchHour).not.toHaveBeenCalled(); + await expect(client.fetchQuery(options)).rejects.toThrow('Gateway usage range is required'); + expect(fetchHour).not.toHaveBeenCalled(); + } finally { + unsubscribe(); + } + }); + + it('exposes progressive cache data to an observer while the next hour is still fetching', async () => { + const first = Promise.withResolvers(); + const second = Promise.withResolvers(); + const { fetchHour } = createOptions(); + fetchHour.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise); + const options = gatewayUsageRangeQueryOptions(singleDay, fetchHour); + const observer = new QueryObserver(client, options); + const observations: QueryObserverResult[] = []; + const unsubscribe = observer.subscribe(result => observations.push(result)); + const result = client.fetchQuery(options); + try { + expect(options.queryKey).toEqual(['admin-gateway-usage-hourly-range', singleDay]); + expect(options.enabled).toBe(true); + expect(observer.getCurrentResult()).toMatchObject({ isFetching: true, data: snapshot(0, 0) }); + expect(fetchHour).toHaveBeenCalledTimes(1); + expect(fetchHour.mock.calls[0][1]).toBeInstanceOf(AbortSignal); + expect(fetchHour.mock.calls[0][1].aborted).toBe(false); + + first.resolve([row]); + await first.promise; + expect(fetchHour).toHaveBeenCalledTimes(2); + expect(fetchHour.mock.calls[1][1]).toBe(fetchHour.mock.calls[0][1]); + expect(observations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ isFetching: true, data: snapshot(0, 1, [row]) }), + expect.objectContaining({ isFetching: true, data: snapshot(1, 1, [row]) }), + ]) + ); + expect(observer.getCurrentResult()).toMatchObject({ + isFetching: true, + data: snapshot(1, 1, [row]), + }); + expect(client.getQueryData(options.queryKey)).toBe(observer.getCurrentResult().data); + + second.resolve([]); + await expect(result).resolves.toEqual(snapshot(23, 24, [row])); + expect(observer.getCurrentResult()).toMatchObject({ + isFetching: false, + isSuccess: true, + data: snapshot(23, 24, [row]), + }); + expect(fetchHour).toHaveBeenCalledTimes(24); + } finally { + unsubscribe(); + first.resolve([]); + second.resolve([]); + } + }); + + it('keeps earlier rows in the cache and observer when a later hour fails', async () => { + const first = Promise.withResolvers(); + const second = Promise.withResolvers(); + const { fetchHour } = createOptions(); + fetchHour.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise); + const options = gatewayUsageRangeQueryOptions(singleDay, fetchHour); + const observer = new QueryObserver(client, options); + const unsubscribe = observer.subscribe(() => {}); + const result = client.fetchQuery(options); + const failure = new Error('hour unavailable'); + const assertion = expect(result).rejects.toMatchObject({ + message: 'Failed to fetch gateway usage for 2024-02-28T01:00:00.000Z: hour unavailable', + cause: failure, + }); + try { + first.resolve([row]); + await first.promise; + const partial = client.getQueryData(options.queryKey); + expect(partial).toEqual(snapshot(1, 1, [row])); + expect(observer.getCurrentResult().isFetching).toBe(true); + second.reject(failure); + await assertion; + + expect(client.getQueryData(options.queryKey)).toBe(partial); + expect(observer.getCurrentResult()).toMatchObject({ + isFetching: false, + isError: true, + data: snapshot(1, 1, [row]), + error: { + message: 'Failed to fetch gateway usage for 2024-02-28T01:00:00.000Z: hour unavailable', + }, + }); + expect(fetchHour).toHaveBeenCalledTimes(2); + } finally { + unsubscribe(); + first.resolve([]); + second.resolve([]); + } + }); + + it.each([false, true])( + 'refetch clears old results without duplication (previous run failed: %s)', + async failed => { + const { fetchHour } = createOptions(); + fetchHour.mockResolvedValueOnce([row]); + if (failed) fetchHour.mockRejectedValueOnce(new Error('first run failed')); + const options = gatewayUsageRangeQueryOptions(singleDay, fetchHour); + const observer = new QueryObserver(client, options); + const unsubscribe = observer.subscribe(() => {}); + const first = Promise.withResolvers(); + const second = Promise.withResolvers(); + try { + const initial = client.fetchQuery(options); + if (failed) await expect(initial).rejects.toThrow('first run failed'); + else await expect(initial).resolves.toEqual(snapshot(23, 24, [row])); + const previous = client.getQueryData(options.queryKey); + expect(previous?.rows).toEqual([row]); + const previousProgress = failed ? snapshot(1, 1, [row]) : snapshot(23, 24, [row]); + expect(previous).toEqual(previousProgress); + const previousCallCount = fetchHour.mock.calls.length; + fetchHour.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise); + + const refetch = observer.refetch(); + expect(observer.getCurrentResult()).toMatchObject({ + isFetching: true, + isError: false, + data: snapshot(0, 0), + }); + expect(client.getQueryData(options.queryKey)).toEqual(snapshot(0, 0)); + expect(fetchHour).toHaveBeenCalledTimes(previousCallCount + 1); + expect(previous).toEqual(previousProgress); + + first.resolve([row]); + await first.promise; + expect(observer.getCurrentResult()).toMatchObject({ + isFetching: true, + data: snapshot(1, 1, [row]), + }); + const secondRow = { ...row, hour_start: hourStart(singleDay.startDate, 1) }; + second.resolve([secondRow]); + await expect(refetch).resolves.toMatchObject({ + isSuccess: true, + isFetching: false, + data: snapshot(23, 24, [row, secondRow]), + }); + expect(client.getQueryData(options.queryKey)).toEqual(snapshot(23, 24, [row, secondRow])); + expect(fetchHour).toHaveBeenCalledTimes(previousCallCount + 24); + expect(previous).toEqual(previousProgress); + } finally { + unsubscribe(); + first.resolve([]); + second.resolve([]); + } + } + ); }); describe('gatewayUsageToTsv', () => { - it('includes date first and exact numeric values without formatting or rounding', () => { + it('includes hour_start first and exact numeric values without formatting or rounding', () => { expect(GATEWAY_USAGE_COLUMNS).toEqual([ - 'date', + 'hour_start', 'provider', 'is_byok', 'users', @@ -384,13 +650,13 @@ describe('gatewayUsageToTsv', () => { 'market_cost', ]); expect(gatewayUsageToTsv([row])).toBe( - `${GATEWAY_USAGE_COLUMNS.join('\t')}\n2024-02-28\topenrouter\tfalse\t123\t100\t9007199254740993\t200\t300\t400\t1234567.890123\t7654321.123456` + `${GATEWAY_USAGE_COLUMNS.join('\t')}\n2024-02-28T00:00:00.000Z\topenrouter\tfalse\t123\t100\t9007199254740993\t200\t300\t400\t1234567.890123\t7654321.123456` ); }); it('preserves true BYOK and negative costs as spreadsheet values', () => { expect(gatewayUsageToTsv([{ ...row, is_byok: true, cost: '-1.25' }]).split('\n')[1]).toBe( - '2024-02-28\topenrouter\ttrue\t123\t100\t9007199254740993\t200\t300\t400\t-1.25\t7654321.123456' + '2024-02-28T00:00:00.000Z\topenrouter\ttrue\t123\t100\t9007199254740993\t200\t300\t400\t-1.25\t7654321.123456' ); }); @@ -399,7 +665,7 @@ describe('gatewayUsageToTsv', () => { gatewayUsageToTsv([ { ...row, provider: null, is_byok: null, input_tokens: null, market_cost: null }, ]).split('\n')[1] - ).toBe('2024-02-28\t\t\t123\t100\t\t200\t300\t400\t1234567.890123\t'); + ).toBe('2024-02-28T00:00:00.000Z\t\t\t123\t100\t\t200\t300\t400\t1234567.890123\t'); }); it.each(['=SUM(1,2)', '+1', '-1', '@SUM(1)', ' =SUM(1)'])( @@ -434,7 +700,7 @@ describe('gatewayUsageToTsv', () => { { ...row, provider: '=SUM("1")', users: '0', input_tokens: '0.000', cost: '-0.000000' }, ]).split('\n')[1] ).toBe( - '2024-02-28\t"\'=SUM(""1"")"\tfalse\t0\t100\t0.000\t200\t300\t400\t-0.000000\t7654321.123456' + '2024-02-28T00:00:00.000Z\t"\'=SUM(""1"")"\tfalse\t0\t100\t0.000\t200\t300\t400\t-0.000000\t7654321.123456' ); }); diff --git a/apps/web/src/app/admin/gateway/gateway-usage-report.ts b/apps/web/src/app/admin/gateway/gateway-usage-report.ts index 4a9e1d1bde..b926376f8f 100644 --- a/apps/web/src/app/admin/gateway/gateway-usage-report.ts +++ b/apps/web/src/app/admin/gateway/gateway-usage-report.ts @@ -1,4 +1,5 @@ -import type { inferRouterOutputs } from '@trpc/server'; +import { queryOptions } from '@tanstack/react-query'; +import type { inferRouterInputs, inferRouterOutputs } from '@trpc/server'; import * as z from 'zod'; import type { RootRouter } from '@/routers/root-router'; @@ -23,45 +24,59 @@ export const GatewayUsageRangeSchema = z export type GatewayUsageRangeInput = z.infer; export type GatewayUsageRow = - inferRouterOutputs['admin']['gatewayUsage']['getDailyUsage'][number]; + inferRouterOutputs['admin']['gatewayUsage']['getHourlyUsage'][number]; export type GatewayUsageProgress = { - date: string; - completedDays: number; - totalDays: number; + hourStart: string; + completedHours: number; + totalHours: number; }; +export type GatewayUsageReport = { + rows: GatewayUsageRow[]; + progress: GatewayUsageProgress; +}; + +type FetchGatewayUsageHour = ( + input: inferRouterInputs['admin']['gatewayUsage']['getHourlyUsage'], + signal: AbortSignal +) => Promise; + export async function queryGatewayUsageRange( input: GatewayUsageRangeInput, options: { signal: AbortSignal; - fetchDay: ( - input: { date: string; model: string }, - signal: AbortSignal - ) => Promise; - onProgress: (progress: GatewayUsageProgress) => void; + fetchHour: FetchGatewayUsageHour; + onProgress: (report: GatewayUsageReport) => void; } -): Promise { +): Promise { const { startDate, endDate, model } = GatewayUsageRangeSchema.parse(input); - const { signal, fetchDay, onProgress } = options; + const { signal, fetchHour, onProgress } = options; signal.throwIfAborted(); const start = Date.parse(`${startDate}T00:00:00.000Z`); - const end = Date.parse(`${endDate}T00:00:00.000Z`); - const dayMilliseconds = 86_400_000; - const totalDays = (end - start) / dayMilliseconds + 1; - const rows: GatewayUsageRow[] = []; - let completedDays = 0; + const end = Date.parse(`${endDate}T23:00:00.000Z`); + const hourMilliseconds = 3_600_000; + const totalHours = (end - start) / hourMilliseconds + 1; + let report: GatewayUsageReport = { + rows: [], + progress: { hourStart: `${startDate}T00:00:00.000Z`, completedHours: 0, totalHours }, + }; - for (let timestamp = start; timestamp <= end; timestamp += dayMilliseconds) { + for (let timestamp = start; timestamp <= end; timestamp += hourMilliseconds) { signal.throwIfAborted(); - const date = new Date(timestamp).toISOString().slice(0, 10); - onProgress({ date, completedDays, totalDays }); + const hour = new Date(timestamp); + const hourStart = hour.toISOString(); + report = { ...report, progress: { ...report.progress, hourStart } }; + onProgress(report); signal.throwIfAborted(); - let dayRows: GatewayUsageRow[]; + let hourRows: GatewayUsageRow[]; try { - dayRows = await fetchDay({ date, model }, signal); + hourRows = await fetchHour( + { date: hourStart.slice(0, 10), hour: hour.getUTCHours(), model }, + signal + ); } catch (error) { signal.throwIfAborted(); if ( @@ -73,23 +88,49 @@ export async function queryGatewayUsageRange( throw error; } throw new Error( - `Failed to fetch gateway usage for ${date}: ${error instanceof Error ? error.message : String(error)}`, + `Failed to fetch gateway usage for ${hourStart}: ${error instanceof Error ? error.message : String(error)}`, { cause: error } ); } signal.throwIfAborted(); - for (const row of dayRows) rows.push(row); - completedDays += 1; - onProgress({ date, completedDays, totalDays }); + report = { + rows: [...report.rows, ...hourRows], + progress: { hourStart, completedHours: report.progress.completedHours + 1, totalHours }, + }; + onProgress(report); + signal.throwIfAborted(); } - signal.throwIfAborted(); - return rows; + return report; +} + +export function gatewayUsageRangeQueryOptions( + input: GatewayUsageRangeInput | null, + fetchHour: FetchGatewayUsageHour +) { + return queryOptions({ + queryKey: ['admin-gateway-usage-hourly-range', input] as const, + queryFn: ({ client, queryKey, signal }) => { + const range = queryKey[1]; + if (range === null) throw new Error('Gateway usage range is required'); + return queryGatewayUsageRange(range, { + signal, + fetchHour, + onProgress: snapshot => client.setQueryData(queryKey, snapshot), + }); + }, + enabled: input !== null, + staleTime: 0, + retry: false, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + refetchOnMount: false, + }); } export const GATEWAY_USAGE_COLUMNS = [ - 'date', + 'hour_start', 'provider', 'is_byok', 'users', diff --git a/apps/web/src/routers/admin/gateway-usage-router.test.ts b/apps/web/src/routers/admin/gateway-usage-router.test.ts index fb56743d8e..3cde710da3 100644 --- a/apps/web/src/routers/admin/gateway-usage-router.test.ts +++ b/apps/web/src/routers/admin/gateway-usage-router.test.ts @@ -24,7 +24,8 @@ let mockUsesSeparateReplica = true; const mockTransaction = jest.mocked(readDb.transaction); const mockReadExecute = jest.mocked(readDb.execute); const mockExecute = jest.fn, [SQL]>(); -const INPUT = { date: '2026-09-15', model: 'anthropic/claude-opus-5' }; +const INPUT = { date: '2026-09-15', hour: 12, model: 'anthropic/claude-opus-5' }; +const HOUR_START = '2026-09-15T12:00:00.000Z'; const ROW = { provider: 'provider-a', is_byok: false, @@ -70,7 +71,7 @@ afterEach(() => { jest.restoreAllMocks(); }); -describe('admin.gatewayUsage.getDailyUsage', () => { +describe('admin.gatewayUsage.getHourlyUsage', () => { beforeEach(() => { jest.spyOn(db, 'transaction').mockRejectedValue(new Error('Unexpected primary transaction')); jest.spyOn(db, 'execute').mockRejectedValue(new Error('Unexpected primary query')); @@ -83,7 +84,7 @@ describe('admin.gatewayUsage.getDailyUsage', () => { }); it('runs text aggregates and parameterized filters in a replica-only timed transaction', async () => { - await caller().getDailyUsage(INPUT); + await caller().getHourlyUsage(INPUT); expect(mockTransaction).toHaveBeenCalledTimes(1); expect(mockExecute).toHaveBeenCalledTimes(2); @@ -100,11 +101,11 @@ describe('admin.gatewayUsage.getDailyUsage', () => { 'SUM(mu.cost)::text AS cost, SUM(meta.market_cost)::text AS market_cost ' + 'FROM microdollar_usage mu INNER JOIN microdollar_usage_metadata meta ON mu.id = meta.id ' + 'WHERE mu.requested_model = $1 AND meta.is_user_byok = false AND mu.input_tokens > 0 ' + - "AND mu.created_at >= ($2::date::timestamp AT TIME ZONE 'UTC') " + - "AND mu.created_at < (($3::date + 1)::timestamp AT TIME ZONE 'UTC') " + + 'AND mu.created_at >= $2::timestamptz ' + + "AND mu.created_at < ($3::timestamptz + interval '1 hour') " + 'GROUP BY mu.provider, meta.is_byok ORDER BY mu.provider, meta.is_byok' ); - expect(executedQuery().params).toEqual([INPUT.model, INPUT.date, INPUT.date]); + expect(executedQuery().params).toEqual([INPUT.model, HOUR_START, HOUR_START]); }); it('waits for SET LOCAL to complete before issuing the aggregation', async () => { @@ -114,7 +115,7 @@ describe('admin.gatewayUsage.getDailyUsage', () => { started.resolve(); return timeout.promise; }); - const result = caller().getDailyUsage(INPUT); + const result = caller().getHourlyUsage(INPUT); await started.promise; expect(mockExecute).toHaveBeenCalledTimes(1); expect(executedQuery(0).sql).toBe("SET LOCAL statement_timeout = '600000'"); @@ -137,25 +138,42 @@ describe('admin.gatewayUsage.getDailyUsage', () => { '2400-02-29', '9999-12-31', ])( - 'binds valid calendar date %s and leaves next-day UTC arithmetic to PostgreSQL', + 'binds the final hour of valid calendar date %s and leaves rollover to PostgreSQL', async date => { + const hourStart = `${date}T23:00:00.000Z`; mockExecute.mockResolvedValue({ rows: [ROW] }); - await expect(caller().getDailyUsage({ ...INPUT, date })).resolves.toEqual([{ ...ROW, date }]); - expect(executedQuery().params).toEqual([INPUT.model, date, date]); - expect(executedQuery().sql).toContain(">= ($2::date::timestamp AT TIME ZONE 'UTC')"); - expect(executedQuery().sql).toContain("< (($3::date + 1)::timestamp AT TIME ZONE 'UTC')"); + await expect(caller().getHourlyUsage({ ...INPUT, date, hour: 23 })).resolves.toEqual([ + { ...ROW, hour_start: hourStart }, + ]); + expect(executedQuery().params).toEqual([INPUT.model, hourStart, hourStart]); + expect(executedQuery().sql).toContain('>= $2::timestamptz'); + expect(executedQuery().sql).toContain("< ($3::timestamptz + interval '1 hour')"); + } + ); + + it.each(Array.from({ length: 24 }, (_, hour) => hour))( + 'binds hour %i as a canonical UTC timestamp', + async hour => { + const hourStart = `${INPUT.date}T${hour.toString().padStart(2, '0')}:00:00.000Z`; + mockExecute.mockResolvedValue({ rows: [ROW] }); + await expect(caller().getHourlyUsage({ ...INPUT, hour })).resolves.toEqual([ + { ...ROW, hour_start: hourStart }, + ]); + expect(executedQuery().params).toEqual([INPUT.model, hourStart, hourStart]); } ); it('trims model input and binds it rather than interpolating SQL', async () => { const model = "model' OR 1=1 --"; - await caller().getDailyUsage({ ...INPUT, model: ` ${model} ` }); + await caller().getHourlyUsage({ ...INPUT, model: ` ${model} ` }); expect(executedQuery().params[0]).toBe(model); expect(executedQuery().sql).not.toContain(model); }); it('accepts a model with exactly 256 characters', async () => { - await expect(caller().getDailyUsage({ ...INPUT, model: 'a'.repeat(256) })).resolves.toEqual([]); + await expect(caller().getHourlyUsage({ ...INPUT, model: 'a'.repeat(256) })).resolves.toEqual( + [] + ); }); it.each([ @@ -191,6 +209,21 @@ describe('admin.gatewayUsage.getDailyUsage', () => { { date: new Date('2026-09-15T00:00:00Z') }, { date: null }, { date: undefined }, + { hour: -1 }, + { hour: 24 }, + { hour: 0.5 }, + { hour: 23.5 }, + { hour: NaN }, + { hour: Infinity }, + { hour: -Infinity }, + { hour: '0' }, + { hour: '12' }, + { hour: '' }, + { hour: true }, + { hour: null }, + { hour: undefined }, + { hour: [] }, + { hour: {} }, { model: '' }, { model: ' \t\n ' }, { model: 'a'.repeat(257) }, @@ -198,29 +231,41 @@ describe('admin.gatewayUsage.getDailyUsage', () => { { model: undefined }, ])('rejects invalid input %p before opening a transaction', async invalid => { await expect( - caller().getDailyUsage({ ...INPUT, ...invalid } as typeof INPUT) + caller().getHourlyUsage({ ...INPUT, ...invalid } as typeof INPUT) ).rejects.toMatchObject({ code: 'BAD_REQUEST' }); expect(mockTransaction).not.toHaveBeenCalled(); expect(mockExecute).not.toHaveBeenCalled(); }); - it('rejects the old monthly input without querying the database', async () => { - await expect( - caller().getDailyUsage({ year: 2026, month: 9, model: INPUT.model } as never) - ).rejects.toMatchObject({ code: 'BAD_REQUEST' }); + it.each([ + { year: 2026, month: 9, model: INPUT.model }, + { date: INPUT.date, model: INPUT.model }, + ])('rejects legacy input %p without querying the database', async input => { + await expect(caller().getHourlyUsage(input as never)).rejects.toMatchObject({ + code: 'BAD_REQUEST', + }); expect(mockTransaction).not.toHaveBeenCalled(); expect(mockExecute).not.toHaveBeenCalled(); }); - it('attaches the queried date to every validated row without mutating SQL results', async () => { - const rows = [ROW, { ...ROW, provider: 'provider-b', date: 'unexpected-database-date' }]; + it('attaches only the queried hour to every validated row without mutating SQL results', async () => { + const rows = [ + ROW, + { + ...ROW, + provider: 'provider-b', + date: 'unexpected-database-date', + hour_start: '2026-09-15 11:00:00+00', + }, + ]; mockExecute.mockResolvedValue({ rows }); - await expect(caller().getDailyUsage(INPUT)).resolves.toEqual([ - { ...ROW, date: INPUT.date }, - { ...ROW, provider: 'provider-b', date: INPUT.date }, + await expect(caller().getHourlyUsage(INPUT)).resolves.toEqual([ + { ...ROW, hour_start: HOUR_START }, + { ...ROW, provider: 'provider-b', hour_start: HOUR_START }, ]); - expect(rows[0]).not.toHaveProperty('date'); + expect(rows[0]).not.toHaveProperty('hour_start'); expect(rows[1]).toHaveProperty('date', 'unexpected-database-date'); + expect(rows[1]).toHaveProperty('hour_start', '2026-09-15 11:00:00+00'); }); it('preserves large numeric strings and decimal precision in microdollar costs', async () => { @@ -237,7 +282,9 @@ describe('admin.gatewayUsage.getDailyUsage', () => { market_cost: '23456789012345678901.987654321', }; mockExecute.mockResolvedValue({ rows: [row] }); - await expect(caller().getDailyUsage(INPUT)).resolves.toEqual([{ ...row, date: INPUT.date }]); + await expect(caller().getHourlyUsage(INPUT)).resolves.toEqual([ + { ...row, hour_start: HOUR_START }, + ]); }); it('keeps the existing nonnegative decimal token sum contract', async () => { @@ -249,7 +296,9 @@ describe('admin.gatewayUsage.getDailyUsage', () => { cache_write_tokens: '12345678901234567890.123456789', }; mockExecute.mockResolvedValue({ rows: [row] }); - await expect(caller().getDailyUsage(INPUT)).resolves.toEqual([{ ...row, date: INPUT.date }]); + await expect(caller().getHourlyUsage(INPUT)).resolves.toEqual([ + { ...row, hour_start: HOUR_START }, + ]); }); it('preserves null provider, BYOK and aggregates while keeping zero counts as strings', async () => { @@ -266,13 +315,15 @@ describe('admin.gatewayUsage.getDailyUsage', () => { market_cost: null, }; mockExecute.mockResolvedValue({ rows: [row] }); - await expect(caller().getDailyUsage(INPUT)).resolves.toEqual([{ ...row, date: INPUT.date }]); + await expect(caller().getHourlyUsage(INPUT)).resolves.toEqual([ + { ...row, hour_start: HOUR_START }, + ]); }); it.each([false, true])('preserves the PostgreSQL boolean %s', async is_byok => { mockExecute.mockResolvedValue({ rows: [{ ...ROW, is_byok }] }); - await expect(caller().getDailyUsage(INPUT)).resolves.toEqual([ - { ...ROW, is_byok, date: INPUT.date }, + await expect(caller().getHourlyUsage(INPUT)).resolves.toEqual([ + { ...ROW, is_byok, hour_start: HOUR_START }, ]); }); @@ -303,22 +354,24 @@ describe('admin.gatewayUsage.getDailyUsage', () => { 'rejects malformed database column %s value %p with a generic error', async (column, value) => { mockExecute.mockResolvedValue({ rows: [{ ...ROW, [column]: value }] }); - await expect(caller().getDailyUsage(INPUT)).rejects.toMatchObject(SANITIZED_ERROR); + await expect(caller().getHourlyUsage(INPUT)).rejects.toMatchObject(SANITIZED_ERROR); } ); it('accepts negative market costs and integer costs without conversion', async () => { const row = { ...ROW, cost: '0', market_cost: '-10.500000000' }; mockExecute.mockResolvedValue({ rows: [row] }); - await expect(caller().getDailyUsage(INPUT)).resolves.toEqual([{ ...row, date: INPUT.date }]); + await expect(caller().getHourlyUsage(INPUT)).resolves.toEqual([ + { ...row, hour_start: HOUR_START }, + ]); }); it('returns an empty array when PostgreSQL returns no rows', async () => { - await expect(caller().getDailyUsage(INPUT)).resolves.toEqual([]); + await expect(caller().getHourlyUsage(INPUT)).resolves.toEqual([]); }); it('requires admin access before opening a transaction', async () => { - await expect(caller(false).getDailyUsage(INPUT)).rejects.toMatchObject({ code: 'FORBIDDEN' }); + await expect(caller(false).getHourlyUsage(INPUT)).rejects.toMatchObject({ code: 'FORBIDDEN' }); expect(mockTransaction).not.toHaveBeenCalled(); expect(mockExecute).not.toHaveBeenCalled(); }); @@ -334,7 +387,7 @@ describe('admin.gatewayUsage.getDailyUsage', () => { } else { mockExecute.mockResolvedValueOnce({ rows: [] }).mockRejectedValueOnce(error); } - await expect(caller().getDailyUsage(INPUT)).rejects.toMatchObject({ + await expect(caller().getHourlyUsage(INPUT)).rejects.toMatchObject({ ...SANITIZED_ERROR, cause: undefined, }); @@ -360,33 +413,37 @@ describe('admin.gatewayUsage.getDailyUsage', () => { '2147483647', ])('uses exactly ten minutes regardless of timeout configuration %p', async timeout => { process.env.USAGE_QUERY_TIMEOUT_ADMIN_MS = timeout; - await caller().getDailyUsage(INPUT); + await caller().getHourlyUsage(INPUT); expect(executedQuery(0).sql).toBe("SET LOCAL statement_timeout = '600000'"); }); - it('sets exactly ten minutes in each daily transaction when no timeout is configured', async () => { + it('sets exactly ten minutes in each hourly transaction when no timeout is configured', async () => { delete process.env.USAGE_QUERY_TIMEOUT_ADMIN_MS; - await caller().getDailyUsage(INPUT); - await caller().getDailyUsage({ ...INPUT, date: '2026-09-16' }); + await caller().getHourlyUsage(INPUT); + await caller().getHourlyUsage({ ...INPUT, hour: 13 }); expect(mockTransaction).toHaveBeenCalledTimes(2); expect(mockExecute).toHaveBeenCalledTimes(4); expect(executedQuery(0).sql).toBe("SET LOCAL statement_timeout = '600000'"); expect(executedQuery(2).sql).toBe("SET LOCAL statement_timeout = '600000'"); - expect(executedQuery(1).params).toEqual([INPUT.model, INPUT.date, INPUT.date]); - expect(executedQuery(3).params).toEqual([INPUT.model, '2026-09-16', '2026-09-16']); + expect(executedQuery(1).params).toEqual([INPUT.model, HOUR_START, HOUR_START]); + expect(executedQuery(3).params).toEqual([ + INPUT.model, + '2026-09-15T13:00:00.000Z', + '2026-09-15T13:00:00.000Z', + ]); }); it('fails closed in production when readDb would fall back to primary', async () => { jest.replaceProperty(process, 'env', { ...process.env, NODE_ENV: 'production' }); mockUsesSeparateReplica = false; - await expect(caller().getDailyUsage(INPUT)).rejects.toMatchObject(SANITIZED_ERROR); + await expect(caller().getHourlyUsage(INPUT)).rejects.toMatchObject(SANITIZED_ERROR); expect(mockTransaction).not.toHaveBeenCalled(); expect(mockExecute).not.toHaveBeenCalled(); }); it('allows a separately configured replica in production', async () => { jest.replaceProperty(process, 'env', { ...process.env, NODE_ENV: 'production' }); - await expect(caller().getDailyUsage(INPUT)).resolves.toEqual([]); + await expect(caller().getHourlyUsage(INPUT)).resolves.toEqual([]); expect(mockTransaction).toHaveBeenCalledTimes(1); }); @@ -395,24 +452,97 @@ describe('admin.gatewayUsage.getDailyUsage', () => { async nodeEnv => { jest.replaceProperty(process, 'env', { ...process.env, NODE_ENV: nodeEnv }); mockUsesSeparateReplica = false; - await expect(caller().getDailyUsage(INPUT)).resolves.toEqual([]); + await expect(caller().getHourlyUsage(INPUT)).resolves.toEqual([]); expect(mockTransaction).toHaveBeenCalledTimes(1); } ); it('does not open a transaction for an already-aborted request', async () => { - await expect(caller(true, AbortSignal.abort()).getDailyUsage(INPUT)).rejects.toMatchObject( + await expect(caller(true, AbortSignal.abort()).getHourlyUsage(INPUT)).rejects.toMatchObject( SANITIZED_ERROR ); expect(mockTransaction).not.toHaveBeenCalled(); expect(mockExecute).not.toHaveBeenCalled(); }); + + it('skips aggregation when aborted while waiting for a transaction connection', async () => { + const controller = new AbortController(); + const started = Promise.withResolvers(); + const connection = Promise.withResolvers(); + mockTransaction.mockImplementation(async callback => { + started.resolve(); + await connection.promise; + return callback({ execute: mockExecute } as never); + }); + + const result = caller(true, controller.signal).getHourlyUsage(INPUT); + await started.promise; + expect(mockExecute).not.toHaveBeenCalled(); + controller.abort(); + connection.resolve(); + + await expect(result).rejects.toMatchObject({ ...SANITIZED_ERROR, cause: undefined }); + expect(mockTransaction).toHaveBeenCalledTimes(1); + expect(mockExecute).toHaveBeenCalledTimes(1); + expect(executedQuery(0).sql).toBe("SET LOCAL statement_timeout = '600000'"); + }); + + it('skips aggregation when aborted while SET LOCAL is pending', async () => { + const controller = new AbortController(); + const started = Promise.withResolvers(); + const timeout = Promise.withResolvers<{ rows: unknown[] }>(); + mockExecute.mockImplementationOnce(() => { + started.resolve(); + return timeout.promise; + }); + + const result = caller(true, controller.signal).getHourlyUsage(INPUT); + await started.promise; + controller.abort(); + timeout.resolve({ rows: [] }); + + await expect(result).rejects.toMatchObject({ ...SANITIZED_ERROR, cause: undefined }); + expect(mockTransaction).toHaveBeenCalledTimes(1); + expect(mockExecute).toHaveBeenCalledTimes(1); + }); + + it.each(['aggregation', 'transaction completion'])( + 'discards the awaited result when aborted during %s', + async stage => { + const controller = new AbortController(); + const started = Promise.withResolvers(); + const completion = Promise.withResolvers<{ rows: unknown[] }>(); + if (stage === 'aggregation') { + mockExecute.mockResolvedValueOnce({ rows: [] }).mockImplementationOnce(() => { + started.resolve(); + return completion.promise; + }); + } else { + mockExecute.mockResolvedValue({ rows: [ROW] }); + mockTransaction.mockImplementation(async callback => { + const rows = await callback({ execute: mockExecute } as never); + started.resolve(); + await completion.promise; + return rows; + }); + } + + const result = caller(true, controller.signal).getHourlyUsage(INPUT); + await started.promise; + controller.abort(); + completion.resolve({ rows: [ROW] }); + + await expect(result).rejects.toMatchObject({ ...SANITIZED_ERROR, cause: undefined }); + expect(mockTransaction).toHaveBeenCalledTimes(1); + expect(mockExecute).toHaveBeenCalledTimes(2); + } + ); }); -describe('admin.gatewayUsage.getDailyUsage PostgreSQL semantics', () => { +describe('admin.gatewayUsage.getHourlyUsage PostgreSQL semantics', () => { let input: typeof INPUT; const singleUsage = { - date: INPUT.date, + hour_start: HOUR_START, provider: 'provider-a', is_byok: false, users: '1', @@ -447,7 +577,7 @@ describe('admin.gatewayUsage.getDailyUsage PostgreSQL semantics', () => { provider: 'provider-a', model: 'different-resolved-model', requested_model: input.model, - created_at: `${input.date} 12:00:00+00`, + created_at: `${input.date} ${input.hour.toString().padStart(2, '0')}:30:00+00`, input_tokens: 10, output_tokens: 20, cache_hit_tokens: 3, @@ -468,15 +598,17 @@ describe('admin.gatewayUsage.getDailyUsage PostgreSQL semantics', () => { } } - it('filters exact model and false user BYOK with an inner join and a half-open UTC day', async () => { - await insertUsage({ created_at: '2026-09-15 00:00:00+00' }); - await insertUsage({ created_at: '2026-09-15 23:59:59.999999+00' }); + it('filters exact model and false user BYOK with an inner join and a half-open UTC hour', async () => { + await insertUsage({ created_at: '2026-09-15 12:00:00+00' }); + await insertUsage({ created_at: '2026-09-15 12:59:59.999999+00' }); await insertUsage({ kilo_user_id: 'anon:lowercase' }); await insertUsage({ kilo_user_id: 'AnOn:mixed-case' }); await insertUsage({ kilo_user_id: 'ANON-without-colon' }); - await insertUsage({ created_at: '2026-09-14 23:59:59.999999+00' }); - await insertUsage({ created_at: '2026-09-16 00:00:00+00' }); + await insertUsage({ created_at: '2026-09-15 11:59:59.999999+00' }); + await insertUsage({ created_at: '2026-09-15 13:00:00+00' }); await insertUsage({ requested_model: 'different-requested-model', model: input.model }); + await insertUsage({ requested_model: input.model.toUpperCase() }); + await insertUsage({ requested_model: `${input.model}-suffix` }); await insertUsage({ requested_model: null }); await insertUsage({}, { is_user_byok: true }); await insertUsage({}, { is_user_byok: null }); @@ -489,7 +621,7 @@ describe('admin.gatewayUsage.getDailyUsage PostgreSQL semantics', () => { market_cost: 1000, }); - await expect(caller().getDailyUsage(input)).resolves.toEqual([ + await expect(caller().getHourlyUsage(input)).resolves.toEqual([ { ...singleUsage, users: '4', @@ -504,34 +636,34 @@ describe('admin.gatewayUsage.getDailyUsage PostgreSQL semantics', () => { ]); }); - it.each<[string, string, string]>([ - ['2000-01-01', '1999-12-31', '2000-01-02'], - ['2000-02-29', '2000-02-28', '2000-03-01'], - ['2024-02-28', '2024-02-27', '2024-02-29'], - ['2024-02-29', '2024-02-28', '2024-03-01'], - ['2026-02-28', '2026-02-27', '2026-03-01'], - ['2026-03-08', '2026-03-07', '2026-03-09'], - ['2026-04-30', '2026-04-29', '2026-05-01'], - ['2026-11-01', '2026-10-31', '2026-11-02'], - ['2026-12-31', '2026-12-30', '2027-01-01'], - ['2100-02-28', '2100-02-27', '2100-03-01'], - ['2400-02-29', '2400-02-28', '2400-03-01'], - ['9999-12-31', '9999-12-30', '10000-01-01'], - ])('queries exactly UTC day %s in a non-UTC database session', async (date, previous, next) => { - input = { ...input, date }; + it.each<[string, string]>([ + ['2000-01-01', '2000-01-02'], + ['2000-02-29', '2000-03-01'], + ['2024-02-28', '2024-02-29'], + ['2024-02-29', '2024-03-01'], + ['2026-02-28', '2026-03-01'], + ['2026-03-08', '2026-03-09'], + ['2026-04-30', '2026-05-01'], + ['2026-11-01', '2026-11-02'], + ['2026-12-31', '2027-01-01'], + ['2100-02-28', '2100-03-01'], + ['2400-02-29', '2400-03-01'], + ['9999-12-31', '10000-01-01'], + ])('queries hour 23 of %s ending at %s in a non-UTC database session', async (date, next) => { + input = { ...input, date, hour: 23 }; await insertUsage({ - created_at: `${previous} 23:59:59.999999+00`, + created_at: `${date} 22:59:59.999999+00`, provider: 'excluded-before', }); - await insertUsage({ created_at: `${date} 00:00:00+00` }); + await insertUsage({ created_at: `${date} 23:00:00+00` }); await insertUsage(); await insertUsage({ created_at: `${date} 23:59:59.999999+00` }); await insertUsage({ created_at: `${next} 00:00:00+00`, provider: 'excluded-after' }); - await expect(caller().getDailyUsage(input)).resolves.toEqual([ + await expect(caller().getHourlyUsage(input)).resolves.toEqual([ { ...singleUsage, - date, + hour_start: `${date}T23:00:00.000Z`, input_tokens: '30', output_tokens: '60', cache_read_tokens: '9', @@ -542,14 +674,17 @@ describe('admin.gatewayUsage.getDailyUsage PostgreSQL semantics', () => { ]); }); - it('reports adjacent days independently for the same provider and user', async () => { - await insertUsage({ created_at: '2026-09-15 00:00:00+00' }); - await insertUsage({ created_at: '2026-09-15 23:59:59.999999+00' }); - await insertUsage({ created_at: '2026-09-16 00:00:00+00' }); + it('queries hour zero at the minimum supported date without including the previous year', async () => { + input = { ...input, date: '2000-01-01', hour: 0 }; + await insertUsage({ created_at: '1999-12-31 23:59:59.999999+00', provider: 'excluded-before' }); + await insertUsage({ created_at: '2000-01-01 00:00:00+00' }); + await insertUsage({ created_at: '2000-01-01 00:59:59.999999+00' }); + await insertUsage({ created_at: '2000-01-01 01:00:00+00', provider: 'excluded-after' }); - await expect(caller().getDailyUsage(input)).resolves.toEqual([ + await expect(caller().getHourlyUsage(input)).resolves.toEqual([ { ...singleUsage, + hour_start: '2000-01-01T00:00:00.000Z', input_tokens: '20', output_tokens: '40', cache_read_tokens: '6', @@ -558,8 +693,82 @@ describe('admin.gatewayUsage.getDailyUsage PostgreSQL semantics', () => { market_cost: '400', }, ]); - await expect(caller().getDailyUsage({ ...input, date: '2026-09-16' })).resolves.toEqual([ - { ...singleUsage, date: '2026-09-16' }, + }); + + it.each<[string, number, string, string, string, number]>([ + ['2026-09-15', 12, '2026-09-15T12:00:00.000Z', '2026-09-15T13:00:00.000Z', '2026-09-15', 13], + ['2026-09-15', 23, '2026-09-15T23:00:00.000Z', '2026-09-16T00:00:00.000Z', '2026-09-16', 0], + ['2026-03-08', 9, '2026-03-08T09:00:00.000Z', '2026-03-08T10:00:00.000Z', '2026-03-08', 10], + ['2026-11-01', 8, '2026-11-01T08:00:00.000Z', '2026-11-01T09:00:00.000Z', '2026-11-01', 9], + ])( + 'reports adjacent hours independently from %s hour %i for the same provider and user', + async (date, hour, start, end, nextDate, nextHour) => { + input = { ...input, date, hour }; + await insertUsage({ created_at: start }); + await insertUsage(); + await insertUsage({ created_at: end }); + + await expect(caller().getHourlyUsage(input)).resolves.toEqual([ + { + ...singleUsage, + hour_start: start, + input_tokens: '20', + output_tokens: '40', + cache_read_tokens: '6', + cache_write_tokens: '8', + cost: '200', + market_cost: '400', + }, + ]); + await expect( + caller().getHourlyUsage({ ...input, date: nextDate, hour: nextHour }) + ).resolves.toEqual([{ ...singleUsage, hour_start: end }]); + } + ); + + it('counts arbitrary user IDs distinctly per group while summing every qualifying row', async () => { + const userIds = [ + 'oauth/google/arbitrary-user', + 'plain-user', + crypto.randomUUID(), + 'anon:repeated', + 'AnOn:mixed-case', + 'ANON-without-colon', + 'prefix-anon:embedded', + '', + ]; + for (const kilo_user_id of userIds) { + await insertUsage({ kilo_user_id }); + await insertUsage({ kilo_user_id }); + } + await insertUsage({ provider: 'provider-b', kilo_user_id: 'anon:repeated' }); + await insertUsage( + { provider: 'provider-b', kilo_user_id: 'anon:repeated' }, + { market_cost: null } + ); + + await expect(caller().getHourlyUsage(input)).resolves.toEqual([ + { + ...singleUsage, + users: '8', + logged_in_users: '6', + input_tokens: '160', + output_tokens: '320', + cache_read_tokens: '48', + cache_write_tokens: '64', + cost: '1600', + market_cost: '3200', + }, + { + ...singleUsage, + provider: 'provider-b', + logged_in_users: '0', + input_tokens: '20', + output_tokens: '40', + cache_read_tokens: '6', + cache_write_tokens: '8', + cost: '200', + }, ]); }); @@ -569,7 +778,7 @@ describe('admin.gatewayUsage.getDailyUsage PostgreSQL semantics', () => { await insertUsage({ input_tokens: -1, kilo_user_id: 'excluded-negative-input-user' }); await insertUsage({ input_tokens: 0, provider: 'excluded-provider' }); - await expect(caller().getDailyUsage(input)).resolves.toEqual([ + await expect(caller().getHourlyUsage(input)).resolves.toEqual([ { ...singleUsage, input_tokens: '1' }, ]); }); @@ -582,7 +791,7 @@ describe('admin.gatewayUsage.getDailyUsage PostgreSQL semantics', () => { await insertUsage({ provider: 'provider-b' }); await insertUsage({ provider: null }, { is_byok: null }); - await expect(caller().getDailyUsage(input)).resolves.toEqual([ + await expect(caller().getHourlyUsage(input)).resolves.toEqual([ { ...singleUsage, input_tokens: '20', @@ -608,7 +817,7 @@ describe('admin.gatewayUsage.getDailyUsage PostgreSQL semantics', () => { kilo_user_id: 'oauth/google/test-user', provider: 'provider-a', requested_model: input.model, - created_at: `${input.date} 12:00:00+00`, + created_at: `${input.date} ${input.hour.toString().padStart(2, '0')}:30:00+00`, input_tokens: sql`${large}::bigint`, output_tokens: sql`${large}::bigint`, cache_hit_tokens: sql`${large}::bigint`, @@ -626,7 +835,7 @@ describe('admin.gatewayUsage.getDailyUsage PostgreSQL semantics', () => { })) ); - await expect(caller().getDailyUsage(input)).resolves.toEqual([ + await expect(caller().getHourlyUsage(input)).resolves.toEqual([ { ...singleUsage, is_byok: true, diff --git a/apps/web/src/routers/admin/gateway-usage-router.ts b/apps/web/src/routers/admin/gateway-usage-router.ts index 17bb67880d..3b4f6710de 100644 --- a/apps/web/src/routers/admin/gateway-usage-router.ts +++ b/apps/web/src/routers/admin/gateway-usage-router.ts @@ -36,14 +36,15 @@ const UsageAggregatesSchema = z.object({ }); export const adminGatewayUsageRouter = createTRPCRouter({ - getDailyUsage: adminProcedure + getHourlyUsage: adminProcedure .input( z.object({ date: UsageDateSchema, + hour: z.number().int().min(0).max(23), model: z.string().trim().min(1).max(256), }) ) - .output(z.array(UsageAggregatesSchema.extend({ date: UsageDateSchema }))) + .output(z.array(UsageAggregatesSchema.extend({ hour_start: z.iso.datetime({ precision: 3 }) }))) .query(async ({ input, signal }) => { try { signal?.throwIfAborted(); @@ -51,16 +52,18 @@ export const adminGatewayUsageRouter = createTRPCRouter({ throw new Error('Gateway usage requires a read replica'); } + const hourStart = `${input.date}T${input.hour.toString().padStart(2, '0')}:00:00.000Z`; const rows = await timedUsageQuery( { db: readDb, - route: 'admin.gatewayUsage.getDailyUsage', - queryLabel: 'daily_model_usage', + route: 'admin.gatewayUsage.getHourlyUsage', + queryLabel: 'hourly_model_usage', scope: 'admin', - period: input.date, + period: hourStart, timeoutMs: 600_000, }, async tx => { + signal?.throwIfAborted(); const result = await tx.execute(sql` SELECT mu.provider, @@ -78,18 +81,19 @@ export const adminGatewayUsageRouter = createTRPCRouter({ WHERE mu.requested_model = ${input.model} AND meta.is_user_byok = false AND mu.input_tokens > 0 - AND mu.created_at >= (${input.date}::date::timestamp AT TIME ZONE 'UTC') - AND mu.created_at < ((${input.date}::date + 1)::timestamp AT TIME ZONE 'UTC') + AND mu.created_at >= ${hourStart}::timestamptz + AND mu.created_at < (${hourStart}::timestamptz + interval '1 hour') GROUP BY mu.provider, meta.is_byok ORDER BY mu.provider, meta.is_byok `); return result.rows; } ); + signal?.throwIfAborted(); return z .array(UsageAggregatesSchema) .parse(rows) - .map(row => ({ ...row, date: input.date })); + .map(row => ({ ...row, hour_start: hourStart })); } catch { throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR',