Skip to content
Merged
Show file tree
Hide file tree
Changes from 23 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
b5bee40
feat: add global daily aggregation tables
steebchen May 7, 2026
3a98438
refactor: hourly incremental global daily aggregation
steebchen May 7, 2026
7b5503c
feat(admin): global daily stats page
steebchen May 7, 2026
3004478
chore(autofix): apply diff
steebchen May 7, 2026
5cf713b
feat: add product hunt badge (#2181)
amelitoalcuitas May 7, 2026
456a972
feat(minimax): enable tool calling on M2.7 models (#2180)
RATCHAW May 7, 2026
a4fb0a5
feat: delete resend contact when banning org (#2185)
steebchen May 7, 2026
d7857a5
fix: surface devpass subscribe API error message (#2187)
steebchen May 7, 2026
b1d5116
fix: redirect devpass cancel to /dashboard (#2190)
steebchen May 7, 2026
6e07fa1
chore(deps-dev): bump @semantic-release/npm in / (#2029)
dependabot[bot] May 7, 2026
f10636a
feat: email verification banner on devpass dashboard (#2188)
steebchen May 7, 2026
b357d44
chore(deps): bump hono from 4.12.7 to 4.12.16 (#2191)
dependabot[bot] May 7, 2026
f0d3ca0
fix(gateway): respect RPM limits in low-uptime fallback (#2042)
steebchen May 7, 2026
a5fc6c4
feat: decouple gateway caching from data retention (#2186)
steebchen May 7, 2026
8b70e48
feat: bill aws bedrock cache write (#2193)
RATCHAW May 7, 2026
f0dbb15
fix(gateway): consume rate-limit slot on fallback retry (#2046)
steebchen May 7, 2026
00ea25d
fix: scope retry health by model and region (#1977)
steebchen May 7, 2026
4fbf03c
test(reasoning): unit-test budget forwarding; fix openai flag (#2079)
steebchen May 7, 2026
7360f84
fix: prevent /me fetch loop on devpass dashboard (#2194)
steebchen May 7, 2026
a22ceed
feat: stuff
steebchen May 7, 2026
7154f5f
fix: defer safety net while incremental walker is behind
steebchen May 7, 2026
81dcc7a
Merge remote-tracking branch 'origin/main' into add-global-daily-aggr…
steebchen May 7, 2026
41f19fb
refactor: rename global daily tables to global
steebchen May 7, 2026
3914289
fix: snake_case excluded.<col> reference in ADD upsert
steebchen May 7, 2026
1aea710
feat(admin): make pie chart metric selectable
steebchen May 7, 2026
ed16f82
fix: don't mark safety-net day done when stop interrupted recompute
steebchen May 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
260 changes: 260 additions & 0 deletions apps/api/src/routes/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ import {
tables,
projectHourlyStats,
projectHourlyModelStats,
globalModelStats,
globalSourceStats,
modelProviderMappingHistory,
modelHistory,
} from "@llmgateway/db";
Expand Down Expand Up @@ -806,6 +808,264 @@ admin.openapi(getTimeseries, async (c) => {
});
});

const globalStatsRangeSchema = z.enum(["7d", "30d", "90d", "365d"]);
const globalStatsGroupBySchema = z.enum(["model", "source"]);

const globalStatsMetricsSchema = z.object({
requestCount: z.number(),
errorCount: z.number(),
cacheCount: z.number(),
inputTokens: z.number(),
cachedTokens: z.number(),
outputTokens: z.number(),
totalTokens: z.number(),
cost: z.number(),
inputCost: z.number(),
cachedInputCost: z.number(),
outputCost: z.number(),
});

const globalStatsTimeseriesPointSchema = globalStatsMetricsSchema
.extend({
date: z.string(),
})
.openapi({});

const globalStatsBreakdownItemSchema = globalStatsMetricsSchema
.extend({
key: z.string(),
label: z.string(),
})
.openapi({});

const globalStatsResponseSchema = z.object({
range: globalStatsRangeSchema,
groupBy: globalStatsGroupBySchema,
totals: globalStatsMetricsSchema,
timeseries: z.array(globalStatsTimeseriesPointSchema),
breakdown: z.array(globalStatsBreakdownItemSchema),
});

const getGlobalStats = createRoute({
method: "get",
path: "/global-stats",
request: {
query: z.object({
range: globalStatsRangeSchema.default("30d").optional(),
groupBy: globalStatsGroupBySchema.default("model").optional(),
}),
},
responses: {
200: {
content: {
"application/json": {
schema: globalStatsResponseSchema.openapi({}),
},
},
description: "Global aggregated stats grouped by model or x-source.",
},
},
});

admin.openapi(getGlobalStats, async (c) => {
const query = c.req.valid("query");
const range = query.range ?? "30d";
const groupBy = query.groupBy ?? "model";

const rangeDays: Record<typeof range, number> = {
"7d": 7,
"30d": 30,
"90d": 90,
"365d": 365,
};
const days = rangeDays[range];

const dayMs = 24 * 60 * 60 * 1000;
const startDate = new Date();
startDate.setUTCHours(0, 0, 0, 0);
const startMs = startDate.getTime() - (days - 1) * dayMs; // eslint-disable-line no-mixed-operators
startDate.setTime(startMs);

const sourceTable =
groupBy === "model" ? globalModelStats : globalSourceStats;

const metricSums = {
requestCount:
sql<number>`COALESCE(SUM(${sourceTable.requestCount}), 0)::int`.as(
"requestCount",
Comment on lines +893 to +895
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Return request totals without int32 downcast

For high-traffic ranges this SUM(... )::int can make /admin/global-stats fail with integer out of range: PostgreSQL's SUM(integer) can exceed 2,147,483,647 over 30/90/365 days, and the commit explicitly targets up to ~1000 r/s where 30 days is ~2.6B requests. Keep these count sums as bigint/numeric or cast to float8 before returning; the same applies to the adjacent errorCount and cacheCount sums.

Useful? React with 👍 / 👎.

),
errorCount:
sql<number>`COALESCE(SUM(${sourceTable.errorCount}), 0)::int`.as(
"errorCount",
),
cacheCount:
sql<number>`COALESCE(SUM(${sourceTable.cacheCount}), 0)::int`.as(
"cacheCount",
),
Comment on lines +894 to +904
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

# First, let's look at the admin.ts file structure and the specific lines mentioned
head -n 910 apps/api/src/routes/admin.ts | tail -n 50

Repository: theopenco/llmgateway

Length of output: 1453


🏁 Script executed:

# Let's get a broader view of the function containing these lines
head -n 920 apps/api/src/routes/admin.ts | tail -n 150

Repository: theopenco/llmgateway

Length of output: 4079


🏁 Script executed:

# Search for all similar SUM casts in the file to see if this is a pattern
rg "SUM\(\$\{" apps/api/src/routes/admin.ts -A 1

Repository: theopenco/llmgateway

Length of output: 46


🏁 Script executed:

# Check the schema to understand the source data types for these count fields
rg "requestCount|errorCount|cacheCount" apps/api/src/routes/admin.ts -B 2 -A 2 | head -n 80

Repository: theopenco/llmgateway

Length of output: 2169


🏁 Script executed:

# Find imports related to SQL and the sourceTable to understand types
head -n 50 apps/api/src/routes/admin.ts

Repository: theopenco/llmgateway

Length of output: 1090


🏁 Script executed:

# Check the database schema definition for these tables
rg "globalDailyModelStats|globalDailySourceStats" --type ts -A 5 -B 2 | head -n 100

Repository: theopenco/llmgateway

Length of output: 6610


🏁 Script executed:

# Look for schema/table definitions in the db module
fd -e ts -path "*/db/*" | head -20

Repository: theopenco/llmgateway

Length of output: 233


🏁 Script executed:

# Search for table schema definitions
rg "export const globalDaily" -A 10 | head -n 50

Repository: theopenco/llmgateway

Length of output: 2027


🏁 Script executed:

# Get the full column definitions for globalDailyModelStats and globalDailySourceStats
rg "export const globalDailyModelStats" packages/db/src/schema.ts -A 30

Repository: theopenco/llmgateway

Length of output: 1348


🏁 Script executed:

# Also check the sourceStats schema
rg "export const globalDailySourceStats" packages/db/src/schema.ts -A 30

Repository: theopenco/llmgateway

Length of output: 1401


🏁 Script executed:

# Look for the column definitions - search for requestCount, errorCount, cacheCount
rg "requestCount|errorCount|cacheCount" packages/db/src/schema.ts -B 2 -A 2

Repository: theopenco/llmgateway

Length of output: 3453


🏁 Script executed:

# Search for other instances of SUM on these count fields in the codebase
rg "SUM.*requestCount|SUM.*errorCount|SUM.*cacheCount" --type ts -B 2 -A 2

Repository: theopenco/llmgateway

Length of output: 8047


🏁 Script executed:

# Check if there are any other aggregate queries on the daily stats tables
rg "globalDailyModelStats|globalDailySourceStats" apps/api/src/routes/admin.ts | grep -i "select\|sum\|aggregate"

Repository: theopenco/llmgateway

Length of output: 46


Remove ::int casts on count aggregates in global daily stats endpoint

At your stated scale (1000 r/s), summing request/error/cache counts over 365 days can exceed the 32-bit integer limit (~2.1B). These casts on lines 894, 898, and 902 force PostgreSQL SUM results back to int, causing data loss. Use bigint instead. Note that other SUM() aggregates in this file (e.g., projectHourlyStats queries) omit the cast and work correctly.

Suggested fix
- requestCount:
- 	sql<number>`COALESCE(SUM(${sourceTable.requestCount}), 0)::int`.as(
+ requestCount:
+ 	sql<number>`COALESCE(SUM(${sourceTable.requestCount}), 0)::bigint`.as(
 		"requestCount",
 	),
- errorCount:
- 	sql<number>`COALESCE(SUM(${sourceTable.errorCount}), 0)::int`.as(
+ errorCount:
+ 	sql<number>`COALESCE(SUM(${sourceTable.errorCount}), 0)::bigint`.as(
 		"errorCount",
 	),
- cacheCount:
- 	sql<number>`COALESCE(SUM(${sourceTable.cacheCount}), 0)::int`.as(
+ cacheCount:
+ 	sql<number>`COALESCE(SUM(${sourceTable.cacheCount}), 0)::bigint`.as(
 		"cacheCount",
 	),
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/routes/admin.ts` around lines 894 - 904, The COALESCE(SUM(...),
0)::int casts in the global daily stats block (the
sql`COALESCE(SUM(${sourceTable.requestCount}), 0)::int`.as("requestCount"), and
analogs for "errorCount" and "cacheCount") force 32-bit ints and can overflow;
replace those casts by either removing the cast entirely or changing to ::bigint
so the SUM results remain 64-bit (e.g., change the sql expressions for
requestCount, errorCount, and cacheCount to not cast to int or to cast to
bigint).

inputTokens:
sql<number>`COALESCE(SUM(CAST(${sourceTable.inputTokens} AS NUMERIC)), 0)::float8`.as(
"inputTokens",
),
cachedTokens:
sql<number>`COALESCE(SUM(CAST(${sourceTable.cachedTokens} AS NUMERIC)), 0)::float8`.as(
"cachedTokens",
),
outputTokens:
sql<number>`COALESCE(SUM(CAST(${sourceTable.outputTokens} AS NUMERIC)), 0)::float8`.as(
"outputTokens",
),
totalTokens:
sql<number>`COALESCE(SUM(CAST(${sourceTable.totalTokens} AS NUMERIC)), 0)::float8`.as(
"totalTokens",
),
cost: sql<number>`COALESCE(SUM(${sourceTable.cost}), 0)::float8`.as("cost"),
inputCost:
sql<number>`COALESCE(SUM(${sourceTable.inputCost}), 0)::float8`.as(
"inputCost",
),
cachedInputCost:
sql<number>`COALESCE(SUM(${sourceTable.cachedInputCost}), 0)::float8`.as(
"cachedInputCost",
),
outputCost:
sql<number>`COALESCE(SUM(${sourceTable.outputCost}), 0)::float8`.as(
"outputCost",
),
};

const dateExpr =
sql<string>`to_char(${sourceTable.dayTimestamp}, 'YYYY-MM-DD')`.as("date");

const timeseriesRows = await db
.select({
date: dateExpr,
...metricSums,
})
.from(sourceTable)
.where(gte(sourceTable.dayTimestamp, startDate))
.groupBy(sourceTable.dayTimestamp)
.orderBy(asc(sourceTable.dayTimestamp));
Comment on lines +883 to +947

const timeseriesMap = new Map<
string,
z.infer<typeof globalStatsTimeseriesPointSchema>
>();
for (const row of timeseriesRows) {
timeseriesMap.set(row.date, {
date: row.date,
requestCount: Number(row.requestCount),
errorCount: Number(row.errorCount),
cacheCount: Number(row.cacheCount),
inputTokens: Number(row.inputTokens),
cachedTokens: Number(row.cachedTokens),
outputTokens: Number(row.outputTokens),
totalTokens: Number(row.totalTokens),
cost: Number(row.cost),
inputCost: Number(row.inputCost),
cachedInputCost: Number(row.cachedInputCost),
outputCost: Number(row.outputCost),
});
}

const totals: z.infer<typeof globalStatsMetricsSchema> = {
requestCount: 0,
errorCount: 0,
cacheCount: 0,
inputTokens: 0,
cachedTokens: 0,
outputTokens: 0,
totalTokens: 0,
cost: 0,
inputCost: 0,
cachedInputCost: 0,
outputCost: 0,
};

const timeseries: z.infer<typeof globalStatsTimeseriesPointSchema>[] = [];
for (let i = 0; i < days; i++) {
const cur = new Date(startDate.getTime() + i * dayMs); // eslint-disable-line no-mixed-operators
const dateStr = cur.toISOString().split("T")[0];
const point = timeseriesMap.get(dateStr) ?? {
date: dateStr,
requestCount: 0,
errorCount: 0,
cacheCount: 0,
inputTokens: 0,
cachedTokens: 0,
outputTokens: 0,
totalTokens: 0,
cost: 0,
inputCost: 0,
cachedInputCost: 0,
outputCost: 0,
};
timeseries.push(point);
totals.requestCount += point.requestCount;
totals.errorCount += point.errorCount;
totals.cacheCount += point.cacheCount;
totals.inputTokens += point.inputTokens;
totals.cachedTokens += point.cachedTokens;
totals.outputTokens += point.outputTokens;
totals.totalTokens += point.totalTokens;
totals.cost += point.cost;
totals.inputCost += point.inputCost;
totals.cachedInputCost += point.cachedInputCost;
totals.outputCost += point.outputCost;
}

const breakdownRows =
groupBy === "model"
? await db
.select({
usedModel: globalModelStats.usedModel,
usedProvider: globalModelStats.usedProvider,
...metricSums,
})
.from(globalModelStats)
.where(gte(globalModelStats.dayTimestamp, startDate))
.groupBy(globalModelStats.usedModel, globalModelStats.usedProvider)
.orderBy(desc(metricSums.requestCount))
: await db
.select({
source: globalSourceStats.source,
...metricSums,
})
.from(globalSourceStats)
.where(gte(globalSourceStats.dayTimestamp, startDate))
.groupBy(globalSourceStats.source)
.orderBy(desc(metricSums.requestCount));
Comment on lines +1033 to +1036
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Limit high-cardinality source breakdowns

When groupBy=source, this query materializes and returns every distinct x-source value in the selected range. Since x-source is supplied on gateway requests and only character-validated, a workload that sends many unique sources over 90d/365d can make the admin stats endpoint group, sort, serialize, and render an unbounded response even though the UI only displays the top slices/rows. Consider limiting in SQL and computing an aggregated “Other” bucket server-side.

Useful? React with 👍 / 👎.


const breakdown: z.infer<typeof globalStatsBreakdownItemSchema>[] =
breakdownRows.map((row) => {
const isModel = "usedModel" in row;
const key = isModel ? `${row.usedProvider}/${row.usedModel}` : row.source;
const label = isModel ? row.usedModel : row.source;
return {
key,
label,
requestCount: Number(row.requestCount),
errorCount: Number(row.errorCount),
cacheCount: Number(row.cacheCount),
inputTokens: Number(row.inputTokens),
cachedTokens: Number(row.cachedTokens),
outputTokens: Number(row.outputTokens),
totalTokens: Number(row.totalTokens),
cost: Number(row.cost),
inputCost: Number(row.inputCost),
cachedInputCost: Number(row.cachedInputCost),
outputCost: Number(row.outputCost),
};
});

return c.json({
range,
groupBy,
totals,
timeseries,
breakdown,
});
});

admin.openapi(getOrganizations, async (c) => {
const query = c.req.valid("query");
const limit = query.limit ?? 50;
Expand Down
85 changes: 85 additions & 0 deletions apps/code/src/lib/api/v1.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1495,6 +1495,91 @@ export interface paths {
patch?: never;
trace?: never;
};
"/admin/global-stats": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: {
parameters: {
query?: {
range?: "7d" | "30d" | "90d" | "365d";
groupBy?: "model" | "source";
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Global aggregated stats grouped by model or x-source. */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
/** @enum {string} */
range: "7d" | "30d" | "90d" | "365d";
/** @enum {string} */
groupBy: "model" | "source";
totals: {
requestCount: number;
errorCount: number;
cacheCount: number;
inputTokens: number;
cachedTokens: number;
outputTokens: number;
totalTokens: number;
cost: number;
inputCost: number;
cachedInputCost: number;
outputCost: number;
};
timeseries: {
requestCount: number;
errorCount: number;
cacheCount: number;
inputTokens: number;
cachedTokens: number;
outputTokens: number;
totalTokens: number;
cost: number;
inputCost: number;
cachedInputCost: number;
outputCost: number;
date: string;
}[];
breakdown: {
requestCount: number;
errorCount: number;
cacheCount: number;
inputTokens: number;
cachedTokens: number;
outputTokens: number;
totalTokens: number;
cost: number;
inputCost: number;
cachedInputCost: number;
outputCost: number;
key: string;
label: string;
}[];
};
};
};
};
};
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/organizations": {
parameters: {
query?: never;
Expand Down
Loading
Loading