Skip to content

feat: global daily aggregation + admin stats page#2195

Merged
steebchen merged 26 commits into
mainfrom
add-global-daily-aggregation
May 7, 2026
Merged

feat: global daily aggregation + admin stats page#2195
steebchen merged 26 commits into
mainfrom
add-global-daily-aggregation

Conversation

@steebchen
Copy link
Copy Markdown
Member

@steebchen steebchen commented May 7, 2026

Summary

  • New globalDailyModelStats + globalDailySourceStats tables aggregating cross-org usage by day, grouped by (usedModel, usedProvider) and coalesce(source, 'unknown') respectively. Same metric column set as the existing project-hourly tables.
  • New worker loop that processes closed hours incrementally with a watermark in globalDailyAggregationState. Each tick reads ~1h of log rows and ADDs the result to the day's totals via col = col + EXCLUDED.col upserts; per-tick work is constant regardless of where in the day we are. Once per day, the previous day is fully recomputed as a safety net for late-arriving logs.
  • Admin API endpoint GET /admin/global-daily-stats?range=7d|30d|90d|365d&groupBy=model|source returning totals + per-day timeseries + per-key breakdown.
  • Admin UI page at /global-daily-stats with range + groupBy selectors, 4 stat cards, daily line chart with metric toggle, and a pie chart of cost share with a top-25 table.

Scaling

At 50 r/s (4.3M rows/day), the previous "rescan whole day every 5 min" would be ~600M row-reads/day. The hourly-incremental design is O(rows-in-1-hour) per tick — comfortable to ~1000 r/s. Trade-off: the current day's stats lag by ~1–2h until each closed hour is processed.

Test plan

  • Migration applied to dev + test DBs
  • pnpm build passes (17/17)
  • pnpm test:unit passes (1151/1153)
  • Worker compiles and loops register
  • Admin API route registered, enforces admin middleware (401 for unauth)
  • SQL aggregation queries verified against seeded test rows
  • Admin page builds and route is generated by Next.js
  • Manual: open /global-daily-stats with admin auth, switch range + groupBy, verify charts render

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Global Stats admin dashboard: selectable ranges (7/30/90/365d) showing requests, cost, tokens, distinct counts; daily timeseries, cost-share pie, and top-N breakdowns grouped by model/provider or source
    • Admin sidebar: new "Global Stats" entry for quick access
  • Chores

    • Deleting an organization now also removes associated external contact records

steebchen and others added 3 commits May 7, 2026 21:23
Adds globalDailyModelStats and globalDailySourceStats with the full
hourly-stats column set, aggregated from log every 5 min by a new
worker loop. Skips current-day rescans when no new logs since the last
refresh.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replaces the 5-min full-day rescan with an hourly walk over closed
hours. A singleton globalDailyAggregationState row tracks the
lastProcessedHour watermark; each tick aggregates one hour of log
rows and ADDs the result to the day's totals via a col=col+EXCLUDED.col
upsert, then advances the watermark in the same transaction. Once a
day, the previous day is fully recomputed as a safety net for any
late-arriving logs.

Per-tick work is now O(rows-in-1-hour) regardless of where in the
day we are, scaling cleanly to 200+ r/s.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds GET /admin/global-daily-stats with range (7d|30d|90d|365d) and
groupBy (model|source) query params, returning totals + per-day
timeseries + per-key breakdown summed from the new
globalDailyModelStats / globalDailySourceStats tables.

Adds an admin UI page at /global-daily-stats with:
- range + groupBy selectors
- 4 stat cards (requests, cost, tokens, distinct keys)
- daily line chart with metric toggle (cost / requests / tokens)
- pie chart of cost share (top 10 + Other) plus a top-25 table

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings May 7, 2026 15:02
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 7, 2026

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Adds global daily stats: DB tables and migration, an incremental aggregation worker with safety-net, worker loop wiring, an admin API GET /global-stats, org-deletion Resend cleanup, and an admin UI page with charts and sidebar navigation.

Changes

Global Daily Statistics Pipeline

Layer / File(s) Summary
DB Schema and Migration
packages/db/migrations/1778172779_fresh_abomination.sql, packages/db/migrations/meta/_journal.json, packages/db/src/schema.ts
Three new tables: global_aggregation_state, global_model_stats, global_source_stats; migration journal updated and indexes added.
Worker Configuration and Helpers
apps/worker/src/services/global-stats-aggregator.ts, apps/worker/src/services/project-stats-aggregator.ts
Exports GLOBAL_STATS_INTERVAL_SECONDS, bucket/day helpers, and exposes formatUTCTimestamp and getCommonAggregationFields.
Worker Aggregation Core
apps/worker/src/services/global-stats-aggregator.ts
Implements incremental aggregation, additive upserts into model/source tables, state persistence, safety-net recompute, and processClosedHours().
Worker Loop Integration
apps/worker/src/worker.ts
Adds runGlobalStatsLoop() and starts it during worker startup; integrates logging and shutdown handling.
Admin API Endpoint
apps/api/src/routes/admin.ts
Adds GET /global-stats (Zod schemas): selects stats table by groupBy, returns zero-filled daily timeseries, totals, and ranked breakdown.
Organization Deletion Cleanup
apps/api/src/routes/admin.ts
When org status set to deleted, deletes Resend contacts for member users via deleteResendContact.
Admin UI Dashboard
ee/admin/src/app/global-stats/page.tsx, ee/admin/src/app/global-stats/client.tsx, ee/admin/src/components/admin-shell.tsx
Protected page requiring session; client renders stat cards, timeseries, pie chart, breakdown table; sidebar adds Global Stats route and BarChart3 icon.

Sequence Diagram(s)

sequenceDiagram
  participant AdminUI as Admin Dashboard
  participant AdminAPI as GET /admin/global-stats
  participant AggWorker as Global Stats Worker
  participant DB as Database
  AdminUI->>AdminAPI: query range, groupBy
  AdminAPI->>DB: select from globalModelStats or globalSourceStats
  DB-->>AdminAPI: daily aggregated rows
  AdminAPI-->>AdminUI: timeseries, totals, breakdown
  
  AggWorker->>DB: read lastProcessedHour watermark
  loop per bucket until max cap
    AggWorker->>DB: select logs in window
    AggWorker->>DB: upsert daily totals (model/source)
    AggWorker->>DB: advance lastProcessedHour
  end
  AggWorker->>DB: check if safety net needed
  alt recompute yesterday
    AggWorker->>DB: delete yesterday daily rows
    AggWorker->>DB: re-aggregate 24 one-hour windows
  end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 32.14% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely summarizes the two main changes: global daily aggregation infrastructure and a new admin stats page.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch add-global-daily-aggregation

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7b5503c121

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +279 to +280
await recomputeDayFully(yesterdayStart);
await setLastSafetyNetDay(yesterdayStart);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Defer the safety net until the watermark reaches it

When the worker is catching up (for example on first deployment, where nextHour starts 30 days back and only 100 hours are processed per tick), this safety-net recomputes yesterday before the incremental watermark has reached yesterday. Later ticks still process those same yesterday hours through the additive upsert path, so yesterday's global totals are added on top of the rows rebuilt here and become permanently inflated. Gate the safety-net on lastProcessedHour being past the end of the recomputed day, or make the later incremental path skip/replace already recomputed hours.

Useful? React with 👍 / 👎.

Comment on lines +315 to +317
await db.transaction(async (tx) => {
await aggregateHourIntoDailyStats(tx, hour);
await setLastProcessedHour(tx, hour);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Serialize additive hourly aggregation across workers

If two worker processes run this loop at the same time, they can both read the same lastProcessedHour, enter this transaction for the same hour, and execute aggregateHourIntoDailyStats, whose conflict handler adds to existing totals. The shared state update does not claim or lock the hour before aggregation, so the same hour is double-counted; use the existing distributed lock pattern or a row-level/state claim before running the additive upserts.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Adds cross-organization daily usage aggregation (by (usedModel, usedProvider) or x-source) and exposes it via a new admin API endpoint plus an admin UI page for visualization.

Changes:

  • Introduces new DB tables for global daily aggregates and a singleton watermark table for incremental processing.
  • Adds a worker loop/service to incrementally fold closed hours into daily totals, with a daily safety-net recomputation pass.
  • Adds GET /admin/global-daily-stats and a new /global-daily-stats admin UI page (timeseries + breakdown charts).

Reviewed changes

Copilot reviewed 11 out of 14 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
packages/db/src/schema.ts Adds global daily model/source stats tables and aggregation state table to the Drizzle schema.
packages/db/migrations/meta/_journal.json Registers the new migrations in the migration journal.
packages/db/migrations/1778152537_sudden_blue_marvel.sql Creates global_daily_model_stats and global_daily_source_stats plus supporting indexes.
packages/db/migrations/1778164250_fresh_blazing_skull.sql Creates global_daily_aggregation_state for incremental watermarking.
ee/admin/src/lib/api/v1.d.ts Adds OpenAPI/typed client definitions for /admin/global-daily-stats.
ee/admin/src/components/admin-shell.tsx Adds sidebar navigation entry for the new global daily stats page.
ee/admin/src/app/global-daily-stats/page.tsx Adds the server page wrapper that requires a session.
ee/admin/src/app/global-daily-stats/client.tsx Implements the admin UI (cards, line chart, pie chart, top-25 table).
apps/worker/src/worker.ts Starts the new global daily stats loop in the worker.
apps/worker/src/services/project-stats-aggregator.ts Exports shared UTC timestamp formatting + common aggregation field builder for reuse.
apps/worker/src/services/global-stats-aggregator.ts Implements incremental closed-hour processing + daily safety-net recompute.
apps/api/src/routes/admin.ts Adds GET /admin/global-daily-stats endpoint aggregating totals/timeseries/breakdown.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +206 to +230
const [row] = await db
.select()
.from(globalDailyAggregationState)
.where(eq(globalDailyAggregationState.id, STATE_ROW_ID))
.limit(1);
return row;
}

async function setLastProcessedHour(database: Tx, hour: Date): Promise<void> {
await database
.insert(globalDailyAggregationState)
.values({ id: STATE_ROW_ID, lastProcessedHour: hour })
.onConflictDoUpdate({
target: globalDailyAggregationState.id,
set: { lastProcessedHour: hour, updatedAt: new Date() },
});
}

async function setLastSafetyNetDay(day: Date): Promise<void> {
await db
.insert(globalDailyAggregationState)
.values({ id: STATE_ROW_ID, lastSafetyNetDay: day })
.onConflictDoUpdate({
target: globalDailyAggregationState.id,
set: { lastSafetyNetDay: day, updatedAt: new Date() },
Comment on lines +269 to +283

const state = await readState();
if (state?.lastSafetyNetDay && state.lastSafetyNetDay >= yesterdayStart) {
return;
}

logger.info(
`[global-safety-net] Recomputing ${formatUTCTimestamp(yesterdayStart)} from logs`,
);

await recomputeDayFully(yesterdayStart);
await setLastSafetyNetDay(yesterdayStart);

logger.info(
`[global-safety-net] Recompute complete for ${formatUTCTimestamp(yesterdayStart)}`,
Comment on lines +883 to +947
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" ? globalDailyModelStats : globalDailySourceStats;

const metricSums = {
requestCount:
sql<number>`COALESCE(SUM(${sourceTable.requestCount}), 0)::int`.as(
"requestCount",
),
errorCount:
sql<number>`COALESCE(SUM(${sourceTable.errorCount}), 0)::int`.as(
"errorCount",
),
cacheCount:
sql<number>`COALESCE(SUM(${sourceTable.cacheCount}), 0)::int`.as(
"cacheCount",
),
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));
breakdownRows.map((row) => {
const isModel = "usedModel" in row;
const key = isModel ? `${row.usedProvider}/${row.usedModel}` : row.source;
const label = isModel ? row.usedModel : row.source;
Comment on lines +237 to +263
async function recomputeDayFully(day: Date): Promise<void> {
const dayStr = formatUTCTimestamp(day);

await db.transaction(async (tx) => {
await tx
.delete(globalDailyModelStats)
.where(sql`${globalDailyModelStats.dayTimestamp} = ${dayStr}::timestamp`);
await tx
.delete(globalDailySourceStats)
.where(
sql`${globalDailySourceStats.dayTimestamp} = ${dayStr}::timestamp`,
);
});

for (let h = 0; h < 24; h++) {
if (isStopRequested()) {
logger.info(
`[global-safety-net] Stop requested mid-recompute of ${dayStr}, leaving lastSafetyNetDay unchanged so next start retries`,
);
return;
}
const hour = new Date(day.getTime() + h * HOUR_MS); // eslint-disable-line no-mixed-operators
await db.transaction(async (tx) => {
await aggregateHourIntoDailyStats(tx, hour);
});
}
}
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 300447879c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

);

await recomputeDayFully(yesterdayStart);
await setLastSafetyNetDay(yesterdayStart);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Do not mark interrupted recomputes complete

If the worker is asked to stop during recomputeDayFully, that helper logs that lastSafetyNetDay should remain unchanged and returns normally after deleting/rebuilding only part of the day. This line still advances lastSafetyNetDay, so a deploy/shutdown during the safety-net pass can leave yesterday's global stats partially rebuilt and prevent the next start from retrying the day.

Useful? React with 👍 / 👎.

Comment on lines +32 to +33
const INITIAL_LOOKBACK_DAYS =
Number(process.env.GLOBAL_DAILY_STATS_INITIAL_LOOKBACK_DAYS) || 30;
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 Backfill every range exposed by the stats API

With the default seed window capped at 30 days, a deployment that already has older logs will never populate the older portions of the new 90d/365d admin ranges exposed by /admin/global-daily-stats; after the watermark is initialized it only moves forward, and the safety-net only recomputes yesterday. Unless operators know to override this env var before first startup, those charts return zeros for days 31–365 even though source logs exist.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (1)
apps/worker/src/services/global-stats-aggregator.ts (1)

151-202: ⚡ Quick win

Replace the per-row upsert loops with a single batch INSERT … ON CONFLICT DO UPDATE.

Each iteration of the two for loops issues a separate DB round-trip. For a 30-day initial catch-up (720 hours × N model+source combos), this can amount to tens of thousands of individual statements. Drizzle supports passing an array to .values() on multi-row upserts, where excluded correctly refers to the specific row that caused each individual conflict.

♻️ Proposed refactor — batch upsert for model rows (apply the same pattern to source rows)
-	for (const row of modelRows) {
-		const { usedModel, usedProvider, ...stats } = row;
-		await database
-			.insert(globalDailyModelStats)
-			.values({
-				dayTimestamp: sql`${dayTimestamp}::timestamp`,
-				usedModel,
-				usedProvider,
-				...stats,
-			})
-			.onConflictDoUpdate({
-				target: [
-					globalDailyModelStats.dayTimestamp,
-					globalDailyModelStats.usedModel,
-					globalDailyModelStats.usedProvider,
-				],
-				set: {
-					...MODEL_ADD_SET,
-					updatedAt: new Date(),
-				},
-			});
-	}
+	if (modelRows.length > 0) {
+		await database
+			.insert(globalDailyModelStats)
+			.values(
+				modelRows.map(({ usedModel, usedProvider, ...stats }) => ({
+					dayTimestamp: sql`${dayTimestamp}::timestamp`,
+					usedModel,
+					usedProvider,
+					...stats,
+				})),
+			)
+			.onConflictDoUpdate({
+				target: [
+					globalDailyModelStats.dayTimestamp,
+					globalDailyModelStats.usedModel,
+					globalDailyModelStats.usedProvider,
+				],
+				set: {
+					...MODEL_ADD_SET,
+					updatedAt: new Date(),
+				},
+			});
+	}
🤖 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/worker/src/services/global-stats-aggregator.ts` around lines 151 - 202,
The code issues one DB round-trip per row in the two for-loops (iterating
modelRows and sourceRows) — replace each per-row upsert with a single batch
upsert: map modelRows into an array of value-objects (setting dayTimestamp as
sql`${dayTimestamp}::timestamp`, usedModel/usedProvider and rest of stats) and
call
database.insert(globalDailyModelStats).values(array).onConflictDoUpdate(...)
using the same target and set (MODEL_ADD_SET plus updatedAt) so the DB performs
a multi-row INSERT ... ON CONFLICT DO UPDATE; do the same for sourceRows with
globalDailySourceStats and SOURCE_ADD_SET. Ensure the mapped arrays match the
column order/types expected by the insert so excluded references work per-row.
🤖 Prompt for all review comments with 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.

Inline comments:
In `@apps/api/src/routes/admin.ts`:
- Around line 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).

In `@apps/worker/src/services/global-stats-aggregator.ts`:
- Around line 265-285: runSafetyNetIfNeeded currently unconditionally calls
setLastSafetyNetDay even when recomputeDayFully returned early due to
isStopRequested(), which marks the day as complete incorrectly; change the
contract so recomputeDayFully signals success (e.g., return boolean completed or
throw on stop) and update runSafetyNetIfNeeded to check that signal and only
call setLastSafetyNetDay(yesterdayStart) when recomputeDayFully indicates a full
successful recompute; reference the functions runSafetyNetIfNeeded,
recomputeDayFully and setLastSafetyNetDay when making the change.

---

Nitpick comments:
In `@apps/worker/src/services/global-stats-aggregator.ts`:
- Around line 151-202: The code issues one DB round-trip per row in the two
for-loops (iterating modelRows and sourceRows) — replace each per-row upsert
with a single batch upsert: map modelRows into an array of value-objects
(setting dayTimestamp as sql`${dayTimestamp}::timestamp`, usedModel/usedProvider
and rest of stats) and call
database.insert(globalDailyModelStats).values(array).onConflictDoUpdate(...)
using the same target and set (MODEL_ADD_SET plus updatedAt) so the DB performs
a multi-row INSERT ... ON CONFLICT DO UPDATE; do the same for sourceRows with
globalDailySourceStats and SOURCE_ADD_SET. Ensure the mapped arrays match the
column order/types expected by the insert so excluded references work per-row.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 2472c427-875e-4a07-807f-7115bd800ab0

📥 Commits

Reviewing files that changed from the base of the PR and between 79f9329 and 7b5503c.

⛔ Files ignored due to path filters (1)
  • ee/admin/src/lib/api/v1.d.ts is excluded by !**/v1.d.ts
📒 Files selected for processing (13)
  • apps/api/src/routes/admin.ts
  • apps/worker/src/services/global-stats-aggregator.ts
  • apps/worker/src/services/project-stats-aggregator.ts
  • apps/worker/src/worker.ts
  • ee/admin/src/app/global-daily-stats/client.tsx
  • ee/admin/src/app/global-daily-stats/page.tsx
  • ee/admin/src/components/admin-shell.tsx
  • packages/db/migrations/1778152537_sudden_blue_marvel.sql
  • packages/db/migrations/1778164250_fresh_blazing_skull.sql
  • packages/db/migrations/meta/1778152537_snapshot.json
  • packages/db/migrations/meta/1778164250_snapshot.json
  • packages/db/migrations/meta/_journal.json
  • packages/db/src/schema.ts

Comment on lines +894 to +904
sql<number>`COALESCE(SUM(${sourceTable.requestCount}), 0)::int`.as(
"requestCount",
),
errorCount:
sql<number>`COALESCE(SUM(${sourceTable.errorCount}), 0)::int`.as(
"errorCount",
),
cacheCount:
sql<number>`COALESCE(SUM(${sourceTable.cacheCount}), 0)::int`.as(
"cacheCount",
),
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).

Comment thread apps/worker/src/services/global-stats-aggregator.ts
amelitoalcuitas and others added 17 commits May 7, 2026 22:22
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added a centered Product Hunt call-to-action in the landing-page hero
that opens the Product Hunt listing in a new tab.
* CTA includes a dedicated Product Hunt icon alongside an arrow for
clearer visual emphasis and improved click-through.

* **Style**
* CTA is styled as an amber, button-like link to stand out in the hero
area.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

<img width="1622" height="929" alt="image"
src="https://github.com/user-attachments/assets/eb11853a-fbba-4b99-a468-3cba890b7b65"
/>

---------

Co-authored-by: Ismail Ghallou <ismai23l@hotmail.com>
## Summary
- When admins ban (status=deleted) an organization via the admin
dashboard, also remove each member's Resend contact via the Resend API
- Errors from Resend are logged but ignored, matching the existing
`createResendContact` / `updateResendContact` pattern
- Only fires on the deleted transition; re-enabling an org is unchanged

## Test plan
- [ ] Ban an org via the admin UI and confirm members' contacts are
removed from the configured Resend audience
- [ ] Ban an org with `RESEND_API_KEY` unset and confirm the request
still succeeds
- [ ] Ban an org whose member email was already removed from Resend and
confirm no failure (warning is logged)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

## Release Notes

* **Chores**
* Organization deletion now automatically removes associated member
email contacts from the contact system.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
## Summary
- The devpass subscribe flow swallowed the API error in
`handleSubscribe`, so users always saw a generic "Failed to start
subscription" toast — even when the API returned a clear, actionable
message like "Email verification required".
- The catch block now extracts `error.message` from the rejected
mutation and surfaces it, falling back to the generic message only when
no message is present.

## Test plan
- [ ] Sign up for a new devpass account and, before verifying email,
click subscribe — toast should now read "Email verification required".
- [ ] Verify email and subscribe — flow should still redirect to Stripe
Checkout as before.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Improved error messaging for dev plan subscription failures by
displaying specific API error details instead of generic notifications,
providing users with clearer feedback when issues occur.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
## Summary
- Stripe checkout cancel for devpass redirected to
`/dashboard/plans?canceled=true`, which 404s. Point it at
`/dashboard?canceled=true` on the same host instead.

## Test plan
- [ ] Start a devpass checkout, cancel, verify the browser lands on
`/dashboard?canceled=true`

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Updated subscription checkout cancellation to redirect to the main
dashboard instead of the plans page.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
[//]: # (dependabot-start)
⚠️  **Dependabot is rebasing this PR** ⚠️ 

Rebasing might not happen immediately, so don't worry if this takes some
time.

Note: if you make any changes to this PR yourself, they will take
precedence over the rebase.

---

[//]: # (dependabot-end)

Bumps the all/@semantic-release/npm group with 1 update:
[@semantic-release/npm](https://github.com/semantic-release/npm).

Updates `@semantic-release/npm` from 12.0.2 to 13.1.5
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/semantic-release/npm/releases"><code>@​semantic-release/npm</code>'s
releases</a>.</em></p>
<blockquote>
<h2>v13.1.5</h2>
<h2><a
href="https://github.com/semantic-release/npm/compare/v13.1.4...v13.1.5">13.1.5</a>
(2026-03-01)</h2>
<h3>Bug Fixes</h3>
<ul>
<li><strong>deps:</strong> update dependency normalize-url to v9 (<a
href="https://redirect.github.com/semantic-release/npm/issues/1095">#1095</a>)
(<a
href="https://github.com/semantic-release/npm/commit/daec492f715f00a6ce61ed1e942d93c735c8fba8">daec492</a>)</li>
</ul>
<h2>v13.1.4</h2>
<h2><a
href="https://github.com/semantic-release/npm/compare/v13.1.3...v13.1.4">13.1.4</a>
(2026-02-06)</h2>
<h3>Bug Fixes</h3>
<ul>
<li><strong>deps:</strong> update dependency <code>@​actions/core</code>
to v3 (<a
href="https://redirect.github.com/semantic-release/npm/issues/1085">#1085</a>)
(<a
href="https://github.com/semantic-release/npm/commit/17abfe1be0864e57be6488061e3d4ecb11189b67">17abfe1</a>)</li>
</ul>
<h2>v13.1.3</h2>
<h2><a
href="https://github.com/semantic-release/npm/compare/v13.1.2...v13.1.3">13.1.3</a>
(2025-12-12)</h2>
<h3>Bug Fixes</h3>
<ul>
<li><strong>deps:</strong> update dependency <code>@​actions/core</code>
to v2 (<a
href="https://redirect.github.com/semantic-release/npm/issues/1055">#1055</a>)
(<a
href="https://github.com/semantic-release/npm/commit/fa4a3abc402b32c5ac0cd595958d60b5ac2571fe">fa4a3ab</a>)</li>
</ul>
<h2>v13.1.2</h2>
<h2><a
href="https://github.com/semantic-release/npm/compare/v13.1.1...v13.1.2">13.1.2</a>
(2025-11-14)</h2>
<h3>Bug Fixes</h3>
<ul>
<li><strong>deps:</strong> update dependency read-pkg to v10 (<a
href="https://redirect.github.com/semantic-release/npm/issues/1032">#1032</a>)
(<a
href="https://github.com/semantic-release/npm/commit/f3f1a00b02c3dfde2ca9a2ec1c3a2b80aee23aeb">f3f1a00</a>)</li>
</ul>
<h2>v13.1.1</h2>
<h2><a
href="https://github.com/semantic-release/npm/compare/v13.1.0...v13.1.1">13.1.1</a>
(2025-10-19)</h2>
<h3>Bug Fixes</h3>
<ul>
<li><strong>publish-dry-run:</strong> temporarily remove the addition of
dry-running the publish step (<a
href="https://github.com/semantic-release/npm/commit/30bd1763e404f2b0a789336ade6748a20c9d3138">30bd176</a>)</li>
</ul>
<h2>v13.1.0</h2>
<h1><a
href="https://github.com/semantic-release/npm/compare/v13.0.0...v13.1.0">13.1.0</a>
(2025-10-19)</h1>
<h3>Features</h3>
<ul>
<li><strong>trusted-publishing:</strong> verify auth, considering OIDC
vs tokens from various registries (<a
href="https://github.com/semantic-release/npm/commit/e3319f1b2cb07eef8f61f9fa613552fa33bc92ae">e3319f1</a>),
closes <a
href="https://redirect.github.com/semantic-release/npm/issues/958">#958</a></li>
<li><strong>trusted-publishing:</strong> refine the messages for related
errors (<a
href="https://github.com/semantic-release/npm/commit/316ce2161e1a7894ce9775b4c8788b966c4dc0db">316ce21</a>),
closes <a
href="https://redirect.github.com/semantic-release/npm/issues/958">#958</a></li>
<li><strong>trusted-publishing:</strong> make request to verify if OIDC
token exchange can succeed (<a
href="https://github.com/semantic-release/npm/commit/c80ecb0404f44fa60c5d9edb1d3424adf8a336f0">c80ecb0</a>),
closes <a
href="https://redirect.github.com/semantic-release/npm/issues/958">#958</a></li>
<li><strong>trusted-publishing:</strong> pass id-token as bearer header
for github actions (<a
href="https://github.com/semantic-release/npm/commit/d83b727440121f4e029d5c2885e9cb55f8aab839">d83b727</a>),
closes <a
href="https://redirect.github.com/semantic-release/npm/issues/958">#958</a></li>
<li><strong>trusted-publishing:</strong> pass id-token as bearer header
for gitlab pipelines (<a
href="https://github.com/semantic-release/npm/commit/6d1c3cf9b3c9a9c44a855ab7fc16b7b9e76026ca">6d1c3cf</a>),
closes <a
href="https://redirect.github.com/semantic-release/npm/issues/958">#958</a></li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/semantic-release/npm/commit/daec492f715f00a6ce61ed1e942d93c735c8fba8"><code>daec492</code></a>
fix(deps): update dependency normalize-url to v9 (<a
href="https://redirect.github.com/semantic-release/npm/issues/1095">#1095</a>)</li>
<li><a
href="https://github.com/semantic-release/npm/commit/af8362f874f817b11d960e3350561fff45286db1"><code>af8362f</code></a>
chore(deps): update dependency strip-ansi to v7.2.0 (<a
href="https://redirect.github.com/semantic-release/npm/issues/1098">#1098</a>)</li>
<li><a
href="https://github.com/semantic-release/npm/commit/7354e1152590de8a8020ac208351a8005add18a3"><code>7354e11</code></a>
chore(deps): update node.js to v24 (<a
href="https://redirect.github.com/semantic-release/npm/issues/1027">#1027</a>)</li>
<li><a
href="https://github.com/semantic-release/npm/commit/2c4d2c94c819ec1fced1ea5cbdff4019ae0f7fc5"><code>2c4d2c9</code></a>
chore(deps): update npm to v11.11.0 (<a
href="https://redirect.github.com/semantic-release/npm/issues/1097">#1097</a>)</li>
<li><a
href="https://github.com/semantic-release/npm/commit/95ba68947e3d7d2b22a3989727cd700692f2035c"><code>95ba689</code></a>
chore(deps): update dependency c8 to v11 (<a
href="https://redirect.github.com/semantic-release/npm/issues/1096">#1096</a>)</li>
<li><a
href="https://github.com/semantic-release/npm/commit/5d32912ab7e1593a329a0aa1a101544b04df1346"><code>5d32912</code></a>
chore(deps): update npm to v11.10.1 (<a
href="https://redirect.github.com/semantic-release/npm/issues/1094">#1094</a>)</li>
<li><a
href="https://github.com/semantic-release/npm/commit/54f908c81a8b8d836653d0eb840e1fb55f259e0c"><code>54f908c</code></a>
chore(deps): lock file maintenance (<a
href="https://redirect.github.com/semantic-release/npm/issues/1093">#1093</a>)</li>
<li><a
href="https://github.com/semantic-release/npm/commit/91e1f14a87086fbb5d783d8684f87e07f7f2a16b"><code>91e1f14</code></a>
chore(deps): update npm to v11.10.0 (<a
href="https://redirect.github.com/semantic-release/npm/issues/1092">#1092</a>)</li>
<li><a
href="https://github.com/semantic-release/npm/commit/0d7d37ef14c6442a5da0ca146f25a81ceabab293"><code>0d7d37e</code></a>
chore(deps): lock file maintenance (<a
href="https://redirect.github.com/semantic-release/npm/issues/1089">#1089</a>)</li>
<li><a
href="https://github.com/semantic-release/npm/commit/c5411cc2d0158e2bca5b8fb01c3ba31bcfbea490"><code>c5411cc</code></a>
chore(deps): update npm to v11.9.0 (<a
href="https://redirect.github.com/semantic-release/npm/issues/1088">#1088</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/semantic-release/npm/compare/v12.0.2...v13.1.5">compare
view</a></li>
</ul>
</details>
<details>
<summary>Maintainer changes</summary>
<p>This version was pushed to npm by [GitHub Actions](<a
href="https://www.npmjs.com/~GitHub">https://www.npmjs.com/~GitHub</a>
Actions), a new releaser for <code>@​semantic-release/npm</code> since
your current version.</p>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=@semantic-release/npm&package-manager=npm_and_yarn&previous-version=12.0.2&new-version=13.1.5)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore <dependency name> major version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's major version (unless you unignore this specific
dependency's major version or upgrade to it yourself)
- `@dependabot ignore <dependency name> minor version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's minor version (unless you unignore this specific
dependency's minor version or upgrade to it yourself)
- `@dependabot ignore <dependency name>` will close this group update PR
and stop Dependabot creating any more for the specific dependency
(unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore <dependency name>` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore <dependency name> <ignore condition>` will
remove the ignore condition of the specified dependency and ignore
conditions


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: steebchen-bot <bot@luca-steeb.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
## Summary
- Add `EmailVerificationBanner` to the devpass dashboard (`apps/code`),
mirroring the existing UI banner in `apps/ui`
- Reuses the same `/auth/send-verification-email` endpoint, so the
existing exponential backoff rate limit (60s base, capped at 24h)
applies automatically
- Adapted to the code app's stack: `sonner` for toasts and the local
shadcn `Button`

## Test plan
- [ ] Sign in to devpass dashboard with an unverified email — banner
appears above main content
- [ ] Click "Resend Email" — toast confirms send, email arrives
- [ ] Verify email — banner disappears
- [ ] Resend repeatedly — backend rate limit returns an error and toast
surfaces it

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Email verification banner added to the dashboard for users with
unverified emails.
* Users can resend verification emails from the banner; button shows
"Sending…" while processing.

* **Improvements**
* Verification emails now return users to the correct dashboard URL
after verification.
  * Resend flow shows clearer success/error notifications.

* **Bug Fixes**
* Subscription error handling now surfaces specific error messages in
toasts.
* **Behavior**
* Cancelling a subscription checkout now redirects to the dashboard
cancel state.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Bumps [hono](https://github.com/honojs/hono) from 4.12.7 to 4.12.16.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/honojs/hono/releases">hono's
releases</a>.</em></p>
<blockquote>
<h2>v4.12.16</h2>
<h2>Security fixes</h2>
<p>This release includes fixes for the following security issues:</p>
<h3>Unvalidated JSX Tag Names in hono/jsx May Allow HTML Injection</h3>
<p>Affects: hono/jsx. Fixes missing validation of JSX tag names when
using <code>jsx()</code> or <code>createElement()</code>, which could
allow HTML injection if untrusted input is used as the tag name.
GHSA-69xw-7hcm-h432</p>
<h3>bodyLimit() can be bypassed for chunked / unknown-length
requests</h3>
<p>Affects: Body Limit Middleware. Fixes late enforcement for request
bodies without a reliable Content-Length (e.g. chunked requests), where
oversized requests could reach handlers and return successful responses
before being rejected. GHSA-9vqf-7f2p-gf9v</p>
<h2>v4.12.15</h2>
<h2>What's Changed</h2>
<ul>
<li>fix(jwt): support single-line PEM keys by <a
href="https://github.com/hiendv"><code>@​hiendv</code></a> in <a
href="https://redirect.github.com/honojs/hono/pull/4889">honojs/hono#4889</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a href="https://github.com/hiendv"><code>@​hiendv</code></a> made
their first contribution in <a
href="https://redirect.github.com/honojs/hono/pull/4889">honojs/hono#4889</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/honojs/hono/compare/v4.12.14...v4.12.15">https://github.com/honojs/hono/compare/v4.12.14...v4.12.15</a></p>
<h2>v4.12.14</h2>
<h2>Security fixes</h2>
<p>This release includes fixes for the following security issues:</p>
<h3>Improper handling of JSX attribute names in hono/jsx SSR</h3>
<p>Affects: hono/jsx. Fixes missing validation of JSX attribute names
during server-side rendering, which could allow malformed attribute keys
to corrupt the generated HTML output and inject unintended attributes or
elements. GHSA-458j-xx4x-4375</p>
<h2>Other changes</h2>
<ul>
<li>fix(aws-lambda): handle invalid header names in request processing
(<a
href="https://redirect.github.com/honojs/hono/issues/4883">#4883</a>)
fa2c74fe</li>
</ul>
<h2>v4.12.13</h2>
<h2>What's Changed</h2>
<ul>
<li>fix(types): infer response type from last handler in app.on
9-/10-handler overloads by <a
href="https://github.com/T4ko0522"><code>@​T4ko0522</code></a> in <a
href="https://redirect.github.com/honojs/hono/pull/4865">honojs/hono#4865</a></li>
<li>feat(trailing-slash): add <code>skip</code> option by <a
href="https://github.com/yusukebe"><code>@​yusukebe</code></a> in <a
href="https://redirect.github.com/honojs/hono/pull/4862">honojs/hono#4862</a></li>
<li>feat(cache): add <code>onCacheNotAvailable</code> option by <a
href="https://github.com/yusukebe"><code>@​yusukebe</code></a> in <a
href="https://redirect.github.com/honojs/hono/pull/4876">honojs/hono#4876</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a href="https://github.com/T4ko0522"><code>@​T4ko0522</code></a>
made their first contribution in <a
href="https://redirect.github.com/honojs/hono/pull/4865">honojs/hono#4865</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/honojs/hono/compare/v4.12.12...v4.12.13">https://github.com/honojs/hono/compare/v4.12.12...v4.12.13</a></p>
<h2>v4.12.12</h2>
<h2>Security fixes</h2>
<p>This release includes fixes for the following security issues:</p>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/honojs/hono/commit/90d4182aabd328e2ec6af3f25ec62ddc574ad8cb"><code>90d4182</code></a>
4.12.16</li>
<li><a
href="https://github.com/honojs/hono/commit/db05b96d7a4de569ba9e965052b0593663b164fc"><code>db05b96</code></a>
Merge commit from fork</li>
<li><a
href="https://github.com/honojs/hono/commit/614b834551378bffff70c810b2295495bc6e4d55"><code>614b834</code></a>
Merge commit from fork</li>
<li><a
href="https://github.com/honojs/hono/commit/027e3dfca9d58bbd6b2e11adef724d26bb4f4123"><code>027e3df</code></a>
fix(method-override): handle Content-Type with charset parameter (<a
href="https://redirect.github.com/honojs/hono/issues/4894">#4894</a>)</li>
<li><a
href="https://github.com/honojs/hono/commit/f774f8df49e7ec7e205f15c5076a37132c515ebf"><code>f774f8d</code></a>
4.12.15</li>
<li><a
href="https://github.com/honojs/hono/commit/18fe604c8cefc2628240651b1af219692e1918c1"><code>18fe604</code></a>
fix(jwt): support single-line PEM keys (<a
href="https://redirect.github.com/honojs/hono/issues/4889">#4889</a>)</li>
<li><a
href="https://github.com/honojs/hono/commit/cf2d2b7edcf07adef2db7614557f4d7f9e2be7ba"><code>cf2d2b7</code></a>
4.12.14</li>
<li><a
href="https://github.com/honojs/hono/commit/66daa2edef8965544c04fcad82c596ab2acdb5ee"><code>66daa2e</code></a>
Merge commit from fork</li>
<li><a
href="https://github.com/honojs/hono/commit/fa2c74fe5c3ce996d025d9d97bf5670c207bb82e"><code>fa2c74f</code></a>
fix(aws-lambda): handle invalid header names in request processing (<a
href="https://redirect.github.com/honojs/hono/issues/4883">#4883</a>)</li>
<li><a
href="https://github.com/honojs/hono/commit/3779927c17201dc6bfd20697f0e1ec65407da779"><code>3779927</code></a>
4.12.13</li>
<li>Additional commits viewable in <a
href="https://github.com/honojs/hono/compare/v4.12.7...v4.12.16">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=hono&package-manager=npm_and_yarn&previous-version=4.12.7&new-version=4.12.16)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
You can disable automated security fix PRs for this repo from the
[Security Alerts
page](https://github.com/theopenco/llmgateway/network/alerts).

</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Luca Steeb <contact@luca-steeb.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
## What changed

This PR scopes routing health and retry decisions to the unit they
actually describe (model + region + key) and recovers from a class of
provider failures that previously slipped past retry.

- **Retry on invalid-key 400 payloads.** Some providers surface invalid
credentials with status `400` and a body like `API key not valid`. These
are now classified as auth failures, so same-provider retry rotates to
the next configured key and the bad credential is excluded for the rest
of the request. Centralized in a new `provider-auth-errors.ts`.
- **Per-model API key health.** Tracked-key health and env-based
round-robin penalties are now scoped per `(key, model)` instead of per
key. A bad key on `gpt-4` no longer fences `claude-3-5-sonnet` traffic
on the same key.
- **Region-consistent direct-provider key selection.** The two early
region-lock lookups in `chat.ts` now go through the same model-scoped
`findProviderKey(...)` path used at request time, so `usedRegion ??=`
cannot pin one key's region to another key's token after failover.
- **Same-provider, same-region retry aggregation.** Worker mapping
aggregation now suppresses a failed attempt from
`model_provider_mapping_history` only when the retry recovers on the
same provider AND same region. A `singapore` failure recovered by
`cn-beijing` is no longer dropped from `singapore`'s mapping uptime.
- **Spec hardening.** Mock servers wait until they're listening before
tests issue requests; touched gateway specs are less flaky under local
and CI timing variance.

## Impact

- Explicit-provider requests recover from invalid-key payloads by
retrying another configured key for the same provider.
- Key selection and uptime penalties are isolated per model.
- Region-aware routing stays consistent with the scoped key actually
selected after failover.
- Provider mapping uptime no longer hides failed regional attempts when
the recovery happens in a different region.

## Main areas changed

- `apps/gateway/src/chat/chat.ts`
- `apps/gateway/src/chat/tools/get-finish-reason-from-error.ts`
- `apps/gateway/src/chat/tools/get-provider-env.ts`
- `apps/gateway/src/chat/tools/resolve-provider-context.ts`
- `apps/gateway/src/chat/tools/retry-with-fallback.ts`
- `apps/gateway/src/lib/api-key-health.ts`
- `apps/gateway/src/lib/cached-queries.ts`
- `apps/gateway/src/lib/provider-auth-errors.ts` (new)
- `apps/gateway/src/lib/round-robin-env.ts`
- `apps/worker/src/services/stats-calculator.ts`
- Gateway and worker specs covering invalid-key retries, model-scoped
key health, region-scoped direct-provider routing, and retry aggregation

Diff against `origin/main`: ~854 insertions / ~79 deletions across 21
files.

## Validation

- `pnpm vitest run apps/gateway/src/fallback.spec.ts
apps/gateway/src/chat/tools/retry-with-fallback.spec.ts
apps/worker/src/services/stats-calculator.spec.ts --no-file-parallelism`
- `pnpm format`, `pnpm lint`, `pnpm build`
- `pnpm test:unit` — all PR-related tests pass

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Key-health and token selection can be scoped by model, isolating
selection per model/region.
* New credential-detection and retry helper to better identify invalid
provider credentials.

* **Bug Fixes**
* Improved alternate-key retry logic for authentication and
credential-error cases.
* More reliable regional failover and routing with model-scoped key
health and logging.

* **Tests**
* Expanded unit/integration tests for key-health, failover, retry,
routing, and shutdown behavior.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
## Summary

OpenAI's API has no numeric reasoning-budget primitive — only
`reasoning.effort` (none/minimal/low/medium/high/xhigh). The
`reasoningMaxTokens: true` flag on `gpt-5.5` and `gpt-5.5-pro` (added in
#2076) was unsupported upstream: a request with `reasoning.max_tokens:
1024` is rejected by OpenAI with `Unknown parameter:
'reasoning.max_tokens'`. The flag is dropped from those provider
mappings; the gateway now correctly returns 400 instead of silently
sending a budget that gets stripped or rejected.

The earlier e2e `test.each(reasoningMaxTokensModels)` round-tripped a
real completion against every capable model just to inspect the upstream
request body — wasteful (real reasoning tokens are not free) and the
response-shape assertions duplicated `basic reasoning`. Replaced with
focused unit tests on `prepareRequestBody` covering each provider that
maps the budget:

- `anthropic` → `thinking.budget_tokens`
- `aws-bedrock` → `additionalModelRequestFields.thinking.budget_tokens`
- `google-ai-studio` → `generationConfig.thinkingConfig.thinkingBudget`
- `google-vertex` → `generationConfig.thinkingConfig.thinkingBudget`

The negative e2e in `api-individual.e2e.ts` (gateway returns 400 when
`reasoning.max_tokens` is sent to a non-capable model) is kept — it
short-circuits at validation, no provider call.

## Test plan
- [x] `pnpm vitest run
packages/actions/src/prepare-request-body.spec.ts` — 35/35 pass,
including 4 new forwarding tests.
- [x] `pnpm vitest run -c vitest/vitest.e2e.config.mts
apps/gateway/src/api-individual.e2e.ts -t "reasoning.max_tokens error"`
— gateway 400 path still verified.
- [x] `pnpm format` clean; full build succeeds.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Corrected reasoning.max_tokens support status for gpt-5.5 and
gpt-5.5-pro models on OpenAI and Azure.
* Enhanced validation to reject reasoning.max_tokens requests on models
that don't support this feature with appropriate error messaging.

* **Tests**
* Added end-to-end tests for reasoning.max_tokens handling and
validation across providers.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
## Summary
- Move the unauthenticated check on `/dashboard` from the client-side
`useUser` redirect to a server-side guard. The page now `await`s
`/user/me` via `fetchServerData` and `redirect()`s to
`/login?returnUrl=/dashboard` when there is no session, so
`DashboardClient` never hydrates while logged out and the client-side
`useUser` → 401 → `router.push` cycle that produced the `/me` fetch loop
can't trigger.
- Mirrors the existing pattern in `apps/ui/src/app/dashboard/page.tsx`.

## Test plan
- [ ] Visit http://localhost:3004/dashboard while logged out — server
redirects to `/login?returnUrl=/dashboard`, no repeated `/user/me` calls
in the network tab.
- [ ] Visit `/dashboard` while logged in — page renders normally.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Dashboard now enforces user authentication requirements, automatically
redirecting unauthenticated users to the login page with return URL
handling to seamlessly return to the dashboard after successful
authentication.
* Dashboard now retrieves and displays current user information on page
load, providing a personalized dashboard experience.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
If the incremental walker hasn't yet reached today (e.g. during a
long backfill that spans multiple ticks), the daily safety-net pass
must not wipe + recompute yesterday — the walker would later ADD
yesterday's hours on top, doubling the totals. Skip when
lastProcessedHour < todayStart.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@chatgpt-codex-connector
Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 8

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (4)
apps/gateway/src/videos/videos.ts (1)

1220-1222: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Pass selectionScope in the hybrid env fallback path.

Line 1220 currently omits selectionScope when selecting env keys, so hybrid mode (without provider keys) can bypass scope-aware health/routing and pick a key from the wrong scope.

🔧 Proposed fix
 	const env = getProviderEnv(providerId, {
 		excludedIndices: getVideoExcludedConfigIndices(providerId),
+		selectionScope,
 	});
🤖 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/gateway/src/videos/videos.ts` around lines 1220 - 1222, The call that
builds env for hybrid fallback currently omits the selectionScope, causing
getProviderEnv to pick keys without scope awareness; update the env construction
so getProviderEnv(providerId, { excludedIndices:
getVideoExcludedConfigIndices(providerId), selectionScope }) includes the
selectionScope value (same selectionScope variable used elsewhere in this
module) so hybrid-mode env selection is scope-aware for health/routing.
packages/models/src/models/anthropic.ts (1)

380-399: ⚠️ Potential issue | 🔴 Critical

Remove cacheWriteInputPrice1h from AWS Bedrock entries for Sonnet 4.6, Opus 4.6, and Opus 4.7

AWS Bedrock's 1-hour TTL prompt caching is only supported on Claude Sonnet 4.5, Haiku 4.5, and Opus 4.5 (GA Jan 26, 2026). The 4.6+ variants (claude-sonnet-4-6, claude-opus-4-6, claude-opus-4-7) do not support 1h TTL on Bedrock as of May 2026.

Remove cacheWriteInputPrice1h and cacheWriteInputPrice from these three Bedrock model entries to prevent incorrect cost calculations that would overcharge users attempting to use 1h caches on unsupported models.

🤖 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 `@packages/models/src/models/anthropic.ts` around lines 380 - 399, In the AWS
Bedrock model entries for modelName "claude-sonnet-4-6", "claude-opus-4-6", and
"claude-opus-4-7" (objects with providerId "aws-bedrock" in
packages/models/src/models/anthropic.ts), remove the cacheWriteInputPrice and
cacheWriteInputPrice1h properties so the entries no longer advertise 1-hour TTL
prompt caching; locate the corresponding objects (e.g., the object that
currently contains cacheWriteInputPrice and cacheWriteInputPrice1h for
claude-sonnet-4-6) and delete those two keys from each of the three model
definitions.
apps/gateway/src/moderations/moderations.ts (1)

385-420: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Scope-aware key selection is only partially applied

Line 389 and Line 400/415 scope key selection by upstreamModel, but later health writes are still unscoped. That can blacklist/penalize a key across unrelated models and defeat scoped failover.

Suggested fix
- reportKeyError(envVarName, configIndex, 0);
+ reportKeyError(envVarName, configIndex, 0, undefined, upstreamModel);

- reportTrackedKeyError(providerKey.id, 0);
+ reportTrackedKeyError(providerKey.id, 0, undefined, upstreamModel);

- reportKeyError(envVarName, configIndex, upstreamResponse.status, upstreamText);
+ reportKeyError(
+ 	envVarName,
+ 	configIndex,
+ 	upstreamResponse.status,
+ 	upstreamText,
+ 	upstreamModel,
+ );

- reportTrackedKeyError(providerKey.id, upstreamResponse.status, upstreamText);
+ reportTrackedKeyError(
+ 	providerKey.id,
+ 	upstreamResponse.status,
+ 	upstreamText,
+ 	upstreamModel,
+ );

- reportKeySuccess(envVarName, configIndex);
+ reportKeySuccess(envVarName, configIndex, upstreamModel);

- reportTrackedKeySuccess(providerKey.id);
+ reportTrackedKeySuccess(providerKey.id, upstreamModel);
🤖 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/gateway/src/moderations/moderations.ts` around lines 385 - 420, The
provider key selection uses upstreamModel in findProviderKey and getProviderEnv
but subsequent health/blacklist updates remain unscoped; update the health-write
calls (e.g., wherever writeProviderKeyHealth, markProviderKeyFailed, or similar
are invoked) to include the upstreamModel/selectionScope so health penalties
apply per model, and ensure any fallback logic in the hybrid branch records
health against the same scoped key returned by findProviderKey/getProviderEnv;
reference findProviderKey, getProviderEnv and the health update functions to
make the change.
apps/gateway/src/chat/chat.ts (1)

4569-4592: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don't consume fallback quota before the retry can actually run.

These branches call checkProviderRateLimit(...) before resolveProviderContextForRetry(...). If context resolution fails or the candidate is skipped locally, the provider's RPM/RPD slot has already been consumed even though no upstream request was sent. Under retry churn, that can artificially exhaust fallback capacity. Move the consume step to just before fetch, or add rollback semantics for locally-aborted retries.

Also applies to: 8148-8169

🤖 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/gateway/src/chat/chat.ts` around lines 4569 - 4592, The code currently
calls checkProviderRateLimit(...) (consuming RPM/RPD quota) before
resolveProviderContextForRetry(...) and before actually issuing the upstream
request, which can consume fallback slots even when the retry is aborted
locally; move the rate-limit consumption to just before the actual fetch/send of
the retry (i.e., after resolveProviderContextForRetry(...) and after any
local-skips) or implement rollback semantics to release the consumed slot when a
local abort occurs; update the retry branches that call providerRetryKey(...)
and checkProviderRateLimit(...) (also the duplicate code at the other reported
location) so that rate-limit checks are performed only immediately prior to the
network call or are undone if resolveProviderContextForRetry(...) fails or the
candidate is skipped locally.
♻️ Duplicate comments (1)
apps/api/src/routes/admin.ts (1)

895-904: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Prevent 32-bit overflow in global daily count aggregates.

SUM(...)::int can overflow for larger ranges/traffic and return corrupted requestCount, errorCount, and cacheCount. Keep these aggregates 64-bit.

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",
 			),
#!/bin/bash
# Verify remaining 32-bit casts for global daily count sums in this route.
rg -nP 'COALESCE\(SUM\(\$\{sourceTable\.(requestCount|errorCount|cacheCount)\}\), 0\)::int' apps/api/src/routes/admin.ts
🤖 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 895 - 904, The SQL sum casts for
the global daily aggregates are using 32-bit (::int) which can overflow; update
the three aggregate expressions that build requestCount, errorCount, and
cacheCount (the sql`COALESCE(SUM(${sourceTable.requestCount}),
0)::int`.as("requestCount") etc.) to use 64-bit casts (e.g. ::bigint or ::int8)
instead of ::int and adjust any TypeScript/sql generic if necessary to reflect a
64-bit numeric type so the returned types line up with requestCount, errorCount,
and cacheCount.
🧹 Nitpick comments (6)
apps/gateway/src/graceful-shutdown.spec.ts (1)

31-41: ⚡ Quick win

waitForServerListening will hang indefinitely if the server errors before emitting listening.

If the server emits error before listening (e.g., due to OS resource exhaustion), the returned Promise never settles and the test runner will time out with no useful diagnostic.

🛡️ Proposed fix — reject on server error
 const waitForServerListening = (server: ServerType): Promise<void> => {
 	const httpServer = server as Server;

 	if (httpServer.listening) {
 		return Promise.resolve();
 	}

-	return new Promise((resolve) => {
-		httpServer.once("listening", () => resolve());
+	return new Promise((resolve, reject) => {
+		const onError = (err: Error) => {
+			httpServer.off("listening", onListening);
+			reject(err);
+		};
+		const onListening = () => {
+			httpServer.off("error", onError);
+			resolve();
+		};
+		httpServer.once("listening", onListening);
+		httpServer.once("error", onError);
 	});
 };
🤖 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/gateway/src/graceful-shutdown.spec.ts` around lines 31 - 41,
waitForServerListening currently only waits for "listening" and will hang if the
server emits "error"; update waitForServerListening (and the local httpServer
variable of type ServerType/Server) to also attach a one-time "error" listener
that rejects the returned Promise with the error, and ensure you clean up the
other listener on settle (e.g., remove the "listening" listener when an error
occurs and remove the "error" listener when listening occurs) so the Promise
always settles and no listeners leak.
apps/api/src/routes/dev-plans.ts (1)

957-1023: ⚡ Quick win

Skip project query when no caching fields were supplied, and avoid silent success if no project exists.

Two related concerns in this block:

  1. The db.query.project.findFirst runs on every settings update even when neither cachingEnabled nor cacheDurationSeconds was provided (e.g. only updating retentionLevel or devPlanAllowAllModels). Gate the lookup on projectUpdate actually having keys.
  2. If the project lookup returns nothing (e.g. data inconsistency for an active dev plan) but the caller passed caching fields, the request still returns success: true and echoes back the requested values without persisting them. Either treat this as 404 or at least log/return the original org-side updates honestly.
♻️ Proposed change
-	const project = await db.query.project.findFirst({
-		where: {
-			organizationId: {
-				eq: personalOrg.id,
-			},
-		},
-		orderBy: {
-			createdAt: "asc",
-		},
-	});
-
 	const projectUpdate: {
 		cachingEnabled?: boolean;
 		cacheDurationSeconds?: number;
 	} = {};
 	if (cachingEnabled !== undefined) {
 		projectUpdate.cachingEnabled = cachingEnabled;
 	}
 	if (cacheDurationSeconds !== undefined) {
 		projectUpdate.cacheDurationSeconds = cacheDurationSeconds;
 	}
 
-	if (project && Object.keys(projectUpdate).length > 0) {
+	let project: typeof tables.project.$inferSelect | undefined;
+	if (Object.keys(projectUpdate).length > 0) {
+		project = await db.query.project.findFirst({
+			where: { organizationId: { eq: personalOrg.id } },
+			orderBy: { createdAt: "asc" },
+		});
+		if (!project) {
+			throw new HTTPException(404, {
+				message: "Default project not found",
+			});
+		}
 		await db
 			.update(tables.project)
 			.set(projectUpdate)
 			.where(eq(tables.project.id, project.id));
🤖 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/dev-plans.ts` around lines 957 - 1023, Only query and
update the project when there are caching-related fields to persist: wrap the
db.query.project.findFirst(...) and subsequent db.update(tables.project) logic
behind a check that Object.keys(projectUpdate).length > 0 so you skip the lookup
when no caching fields were supplied; additionally, if projectUpdate has keys
but db.query.project.findFirst(...) returns null, do not return success echoing
unpersisted values—return a 404 (or an error) indicating missing project and
skip writing the success response/logAuditEvent, ensuring you reference project,
projectUpdate, cachingEnabled, cacheDurationSeconds, db.query.project.findFirst,
and db.update(tables.project) when making the change.
apps/gateway/src/lib/costs.spec.ts (1)

203-237: 💤 Low value

Test couples to hardcoded model pricing — consider extracting expected rates.

The expected costs are derived from inline literals (1.0/1M, 5.0/1M, 1.25/1M, 2.0/1M, discount=0.2). If Bedrock Claude Haiku 4.5 pricing changes, this test fails alongside the actual behavioral regression you'd want to catch, but the failure message will look like a math mismatch rather than a pricing-data change. Pre-existing pattern in this file though, so not a blocker.

🤖 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/gateway/src/lib/costs.spec.ts` around lines 203 - 237, The test "should
calculate AWS Bedrock Claude cache write costs" couples expected rates and
discount to hardcoded literals; update the spec to reference a single source of
truth instead of inline numbers by extracting the Bedrock Claude Haiku 4.5
per-token rates and discount into named constants or reading them from the same
pricing lookup used by calculateCosts (e.g., use constants like
CLAUDE_HAIKU_INPUT_RATE, CLAUDE_HAIKU_OUTPUT_RATE, CLAUDE_HAIKU_5M_WRITE_RATE,
CLAUDE_HAIKU_1H_WRITE_RATE, DISCOUNT_MULTIPLIER) and compute expected values
from those constants so the test asserts derived values rather than magic
numbers.
packages/actions/src/prepare-request-body.spec.ts (1)

1076-1208: 💤 Low value

Reasonable forwarding tests; consider adding a negative case.

Tests verify the happy path for reasoning_max_tokens forwarding across anthropic, aws-bedrock, google-ai-studio, and google-vertex. One small gap worth filling later: there's no assertion that providers cap or clamp the budget (Anthropic clamps to [1024, 128000] per getThinkingBudget). A test passing budget = 200 and asserting budget_tokens === 1024, plus one with budget = 200000 asserting clamp to 128000, would lock in that boundary behavior.

🤖 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 `@packages/actions/src/prepare-request-body.spec.ts` around lines 1076 - 1208,
Add negative boundary tests to prepare-request-body.spec.ts to assert clamping
behavior: call prepareRequestBody (same signature as existing tests) with
reasoning_max_tokens set to 200 and assert the resulting anthropic
requestBody.thinking.budget_tokens equals 1024, and call it with
reasoning_max_tokens set to 200000 and assert budget_tokens equals 128000;
reference prepareRequestBody and the getThinkingBudget clamping behavior
(Anthropic path / requestBody.thinking) so reviewers can locate the anthropic
branch to verify the expected clamped values.
apps/gateway/src/lib/api-key-health.ts (1)

449-461: 💤 Low value

Consider also resetting consecutiveErrors on the scoped record when permanently blacklisting.

When a permanent auth error triggers, you set health.permanentlyBlacklisted = true and push to history, but consecutiveErrors is not reset/touched. Most call paths (isKeyHealthy) short-circuit on permanentlyBlacklisted so this is benign today, but KeyHealth.consecutiveErrors could be stale (still showing pre-auth errors) for any future code path that inspects getKeyHealth() directly for diagnostics. Same observation in reportTrackedKeyError at lines 504–513.

Minor / debug-surface only — feel free to skip.

🤖 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/gateway/src/lib/api-key-health.ts` around lines 449 - 461, When
permanently blacklisting a key (the block that calls
markUnscopedPermanentBlacklist(getHealthKey(envVarName, keyIndex)) and sets
health.permanentlyBlacklisted = true), also reset the scoped
KeyHealth.consecutiveErrors to 0 on the same scoped record (the local variable
health) before pushing history/pruning and returning; make the same change in
reportTrackedKeyError (the block around lines 504–513) so any scoped health
returned by getKeyHealth() has consecutiveErrors cleared when
permanentlyBlacklisted is set.
apps/gateway/src/chat/tools/retry-with-fallback.ts (1)

46-58: ⚡ Quick win

shouldRetryAlternateKey's second clause is dead code

isRetryableErrorType already includes "gateway_error" (line 34), so whenever errorType === "gateway_error" the first operand of || is already true and short-circuits. The (errorType === "gateway_error" && ...) clause can never be reached, making the function equivalent to return isRetryableErrorType(errorType).

The docstring intent — "Auth failures are not eligible for cross-provider fallback, but should rotate to another configured key" — is never enforced as written, because shouldRetryRequest also passes gateway_error through isRetryableErrorType unchanged.

Two consistent fixes depending on intent:

Option A – Simplify (if 401/403 cross-provider fallback is acceptable):

♻️ Proposed refactor
-import { hasInvalidProviderCredentialError } from "@/lib/provider-auth-errors.js";
-
 export const MAX_RETRIES = 2;
 
-/**
- * Determines whether a failed request should be retried against another key
- * for the same provider.
- *
- * Auth failures (401/403) are not eligible for cross-provider fallback, but
- * they should still rotate to another configured key for the current provider
- * because the failure is often isolated to a single credential.
- */
-export function shouldRetryAlternateKey(
-	errorType: string,
-	statusCode?: number,
-	errorText?: string,
-): boolean {
-	return (
-		isRetryableErrorType(errorType) ||
-		(errorType === "gateway_error" &&
-			((statusCode !== undefined &&
-				(statusCode === 401 || statusCode === 403)) ||
-				hasInvalidProviderCredentialError(errorText)))
-	);
-}
+export function shouldRetryAlternateKey(errorType: string): boolean {
+	return isRetryableErrorType(errorType);
+}

Option B – Align with the docstring (if 401/403 should NOT do cross-provider fallback):

Remove "gateway_error" from isRetryableErrorType so shouldRetryRequest gates it out, while shouldRetryAlternateKey's second clause becomes the only path that allows same-provider key rotation on auth errors. This is a more intentional design change and should be validated against existing fallback tests.

🤖 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/gateway/src/chat/tools/retry-with-fallback.ts` around lines 46 - 58,
shouldRetryAlternateKey contains a dead branch because isRetryableErrorType
already treats "gateway_error" as retryable, so the (errorType ===
"gateway_error" && ...) clause never runs; fix by either A) simplifying
shouldRetryAlternateKey to just return isRetryableErrorType(errorType) if
401/403 cross-provider fallback is acceptable, or B) make auth errors behave
differently by removing "gateway_error" from isRetryableErrorType and keeping
the existing gateway-specific clause in shouldRetryAlternateKey (and then run
tests that exercise shouldRetryRequest/shouldRetryAlternateKey to ensure
fallback behavior remains correct); locate and edit the functions
isRetryableErrorType, shouldRetryAlternateKey, and any callers like
shouldRetryRequest to implement the chosen option.
🤖 Prompt for all review comments with 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.

Inline comments:
In `@apps/api/src/routes/admin.ts`:
- Around line 4496-4504: The current unbounded Promise.all over members.map
calling deleteResendContact can spawn too many concurrent requests; change the
fan-out to process members in bounded batches (e.g., chunk members into groups
of N, such as 10) and run Promise.all for each chunk sequentially or use a
concurrency limiter (p-limit) to ensure only N concurrent deleteResendContact
calls at a time; update the code around the members.map(...) /
deleteResendContact usage to implement batching or a concurrency limiter so
deletions are rate-limited.

In `@apps/code/src/app/page.tsx`:
- Around line 110-114: Replace the raw anchor element used for the Product Hunt
badge with Next.js' Link component and add the missing opener protection: import
and use Link (from next/link) for the element currently rendered by the <a ...>
starting at the snippet and move the href, target="_blank" and className into
the Link; update the rel value to include "noopener" alongside "noreferrer"
(i.e. rel="noopener noreferrer") to harden external-link opener protection while
preserving the target and styling.

In `@apps/code/src/components/EmailVerificationBanner.tsx`:
- Around line 19-38: The handleResendVerification function can leave isResending
true if sendVerificationEmail throws; wrap the await call in a try/catch/finally
so setIsResending(true) stays at the top, await sendVerificationEmail(...)
inside try, show toast.success on success and toast.error in catch (using
error.message when available), and always call setIsResending(false) in the
finally block; reference handleResendVerification, sendVerificationEmail, and
setIsResending when making the change.

In `@apps/gateway/src/chat/chat.ts`:
- Around line 2900-2905: When blacklisting failed credentials, use
trackedKeyHealthId (not providerKey?.id) so we only exclude the key that was
actually attempted; update calls to rememberFailedKey(...) and any manipulation
of excludedProviderKeyIds to pass trackedKeyHealthId when trackedKeyHealthId is
set (fall back to providerKey?.id only if trackedKeyHealthId is undefined).
Search for uses in the retry/exclusion paths (e.g., rememberFailedKey,
excludedProviderKeyIds updates and tryResolveAlternateKeyForCurrentProvider) and
change them to prefer trackedKeyHealthId so env-overrides that cleared
trackedKeyHealthId do not incorrectly blacklist DB keys.
- Around line 2945-2960: The current region-env override replaces the tenant/DB
BYOK token (usedToken) with a gateway env credential, which breaks BYOK
isolation; change the if-block that checks regionEnvVarName/regionToken to only
perform the override when the original provider credential is a shared/credits
gateway credential (i.e., detect the provider key origin instead of always
overriding). Concretely, before assigning
usedToken/envVarName/configIndex/trackedKeyHealthId, add a guard that verifies
the original provider key is not a BYOK/DB/org-scoped key (use whatever
identifying flag on the providerKey or its metadata the code uses to mark
BYOK/shared keys), and only then allow the env override; alternatively, if the
provider key cannot serve the requested region, reject the region early rather
than falling back—apply the same check in both the api-keys and hybrid DB-key
branches where usedRegion/usedProvider/usedToken are modified.

In `@apps/gateway/src/chat/tools/extract-token-usage.ts`:
- Around line 34-57: The function extractBedrockCacheCreationDetails currently
accepts an untyped usage: any; replace that with a minimal typed shape (e.g., an
interface or inline type) describing usage.cacheDetails as an array of objects
with optional ttl (string literal union like '5m'|'1h'|string) and optional
inputTokens: number, and update local variables to use number types
(fiveMinuteTokens: number, oneHourTokens: number) and narrow detail to that
typed element (e.g., const cacheDetails: Array<{ ttl?: '5m'|'1h'|string;
inputTokens?: number }> = Array.isArray(usage?.cacheDetails) ?
usage.cacheDetails : []); keep the existing return type and logic in
extractBedrockCacheCreationDetails unchanged.

In `@apps/gateway/src/native-anthropic-cache.e2e.ts`:
- Around line 592-597: The comment describing the test references the wrong
model name ("Haiku 4.5"); update that comment to reference the actual model used
in the test (`aws-bedrock/claude-sonnet-4-6`) so the description for the 1h
cache TTL Bedrock test (the block that mentions "cache write rate" and
"prompt_tokens_details.cache_creation.ephemeral_1h_input_tokens") accurately
matches the test body.

In `@apps/ui/src/components/email-verification-banner.tsx`:
- Around line 19-41: The handleResendVerification function can leave isResending
true if sendVerificationEmail throws (network/runtime error); wrap the await
sendVerificationEmail call in try/catch/finally (use the existing
setIsResending(true) at start and move setIsResending(false) into a finally
block) so isResending is always reset, catch optional errors to show the toast
(use error?.message or a generic message), and reference
handleResendVerification, sendVerificationEmail, and setIsResending when
applying the change.

---

Outside diff comments:
In `@apps/gateway/src/chat/chat.ts`:
- Around line 4569-4592: The code currently calls checkProviderRateLimit(...)
(consuming RPM/RPD quota) before resolveProviderContextForRetry(...) and before
actually issuing the upstream request, which can consume fallback slots even
when the retry is aborted locally; move the rate-limit consumption to just
before the actual fetch/send of the retry (i.e., after
resolveProviderContextForRetry(...) and after any local-skips) or implement
rollback semantics to release the consumed slot when a local abort occurs;
update the retry branches that call providerRetryKey(...) and
checkProviderRateLimit(...) (also the duplicate code at the other reported
location) so that rate-limit checks are performed only immediately prior to the
network call or are undone if resolveProviderContextForRetry(...) fails or the
candidate is skipped locally.

In `@apps/gateway/src/moderations/moderations.ts`:
- Around line 385-420: The provider key selection uses upstreamModel in
findProviderKey and getProviderEnv but subsequent health/blacklist updates
remain unscoped; update the health-write calls (e.g., wherever
writeProviderKeyHealth, markProviderKeyFailed, or similar are invoked) to
include the upstreamModel/selectionScope so health penalties apply per model,
and ensure any fallback logic in the hybrid branch records health against the
same scoped key returned by findProviderKey/getProviderEnv; reference
findProviderKey, getProviderEnv and the health update functions to make the
change.

In `@apps/gateway/src/videos/videos.ts`:
- Around line 1220-1222: The call that builds env for hybrid fallback currently
omits the selectionScope, causing getProviderEnv to pick keys without scope
awareness; update the env construction so getProviderEnv(providerId, {
excludedIndices: getVideoExcludedConfigIndices(providerId), selectionScope })
includes the selectionScope value (same selectionScope variable used elsewhere
in this module) so hybrid-mode env selection is scope-aware for health/routing.

In `@packages/models/src/models/anthropic.ts`:
- Around line 380-399: In the AWS Bedrock model entries for modelName
"claude-sonnet-4-6", "claude-opus-4-6", and "claude-opus-4-7" (objects with
providerId "aws-bedrock" in packages/models/src/models/anthropic.ts), remove the
cacheWriteInputPrice and cacheWriteInputPrice1h properties so the entries no
longer advertise 1-hour TTL prompt caching; locate the corresponding objects
(e.g., the object that currently contains cacheWriteInputPrice and
cacheWriteInputPrice1h for claude-sonnet-4-6) and delete those two keys from
each of the three model definitions.

---

Duplicate comments:
In `@apps/api/src/routes/admin.ts`:
- Around line 895-904: The SQL sum casts for the global daily aggregates are
using 32-bit (::int) which can overflow; update the three aggregate expressions
that build requestCount, errorCount, and cacheCount (the
sql`COALESCE(SUM(${sourceTable.requestCount}), 0)::int`.as("requestCount") etc.)
to use 64-bit casts (e.g. ::bigint or ::int8) instead of ::int and adjust any
TypeScript/sql generic if necessary to reflect a 64-bit numeric type so the
returned types line up with requestCount, errorCount, and cacheCount.

---

Nitpick comments:
In `@apps/api/src/routes/dev-plans.ts`:
- Around line 957-1023: Only query and update the project when there are
caching-related fields to persist: wrap the db.query.project.findFirst(...) and
subsequent db.update(tables.project) logic behind a check that
Object.keys(projectUpdate).length > 0 so you skip the lookup when no caching
fields were supplied; additionally, if projectUpdate has keys but
db.query.project.findFirst(...) returns null, do not return success echoing
unpersisted values—return a 404 (or an error) indicating missing project and
skip writing the success response/logAuditEvent, ensuring you reference project,
projectUpdate, cachingEnabled, cacheDurationSeconds, db.query.project.findFirst,
and db.update(tables.project) when making the change.

In `@apps/gateway/src/chat/tools/retry-with-fallback.ts`:
- Around line 46-58: shouldRetryAlternateKey contains a dead branch because
isRetryableErrorType already treats "gateway_error" as retryable, so the
(errorType === "gateway_error" && ...) clause never runs; fix by either A)
simplifying shouldRetryAlternateKey to just return
isRetryableErrorType(errorType) if 401/403 cross-provider fallback is
acceptable, or B) make auth errors behave differently by removing
"gateway_error" from isRetryableErrorType and keeping the existing
gateway-specific clause in shouldRetryAlternateKey (and then run tests that
exercise shouldRetryRequest/shouldRetryAlternateKey to ensure fallback behavior
remains correct); locate and edit the functions isRetryableErrorType,
shouldRetryAlternateKey, and any callers like shouldRetryRequest to implement
the chosen option.

In `@apps/gateway/src/graceful-shutdown.spec.ts`:
- Around line 31-41: waitForServerListening currently only waits for "listening"
and will hang if the server emits "error"; update waitForServerListening (and
the local httpServer variable of type ServerType/Server) to also attach a
one-time "error" listener that rejects the returned Promise with the error, and
ensure you clean up the other listener on settle (e.g., remove the "listening"
listener when an error occurs and remove the "error" listener when listening
occurs) so the Promise always settles and no listeners leak.

In `@apps/gateway/src/lib/api-key-health.ts`:
- Around line 449-461: When permanently blacklisting a key (the block that calls
markUnscopedPermanentBlacklist(getHealthKey(envVarName, keyIndex)) and sets
health.permanentlyBlacklisted = true), also reset the scoped
KeyHealth.consecutiveErrors to 0 on the same scoped record (the local variable
health) before pushing history/pruning and returning; make the same change in
reportTrackedKeyError (the block around lines 504–513) so any scoped health
returned by getKeyHealth() has consecutiveErrors cleared when
permanentlyBlacklisted is set.

In `@apps/gateway/src/lib/costs.spec.ts`:
- Around line 203-237: The test "should calculate AWS Bedrock Claude cache write
costs" couples expected rates and discount to hardcoded literals; update the
spec to reference a single source of truth instead of inline numbers by
extracting the Bedrock Claude Haiku 4.5 per-token rates and discount into named
constants or reading them from the same pricing lookup used by calculateCosts
(e.g., use constants like CLAUDE_HAIKU_INPUT_RATE, CLAUDE_HAIKU_OUTPUT_RATE,
CLAUDE_HAIKU_5M_WRITE_RATE, CLAUDE_HAIKU_1H_WRITE_RATE, DISCOUNT_MULTIPLIER) and
compute expected values from those constants so the test asserts derived values
rather than magic numbers.

In `@packages/actions/src/prepare-request-body.spec.ts`:
- Around line 1076-1208: Add negative boundary tests to
prepare-request-body.spec.ts to assert clamping behavior: call
prepareRequestBody (same signature as existing tests) with reasoning_max_tokens
set to 200 and assert the resulting anthropic requestBody.thinking.budget_tokens
equals 1024, and call it with reasoning_max_tokens set to 200000 and assert
budget_tokens equals 128000; reference prepareRequestBody and the
getThinkingBudget clamping behavior (Anthropic path / requestBody.thinking) so
reviewers can locate the anthropic branch to verify the expected clamped values.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: e0cfe632-b8ff-4563-98bd-eeb73dcd92b0

📥 Commits

Reviewing files that changed from the base of the PR and between 7b5503c and 7154f5f.

⛔ Files ignored due to path filters (5)
  • apps/code/src/lib/api/v1.d.ts is excluded by !**/v1.d.ts
  • apps/playground/src/lib/api/v1.d.ts is excluded by !**/v1.d.ts
  • apps/ui/src/lib/api/v1.d.ts is excluded by !**/v1.d.ts
  • ee/admin/src/lib/api/v1.d.ts is excluded by !**/v1.d.ts
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (62)
  • apps/api/package.json
  • apps/api/src/auth/config.ts
  • apps/api/src/routes/admin.ts
  • apps/api/src/routes/dev-plans.ts
  • apps/code/src/app/dashboard/DashboardClient.tsx
  • apps/code/src/app/dashboard/components/DevPlanSettings.tsx
  • apps/code/src/app/dashboard/page.tsx
  • apps/code/src/app/page.tsx
  • apps/code/src/components/EmailVerificationBanner.tsx
  • apps/code/src/components/ProductHuntIcon.tsx
  • apps/code/src/lib/auth-client.ts
  • apps/gateway/package.json
  • apps/gateway/src/api.spec.ts
  • apps/gateway/src/chat/chat.ts
  • apps/gateway/src/chat/tools/extract-token-usage.spec.ts
  • apps/gateway/src/chat/tools/extract-token-usage.ts
  • apps/gateway/src/chat/tools/get-finish-reason-from-error.spec.ts
  • apps/gateway/src/chat/tools/get-finish-reason-from-error.ts
  • apps/gateway/src/chat/tools/get-provider-env.spec.ts
  • apps/gateway/src/chat/tools/get-provider-env.ts
  • apps/gateway/src/chat/tools/parse-provider-response.spec.ts
  • apps/gateway/src/chat/tools/parse-provider-response.ts
  • apps/gateway/src/chat/tools/resolve-provider-context.ts
  • apps/gateway/src/chat/tools/retry-with-fallback.spec.ts
  • apps/gateway/src/chat/tools/retry-with-fallback.ts
  • apps/gateway/src/chat/tools/transform-streaming-to-openai.spec.ts
  • apps/gateway/src/chat/tools/transform-streaming-to-openai.ts
  • apps/gateway/src/fallback.spec.ts
  • apps/gateway/src/graceful-shutdown.spec.ts
  • apps/gateway/src/lib/anthropic-pricing.spec.ts
  • apps/gateway/src/lib/api-key-health.spec.ts
  • apps/gateway/src/lib/api-key-health.ts
  • apps/gateway/src/lib/cached-queries.spec.ts
  • apps/gateway/src/lib/cached-queries.ts
  • apps/gateway/src/lib/costs.spec.ts
  • apps/gateway/src/lib/provider-auth-errors.ts
  • apps/gateway/src/lib/provider-rate-limit.spec.ts
  • apps/gateway/src/lib/provider-rate-limit.ts
  • apps/gateway/src/lib/round-robin-env.spec.ts
  • apps/gateway/src/lib/round-robin-env.ts
  • apps/gateway/src/moderations/moderations.ts
  • apps/gateway/src/native-anthropic-cache.e2e.ts
  • apps/gateway/src/test-utils/mock-openai-server.ts
  • apps/gateway/src/videos/videos.ts
  • apps/ui/src/components/email-verification-banner.tsx
  • apps/ui/src/components/settings/caching-settings.tsx
  • apps/ui/src/components/settings/organization-retention-settings.tsx
  • apps/ui/src/lib/auth-client.ts
  • apps/worker/src/services/global-stats-aggregator.ts
  • apps/worker/src/services/stats-calculator.spec.ts
  • apps/worker/src/services/stats-calculator.ts
  • package.json
  • packages/actions/src/prepare-request-body.spec.ts
  • packages/actions/src/prepare-request-body.ts
  • packages/db/migrations/1778166992_rare_plazm.sql
  • packages/db/migrations/meta/1778166992_snapshot.json
  • packages/db/migrations/meta/_journal.json
  • packages/instrumentation/package.json
  • packages/models/src/models/anthropic.ts
  • packages/models/src/models/minimax.ts
  • packages/models/src/models/openai.ts
  • packages/models/src/provider.ts
💤 Files with no reviewable changes (1)
  • packages/models/src/models/openai.ts
✅ Files skipped from review due to trivial changes (5)
  • apps/gateway/package.json
  • packages/instrumentation/package.json
  • apps/gateway/src/chat/tools/retry-with-fallback.spec.ts
  • apps/gateway/src/lib/provider-auth-errors.ts
  • apps/code/src/components/ProductHuntIcon.tsx
🚧 Files skipped from review as they are similar to previous changes (2)
  • packages/db/migrations/meta/_journal.json
  • apps/worker/src/services/global-stats-aggregator.ts

Comment on lines +4496 to +4504
if (status === "deleted" && memberUserIds.length > 0) {
const members = await db.query.user.findMany({
where: { id: { in: memberUserIds } },
columns: { email: true },
});

await Promise.all(
members.map((member) => deleteResendContact(member.email)),
);
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

Bound external deletion fan-out to avoid request-path spikes.

Deleting all contacts with one unbounded Promise.all can trigger large concurrent calls to Resend and hurt reliability (429s/timeouts). Batch concurrency.

Suggested fix
 	if (status === "deleted" && memberUserIds.length > 0) {
 		const members = await db.query.user.findMany({
 			where: { id: { in: memberUserIds } },
 			columns: { email: true },
 		});

-		await Promise.all(
-			members.map((member) => deleteResendContact(member.email)),
-		);
+		const batchSize = 25;
+		for (let i = 0; i < members.length; i += batchSize) {
+			const batch = members.slice(i, i + batchSize);
+			await Promise.all(
+				batch.map((member) => deleteResendContact(member.email)),
+			);
+		}
 	}
🤖 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 4496 - 4504, The current unbounded
Promise.all over members.map calling deleteResendContact can spawn too many
concurrent requests; change the fan-out to process members in bounded batches
(e.g., chunk members into groups of N, such as 10) and run Promise.all for each
chunk sequentially or use a concurrency limiter (p-limit) to ensure only N
concurrent deleteResendContact calls at a time; update the code around the
members.map(...) / deleteResendContact usage to implement batching or a
concurrency limiter so deletions are rate-limited.

Comment on lines +110 to +114
<a
href="https://www.producthunt.com/products/devpass-by-llm-gateway?launch=devpass-by-llm-gateway"
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-2.5 rounded-full border border-amber-500/40 bg-amber-500/10 px-5 py-2.5 text-sm font-semibold text-amber-700 transition-colors hover:bg-amber-500/20 dark:text-amber-300"
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 | 🟡 Minor | ⚡ Quick win

Use next/link here and harden external-link rel

At Line 110, this should use next/link per project rules, and at Line 113 please include noopener with noreferrer for explicit opener protection.

Suggested patch
-							<div className="mt-6 flex justify-center">
-								<a
+							<div className="mt-6 flex justify-center">
+								<Link
 									href="https://www.producthunt.com/products/devpass-by-llm-gateway?launch=devpass-by-llm-gateway"
 									target="_blank"
-									rel="noreferrer"
+									rel="noopener noreferrer"
 									className="inline-flex items-center gap-2.5 rounded-full border border-amber-500/40 bg-amber-500/10 px-5 py-2.5 text-sm font-semibold text-amber-700 transition-colors hover:bg-amber-500/20 dark:text-amber-300"
 								>
 									<ProductHuntIcon className="h-6 w-6" />
@@
-								</a>
+								</Link>
 							</div>

As per coding guidelines, "Use next/link for links and next/navigation's router for programmatic navigation."

🤖 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/code/src/app/page.tsx` around lines 110 - 114, Replace the raw anchor
element used for the Product Hunt badge with Next.js' Link component and add the
missing opener protection: import and use Link (from next/link) for the element
currently rendered by the <a ...> starting at the snippet and move the href,
target="_blank" and className into the Link; update the rel value to include
"noopener" alongside "noreferrer" (i.e. rel="noopener noreferrer") to harden
external-link opener protection while preserving the target and styling.

Comment on lines +19 to +38
const handleResendVerification = async () => {
setIsResending(true);

const { error } = await sendVerificationEmail({
email: user.email,
callbackURL: `${window.location.origin}/dashboard?emailVerified=true`,
});

if (error) {
toast.error("Error", {
description: error.message ?? "Failed to send verification email",
});
} else {
toast.success("Verification email sent", {
description: "Please check your inbox for the verification email.",
});
}

setIsResending(false);
};
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 | 🟡 Minor | ⚡ Quick win

Wrap the async call in try/finally to avoid stuck isResending state.

If sendVerificationEmail throws (network failure, runtime error, etc.), setIsResending(false) will never run and the button stays disabled. Same concern as the ui-app counterpart.

🛡️ Proposed fix
 	const handleResendVerification = async () => {
 		setIsResending(true);
-
-		const { error } = await sendVerificationEmail({
-			email: user.email,
-			callbackURL: `${window.location.origin}/dashboard?emailVerified=true`,
-		});
-
-		if (error) {
-			toast.error("Error", {
-				description: error.message ?? "Failed to send verification email",
-			});
-		} else {
-			toast.success("Verification email sent", {
-				description: "Please check your inbox for the verification email.",
-			});
+		try {
+			const { error } = await sendVerificationEmail({
+				email: user.email,
+				callbackURL: `${window.location.origin}/dashboard?emailVerified=true`,
+			});
+
+			if (error) {
+				toast.error("Error", {
+					description: error.message ?? "Failed to send verification email",
+				});
+			} else {
+				toast.success("Verification email sent", {
+					description: "Please check your inbox for the verification email.",
+				});
+			}
+		} finally {
+			setIsResending(false);
 		}
-
-		setIsResending(false);
 	};
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const handleResendVerification = async () => {
setIsResending(true);
const { error } = await sendVerificationEmail({
email: user.email,
callbackURL: `${window.location.origin}/dashboard?emailVerified=true`,
});
if (error) {
toast.error("Error", {
description: error.message ?? "Failed to send verification email",
});
} else {
toast.success("Verification email sent", {
description: "Please check your inbox for the verification email.",
});
}
setIsResending(false);
};
const handleResendVerification = async () => {
setIsResending(true);
try {
const { error } = await sendVerificationEmail({
email: user.email,
callbackURL: `${window.location.origin}/dashboard?emailVerified=true`,
});
if (error) {
toast.error("Error", {
description: error.message ?? "Failed to send verification email",
});
} else {
toast.success("Verification email sent", {
description: "Please check your inbox for the verification email.",
});
}
} finally {
setIsResending(false);
}
};
🤖 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/code/src/components/EmailVerificationBanner.tsx` around lines 19 - 38,
The handleResendVerification function can leave isResending true if
sendVerificationEmail throws; wrap the await call in a try/catch/finally so
setIsResending(true) stays at the top, await sendVerificationEmail(...) inside
try, show toast.success on success and toast.error in catch (using error.message
when available), and always call setIsResending(false) in the finally block;
reference handleResendVerification, sendVerificationEmail, and setIsResending
when making the change.

Comment on lines +2900 to +2905
// ID for tracked-key health attribution. Equal to providerKey.id when the
// DB-provided key is what's actually sent. Cleared when a region-specific
// env var override replaces the token, so health failures route to the env
// credential via envVarName instead of blaming an unused DB key. Endpoint
// and option resolution still use providerKey for BYOK base URLs/options.
let trackedKeyHealthId: string | undefined;
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

Wire trackedKeyHealthId into retry exclusion, not just health reporting.

trackedKeyHealthId is cleared when an env override wins, but the retry path still blacklists providerKey?.id via rememberFailedKey(...). A failure on the env credential will therefore still exclude the DB key in excludedProviderKeyIds, even though that key was never attempted, and tryResolveAlternateKeyForCurrentProvider() can stop early for the wrong reason.

Suggested fix
- providerKeyId: providerKey?.id,
+ providerKeyId: trackedKeyHealthId,

Also applies to: 2943-2960, 3042-3059, 4240-4240

🤖 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/gateway/src/chat/chat.ts` around lines 2900 - 2905, When blacklisting
failed credentials, use trackedKeyHealthId (not providerKey?.id) so we only
exclude the key that was actually attempted; update calls to
rememberFailedKey(...) and any manipulation of excludedProviderKeyIds to pass
trackedKeyHealthId when trackedKeyHealthId is set (fall back to providerKey?.id
only if trackedKeyHealthId is undefined). Search for uses in the retry/exclusion
paths (e.g., rememberFailedKey, excludedProviderKeyIds updates and
tryResolveAlternateKeyForCurrentProvider) and change them to prefer
trackedKeyHealthId so env-overrides that cleared trackedKeyHealthId do not
incorrectly blacklist DB keys.

Comment on lines +2945 to +2960
// Override with region-specific env var if the DB key doesn't match the requested region.
// When we do override, route health attribution to the regional env credential.
// providerKey stays set so endpoint/options/baseUrl construction keeps the BYOK context;
// only trackedKeyHealthId is cleared so reportTrackedKey* doesn't blame the unused DB key.
if (usedRegion) {
const regionToken = getRegionSpecificEnvValue(usedProvider, usedRegion);
if (regionToken && regionToken !== usedToken) {
usedToken = regionToken;
const regionEnvVarName = getRegionSpecificEnvVarName(
usedProvider,
usedRegion,
);
if (regionEnvVarName) {
const regionToken = process.env[regionEnvVarName];
if (regionToken && regionToken !== usedToken) {
usedToken = regionToken;
envVarName = regionEnvVarName;
configIndex = 0;
trackedKeyHealthId = undefined;
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

Don't fall back to gateway env credentials in BYOK flows.

In api-keys and in the DB-key branch of hybrid, this now replaces the organization's providerKey.token with a region-specific process.env[...] token whenever one exists. That means a BYOK request can leave under the shared gateway credential instead of the tenant's key, which breaks isolation/billing expectations and can hide a misconfigured regional DB key. Restrict this override to credits-only flows, or reject the region when the configured provider key cannot serve it.

Also applies to: 3044-3059

🤖 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/gateway/src/chat/chat.ts` around lines 2945 - 2960, The current
region-env override replaces the tenant/DB BYOK token (usedToken) with a gateway
env credential, which breaks BYOK isolation; change the if-block that checks
regionEnvVarName/regionToken to only perform the override when the original
provider credential is a shared/credits gateway credential (i.e., detect the
provider key origin instead of always overriding). Concretely, before assigning
usedToken/envVarName/configIndex/trackedKeyHealthId, add a guard that verifies
the original provider key is not a BYOK/DB/org-scoped key (use whatever
identifying flag on the providerKey or its metadata the code uses to mark
BYOK/shared keys), and only then allow the env override; alternatively, if the
provider key cannot serve the requested region, reject the region early rather
than falling back—apply the same check in both the api-keys and hybrid DB-key
branches where usedRegion/usedProvider/usedToken are modified.

Comment on lines +34 to +57
export function extractBedrockCacheCreationDetails(usage: any): {
cacheCreation5mTokens: number | null;
cacheCreation1hTokens: number | null;
} {
let fiveMinuteTokens = 0;
let oneHourTokens = 0;

const cacheDetails = Array.isArray(usage?.cacheDetails)
? usage.cacheDetails
: [];
for (const detail of cacheDetails) {
const inputTokens = detail?.inputTokens ?? 0;
if (detail?.ttl === "1h") {
oneHourTokens += inputTokens;
} else if (detail?.ttl === "5m") {
fiveMinuteTokens += inputTokens;
}
}

return {
cacheCreation5mTokens: fiveMinuteTokens > 0 ? fiveMinuteTokens : null,
cacheCreation1hTokens: oneHourTokens > 0 ? oneHourTokens : null,
};
}
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.

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Avoid introducing new any in the Bedrock cache-details helper

Line 34 adds a new any parameter. Please type the minimal usage shape and keep narrowing local.

Suggested fix
-export function extractBedrockCacheCreationDetails(usage: any): {
+type BedrockUsageCacheDetail = {
+	ttl?: "5m" | "1h" | string;
+	inputTokens?: number;
+};
+
+type BedrockUsage = {
+	cacheDetails?: BedrockUsageCacheDetail[];
+};
+
+export function extractBedrockCacheCreationDetails(usage: BedrockUsage): {
 	cacheCreation5mTokens: number | null;
 	cacheCreation1hTokens: number | null;
 } {

As per coding guidelines, "Do not use any type in TypeScript; use strict typing instead".

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export function extractBedrockCacheCreationDetails(usage: any): {
cacheCreation5mTokens: number | null;
cacheCreation1hTokens: number | null;
} {
let fiveMinuteTokens = 0;
let oneHourTokens = 0;
const cacheDetails = Array.isArray(usage?.cacheDetails)
? usage.cacheDetails
: [];
for (const detail of cacheDetails) {
const inputTokens = detail?.inputTokens ?? 0;
if (detail?.ttl === "1h") {
oneHourTokens += inputTokens;
} else if (detail?.ttl === "5m") {
fiveMinuteTokens += inputTokens;
}
}
return {
cacheCreation5mTokens: fiveMinuteTokens > 0 ? fiveMinuteTokens : null,
cacheCreation1hTokens: oneHourTokens > 0 ? oneHourTokens : null,
};
}
type BedrockUsageCacheDetail = {
ttl?: "5m" | "1h" | string;
inputTokens?: number;
};
type BedrockUsage = {
cacheDetails?: BedrockUsageCacheDetail[];
};
export function extractBedrockCacheCreationDetails(usage: BedrockUsage): {
cacheCreation5mTokens: number | null;
cacheCreation1hTokens: number | null;
} {
let fiveMinuteTokens = 0;
let oneHourTokens = 0;
const cacheDetails = Array.isArray(usage?.cacheDetails)
? usage.cacheDetails
: [];
for (const detail of cacheDetails) {
const inputTokens = detail?.inputTokens ?? 0;
if (detail?.ttl === "1h") {
oneHourTokens += inputTokens;
} else if (detail?.ttl === "5m") {
fiveMinuteTokens += inputTokens;
}
}
return {
cacheCreation5mTokens: fiveMinuteTokens > 0 ? fiveMinuteTokens : null,
cacheCreation1hTokens: oneHourTokens > 0 ? oneHourTokens : null,
};
}
🤖 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/gateway/src/chat/tools/extract-token-usage.ts` around lines 34 - 57, The
function extractBedrockCacheCreationDetails currently accepts an untyped usage:
any; replace that with a minimal typed shape (e.g., an interface or inline type)
describing usage.cacheDetails as an array of objects with optional ttl (string
literal union like '5m'|'1h'|string) and optional inputTokens: number, and
update local variables to use number types (fiveMinuteTokens: number,
oneHourTokens: number) and narrow detail to that typed element (e.g., const
cacheDetails: Array<{ ttl?: '5m'|'1h'|string; inputTokens?: number }> =
Array.isArray(usage?.cacheDetails) ? usage.cacheDetails : []); keep the existing
return type and logic in extractBedrockCacheCreationDetails unchanged.

Comment on lines +592 to +597
// 1h cache TTL via Bedrock /v1/chat/completions: opts into Bedrock's 1h
// cache write rate (2x base) on a model that supports it (Haiku 4.5) and
// asserts the gateway forwards ttl:"1h" to the Converse API cachePoint and
// surfaces the response breakdown
// (prompt_tokens_details.cache_creation.ephemeral_1h_input_tokens) so SDK
// clients can attribute spend across rates.
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 | 🟡 Minor | ⚡ Quick win

Stale model name in comment — says "Haiku 4.5" but the test targets claude-sonnet-4-6

The comment at Line 594 reads "a model that supports it (Haiku 4.5)", but the test body uses aws-bedrock/claude-sonnet-4-6. This is a copy-paste artifact from the Anthropic test above.

📝 Proposed fix
-	// 1h cache TTL via Bedrock /v1/chat/completions: opts into Bedrock's 1h
-	// cache write rate (2x base) on a model that supports it (Haiku 4.5) and
-	// asserts the gateway forwards ttl:"1h" to the Converse API cachePoint and
+	// 1h cache TTL via Bedrock /v1/chat/completions: opts into Bedrock's 1h
+	// cache write rate (2x base) on a model that supports it (Sonnet 4.6) and
+	// asserts the gateway forwards ttl:"1h" to the Converse API cachePoint and
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// 1h cache TTL via Bedrock /v1/chat/completions: opts into Bedrock's 1h
// cache write rate (2x base) on a model that supports it (Haiku 4.5) and
// asserts the gateway forwards ttl:"1h" to the Converse API cachePoint and
// surfaces the response breakdown
// (prompt_tokens_details.cache_creation.ephemeral_1h_input_tokens) so SDK
// clients can attribute spend across rates.
// 1h cache TTL via Bedrock /v1/chat/completions: opts into Bedrock's 1h
// cache write rate (2x base) on a model that supports it (Sonnet 4.6) and
// asserts the gateway forwards ttl:"1h" to the Converse API cachePoint and
// surfaces the response breakdown
// (prompt_tokens_details.cache_creation.ephemeral_1h_input_tokens) so SDK
// clients can attribute spend across rates.
🤖 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/gateway/src/native-anthropic-cache.e2e.ts` around lines 592 - 597, The
comment describing the test references the wrong model name ("Haiku 4.5");
update that comment to reference the actual model used in the test
(`aws-bedrock/claude-sonnet-4-6`) so the description for the 1h cache TTL
Bedrock test (the block that mentions "cache write rate" and
"prompt_tokens_details.cache_creation.ephemeral_1h_input_tokens") accurately
matches the test body.

Comment on lines 19 to 41
const handleResendVerification = async () => {
setIsResending(true);

try {
const response = await fetch(
`${config.apiUrl}/auth/send-verification-email`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
email: user.email,
callbackURL: `${window.location.origin}/dashboard?emailVerified=true`,
}),
},
);
const { error } = await sendVerificationEmail({
email: user.email,
callbackURL: `${window.location.origin}/dashboard?emailVerified=true`,
});

if (!response.ok) {
throw new Error("Failed to send verification email");
}

toast({
title: "Verification email sent",
description: "Please check your inbox for the verification email.",
});
} catch (error) {
if (error) {
toast({
title: "Error",
description:
error instanceof Error
? error.message
: "Failed to send verification email",
description: error.message ?? "Failed to send verification email",
variant: "destructive",
});
} finally {
setIsResending(false);
} else {
toast({
title: "Verification email sent",
description: "Please check your inbox for the verification email.",
});
}

setIsResending(false);
};
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 | 🟡 Minor | ⚡ Quick win

Restore finally to reset isResending on thrown errors.

While sendVerificationEmail returns { error } for handled API failures, it can still throw on network/runtime errors (better-auth uses better-fetch under the hood). The previous implementation wrapped this in try/catch/finally; without it, an unexpected throw leaves isResending stuck at true and the button permanently disabled.

🛡️ Proposed fix
 	const handleResendVerification = async () => {
 		setIsResending(true);
-
-		const { error } = await sendVerificationEmail({
-			email: user.email,
-			callbackURL: `${window.location.origin}/dashboard?emailVerified=true`,
-		});
-
-		if (error) {
-			toast({
-				title: "Error",
-				description: error.message ?? "Failed to send verification email",
-				variant: "destructive",
-			});
-		} else {
-			toast({
-				title: "Verification email sent",
-				description: "Please check your inbox for the verification email.",
-			});
+		try {
+			const { error } = await sendVerificationEmail({
+				email: user.email,
+				callbackURL: `${window.location.origin}/dashboard?emailVerified=true`,
+			});
+
+			if (error) {
+				toast({
+					title: "Error",
+					description: error.message ?? "Failed to send verification email",
+					variant: "destructive",
+				});
+			} else {
+				toast({
+					title: "Verification email sent",
+					description: "Please check your inbox for the verification email.",
+				});
+			}
+		} finally {
+			setIsResending(false);
 		}
-
-		setIsResending(false);
 	};
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const handleResendVerification = async () => {
setIsResending(true);
try {
const response = await fetch(
`${config.apiUrl}/auth/send-verification-email`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
email: user.email,
callbackURL: `${window.location.origin}/dashboard?emailVerified=true`,
}),
},
);
const { error } = await sendVerificationEmail({
email: user.email,
callbackURL: `${window.location.origin}/dashboard?emailVerified=true`,
});
if (!response.ok) {
throw new Error("Failed to send verification email");
}
toast({
title: "Verification email sent",
description: "Please check your inbox for the verification email.",
});
} catch (error) {
if (error) {
toast({
title: "Error",
description:
error instanceof Error
? error.message
: "Failed to send verification email",
description: error.message ?? "Failed to send verification email",
variant: "destructive",
});
} finally {
setIsResending(false);
} else {
toast({
title: "Verification email sent",
description: "Please check your inbox for the verification email.",
});
}
setIsResending(false);
};
const handleResendVerification = async () => {
setIsResending(true);
try {
const { error } = await sendVerificationEmail({
email: user.email,
callbackURL: `${window.location.origin}/dashboard?emailVerified=true`,
});
if (error) {
toast({
title: "Error",
description: error.message ?? "Failed to send verification email",
variant: "destructive",
});
} else {
toast({
title: "Verification email sent",
description: "Please check your inbox for the verification email.",
});
}
} finally {
setIsResending(false);
}
};
🤖 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/ui/src/components/email-verification-banner.tsx` around lines 19 - 41,
The handleResendVerification function can leave isResending true if
sendVerificationEmail throws (network/runtime error); wrap the await
sendVerificationEmail call in try/catch/finally (use the existing
setIsResending(true) at start and move setIsResending(false) into a finally
block) so isResending is always reset, catch optional errors to show the toast
(use error?.message or a generic message), and reference
handleResendVerification, sendVerificationEmail, and setIsResending when
applying the change.

steebchen and others added 2 commits May 7, 2026 22:54
…egation

# Conflicts:
#	apps/gateway/src/lib/costs.spec.ts
Drop "daily" from table names and adjacent identifiers since the
aggregation cadence is configurable via GLOBAL_STATS_BUCKET_SECONDS.
The rows themselves are still day-bucketed (dayTimestamp column is
unchanged), but the table names no longer imply a specific update
frequency.

Renames:
  table   global_daily_model_stats         -> global_model_stats
          global_daily_source_stats        -> global_source_stats
          global_daily_aggregation_state   -> global_aggregation_state
  env     GLOBAL_DAILY_STATS_*             -> GLOBAL_STATS_*
          MAX_HOURS_PER_TICK               -> MAX_BUCKETS_PER_TICK
  api     /admin/global-daily-stats        -> /admin/global-stats
  page    /global-daily-stats              -> /global-stats
  fn      aggregateWindowIntoDailyStats    -> aggregateWindowIntoStats

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 41f19fb567

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +893 to +895
requestCount:
sql<number>`COALESCE(SUM(${sourceTable.requestCount}), 0)::int`.as(
"requestCount",
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 👍 / 👎.

getTableColumns() returns the JS-side camelCase identifier in
col.name when no explicit name was passed. Drizzle's snake_case
casing only applies at SQL emission time, so excluded.requestCount
was referencing a column that doesn't exist (the actual column is
request_count). Convert the JS key to snake_case before passing to
sql.identifier().

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

♻️ Duplicate comments (1)
apps/worker/src/services/global-stats-aggregator.ts (1)

308-313: ⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

setLastSafetyNetDay is still called unconditionally after a potentially partial recomputeDayFully.

This issue was raised in a previous review and remains unresolved. When recomputeDayFully returns early because isStopRequested() fired mid-loop, the DELETE has already run but not all 24 hours have been re-inserted. setLastSafetyNetDay then marks yesterday as fully recomputed, so the safety net will never retry, and the incremental walker (whose watermark is already past yesterday) will also never backfill the missing hours.

🐛 Proposed fix (same as previous review)
 	await recomputeDayFully(yesterdayStart);
-	await setLastSafetyNetDay(yesterdayStart);
-
-	logger.info(
-		`[global-safety-net] Recompute complete for ${formatUTCTimestamp(yesterdayStart)}`,
-	);
+	if (!isStopRequested()) {
+		await setLastSafetyNetDay(yesterdayStart);
+		logger.info(
+			`[global-safety-net] Recompute complete for ${formatUTCTimestamp(yesterdayStart)}`,
+		);
+	}
🤖 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/worker/src/services/global-stats-aggregator.ts` around lines 308 - 313,
recomputeDayFully may return early when isStopRequested() fires, but
setLastSafetyNetDay(yesterdayStart) is still called unconditionally; change the
flow so setLastSafetyNetDay is only called when recomputeDayFully actually
completed all hours. Have recomputeDayFully return a boolean (e.g.,
completedFully) or throw on unrecoverable failure, update the caller to await
const completed = await recomputeDayFully(yesterdayStart) and only call await
setLastSafetyNetDay(yesterdayStart) (and log the success message) when completed
is true; keep existing isStopRequested checks inside recomputeDayFully and
ensure the returned value accurately reflects a full recompute.
🧹 Nitpick comments (1)
apps/worker/src/services/global-stats-aggregator.ts (1)

114-126: ⚡ Quick win

Add a runtime guard for unknown keys in buildAddUpsertSet.

cols[key] is typed as non-undefined due to the as Record<…> cast, but if any entry in AGGREGATE_KEYS is absent from the table (e.g. after a schema rename), col is undefined at runtime. Since MODEL_ADD_SET / SOURCE_ADD_SET are built once at module load and reused for every upsert, a single stale key would silently corrupt the SET clause for all incremental writes with no TypeScript warning.

🛡️ Proposed defensive guard
 function buildAddUpsertSet(table: AnyTable) {
 	const cols = getTableColumns(table) as Record<
 		string,
 		{ name: string } & object
 	>;
 	const set: Record<string, ReturnType<typeof sql>> = {};
 	for (const key of AGGREGATE_KEYS) {
 		const col = cols[key];
+		if (!col) {
+			throw new Error(
+				`buildAddUpsertSet: column "${key}" not found in table – AGGREGATE_KEYS is out of sync with the schema`,
+			);
+		}
 		const snakeName = toSnakeCase(key);
 		set[key] = sql`${col} + excluded.${sql.identifier(snakeName)}`;
 	}
 	return set;
 }
🤖 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/worker/src/services/global-stats-aggregator.ts` around lines 114 - 126,
The buildAddUpsertSet function currently assumes every key in AGGREGATE_KEYS
exists in cols; add a runtime guard after const col = cols[key] to detect
missing columns (cols[key] === undefined) and throw a clear Error (or fail fast)
naming the missing key and the table to prevent constructing an invalid SET
clause; update callers that build MODEL_ADD_SET / SOURCE_ADD_SET to rely on this
early failure so stale keys are caught at module load instead of silently
corrupting upserts.
🤖 Prompt for all review comments with 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.

Duplicate comments:
In `@apps/worker/src/services/global-stats-aggregator.ts`:
- Around line 308-313: recomputeDayFully may return early when isStopRequested()
fires, but setLastSafetyNetDay(yesterdayStart) is still called unconditionally;
change the flow so setLastSafetyNetDay is only called when recomputeDayFully
actually completed all hours. Have recomputeDayFully return a boolean (e.g.,
completedFully) or throw on unrecoverable failure, update the caller to await
const completed = await recomputeDayFully(yesterdayStart) and only call await
setLastSafetyNetDay(yesterdayStart) (and log the success message) when completed
is true; keep existing isStopRequested checks inside recomputeDayFully and
ensure the returned value accurately reflects a full recompute.

---

Nitpick comments:
In `@apps/worker/src/services/global-stats-aggregator.ts`:
- Around line 114-126: The buildAddUpsertSet function currently assumes every
key in AGGREGATE_KEYS exists in cols; add a runtime guard after const col =
cols[key] to detect missing columns (cols[key] === undefined) and throw a clear
Error (or fail fast) naming the missing key and the table to prevent
constructing an invalid SET clause; update callers that build MODEL_ADD_SET /
SOURCE_ADD_SET to rely on this early failure so stale keys are caught at module
load instead of silently corrupting upserts.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 58a2fa9f-5d74-4b50-a6b4-5a0029829975

📥 Commits

Reviewing files that changed from the base of the PR and between 41f19fb and 3914289.

📒 Files selected for processing (1)
  • apps/worker/src/services/global-stats-aggregator.ts

The pie chart and breakdown table now respond to the same
cost/requests/total tokens toggle as the line chart, so the metric
selector in the pie card switches both the slice values and the
percentages in the tooltip + the column shown in the breakdown
table.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1aea710e53

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +1033 to +1036
.from(globalSourceStats)
.where(gte(globalSourceStats.dayTimestamp, startDate))
.groupBy(globalSourceStats.source)
.orderBy(desc(metricSums.requestCount));
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 👍 / 👎.

recomputeDayFully returned early on isStopRequested() but the caller
unconditionally called setLastSafetyNetDay, causing the day to be
flagged as fully recomputed even though only a partial subset of its
24 hour-chunks had been re-aggregated. Next worker start would skip
the safety net for that day, leaving the partial data in place.

Change recomputeDayFully to return a boolean (true on full
completion, false on stop) and gate setLastSafetyNetDay on it.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@steebchen steebchen enabled auto-merge May 7, 2026 17:32
@steebchen steebchen added this pull request to the merge queue May 7, 2026
Merged via the queue into main with commit 341e0f1 May 7, 2026
11 checks passed
@steebchen steebchen deleted the add-global-daily-aggregation branch May 7, 2026 17:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants