Skip to content

feat(admin): add provider/model discount management - #1606

Merged
steebchen merged 15 commits into
mainfrom
add-provider-model-discounts
Feb 9, 2026
Merged

steebchen merged 15 commits into
mainfrom
add-provider-model-discounts

Conversation

@steebchen

@steebchen steebchen commented Feb 6, 2026 •

Copy link
Copy Markdown
Member

Summary

  • Add ability to configure discounts for providers and models at two levels:

    • Global discounts: Apply to all organizations (override hardcoded model discounts)
    • Organization-specific discounts: Apply to a single org (highest precedence)
  • Database: new discount table with organization_id, provider, model, discount_percent fields and appropriate indexes

  • Backend API endpoints for CRUD operations on global and org-specific discounts with validation

  • Cost calculation: calculateCosts() is now async with optional organizationId parameter, uses cached database for discount lookups with proper precedence

  • Admin dashboard: new pages for global discounts (/discounts) and org-specific discounts (/organizations/[orgId]/discounts) with forms and navigation

Test plan

  • Verify database migration runs successfully
  • Test global discount CRUD via admin dashboard
  • Test org-specific discount CRUD via admin dashboard
  • Verify discount precedence: org+provider+model > org+provider > org+model > global+provider+model > global+provider > global+model > hardcoded
  • Verify costs are calculated correctly with dynamic discounts
  • Verify validation rejects invalid discount values and provider/model IDs

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Introduced discount management system for global and organization-specific discounts with create, view, and delete capabilities
    • Added admin interface to manage discounts with filtering by provider and model options
    • Added navigation links to access discount management from the admin dashboard
    • Discount system now integrated with cost calculations for accurate pricing

Add ability to configure discounts for providers and models at two levels:
- Global discounts: Apply to all organizations (override hardcoded model discounts)
- Organization-specific discounts: Apply to a single org (highest precedence)

Database changes:
- Add new `discount` table with organization_id, provider, model, discount_percent
- Unique constraint on (organization_id, provider, model) combination
- Indexes for efficient lookups

Backend API:
- GET/POST/DELETE /admin/discounts for global discounts
- GET/POST/DELETE /admin/organizations/{orgId}/discounts for org discounts
- GET /admin/discounts/options for available providers/models
- Validation for discount values (0-100%) and provider/model IDs

Cost calculation:
- calculateCosts() is now async with optional organizationId parameter
- Uses getEffectiveDiscount() helper with cdb (cached database) for lookups
- Discount precedence: org+provider+model > org+provider > org+model >
  global+provider+model > global+provider > global+model > hardcoded

Admin dashboard:
- /discounts page for global discount management
- /organizations/[orgId]/discounts page for org-specific discounts
- DiscountForm component with provider/model selectors
- Navigation links in sidebar and org detail page

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings February 6, 2026 17:36
@coderabbitai

coderabbitai Bot commented Feb 6, 2026 •

Copy link
Copy Markdown
Contributor

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

This PR introduces a comprehensive discount management system for the platform. It adds database schema and migration for storing global and organization-specific discounts with provider/model targeting, creates admin UI pages with forms for managing discounts, updates the cost calculation pipeline to resolve effective discounts based on organization context, and establishes backend API endpoints for discount CRUD operations with validation.

Changes

Cohort / File(s) Summary
Admin Discount Pages
apps/admin/src/app/discounts/page.tsx, apps/admin/src/app/organizations/[orgId]/discounts/page.tsx, apps/admin/src/app/organizations/[orgId]/page.tsx
New server-rendered pages for managing global and organization-specific discounts; include data fetching, sign-in prompts, forms, tables, and server actions for create/delete operations. Organization page updated with link to discount management.
Admin Components
apps/admin/src/components/discount-form.tsx, apps/admin/src/components/admin-shell.tsx, apps/admin/src/components/ui/command.tsx, apps/admin/src/app/globals.css
New DiscountForm and DeleteDiscountButton components with form validation and dialog UX; sidebar navigation updated with global discounts link; command palette and base styling adjustments for overflow behavior.
Admin Library & API
apps/admin/src/lib/admin-discounts.ts, apps/api/src/routes/admin.ts
New admin-discounts library module with typed API helpers and client-side gatekeepers; comprehensive admin API routes for global/organization discounts with CRUD endpoints, validation, duplicate prevention, and consistent error handling.
Cost Calculation System
apps/gateway/src/lib/costs.ts, apps/gateway/src/chat/chat.ts, apps/gateway/src/lib/costs.spec.ts
Made calculateCosts async with added organizationId parameter; integrated getEffectiveDiscount for org-level discount resolution; updated all call sites in chat.ts to await results and propagate organization context; test suite converted to async pattern.
Database Schema & Relations
packages/db/src/schema.ts, packages/db/src/discount-helpers.ts, packages/db/src/relations.ts
New discount table supporting global or org-specific scope with provider/model targeting, unique constraints, and indexes; new getEffectiveDiscount function implementing multi-tier discount precedence (org_provider_model → org_provider → org_model → global_provider_model → global_provider → global_model); discount-organization relations defined.
Database Migration & Exports
packages/db/migrations/1770629650_jazzy_millenium_guard.sql, packages/db/migrations/meta/_journal.json, packages/db/src/index.ts
New SQL migration for discount table with foreign key to organization and multi-column indexes; migration journal entry added; discount-helpers module re-exported from db package index.
Package Updates
apps/admin/package.json, packages/shared/src/index.ts
Added @llmgateway/shared dependency to admin app; getProviderIcon exported from shared components barrel.

Sequence Diagrams

sequenceDiagram
    participant Admin as Admin UI
    participant Server as Admin Server
    participant API as API Routes
    participant DB as Database
    participant Log as Audit Log

    Admin->>Server: POST /admin/organizations/{orgId}/discounts
    Server->>API: POST request with discount data
    API->>DB: Validate provider/model exist
    API->>DB: Check no duplicate discount
    API->>DB: INSERT discount record
    DB-->>API: Return created discount
    API->>Log: Log discount creation
    API-->>Server: Return success response
    Server->>Admin: Trigger router.refresh()
    Admin->>Server: Re-fetch discounts
    Server->>API: GET /admin/organizations/{orgId}/discounts
    API->>DB: Query discounts with filters
    DB-->>API: Return filtered discount list
    API-->>Server: Return discounts + options
    Server->>Admin: Re-render page with updated list
Loading
sequenceDiagram
    participant Chat as Chat Handler
    participant Cost as Cost Calculator
    participant DB as Database/Cache
    participant Log as Logging

    Chat->>Cost: calculateCosts(model, provider, tokens, organizationId)
    Cost->>DB: getEffectiveDiscount(organizationId, provider, model)
    DB->>DB: Query org_provider_model discount
    alt Found org_provider_model
        DB-->>Cost: Return discount + "org_provider_model" source
    else Query org_provider
        DB->>DB: Query org_provider discount
        DB-->>Cost: Return discount + "org_provider" source
    else Query org_model
        DB->>DB: Query org_model discount
        DB-->>Cost: Return discount + "org_model" source
    else Fallback to global or hardcoded
        DB-->>Cost: Return discount with source
    end
    Cost->>Cost: Apply discountMultiplier = 1 - discount
    Cost->>Cost: Calculate costs with multiplier
    Cost-->>Chat: Return costs + metadata
    Chat->>Log: Log cost with discount source
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 39.29% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat(admin): add provider/model discount management' accurately describes the primary change: adding a new discount management feature for providers and models in the admin interface.

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

✨ Finishing touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch add-provider-model-discounts

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.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Adds admin-configurable discounts that can be applied globally or per-organization, and wires those discounts into gateway cost calculation and the admin dashboard.

Changes:

  • Introduces a new discount table (schema + migration) plus DB helper logic to resolve effective discounts by precedence.
  • Adds admin API endpoints for listing/creating/deleting global and org-specific discounts, plus an “options” endpoint for provider/model lists.
  • Updates gateway cost calculation to fetch effective discounts asynchronously and adds new admin UI pages/forms for managing discounts.

Reviewed changes

Copilot reviewed 16 out of 21 changed files in this pull request and generated 10 comments.

Show a summary per file
File Description
packages/db/src/schema.ts Adds discount table definition, indexes, and uniqueness constraint.
packages/db/src/relations.ts Adds organization↔discount relations.
packages/db/src/index.ts Exports new discount helper module.
packages/db/src/discount-helpers.ts Implements effective discount lookup with precedence + expiry handling via cached DB client.
packages/db/migrations/meta/_journal.json Records the new migration in the migration journal.
packages/db/migrations/1770379067_confused_magdalene.sql Creates discount table + indexes + FK + uniqueness constraint.
apps/api/src/routes/admin.ts Adds admin CRUD endpoints for global/org discounts and an options endpoint; includes validation + formatting helpers.
apps/gateway/src/lib/costs.ts Makes calculateCosts() async and applies effective discounts from DB (with orgId support).
apps/gateway/src/lib/costs.spec.ts Updates tests to await the async calculateCosts().
apps/gateway/src/chat/chat.ts Updates call sites to await calculateCosts() and passes project.organizationId.
apps/admin/src/lib/api/v1.d.ts Updates generated API types for new admin discount endpoints.
apps/ui/src/lib/api/v1.d.ts Updates generated API types for new admin discount endpoints.
apps/playground/src/lib/api/v1.d.ts Updates generated API types for new admin discount endpoints.
apps/code/src/lib/api/v1.d.ts Updates generated API types for new admin discount endpoints.
apps/admin/src/lib/admin-discounts.ts Adds admin-side API wrapper functions for discount CRUD + options.
apps/admin/src/components/discount-form.tsx Adds reusable discount create/delete UI components.
apps/admin/src/components/admin-shell.tsx Adds “Global Discounts” navigation entry.
apps/admin/src/app/organizations/[orgId]/page.tsx Adds “Manage Discounts” link from org page.
apps/admin/src/app/organizations/[orgId]/discounts/page.tsx Adds org-specific discounts management page.
apps/admin/src/app/discounts/page.tsx Adds global discounts management page.

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

Comment on lines +976 to +989
const [created] = await db
.insert(tables.discount)
.values({
organizationId: orgId,
provider,
model,
discountPercent: discountDecimal,
reason: body.reason ?? null,
expiresAt: body.expiresAt ? new Date(body.expiresAt) : null,
})
.returning();

return c.json(formatDiscount(created), 201);
});

Copilot AI Feb 6, 2026

Copy link

Choose a reason for hiding this comment

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

This insert uses db while discount reads use cdb (cached). Without cache invalidation, newly-created org discounts may not be visible immediately and gateway billing may apply old discounts until TTL expiry. Prefer performing this mutation through cdb (to trigger invalidation) or manually invalidating cached discount queries.

Copilot uses AI. Check for mistakes.
Comment on lines +994 to +1009
const [deleted] = await db
.delete(tables.discount)
.where(
and(
eq(tables.discount.id, discountId),
eq(tables.discount.organizationId, orgId),
),
)
.returning({ id: tables.discount.id });

if (!deleted) {
throw new HTTPException(404, { message: "Discount not found" });
}

return c.json({ success: true });
});

Copilot AI Feb 6, 2026

Copy link

Choose a reason for hiding this comment

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

This delete uses db while reads use cdb (cached). Without invalidation, removed org discounts may still be applied/returned until cache TTL expires. Use cdb for the mutation or explicitly invalidate cached discount queries for the affected org.

Copilot uses AI. Check for mistakes.
Comment on lines +41 to +43
if (!hasAuth) {
console.log("[admin-discounts] No session cookie found");
}

Copilot AI Feb 6, 2026

Copy link

Choose a reason for hiding this comment

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

hasSession() logs directly with console.log on missing auth. In this codebase other server-side paths generally use the structured @llmgateway/logger; console logging can add noise and make log routing inconsistent. Consider switching to logger.debug/info (or removing the log if it isn’t actionable).

Suggested change
if (!hasAuth) {
console.log("[admin-discounts] No session cookie found");
}

Copilot uses AI. Check for mistakes.
Comment on lines +886 to +900
const [deleted] = await db
.delete(tables.discount)
.where(
and(
eq(tables.discount.id, discountId),
isNull(tables.discount.organizationId),
),
)
.returning({ id: tables.discount.id });

if (!deleted) {
throw new HTTPException(404, { message: "Discount not found" });
}

return c.json({ success: true });

Copilot AI Feb 6, 2026

Copy link

Choose a reason for hiding this comment

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

This delete uses db while reads use cdb (cached). Without invalidation, deleted discounts may continue to be returned from cache until TTL expiry. Use cdb for this mutation or explicitly invalidate discount table cache entries on delete.

Copilot uses AI. Check for mistakes.
<div className="space-y-2">
<Label htmlFor="provider">Provider</Label>
<Select value={provider} onValueChange={setProvider}>
<SelectTrigger className="w-full">

Copilot AI Feb 6, 2026

Copy link

Choose a reason for hiding this comment

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

The <Label htmlFor="provider"> isn’t associated with an element id in this Select (the SelectTrigger has no id). This can reduce form accessibility (label click/focus, screen readers). Consider adding matching id props to SelectTrigger (and similarly for the model select), or use aria-labelledby / wrap the control with the label per Radix guidance.

Suggested change
<SelectTrigger className="w-full">
<SelectTrigger id="provider" className="w-full">

Copilot uses AI. Check for mistakes.
Comment thread packages/db/src/schema.ts
Comment on lines +1160 to +1166
// Unique constraint: one discount per org+provider+model combo
// Using COALESCE to handle nulls in unique constraint
unique("discount_org_provider_model_unique").on(
table.organizationId,
table.provider,
table.model,
),

Copilot AI Feb 6, 2026

Copy link

Choose a reason for hiding this comment

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

The UNIQUE constraint on (organizationId, provider, model) does not prevent duplicates when any of these columns are NULL (Postgres treats NULLs as distinct in UNIQUE constraints). This breaks the “one discount per scope+target combo” guarantee and can cause nondeterministic precedence behavior. Consider enforcing uniqueness with a UNIQUE INDEX that normalizes NULLs (e.g., COALESCE to a sentinel) or with partial unique indexes per shape (provider+model, provider-only, model-only) for both global and org scopes.

Copilot uses AI. Check for mistakes.
Comment on lines +10 to +13
"expires_at" timestamp,
CONSTRAINT "discount_org_provider_model_unique" UNIQUE("organization_id","provider","model")
);
--> statement-breakpoint

Copilot AI Feb 6, 2026

Copy link

Choose a reason for hiding this comment

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

The migration adds UNIQUE(organization_id, provider, model), but this will still allow multiple rows where provider or model is NULL due to Postgres NULL semantics. To guarantee uniqueness for provider-only/model-only discounts (and prevent race-condition duplicates), use a UNIQUE INDEX with COALESCE (or equivalent partial unique indexes).

Suggested change
"expires_at" timestamp,
CONSTRAINT "discount_org_provider_model_unique" UNIQUE("organization_id","provider","model")
);
--> statement-breakpoint
"expires_at" timestamp
);
--> statement-breakpoint
CREATE UNIQUE INDEX "discount_org_provider_model_unique_idx"
ON "discount" (
"organization_id",
COALESCE("provider", ''),
COALESCE("model", '')
);

Copilot uses AI. Check for mistakes.
Comment on lines +565 to +574
const createDiscountBodySchema = z.object({
provider: z.string().nullable().optional(),
model: z.string().nullable().optional(),
discountPercent: z.coerce
.number()
.min(0, "Discount must be at least 0%")
.max(100, "Discount cannot exceed 100%"),
reason: z.string().nullable().optional(),
expiresAt: z.string().nullable().optional(),
});

Copilot AI Feb 6, 2026

Copy link

Choose a reason for hiding this comment

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

expiresAt is accepted as a free-form string and then passed to new Date(...). Invalid inputs (e.g. non-ISO strings) will produce an Invalid Date and may error at insert time or store unexpected values. Validate expiresAt as a datetime (e.g. z.string().datetime()) and reject invalid dates before inserting.

Copilot uses AI. Check for mistakes.
Comment on lines +565 to +574
const createDiscountBodySchema = z.object({
provider: z.string().nullable().optional(),
model: z.string().nullable().optional(),
discountPercent: z.coerce
.number()
.min(0, "Discount must be at least 0%")
.max(100, "Discount cannot exceed 100%"),
reason: z.string().nullable().optional(),
expiresAt: z.string().nullable().optional(),
});

Copilot AI Feb 6, 2026

Copy link

Choose a reason for hiding this comment

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

The request field discountPercent is validated as 0–100 (percentage) but the stored/returned discountPercent is a 0–1 decimal string (e.g. "0.3000"). Using the same field name for different units between request vs response is error-prone for API consumers. Consider standardizing the unit (either accept/return 0–1 or accept/return 0–100) or using different field names (e.g. discountFraction internally / percentOff externally).

Copilot uses AI. Check for mistakes.
Comment on lines +868 to +878
const [created] = await db
.insert(tables.discount)
.values({
organizationId: null,
provider,
model,
discountPercent: discountDecimal,
reason: body.reason ?? null,
expiresAt: body.expiresAt ? new Date(body.expiresAt) : null,
})
.returning();

Copilot AI Feb 6, 2026

Copy link

Choose a reason for hiding this comment

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

These handlers mutate the discount table using db, but reads are served via cdb (Redis-cached). Because db mutations don’t trigger Drizzle cache invalidation, the /discounts list (and gateway discount lookups) can remain stale for up to the cache TTL. Use cdb for inserts/deletes (so RedisCache.onMutate runs) or explicitly invalidate discount-table cache entries after mutation.

Copilot uses AI. Check for mistakes.

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 6

🤖 Fix all issues with AI agents
In `@apps/admin/src/app/discounts/page.tsx`:
- Around line 68-98: The console.error call in handleCreateDiscount violates the
no-console lint; remove it or replace it with the project's approved logger
(e.g., use an injected/available logger or processLogger) and ensure any error
details are passed to that logger; update the catch block in
handleCreateDiscount to call the approved logging function (instead of
console.error) and keep returning the existing error response, referencing the
handleCreateDiscount function and the call to createGlobalDiscount to find the
block to change.

In `@apps/admin/src/app/organizations/`[orgId]/discounts/page.tsx:
- Around line 82-112: The console.error call inside handleCreateDiscount should
be removed or routed through the project's approved logger to satisfy
no-console; replace the line console.error("Error creating discount:", error);
with a call to your app logger (for example processLogger.error(...) or
logger.error(...)) passing the same message and error, or simply remove the call
entirely if no logger is available, keeping the existing catch return behavior;
update handleCreateDiscount and usages of createOrganizationDiscount/orgId
accordingly so ESLint no-console no longer triggers.

In `@apps/admin/src/lib/admin-discounts.ts`:
- Line 42: Replace the console.log("[admin-discounts] No session cookie found")
call with your structured logger (e.g., a module-level logger or processLogger)
or remove it if unnecessary; update the call site in admin-discounts.ts where
the exact console.log call appears so the message is emitted via the project’s
logging utility (use an appropriate level like warn or info and include
contextual fields such as sessionId or request metadata) to satisfy the
no-console ESLint rule.

In `@apps/api/src/routes/admin.ts`:
- Around line 846-860: Replace the manual select+limit(1) duplicate-checks for
the discount table with the Drizzle object-style finder: use
db().query.discount.findFirst(...) (instead of
db.select(...).from(tables.discount).where(...).limit(1)) for both the global
check (current existing variable around tables.discount with provider/model
predicates) and the org-scoped check later; update the subsequent existence
tests from array length checks to simple truthy checks of the returned record
and preserve the same where conditions (isNull/eq on
tables.discount.organizationId, provider, model) when converting to the
findFirst call.

In `@packages/db/migrations/1770379067_confused_magdalene.sql`:
- Line 11: The UNIQUE constraint CONSTRAINT "discount_org_provider_model_unique"
on table discount doesn't treat NULLs as equal, so multiple rows with NULLs can
violate the intended invariant; replace the plain UNIQUE constraint with a
NULL-aware uniqueness enforcement: either alter the migration to define the
constraint as UNIQUE NULLS NOT DISTINCT("organization_id","provider","model")
(Postgres 15+), or instead create a unique index using COALESCE on the three
columns (COALESCE(organization_id,''), COALESCE(provider,''),
COALESCE(model,'')) and remove the plain UNIQUE constraint; update the migration
so it drops the existing constraint before adding the new NULL-aware
constraint/index and reference the constraint name
"discount_org_provider_model_unique" and table discount when making the change.

In `@packages/db/src/schema.ts`:
- Around line 1133-1170: The unique() constraint on the discount table
(unique("discount_org_provider_model_unique") referencing table.organizationId,
table.provider, table.model) does not prevent multiple rows with NULLs because
Postgres treats NULLs as distinct; replace it with a COALESCE-based unique
index: remove the existing unique(...) entry and add a uniqueIndex (or create a
unique index via raw SQL) named "discount_org_provider_model_unique" that uses
coalesce on organizationId, provider, and model (e.g. coalesce(organization_id,
'') / other sentinel) so NULLs collapse to a single canonical value and true
uniqueness is enforced across global/org+provider+model combos.
🧹 Nitpick comments (4)
apps/gateway/src/lib/costs.ts (1)

257-267: Discount integration looks clean, but guard against out-of-range values.

If discount exceeds 1 (due to a data entry mistake or a mismatch in stored range), discountMultiplier goes negative, producing negative costs. A defensive clamp would prevent this:

Proposed defensive clamp
 	const discount = effectiveDiscountResult.discount;
-	const discountMultiplier = new Decimal(1).minus(discount);
+	const clampedDiscount = Math.max(0, Math.min(1, discount));
+	const discountMultiplier = new Decimal(1).minus(clampedDiscount);
apps/gateway/src/lib/costs.spec.ts (1)

135-168: No tests exercise the organizationId parameter or mock getEffectiveDiscount.

The new discount resolution path (getEffectiveDiscount) likely falls back to hardcoded values in tests (via its error catch). This means the DB-backed discount precedence logic is entirely untested. Consider adding at least one test that mocks getEffectiveDiscount to verify:

  1. An org-specific discount is applied when organizationId is passed.
  2. The discount field appears in the result with the correct value.

This would catch regressions in the integration between calculateCosts and the discount resolution layer.

packages/db/migrations/1770379067_confused_magdalene.sql (1)

8-8: Add a CHECK constraint on discount_percent to ensure data integrity at the database level.

The API validates 0–100%, but this constraint would prevent invalid data from migrations, manual edits, or other clients. Since the value is stored as a decimal between 0–1 in the database (the API divides the input percentage by 100 before storing), the constraint should be:

Suggested SQL
"discount_percent" numeric NOT NULL CHECK ("discount_percent" >= 0 AND "discount_percent" <= 1)
apps/admin/src/lib/admin-discounts.ts (1)

35-45: Extract hasSession into a shared utility to eliminate duplication.

This function is duplicated identically in both admin-discounts.ts and admin-organizations.ts (with only module-specific console.log messages differing). Move it to auth-client.ts or utils.ts and import it in both modules to reduce code duplication and ensure consistent session validation across admin modules.

Comment on lines +68 to +98
async function handleCreateDiscount(data: {
provider: string | null;
model: string | null;
discountPercent: number;
reason: string | null;
}): Promise<{ success: boolean; error?: string }> {
"use server";

try {
const result = await createGlobalDiscount({
provider: data.provider,
model: data.model,
discountPercent: data.discountPercent,
reason: data.reason,
});

if (!result) {
return {
success: false,
error: "Failed to create discount. It may already exist.",
};
}

return { success: true };
} catch (error) {
console.error("Error creating discount:", error);
return {
success: false,
error: "An error occurred while creating the discount",
};
}

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

Remove console.error to satisfy no-console lint.

ESLint flags the console call on Line 93. Prefer dropping it or routing through an approved logger.

🧹 Suggested change
-		} catch (error) {
-			console.error("Error creating discount:", error);
+		} catch {
 			return {
 				success: false,
 				error: "An error occurred while creating the discount",
 			};
 		}
📝 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
async function handleCreateDiscount(data: {
provider: string | null;
model: string | null;
discountPercent: number;
reason: string | null;
}): Promise<{ success: boolean; error?: string }> {
"use server";
try {
const result = await createGlobalDiscount({
provider: data.provider,
model: data.model,
discountPercent: data.discountPercent,
reason: data.reason,
});
if (!result) {
return {
success: false,
error: "Failed to create discount. It may already exist.",
};
}
return { success: true };
} catch (error) {
console.error("Error creating discount:", error);
return {
success: false,
error: "An error occurred while creating the discount",
};
}
async function handleCreateDiscount(data: {
provider: string | null;
model: string | null;
discountPercent: number;
reason: string | null;
}): Promise<{ success: boolean; error?: string }> {
"use server";
try {
const result = await createGlobalDiscount({
provider: data.provider,
model: data.model,
discountPercent: data.discountPercent,
reason: data.reason,
});
if (!result) {
return {
success: false,
error: "Failed to create discount. It may already exist.",
};
}
return { success: true };
} catch {
return {
success: false,
error: "An error occurred while creating the discount",
};
}
}
🧰 Tools
🪛 ESLint

[error] 93-93: Unexpected console statement.

(no-console)

🤖 Prompt for AI Agents
In `@apps/admin/src/app/discounts/page.tsx` around lines 68 - 98, The
console.error call in handleCreateDiscount violates the no-console lint; remove
it or replace it with the project's approved logger (e.g., use an
injected/available logger or processLogger) and ensure any error details are
passed to that logger; update the catch block in handleCreateDiscount to call
the approved logging function (instead of console.error) and keep returning the
existing error response, referencing the handleCreateDiscount function and the
call to createGlobalDiscount to find the block to change.

Comment on lines +82 to +112
async function handleCreateDiscount(data: {
provider: string | null;
model: string | null;
discountPercent: number;
reason: string | null;
}): Promise<{ success: boolean; error?: string }> {
"use server";

try {
const result = await createOrganizationDiscount(orgId, {
provider: data.provider,
model: data.model,
discountPercent: data.discountPercent,
reason: data.reason,
});

if (!result) {
return {
success: false,
error: "Failed to create discount. It may already exist.",
};
}

return { success: true };
} catch (error) {
console.error("Error creating discount:", error);
return {
success: false,
error: "An error occurred while creating the discount",
};
}

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

Remove console.error to satisfy no-console lint.

ESLint flags the console call on Line 107. Prefer dropping it or routing through an approved logger.

🧹 Suggested change
-		} catch (error) {
-			console.error("Error creating discount:", error);
+		} catch {
 			return {
 				success: false,
 				error: "An error occurred while creating the discount",
 			};
 		}
📝 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
async function handleCreateDiscount(data: {
provider: string | null;
model: string | null;
discountPercent: number;
reason: string | null;
}): Promise<{ success: boolean; error?: string }> {
"use server";
try {
const result = await createOrganizationDiscount(orgId, {
provider: data.provider,
model: data.model,
discountPercent: data.discountPercent,
reason: data.reason,
});
if (!result) {
return {
success: false,
error: "Failed to create discount. It may already exist.",
};
}
return { success: true };
} catch (error) {
console.error("Error creating discount:", error);
return {
success: false,
error: "An error occurred while creating the discount",
};
}
async function handleCreateDiscount(data: {
provider: string | null;
model: string | null;
discountPercent: number;
reason: string | null;
}): Promise<{ success: boolean; error?: string }> {
"use server";
try {
const result = await createOrganizationDiscount(orgId, {
provider: data.provider,
model: data.model,
discountPercent: data.discountPercent,
reason: data.reason,
});
if (!result) {
return {
success: false,
error: "Failed to create discount. It may already exist.",
};
}
return { success: true };
} catch {
return {
success: false,
error: "An error occurred while creating the discount",
};
}
}
🧰 Tools
🪛 ESLint

[error] 107-107: Unexpected console statement.

(no-console)

🤖 Prompt for AI Agents
In `@apps/admin/src/app/organizations/`[orgId]/discounts/page.tsx around lines 82
- 112, The console.error call inside handleCreateDiscount should be removed or
routed through the project's approved logger to satisfy no-console; replace the
line console.error("Error creating discount:", error); with a call to your app
logger (for example processLogger.error(...) or logger.error(...)) passing the
same message and error, or simply remove the call entirely if no logger is
available, keeping the existing catch return behavior; update
handleCreateDiscount and usages of createOrganizationDiscount/orgId accordingly
so ESLint no-console no longer triggers.

const secureSessionCookie = cookieStore.get(`__Secure-${key}`);
const hasAuth = !!(sessionCookie || secureSessionCookie);
if (!hasAuth) {
console.log("[admin-discounts] No session cookie found");

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

Remove or replace console.log statement.

ESLint flags this as a violation of the no-console rule. If logging is needed here, use a structured logger instead.

Proposed fix
 	if (!hasAuth) {
-		console.log("[admin-discounts] No session cookie found");
+		// Silently return false; callers handle the no-session case
 	}
🧰 Tools
🪛 ESLint

[error] 42-42: Unexpected console statement.

(no-console)

🤖 Prompt for AI Agents
In `@apps/admin/src/lib/admin-discounts.ts` at line 42, Replace the
console.log("[admin-discounts] No session cookie found") call with your
structured logger (e.g., a module-level logger or processLogger) or remove it if
unnecessary; update the call site in admin-discounts.ts where the exact
console.log call appears so the message is emitted via the project’s logging
utility (use an appropriate level like warn or info and include contextual
fields such as sessionId or request metadata) to satisfy the no-console ESLint
rule.

Comment on lines +846 to +860
const existing = await db
.select({ id: tables.discount.id })
.from(tables.discount)
.where(
and(
isNull(tables.discount.organizationId),
provider
? eq(tables.discount.provider, provider)
: isNull(tables.discount.provider),
model
? eq(tables.discount.model, model)
: isNull(tables.discount.model),
),
)
.limit(1);

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

🧩 Analysis chain

🏁 Script executed:

# Read the specific lines from the file to verify the current state
sed -n '846,860p' apps/api/src/routes/admin.ts

Repository: theopenco/llmgateway

Length of output: 426


🏁 Script executed:

# Read the second location mentioned
sed -n '954,968p' apps/api/src/routes/admin.ts

Repository: theopenco/llmgateway

Length of output: 429


🏁 Script executed:

# Check how existing is used after line 860
sed -n '860,870p' apps/api/src/routes/admin.ts

Repository: theopenco/llmgateway

Length of output: 292


🏁 Script executed:

# Check how existing is used after line 968
sed -n '968,978p' apps/api/src/routes/admin.ts

Repository: theopenco/llmgateway

Length of output: 292


🏁 Script executed:

# Search for other similar patterns in the file to understand if this is a widespread pattern
rg 'db\s*\n\s*\.select' apps/api/src/routes/admin.ts -A 10 | head -80

Repository: theopenco/llmgateway

Length of output: 255


Use findFirst for duplicate checks.

The duplicate-check queries use db.select(...).limit(1) with .length checks; switch to db().query.<table>.findFirst() to match the required read style (applies to both global and org checks at lines 846–860 and 954–968).

♻️ Example (global duplicate check)
-	const existing = await db
-		.select({ id: tables.discount.id })
-		.from(tables.discount)
-		.where(
-			and(
-				isNull(tables.discount.organizationId),
-				provider
-					? eq(tables.discount.provider, provider)
-					: isNull(tables.discount.provider),
-				model
-					? eq(tables.discount.model, model)
-					: isNull(tables.discount.model),
-			),
-		)
-		.limit(1);
+	const existing = await db().query.discount.findFirst({
+		columns: { id: true },
+		where: (discount, { and, eq, isNull }) =>
+			and(
+				isNull(discount.organizationId),
+				provider ? eq(discount.provider, provider) : isNull(discount.provider),
+				model ? eq(discount.model, model) : isNull(discount.model),
+			),
+	});
 
-	if (existing.length > 0) {
+	if (existing) {
 		throw new HTTPException(409, {
 			message: "A discount already exists for this provider/model combination",
 		});
 	}

Per coding guidelines for {apps/api,apps/gateway,packages/db}/**/*.ts: use db().query.<table>.findFirst() for database reads with Drizzle ORM latest object syntax.

📝 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 existing = await db
.select({ id: tables.discount.id })
.from(tables.discount)
.where(
and(
isNull(tables.discount.organizationId),
provider
? eq(tables.discount.provider, provider)
: isNull(tables.discount.provider),
model
? eq(tables.discount.model, model)
: isNull(tables.discount.model),
),
)
.limit(1);
const existing = await db().query.discount.findFirst({
columns: { id: true },
where: (discount, { and, eq, isNull }) =>
and(
isNull(discount.organizationId),
provider ? eq(discount.provider, provider) : isNull(discount.provider),
model ? eq(discount.model, model) : isNull(discount.model),
),
});
if (existing) {
throw new HTTPException(409, {
message: "A discount already exists for this provider/model combination",
});
}
🤖 Prompt for AI Agents
In `@apps/api/src/routes/admin.ts` around lines 846 - 860, Replace the manual
select+limit(1) duplicate-checks for the discount table with the Drizzle
object-style finder: use db().query.discount.findFirst(...) (instead of
db.select(...).from(tables.discount).where(...).limit(1)) for both the global
check (current existing variable around tables.discount with provider/model
predicates) and the org-scoped check later; update the subsequent existence
tests from array length checks to simple truthy checks of the returned record
and preserve the same where conditions (isNull/eq on
tables.discount.organizationId, provider, model) when converting to the
findFirst call.

"discount_percent" numeric NOT NULL,
"reason" text,
"expires_at" timestamp,
CONSTRAINT "discount_org_provider_model_unique" UNIQUE("organization_id","provider","model")

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 | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

cat -n packages/db/migrations/1770379067_confused_magdalene.sql

Repository: theopenco/llmgateway

Length of output: 1031


🏁 Script executed:

rg -n "COALESCE" packages/db/src/schema.ts -B 2 -A 2

Repository: theopenco/llmgateway

Length of output: 301


🏁 Script executed:

cat packages/db/package.json | rg -i "postgres|version" -A 1 -B 1

Repository: theopenco/llmgateway

Length of output: 129


🏁 Script executed:

rg -n "NULLS NOT DISTINCT" --type sql

Repository: theopenco/llmgateway

Length of output: 46


🏁 Script executed:

# Check schema.ts for the discount table definition and any comments
rg -n "discount" packages/db/src/schema.ts -i -B 2 -A 5

Repository: theopenco/llmgateway

Length of output: 2120


🏁 Script executed:

find . -name "Dockerfile*" -o -name "docker-compose*" -o -name ".dockerignore" | head -20

Repository: theopenco/llmgateway

Length of output: 248


🏁 Script executed:

fd -e yaml -e yml | xargs rg -l "postgres" 2>/dev/null | head -5

Repository: theopenco/llmgateway

Length of output: 218


🏁 Script executed:

rg "postgre" packages/db -i -A 2 -B 2 | head -30

Repository: theopenco/llmgateway

Length of output: 1641


🏁 Script executed:

# Check if there's validation logic for discount creation
rg "discount" packages -i -t ts -t tsx | grep -i "create\|insert\|validate" | head -10

Repository: theopenco/llmgateway

Length of output: 91


🏁 Script executed:

cat infra/docker-compose.split.yml | rg -A 5 "postgres" -i

Repository: theopenco/llmgateway

Length of output: 1878


🏁 Script executed:

# Check for application-level validation of discount uniqueness
rg "discount" packages/api packages/services -i -t ts | grep -i "where\|filter\|unique\|duplicate" | head -15

Repository: theopenco/llmgateway

Length of output: 174


🏁 Script executed:

find packages -name "*.ts" -type f | xargs rg -l "discount" 2>/dev/null | head -10

Repository: theopenco/llmgateway

Length of output: 426


🏁 Script executed:

# Look for discount table queries and operations
rg "discount" packages -t ts -A 3 | grep -i "insert\|create\|where" | head -20

Repository: theopenco/llmgateway

Length of output: 275


🏁 Script executed:

# Check if there's any application-level unique check
rg "organizationId.*provider.*model\|provider.*model" packages -t ts -B 2 -A 2 | head -30

Repository: theopenco/llmgateway

Length of output: 46


🏁 Script executed:

cat packages/db/src/discount-helpers.ts

Repository: theopenco/llmgateway

Length of output: 5404


🏁 Script executed:

# Look for any insert/create operations on discount table
rg "discount.*insert\|insert.*discount" packages -t ts -A 5

Repository: theopenco/llmgateway

Length of output: 46


🏁 Script executed:

# Check if Drizzle has any built-in handling or if there's app-level validation
rg "unique\|UNIQUE" packages -t ts | grep -v node_modules

Repository: theopenco/llmgateway

Length of output: 46


PostgreSQL UNIQUE constraint does not enforce uniqueness when columns contain NULLs.

In PostgreSQL (including version 17 used in this project), NULL values are treated as distinct in unique constraints. This allows multiple rows with (NULL, 'openai', NULL) — i.e., two global provider-only discounts for the same provider — to be inserted, defeating the intended "one discount per org+provider+model combo" invariant stated in the schema comment (line 1160).

The schema comment on line 1161 mentions "Using COALESCE to handle nulls in unique constraint," but the Drizzle unique().on() call (lines 1162–1166) generates a standard SQL UNIQUE constraint without COALESCE.

Fix options:

  1. Use NULLS NOT DISTINCT (PostgreSQL 15+):
    CONSTRAINT "discount_org_provider_model_unique" UNIQUE NULLS NOT DISTINCT("organization_id","provider","model")
  2. Use a unique index with COALESCE:
    CREATE UNIQUE INDEX discount_org_provider_model_unique
      ON discount (COALESCE(organization_id, ''), COALESCE(provider, ''), COALESCE(model, ''));
🤖 Prompt for AI Agents
In `@packages/db/migrations/1770379067_confused_magdalene.sql` at line 11, The
UNIQUE constraint CONSTRAINT "discount_org_provider_model_unique" on table
discount doesn't treat NULLs as equal, so multiple rows with NULLs can violate
the intended invariant; replace the plain UNIQUE constraint with a NULL-aware
uniqueness enforcement: either alter the migration to define the constraint as
UNIQUE NULLS NOT DISTINCT("organization_id","provider","model") (Postgres 15+),
or instead create a unique index using COALESCE on the three columns
(COALESCE(organization_id,''), COALESCE(provider,''), COALESCE(model,'')) and
remove the plain UNIQUE constraint; update the migration so it drops the
existing constraint before adding the new NULL-aware constraint/index and
reference the constraint name "discount_org_provider_model_unique" and table
discount when making the change.

Comment thread packages/db/src/schema.ts
Comment on lines +1133 to +1170
// Discount - Admin-configurable discounts for providers/models
// Can be global (organizationId = null) or org-specific
export const discount = pgTable(
"discount",
{
id: text().primaryKey().notNull().$defaultFn(shortid),
createdAt: timestamp().notNull().defaultNow(),
updatedAt: timestamp()
.notNull()
.defaultNow()
.$onUpdate(() => new Date()),
// Scope: null = global discount, otherwise org-specific
organizationId: text().references(() => organization.id, {
onDelete: "cascade",
}),
// Target: provider-only, model-only, or both
// null provider = applies to all providers
provider: text(),
// null model = applies to all models (of provider if specified)
model: text(),
// Discount value (0-1, where 0.3 = 30% off, user pays 70%)
discountPercent: decimal().notNull(),
// Optional metadata
reason: text(),
expiresAt: timestamp(),
},
(table) => [
// Unique constraint: one discount per org+provider+model combo
// Using COALESCE to handle nulls in unique constraint
unique("discount_org_provider_model_unique").on(
table.organizationId,
table.provider,
table.model,
),
index("discount_organization_id_idx").on(table.organizationId),
index("discount_provider_idx").on(table.provider),
index("discount_model_idx").on(table.model),
],

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

🧩 Analysis chain

🏁 Script executed:

# First, verify the discount table code exists and matches the snippet
cd /repo && sed -n '1133,1170p' packages/db/src/schema.ts

Repository: theopenco/llmgateway

Length of output: 119


🏁 Script executed:

# Also check the imports at the top of the file to understand the current setup
head -50 packages/db/src/schema.ts

Repository: theopenco/llmgateway

Length of output: 1273


🏁 Script executed:

# Search for any existing use of uniqueIndex or COALESCE patterns in the codebase
rg "uniqueIndex|coalesce" --type ts --type tsx

Repository: theopenco/llmgateway

Length of output: 91


🏁 Script executed:

# Check the discount table definition (lines 1133-1170)
sed -n '1133,1170p' packages/db/src/schema.ts

Repository: theopenco/llmgateway

Length of output: 1372


🏁 Script executed:

# Search for any existing uniqueIndex usage in the codebase
rg "uniqueIndex" --type ts

Repository: theopenco/llmgateway

Length of output: 46


🏁 Script executed:

# Search for coalesce patterns
rg "coalesce" --type ts

Repository: theopenco/llmgateway

Length of output: 2523


🏁 Script executed:

# Check the Drizzle ORM version in the package.json
cat packages/db/package.json | grep -A 2 -B 2 "drizzle-orm"

Repository: theopenco/llmgateway

Length of output: 208


🏁 Script executed:

# Search for how other tables handle unique constraints in the codebase
rg "unique\(" packages/db/src/schema.ts -A 2

Repository: theopenco/llmgateway

Length of output: 1689


🏁 Script executed:

# Check if there are any index definitions using sql patterns similar to the suggestion
rg "index\(" packages/db/src/schema.ts -A 2 -B 1

Repository: theopenco/llmgateway

Length of output: 4325


🏁 Script executed:

# Check if uniqueIndex is available in Drizzle ORM beta.1
# Search the node_modules or check package structure
find node_modules/drizzle-orm -name "*.d.ts" -o -name "index.ts" 2>/dev/null | head -20

Repository: theopenco/llmgateway

Length of output: 46


🏁 Script executed:

# Try to find the actual exports from drizzle-orm/pg-core in the installed version
grep -r "uniqueIndex" node_modules/drizzle-orm/pg-core 2>/dev/null | head -5

Repository: theopenco/llmgateway

Length of output: 46


🌐 Web query:

Drizzle ORM 1.0.0-beta.1 uniqueIndex API documentation

💡 Result:

uniqueIndex (Drizzle ORM 1.0.0-beta.1) — API + usage

Drizzle’s schema API lets you declare a unique index in the table’s “extra config” callback:

import { pgTable, text, serial, uniqueIndex, index } from "drizzle-orm/pg-core";

export const user = pgTable("user", {
  id: serial("id").primaryKey(),
  name: text("name"),
  email: text("email"),
}, (t) => [
  index("name_idx").on(t.name),
  uniqueIndex("email_idx").on(t.email),
]);

This generates CREATE UNIQUE INDEX ... in SQL. [1]

Imports (by dialect)

uniqueIndex is exported from the dialect core package you’re using, e.g.:

  • drizzle-orm/pg-core
  • drizzle-orm/mysql-core
  • drizzle-orm/sqlite-core
  • drizzle-orm/singlestore-core
  • drizzle-orm/mssql-core
  • drizzle-orm/cockroach-core [1]

Index builder options

Drizzle’s “new index API” (available in the v1 beta line) supports additional builder options (dialect-dependent), such as:

  • Postgres: .on(...) / .onOnly(...), .using(...), .concurrently(), .where(sql``), .with(...) [1]
  • MySQL: .algorythm(...), .using(...), .lock(...) (and .on(...)) [1]
  • SQLite / SingleStore / MSSQL / Cockroach: support .on(...) and (depending on dialect) .where(sql``) [1]

Official docs page: “Indexes & Constraints” → “Indexes” section. [1]

Sources
[1] Drizzle ORM docs — Indexes & Constraints (uniqueIndex examples + index API options) (orm.drizzle.team)

Citations:


Unique constraint won't prevent duplicate NULL-scope discounts.

The comment claims to use COALESCE, but Lines 1162–1166 use a standard unique() constraint on nullable columns. PostgreSQL treats NULLs as distinct, allowing multiple "global / all-provider / all-model" rows and other NULL combinations. This breaks the intended uniqueness and can lead to ambiguous discount resolution. Use uniqueIndex with coalesce() to enforce true uniqueness:

🔐 Example fix using a COALESCE-based unique index
 import {
 	boolean,
 	decimal,
 	index,
 	integer,
 	json,
 	jsonb,
 	pgTable,
 	real,
 	text,
 	timestamp,
 	unique,
+	uniqueIndex,
 } from "drizzle-orm/pg-core";
@@
-		unique("discount_org_provider_model_unique").on(
-			table.organizationId,
-			table.provider,
-			table.model,
-		),
+		uniqueIndex("discount_org_provider_model_unique").on(
+			sql`coalesce(${table.organizationId}, '__global__')`,
+			sql`coalesce(${table.provider}, '__all__')`,
+			sql`coalesce(${table.model}, '__all__')`,
+		),
🤖 Prompt for AI Agents
In `@packages/db/src/schema.ts` around lines 1133 - 1170, The unique() constraint
on the discount table (unique("discount_org_provider_model_unique") referencing
table.organizationId, table.provider, table.model) does not prevent multiple
rows with NULLs because Postgres treats NULLs as distinct; replace it with a
COALESCE-based unique index: remove the existing unique(...) entry and add a
uniqueIndex (or create a unique index via raw SQL) named
"discount_org_provider_model_unique" that uses coalesce on organizationId,
provider, and model (e.g. coalesce(organization_id, '') / other sentinel) so
NULLs collapse to a single canonical value and true uniqueness is enforced
across global/org+provider+model combos.

steebchen and others added 2 commits February 6, 2026 19:44
The API should always use db (regular database) not cdb (cached database).
Only the gateway should use cdb.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 3

🤖 Fix all issues with AI agents
In `@apps/api/src/routes/admin.ts`:
- Around line 845-877: The existence check before inserting into tables.discount
(the select against tables.discount and the subsequent db.insert returning()) is
subject to a TOCTOU race; wrap the insert in a try/catch and map the DB
unique-constraint error to an HTTP 409 instead of letting it bubble as a 500
(for Postgres check error code '23505' or whatever DB-specific duplicate-key
code you use), or alternatively replace the insert with an upsert using ON
CONFLICT that either does nothing or returns a controlled error; apply the same
pattern to the organization-scoped create block (the similar insert for
organizationId !== null) so concurrent requests produce a 409 with the
existing-message rather than a 500.
- Line 572: The schema's expiresAt (z.string().nullable().optional()) accepts
any string so malformed values like "not-a-date" reach the handler and new
Date(body.expiresAt) becomes Invalid Date; update the Zod schema for expiresAt
to enforce a parseable datetime (either replace with a preprocessed z.preprocess
that parses to z.date().nullable().optional() or add a .refine(s => !s ||
!isNaN(Date.parse(s)), { message: 'Invalid date' }) on expiresAt), and
additionally add a defensive check before using new Date(body.expiresAt) (e.g.,
verify Date.parse(body.expiresAt) is not NaN or that new Date(...) is valid) to
prevent persisting invalid dates; target the expiresAt schema and the handler
reference body.expiresAt.
- Around line 547-557: The field discountPercent in discountSchema is misleading
because the code stores/returns a decimal fraction (e.g., "0.3000") but the name
implies a percentage; update the schema and all mapping sites that create/return
this value to use a clear name (choose either discountFraction or
discountMultiplier) or keep discountPercent but convert the stored decimal back
to a percentage before returning; specifically, rename discountPercent in the
z.object (discountSchema) and update every place that sets/parses this property
(the conversion/serialization locations where values like "0.3000" are produced
and responses are built) so the property name and the value semantics match
across creation, storage, and API responses.
🧹 Nitpick comments (2)
apps/api/src/routes/admin.ts (2)

817-828: Use db.query.discount.findMany() for read operations.

Per coding guidelines, database reads should use the Drizzle query API (db().query.<table>.findMany()). The same applies to the org-scoped list at lines 916–920.

♻️ Suggested refactor
-	const discounts = await db
-		.select()
-		.from(tables.discount)
-		.where(isNull(tables.discount.organizationId))
-		.orderBy(desc(tables.discount.createdAt));
+	const discounts = await db.query.discount.findMany({
+		where: (discount, { isNull }) => isNull(discount.organizationId),
+		orderBy: (discount, { desc }) => [desc(discount.createdAt)],
+	});

As per coding guidelines: "For database reads: Use db().query.<table>.findMany() or db().query.<table>.findFirst()"


1015-1021: Remove the type assertion — TypeScript can infer the correct types without it.

The models array is already typed as ModelDefinition[], which includes id: string, name?: string, and family: string. The type assertion is unnecessary and throws away type information. Simply removing it will allow TypeScript to correctly infer the result type:

-	models: (
-		models as Array<{ id: string; name?: string; family: string }>
-	).map((m) => ({
+	models: models.map((m) => ({
 		id: m.id,
 		name: m.name || m.id,
 		family: m.family,
 	})),

The m.name || m.id fallback is already type-safe since m.name is optional and m.id is always a string.

Comment on lines +547 to +557
const discountSchema = z.object({
id: z.string(),
organizationId: z.string().nullable(),
provider: z.string().nullable(),
model: z.string().nullable(),
discountPercent: z.string(),
reason: z.string().nullable(),
expiresAt: z.string().nullable(),
createdAt: z.string(),
updatedAt: z.string(),
});

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

discountPercent naming is misleading — the stored/returned value is a decimal fraction, not a percentage.

The input schema accepts a percentage (e.g., 30 for 30%), but the value is converted to a decimal on line 842/950 (30 → "0.3000") and returned as such in the response. A consumer reading discountPercent: "0.3000" would reasonably expect 30. Either rename the field (e.g., discountMultiplier or discountFraction) or return the percentage value in the response.

Also applies to: 842-842, 950-950

🤖 Prompt for AI Agents
In `@apps/api/src/routes/admin.ts` around lines 547 - 557, The field
discountPercent in discountSchema is misleading because the code stores/returns
a decimal fraction (e.g., "0.3000") but the name implies a percentage; update
the schema and all mapping sites that create/return this value to use a clear
name (choose either discountFraction or discountMultiplier) or keep
discountPercent but convert the stored decimal back to a percentage before
returning; specifically, rename discountPercent in the z.object (discountSchema)
and update every place that sets/parses this property (the
conversion/serialization locations where values like "0.3000" are produced and
responses are built) so the property name and the value semantics match across
creation, storage, and API responses.

.min(0, "Discount must be at least 0%")
.max(100, "Discount cannot exceed 100%"),
reason: z.string().nullable().optional(),
expiresAt: z.string().nullable().optional(),

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

No validation that expiresAt is a parseable date.

z.string().nullable().optional() accepts any string. On line 875, new Date(body.expiresAt) will silently produce an Invalid Date for malformed input (e.g., "not-a-date"), which will be persisted. Add a Zod datetime() refinement or validate the parsed Date before use.

🛡️ Option A: Validate in the schema
-	expiresAt: z.string().nullable().optional(),
+	expiresAt: z.string().datetime().nullable().optional(),
🛡️ Option B: Validate after parsing
-		expiresAt: body.expiresAt ? new Date(body.expiresAt) : null,
+		expiresAt: body.expiresAt ? (() => {
+			const d = new Date(body.expiresAt);
+			if (Number.isNaN(d.getTime())) {
+				throw new HTTPException(400, { message: "Invalid expiresAt date" });
+			}
+			return d;
+		})() : null,

Also applies to: 875-875

🤖 Prompt for AI Agents
In `@apps/api/src/routes/admin.ts` at line 572, The schema's expiresAt
(z.string().nullable().optional()) accepts any string so malformed values like
"not-a-date" reach the handler and new Date(body.expiresAt) becomes Invalid
Date; update the Zod schema for expiresAt to enforce a parseable datetime
(either replace with a preprocessed z.preprocess that parses to
z.date().nullable().optional() or add a .refine(s => !s ||
!isNaN(Date.parse(s)), { message: 'Invalid date' }) on expiresAt), and
additionally add a defensive check before using new Date(body.expiresAt) (e.g.,
verify Date.parse(body.expiresAt) is not NaN or that new Date(...) is valid) to
prevent persisting invalid dates; target the expiresAt schema and the handler
reference body.expiresAt.

Comment on lines +845 to +877
const existing = await db
.select({ id: tables.discount.id })
.from(tables.discount)
.where(
and(
isNull(tables.discount.organizationId),
provider
? eq(tables.discount.provider, provider)
: isNull(tables.discount.provider),
model
? eq(tables.discount.model, model)
: isNull(tables.discount.model),
),
)
.limit(1);

if (existing.length > 0) {
throw new HTTPException(409, {
message: "A discount already exists for this provider/model combination",
});
}

const [created] = await db
.insert(tables.discount)
.values({
organizationId: null,
provider,
model,
discountPercent: discountDecimal,
reason: body.reason ?? null,
expiresAt: body.expiresAt ? new Date(body.expiresAt) : null,
})
.returning();

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

TOCTOU between duplicate check and insert — unique-constraint violation will surface as 500.

The duplicate-existence check and the INSERT are not atomic. If a concurrent request slips between them, the DB unique constraint will fire, producing an unhandled error (likely 500). Consider wrapping the insert in a try/catch that maps the unique-constraint error to a 409, or use an upsert / ON CONFLICT clause. The same applies to the org-scoped create at lines 953–985.

🤖 Prompt for AI Agents
In `@apps/api/src/routes/admin.ts` around lines 845 - 877, The existence check
before inserting into tables.discount (the select against tables.discount and
the subsequent db.insert returning()) is subject to a TOCTOU race; wrap the
insert in a try/catch and map the DB unique-constraint error to an HTTP 409
instead of letting it bubble as a 500 (for Postgres check error code '23505' or
whatever DB-specific duplicate-key code you use), or alternatively replace the
insert with an upsert using ON CONFLICT that either does nothing or returns a
controlled error; apply the same pattern to the organization-scoped create block
(the similar insert for organizationId !== null) so concurrent requests produce
a 409 with the existing-message rather than a 500.

- Add provider icons from shared package to provider selector
- Implement searchable combobox for provider selection
- Implement searchable combobox for model selection (searches by id, name, family)
- Add @llmgateway/shared dependency to admin app
- Export getProviderIcon from shared package

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In `@apps/admin/src/components/discount-form.tsx`:
- Around line 79-116: handleSubmit currently awaits onSubmit without try/catch
so if onSubmit throws the function exits before calling setLoading(false) or
setError; wrap the call to onSubmit in a try/catch/finally inside handleSubmit
(catch should setError with the caught error message or a fallback, finally must
call setLoading(false)) and keep the existing success/failure handling (use the
result variable only when await succeeds). Refer to handleSubmit, onSubmit,
setLoading, setError, and router.refresh when making the change.
- Around line 345-381: The DeleteDiscountButton's handleDelete can leave loading
true if onDelete throws and provides no user feedback on failure; wrap the await
onDelete(discountId) call in try/catch/finally inside handleDelete (use finally
to always call setLoading(false)), handle both thrown errors and result.success
=== false by setting an error state (e.g., error message string via useState)
and surface it to the user (toast or inline message), and only call
router.refresh() on success; reference DeleteDiscountButton, handleDelete,
setLoading, onDelete, and router.refresh when implementing these changes.
🧹 Nitpick comments (2)
apps/admin/src/components/discount-form.tsx (2)

184-205: Provider combobox search only matches by id, unlike model combobox which matches by id+name+family.

On line 189, value={p.id} means typing a provider name in the search box won't find it if the name differs from the id. The model combobox (line 253) uses value={`${m.id} ${m.name} ${m.family}`} for broader matching. Consider applying the same pattern here.

Proposed fix
 										<CommandItem
 											key={p.id}
-											value={p.id}
+											value={`${p.id} ${p.name}`}
 											onSelect={() => {
 												setProvider(p.id);
 												setProviderPopoverOpen(false);

298-301: Live display can show misleading values for out-of-range inputs.

If the user types a value > 100 or < 0, the helper text shows nonsensical results like "Customer pays -50%" before submit validation kicks in. Consider clamping the display value to [0, 100].

Proposed fix
 					<p className="text-xs text-muted-foreground">
-						Customer pays {100 - (parseFloat(discountPercent) || 0)}% of the
-						original price
+						Customer pays {Math.max(0, Math.min(100, 100 - (parseFloat(discountPercent) || 0)))}% of the
+						original price
 					</p>

Comment on lines +79 to +116
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setLoading(true);

const percent = parseFloat(discountPercent);
if (isNaN(percent) || percent < 0 || percent > 100) {
setError("Discount must be between 0 and 100");
setLoading(false);
return;
}

if (provider === "__all__" && model === "__all__") {
setError("Please select at least a provider or a model");
setLoading(false);
return;
}

const result = await onSubmit({
provider: provider === "__all__" ? null : provider,
model: model === "__all__" ? null : model,
discountPercent: percent,
reason: reason || null,
});

setLoading(false);

if (result.success) {
setOpen(false);
setProvider("__all__");
setModel("__all__");
setDiscountPercent("");
setReason("");
router.refresh();
} else {
setError(result.error || "Failed to create discount");
}
};

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

Missing try/catch around onSubmit — loading state can get stuck permanently.

If onSubmit throws (network error, unexpected exception), setLoading(false) on line 104 is never reached, leaving the button permanently disabled and no error shown to the user.

Proposed fix: wrap in try/catch
 	const handleSubmit = async (e: React.FormEvent) => {
 		e.preventDefault();
 		setError(null);
 		setLoading(true);
 
 		const percent = parseFloat(discountPercent);
 		if (isNaN(percent) || percent < 0 || percent > 100) {
 			setError("Discount must be between 0 and 100");
 			setLoading(false);
 			return;
 		}
 
 		if (provider === "__all__" && model === "__all__") {
 			setError("Please select at least a provider or a model");
 			setLoading(false);
 			return;
 		}
 
-		const result = await onSubmit({
-			provider: provider === "__all__" ? null : provider,
-			model: model === "__all__" ? null : model,
-			discountPercent: percent,
-			reason: reason || null,
-		});
-
-		setLoading(false);
-
-		if (result.success) {
-			setOpen(false);
-			setProvider("__all__");
-			setModel("__all__");
-			setDiscountPercent("");
-			setReason("");
-			router.refresh();
-		} else {
-			setError(result.error || "Failed to create discount");
+		try {
+			const result = await onSubmit({
+				provider: provider === "__all__" ? null : provider,
+				model: model === "__all__" ? null : model,
+				discountPercent: percent,
+				reason: reason || null,
+			});
+
+			if (result.success) {
+				setOpen(false);
+				setProvider("__all__");
+				setModel("__all__");
+				setDiscountPercent("");
+				setReason("");
+				router.refresh();
+			} else {
+				setError(result.error || "Failed to create discount");
+			}
+		} catch {
+			setError("An unexpected error occurred");
+		} finally {
+			setLoading(false);
 		}
 	};
🤖 Prompt for AI Agents
In `@apps/admin/src/components/discount-form.tsx` around lines 79 - 116,
handleSubmit currently awaits onSubmit without try/catch so if onSubmit throws
the function exits before calling setLoading(false) or setError; wrap the call
to onSubmit in a try/catch/finally inside handleSubmit (catch should setError
with the caught error message or a fallback, finally must call
setLoading(false)) and keep the existing success/failure handling (use the
result variable only when await succeeds). Refer to handleSubmit, onSubmit,
setLoading, setError, and router.refresh when making the change.

Comment on lines +345 to +381
export function DeleteDiscountButton({
discountId,
onDelete,
}: DeleteDiscountButtonProps) {
const router = useRouter();
const [loading, setLoading] = useState(false);

const handleDelete = async () => {
if (!confirm("Are you sure you want to delete this discount?")) {
return;
}

setLoading(true);
const result = await onDelete(discountId);
setLoading(false);

if (result.success) {
router.refresh();
}
};

return (
<Button
variant="ghost"
size="icon-sm"
onClick={handleDelete}
disabled={loading}
className="text-destructive hover:text-destructive"
>
{loading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="h-4 w-4" />
)}
</Button>
);
}

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

Same missing try/catch issue, plus no error feedback on failed deletions.

  1. If onDelete throws, setLoading(false) is never called — same stuck-loading bug as handleSubmit.
  2. When result.success is false, the user gets no visual indication that the deletion failed.
Proposed fix: add try/catch and error state
 export function DeleteDiscountButton({
 	discountId,
 	onDelete,
 }: DeleteDiscountButtonProps) {
 	const router = useRouter();
 	const [loading, setLoading] = useState(false);
+	const [error, setError] = useState(false);
 
 	const handleDelete = async () => {
 		if (!confirm("Are you sure you want to delete this discount?")) {
 			return;
 		}
 
 		setLoading(true);
-		const result = await onDelete(discountId);
-		setLoading(false);
-
-		if (result.success) {
-			router.refresh();
+		setError(false);
+		try {
+			const result = await onDelete(discountId);
+			if (result.success) {
+				router.refresh();
+			} else {
+				setError(true);
+			}
+		} catch {
+			setError(true);
+		} finally {
+			setLoading(false);
 		}
 	};
 
 	return (
-		<Button
-			variant="ghost"
-			size="icon-sm"
-			onClick={handleDelete}
-			disabled={loading}
-			className="text-destructive hover:text-destructive"
-		>
-			{loading ? (
-				<Loader2 className="h-4 w-4 animate-spin" />
-			) : (
-				<Trash2 className="h-4 w-4" />
-			)}
-		</Button>
+		<span title={error ? "Failed to delete discount" : undefined}>
+			<Button
+				variant="ghost"
+				size="icon-sm"
+				onClick={handleDelete}
+				disabled={loading}
+				className={cn("text-destructive hover:text-destructive", error && "ring-2 ring-destructive")}
+			>
+				{loading ? (
+					<Loader2 className="h-4 w-4 animate-spin" />
+				) : (
+					<Trash2 className="h-4 w-4" />
+				)}
+			</Button>
+		</span>
 	);
 }
📝 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 DeleteDiscountButton({
discountId,
onDelete,
}: DeleteDiscountButtonProps) {
const router = useRouter();
const [loading, setLoading] = useState(false);
const handleDelete = async () => {
if (!confirm("Are you sure you want to delete this discount?")) {
return;
}
setLoading(true);
const result = await onDelete(discountId);
setLoading(false);
if (result.success) {
router.refresh();
}
};
return (
<Button
variant="ghost"
size="icon-sm"
onClick={handleDelete}
disabled={loading}
className="text-destructive hover:text-destructive"
>
{loading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="h-4 w-4" />
)}
</Button>
);
}
export function DeleteDiscountButton({
discountId,
onDelete,
}: DeleteDiscountButtonProps) {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [error, setError] = useState(false);
const handleDelete = async () => {
if (!confirm("Are you sure you want to delete this discount?")) {
return;
}
setLoading(true);
setError(false);
try {
const result = await onDelete(discountId);
if (result.success) {
router.refresh();
} else {
setError(true);
}
} catch {
setError(true);
} finally {
setLoading(false);
}
};
return (
<span title={error ? "Failed to delete discount" : undefined}>
<Button
variant="ghost"
size="icon-sm"
onClick={handleDelete}
disabled={loading}
className={cn("text-destructive hover:text-destructive", error && "ring-2 ring-destructive")}
>
{loading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="h-4 w-4" />
)}
</Button>
</span>
);
}
🤖 Prompt for AI Agents
In `@apps/admin/src/components/discount-form.tsx` around lines 345 - 381, The
DeleteDiscountButton's handleDelete can leave loading true if onDelete throws
and provides no user feedback on failure; wrap the await onDelete(discountId)
call in try/catch/finally inside handleDelete (use finally to always call
setLoading(false)), handle both thrown errors and result.success === false by
setting an error state (e.g., error message string via useState) and surface it
to the user (toast or inline message), and only call router.refresh() on
success; reference DeleteDiscountButton, handleDelete, setLoading, onDelete, and
router.refresh when implementing these changes.

steebchen and others added 5 commits February 6, 2026 20:13
- Remove overflow-hidden from CommandGroup that was blocking scroll
- Increase max-height of CommandList for better usability
- Add overflow-x-hidden to prevent horizontal scroll

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
The overflow-hidden on the Command wrapper was preventing the
CommandList scroll from working properly. Scroll is handled by
CommandList itself.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Simplify CommandList overflow to just overflow-auto
- Add explicit max-h-[200px] on CommandList in discount form
- Ensure scroll container has proper constraints

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Force overflow:auto on [cmdk-list] and overflow:visible on [cmdk-group]
to fix scrolling issues in Command components.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@apps/admin/src/components/discount-form.tsx`:
- Around line 203-206: The live preview uses discountPercent directly and can
render negative or >100 values; parse discountPercent
(parseFloat(discountPercent) || 0), compute pay = 100 - parsed, then clamp pay
to the 0–100 range (e.g., payClamped = Math.max(0, Math.min(100, pay))) and use
payClamped in the JSX text instead of the raw calculation so previews never show
nonsensical negative or >100 percentages; keep formatting (e.g., toFixed or
rounding) as desired.

Comment on lines +203 to +206
<p className="text-xs text-muted-foreground">
Customer pays {100 - (parseFloat(discountPercent) || 0)}% of the
original price
</p>

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

Live preview can display nonsensical negative percentages.

If the user types a value > 100 (e.g. "150"), this renders "Customer pays -50% of the original price" before submission validation kicks in. Clamp the preview value:

Proposed fix
 					<p className="text-xs text-muted-foreground">
-						Customer pays {100 - (parseFloat(discountPercent) || 0)}% of the
-						original price
+						Customer pays {Math.max(0, Math.min(100, 100 - (parseFloat(discountPercent) || 0)))}% of the
+						original price
 					</p>
📝 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
<p className="text-xs text-muted-foreground">
Customer pays {100 - (parseFloat(discountPercent) || 0)}% of the
original price
</p>
<p className="text-xs text-muted-foreground">
Customer pays {Math.max(0, Math.min(100, 100 - (parseFloat(discountPercent) || 0)))}% of the
original price
</p>
🤖 Prompt for AI Agents
In `@apps/admin/src/components/discount-form.tsx` around lines 203 - 206, The live
preview uses discountPercent directly and can render negative or >100 values;
parse discountPercent (parseFloat(discountPercent) || 0), compute pay = 100 -
parsed, then clamp pay to the 0–100 range (e.g., payClamped = Math.max(0,
Math.min(100, pay))) and use payClamped in the JSX text instead of the raw
calculation so previews never show nonsensical negative or >100 percentages;
keep formatting (e.g., toFixed or rounding) as desired.

smakosh and others added 3 commits February 6, 2026 22:10
…scounts

# Conflicts:
#	apps/admin/src/app/organizations/[orgId]/page.tsx
#	apps/admin/src/lib/api/v1.d.ts
#	apps/api/src/routes/admin.ts
#	apps/code/src/lib/api/v1.d.ts
#	apps/playground/src/lib/api/v1.d.ts
#	apps/ui/src/lib/api/v1.d.ts
…t migration

Will regenerate migrations for the discount table.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Create discount table with:
- Unique constraint on org + provider + model combination
- Indexes for organization_id, provider, and model
- Foreign key to organization with cascade delete

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 1

Caution

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

⚠️ Outside diff range comments (1)
apps/gateway/src/lib/costs.spec.ts (1)

296-386: ⚠️ Potential issue | 🔴 Critical

Four tests are missing async/await, causing the pipeline failures.

calculateCosts is now async, but these four tests still call it synchronously. The result is a Promise, so property access like .imageInputTokens fails at compile time — exactly matching both CI and e2e pipeline errors at line 312.

🐛 Proposed fix: add async/await to the remaining tests
-	it("should track image input tokens and costs separately", () => {
+	it("should track image input tokens and costs separately", async () => {
 		// Test with gemini-3-pro-image-preview which has imageInputPrice
-		const result = calculateCosts(
+		const result = await calculateCosts(
 			"gemini-3-pro-image-preview",
 			"google-ai-studio",
 			1000, // text prompt tokens
@@ -323,9 +323,9 @@
 	});
 
-	it("should track image output tokens and costs separately", () => {
+	it("should track image output tokens and costs separately", async () => {
 		// Test with gemini-3-pro-image-preview for image output
-		const result = calculateCosts(
+		const result = await calculateCosts(
 			"gemini-3-pro-image-preview",
 			"google-ai-studio",
 			1000, // text prompt tokens
@@ -351,8 +351,8 @@
 	});
 
-	it("should return null for all image fields when no images", () => {
-		const result = calculateCosts("gpt-4", "openai", 100, 50, null);
+	it("should return null for all image fields when no images", async () => {
+		const result = await calculateCosts("gpt-4", "openai", 100, 50, null);
 
 		expect(result.imageInputTokens).toBeNull();
@@ -360,9 +360,9 @@
 	});
 
-	it("should include image costs in totalCost sum", () => {
+	it("should include image costs in totalCost sum", async () => {
 		// Test that totalCost = inputCost + outputCost + cachedInputCost + requestCost + webSearchCost + imageInputCost + imageOutputCost
-		const result = calculateCosts(
+		const result = await calculateCosts(
 			"gemini-3-pro-image-preview",
 			"google-ai-studio",
🤖 Fix all issues with AI agents
In `@packages/db/src/schema.ts`:
- Around line 1157-1158: The comment and name for discountPercent are
inconsistent (comment says 0–1 while name/PR expect 0–100) which can break cost
math; pick and enforce one convention across schema, validation and consumers:
either rename discountPercent to discountFraction and keep decimal 0–1
semantics, or keep discountPercent and change the type/validation to store 0–100
and update consumers. Update the schema field (discountPercent) and its comment
to match the chosen convention, adjust any validation functions (e.g.,
getEffectiveDiscount or validators that check 0–100 vs 0–1), and update all
usages found by the ripgrep command so calculations multiply/divide correctly
(e.g., dividing by 100 only when using percent values). Ensure tests/validators
reflect the final convention.
🧹 Nitpick comments (1)
packages/db/migrations/1770629650_jazzy_millenium_guard.sql (1)

8-8: Consider adding a CHECK constraint on discount_percent.

There's no database-level guard against out-of-range values. If app-level validation is bypassed (e.g., direct DB access, admin scripts, future code paths), invalid discounts could silently corrupt cost calculations. A CHECK constraint is cheap insurance:

Proposed fix
-	"discount_percent" numeric NOT NULL,
+	"discount_percent" numeric NOT NULL CHECK ("discount_percent" >= 0 AND "discount_percent" <= 100),

Adjust the upper bound to 1 if the intended range is 0–1 rather than 0–100 (see the ambiguity noted in the schema review).

Comment thread packages/db/src/schema.ts
Comment on lines +1157 to +1158
// Discount value (0-1, where 0.3 = 30% off, user pays 70%)
discountPercent: decimal().notNull(),

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

Ambiguous discountPercent semantics: comment says 0–1 but column name implies 0–100.

Line 1157 documents the value as "0-1, where 0.3 = 30% off," but the column is named discountPercent (suggesting a percentage like 30 for 30%). The PR objectives state validation enforces "0–100%." If consumers interpret this inconsistently, cost calculations will be off by a factor of 100. Clarify and align the comment, column name, and all consumers on a single convention.

#!/bin/bash
# Check how discountPercent is used in cost calculations and validation
rg -n "discountPercent\|discount_percent\|getEffectiveDiscount" --type ts -C 3
🤖 Prompt for AI Agents
In `@packages/db/src/schema.ts` around lines 1157 - 1158, The comment and name for
discountPercent are inconsistent (comment says 0–1 while name/PR expect 0–100)
which can break cost math; pick and enforce one convention across schema,
validation and consumers: either rename discountPercent to discountFraction and
keep decimal 0–1 semantics, or keep discountPercent and change the
type/validation to store 0–100 and update consumers. Update the schema field
(discountPercent) and its comment to match the chosen convention, adjust any
validation functions (e.g., getEffectiveDiscount or validators that check 0–100
vs 0–1), and update all usages found by the ripgrep command so calculations
multiply/divide correctly (e.g., dividing by 100 only when using percent
values). Ensure tests/validators reflect the final convention.

steebchen and others added 3 commits February 9, 2026 09:42
- Update discount form to select provider first, then show filtered models
  for that provider based on provider/model mappings
- Update API to return mappings (provider + model name combinations)
  instead of just root models
- Update validation to check that model is valid for selected provider
- Update discount helper to support matching both root model ID and
  provider-specific model name
- Pass provider model name to getEffectiveDiscount for matching
- Fix async/await in cost calculation tests

This allows configuring discounts for specific provider/model combinations
rather than just root models, enabling more granular pricing control.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@steebchen
steebchen enabled auto-merge February 9, 2026 10:17
@steebchen
steebchen added this pull request to the merge queue Feb 9, 2026
Merged via the queue into main with commit 4501295 Feb 9, 2026
24 of 26 checks passed
@steebchen
steebchen deleted the add-provider-model-discounts branch February 9, 2026 10:33
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.

3 participants