feat(admin): add provider/model discount management - #1606
Conversation
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>
|
Note Reviews pausedIt 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 Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
WalkthroughThis 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
Sequence DiagramssequenceDiagram
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
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
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
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
discounttable (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.
| 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); | ||
| }); |
There was a problem hiding this comment.
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.
| 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 }); | ||
| }); |
There was a problem hiding this comment.
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.
| if (!hasAuth) { | ||
| console.log("[admin-discounts] No session cookie found"); | ||
| } |
There was a problem hiding this comment.
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).
| if (!hasAuth) { | |
| console.log("[admin-discounts] No session cookie found"); | |
| } |
| 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 }); |
There was a problem hiding this comment.
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.
| <div className="space-y-2"> | ||
| <Label htmlFor="provider">Provider</Label> | ||
| <Select value={provider} onValueChange={setProvider}> | ||
| <SelectTrigger className="w-full"> |
There was a problem hiding this comment.
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.
| <SelectTrigger className="w-full"> | |
| <SelectTrigger id="provider" className="w-full"> |
| // 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, | ||
| ), |
There was a problem hiding this comment.
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.
| "expires_at" timestamp, | ||
| CONSTRAINT "discount_org_provider_model_unique" UNIQUE("organization_id","provider","model") | ||
| ); | ||
| --> statement-breakpoint |
There was a problem hiding this comment.
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).
| "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", '') | |
| ); |
| 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(), | ||
| }); |
There was a problem hiding this comment.
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.
| 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(), | ||
| }); |
There was a problem hiding this comment.
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).
| 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(); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
discountexceeds 1 (due to a data entry mistake or a mismatch in stored range),discountMultipliergoes 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 theorganizationIdparameter or mockgetEffectiveDiscount.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 mocksgetEffectiveDiscountto verify:
- An org-specific discount is applied when
organizationIdis passed.- The
discountfield appears in the result with the correct value.This would catch regressions in the integration between
calculateCostsand the discount resolution layer.packages/db/migrations/1770379067_confused_magdalene.sql (1)
8-8: Add a CHECK constraint ondiscount_percentto 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: ExtracthasSessioninto a shared utility to eliminate duplication.This function is duplicated identically in both
admin-discounts.tsandadmin-organizations.ts(with only module-specific console.log messages differing). Move it toauth-client.tsorutils.tsand import it in both modules to reduce code duplication and ensure consistent session validation across admin modules.
| 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", | ||
| }; | ||
| } |
There was a problem hiding this comment.
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.
| 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.
| 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", | ||
| }; | ||
| } |
There was a problem hiding this comment.
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.
| 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"); |
There was a problem hiding this comment.
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.
| 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); |
There was a problem hiding this comment.
🛠️ 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.tsRepository: theopenco/llmgateway
Length of output: 426
🏁 Script executed:
# Read the second location mentioned
sed -n '954,968p' apps/api/src/routes/admin.tsRepository: 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.tsRepository: 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.tsRepository: 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 -80Repository: 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.
| 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") |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
cat -n packages/db/migrations/1770379067_confused_magdalene.sqlRepository: theopenco/llmgateway
Length of output: 1031
🏁 Script executed:
rg -n "COALESCE" packages/db/src/schema.ts -B 2 -A 2Repository: theopenco/llmgateway
Length of output: 301
🏁 Script executed:
cat packages/db/package.json | rg -i "postgres|version" -A 1 -B 1Repository: theopenco/llmgateway
Length of output: 129
🏁 Script executed:
rg -n "NULLS NOT DISTINCT" --type sqlRepository: 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 5Repository: theopenco/llmgateway
Length of output: 2120
🏁 Script executed:
find . -name "Dockerfile*" -o -name "docker-compose*" -o -name ".dockerignore" | head -20Repository: theopenco/llmgateway
Length of output: 248
🏁 Script executed:
fd -e yaml -e yml | xargs rg -l "postgres" 2>/dev/null | head -5Repository: theopenco/llmgateway
Length of output: 218
🏁 Script executed:
rg "postgre" packages/db -i -A 2 -B 2 | head -30Repository: 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 -10Repository: theopenco/llmgateway
Length of output: 91
🏁 Script executed:
cat infra/docker-compose.split.yml | rg -A 5 "postgres" -iRepository: 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 -15Repository: theopenco/llmgateway
Length of output: 174
🏁 Script executed:
find packages -name "*.ts" -type f | xargs rg -l "discount" 2>/dev/null | head -10Repository: 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 -20Repository: 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 -30Repository: theopenco/llmgateway
Length of output: 46
🏁 Script executed:
cat packages/db/src/discount-helpers.tsRepository: 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 5Repository: 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_modulesRepository: 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:
- Use
NULLS NOT DISTINCT(PostgreSQL 15+):CONSTRAINT "discount_org_provider_model_unique" UNIQUE NULLS NOT DISTINCT("organization_id","provider","model")
- 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.
| // 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), | ||
| ], |
There was a problem hiding this comment.
🧩 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.tsRepository: 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.tsRepository: 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 tsxRepository: theopenco/llmgateway
Length of output: 91
🏁 Script executed:
# Check the discount table definition (lines 1133-1170)
sed -n '1133,1170p' packages/db/src/schema.tsRepository: theopenco/llmgateway
Length of output: 1372
🏁 Script executed:
# Search for any existing uniqueIndex usage in the codebase
rg "uniqueIndex" --type tsRepository: theopenco/llmgateway
Length of output: 46
🏁 Script executed:
# Search for coalesce patterns
rg "coalesce" --type tsRepository: 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 2Repository: 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 1Repository: 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 -20Repository: 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 -5Repository: 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-coredrizzle-orm/mysql-coredrizzle-orm/sqlite-coredrizzle-orm/singlestore-coredrizzle-orm/mssql-coredrizzle-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.
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>
There was a problem hiding this comment.
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: Usedb.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()ordb().query.<table>.findFirst()"
1015-1021: Remove the type assertion — TypeScript can infer the correct types without it.The
modelsarray is already typed asModelDefinition[], which includesid: string,name?: string, andfamily: 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.idfallback is already type-safe sincem.nameis optional andm.idis always a string.
| 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(), | ||
| }); |
There was a problem hiding this comment.
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(), |
There was a problem hiding this comment.
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.
| 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(); |
There was a problem hiding this comment.
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>
There was a problem hiding this comment.
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 byid, 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) usesvalue={`${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>
| 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"); | ||
| } | ||
| }; |
There was a problem hiding this comment.
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.
| 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> | ||
| ); | ||
| } |
There was a problem hiding this comment.
Same missing try/catch issue, plus no error feedback on failed deletions.
- If
onDeletethrows,setLoading(false)is never called — same stuck-loading bug ashandleSubmit. - When
result.successisfalse, 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.
| 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.
- 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>
…el selection in DiscountForm
There was a problem hiding this comment.
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.
| <p className="text-xs text-muted-foreground"> | ||
| Customer pays {100 - (parseFloat(discountPercent) || 0)}% of the | ||
| original price | ||
| </p> |
There was a problem hiding this comment.
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.
| <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.
…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>
There was a problem hiding this comment.
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 | 🔴 CriticalFour tests are missing
async/await, causing the pipeline failures.
calculateCostsis now async, but these four tests still call it synchronously. The result is aPromise, so property access like.imageInputTokensfails 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 ondiscount_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
1if the intended range is 0–1 rather than 0–100 (see the ambiguity noted in the schema review).
| // Discount value (0-1, where 0.3 = 30% off, user pays 70%) | ||
| discountPercent: decimal().notNull(), |
There was a problem hiding this comment.
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.
- 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>
Summary
Add ability to configure discounts for providers and models at two levels:
Database: new
discounttable with organization_id, provider, model, discount_percent fields and appropriate indexesBackend 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 precedenceAdmin dashboard: new pages for global discounts (
/discounts) and org-specific discounts (/organizations/[orgId]/discounts) with forms and navigationTest plan
🤖 Generated with Claude Code
Summary by CodeRabbit