diff --git a/.claude/skills/verify-debatelab/features/README.md b/.claude/skills/verify-debatelab/features/README.md index 7a136faf8..012a5abbd 100644 --- a/.claude/skills/verify-debatelab/features/README.md +++ b/.claude/skills/verify-debatelab/features/README.md @@ -51,6 +51,7 @@ Each feature file starts with an H1 title and one paragraph describing the user- - [Workspace autosave](./workspace-autosave.md): a slow Crossfire or Sparring save from a Workspace never overwrites newer text (`recipes/workspace-autosave.spec.ts`). Needs `up --collab`; skips without it. - [Account data](./account-data.md): Download my data and Delete account, each proven against a bystander (`recipes/account-data.spec.ts`); Delete All My Data scoped against a bystander (`e2e/full-stack/account-data.spec.ts`). - Account: output language reaching the model from a new device, flowchart node delete (at once, with an `Undo` toast; no confirm dialog), save and load, and the speech timer (`R` resets only while paused) (`recipes/account.spec.ts`). +- [Usage limits](./usage-limits.md): Opus refused when its weight does not fit today while Haiku still runs, and the weekly AI budget dialog (`recipes/usage-limits.spec.ts`). - Billing: expired account blocked, promo code restores access (`recipes/billing.spec.ts`); signed, forged, and replayed Stripe webhooks (`e2e/full-stack/billing.spec.ts`). - Admin announcements and the non-admin block (`e2e/full-stack/admin.spec.ts`), public share links (`e2e/full-stack/public.spec.ts`), and Workspaces with live collaboration and invites (`e2e/workspace-collaboration.spec.ts`). diff --git a/.claude/skills/verify-debatelab/features/usage-limits.md b/.claude/skills/verify-debatelab/features/usage-limits.md new file mode 100644 index 000000000..45c8d46d3 --- /dev/null +++ b/.claude/skills/verify-debatelab/features/usage-limits.md @@ -0,0 +1,25 @@ +# Usage limits: weighted daily quota and weekly AI budget + +The daily AI allowance counts units, not requests. An expensive model uses several units per request (Claude Opus 5.5 counts as 5, Sonnet 5.5 as 2, cheap models as 1), so a request that does not fit in what is left today is refused with a message that names the model's weight and the units left, while a lighter model still runs. Separately, once a user's AI spend for the current week (from Monday 00:00 UTC) reaches the weekly budget ($2 by default), every AI request is refused with its own dialog until Monday. Only `ai_requests.cap_cost_usd` counts: OpenRouter credit charges plus Exa and ElevenLabs; calls billed to our own BYOK key add only OpenRouter's fee. The weights, the budget and the tier defaults live in `supabase/functions/_shared/usage-limits.ts`; the reservation is `consume_ai_quota` in `supabase/migrations/20261022130001_weighted_quota_weekly_budget.sql`. + +## Sub-features + +- `usage-weighted`: with 4 units left, Opus is refused (`Daily Access Paused`, "This model counts as 5 uses, and you have 4 left today.") without a provider call or a counter change; switching to Claude Haiku 5.5 runs and uses 1 unit. +- `usage-weekly`: with `ai_requests.cap_cost_usd` for this week at or above the budget, the next request opens `Weekly AI Allowance Used` with an `OK` button and no upgrade offer, makes no provider call, and leaves the daily counter alone. + +- `usage-weekly-inline`: Sparring shows refusals inline rather than in the dialog. With the budget reached, the opponent's turn shows the translated weekly message from the catalog, not the server's English reason, and no provider call is made. + +## How to get to it (user POV) + +- Any AI tool, for example `/app/generate`, once the day's allowance or the week's budget is used up. The dialog opens on submit. + +## Driving it with verify.sh + Playwright + +- `bin/verify.sh drive recipes/usage-limits` covers the dialog sub-features in one test and the Sparring inline message in a second. +- The recipe sets `daily_limit_override: 7` on the demo user and inserts a `daily_usage_counters` row (`quota_key: "ai"`, today's UTC date, `request_count: 3`), then inserts an `ai_requests` row with `cap_cost_usd: 2` for the weekly step. +- End states are read from `daily_usage_counters.request_count` and from the recorded provider calls. + +## Gotchas + +- Demo users are Pro with Claude Opus 5.5 as the default model, so every Generate in other recipes uses 5 units of their 999. +- The weekly sum counts `cap_cost_usd` of every `ai_requests` row since Monday 00:00 UTC, including voice and Jev routing rows; NULL (unknown or logged before the column existed) counts as nothing, and `cost_usd` alone never counts. Cost is logged after the provider answers, so the cap can be passed by about one request. diff --git a/.claude/skills/verify-debatelab/recipes/usage-limits.spec.ts b/.claude/skills/verify-debatelab/recipes/usage-limits.spec.ts new file mode 100644 index 000000000..b3cd94846 --- /dev/null +++ b/.claude/skills/verify-debatelab/recipes/usage-limits.spec.ts @@ -0,0 +1,98 @@ +import type { Page } from "@playwright/test"; +import { admin, AI_TIMEOUT, aiResponse, chooseForMotion, expect, providerCalls, startDebate, test } from "./fixtures"; + +// Feature map: features/usage-limits.md. +const motion = "This House would make public transport free at the point of use"; + +async function openGenerate(page: Page) { + await startDebate(page, motion); + await page.goto("/app/generate"); + await expect(page.getByRole("heading", { name: "Ready to Generate" })).toBeVisible(); +} + +async function aiCounter(userId: string): Promise { + const { data, error } = await admin.from("daily_usage_counters").select("request_count") + .eq("user_id", userId).eq("quota_key", "ai").eq("usage_date", new Date().toISOString().slice(0, 10)).maybeSingle(); + if (error) throw error; + return data?.request_count ?? 0; +} + +async function generateCalls(): Promise { + return (await providerCalls()).filter((call) => call.feature === "generate").length; +} + +test("usage-weighted: Opus counts as 5 requests, a lighter model still runs, and the weekly cap stops everything", async ({ signedIn: page, user, evidence }) => { + const userId = await user.userId(); + // Demo users are Pro on Claude Opus 5.5 with a limit of 999. Leave room for + // 4 more units today: too few for Opus, enough for Haiku. + await admin.from("user_settings").update({ daily_limit_override: 7 }).eq("user_id", userId); + const { error: seedError } = await admin.from("daily_usage_counters").insert({ + user_id: userId, usage_date: new Date().toISOString().slice(0, 10), quota_key: "ai", request_count: 3, + }); + if (seedError) throw seedError; + + await openGenerate(page); + const selector = page.getByRole("combobox", { name: "Select AI model for generate" }); + await expect(selector).toContainText("Claude Opus 5.5"); + await page.getByRole("button", { name: "Generate Debate Case" }).click(); + const daily = page.getByRole("dialog"); + await expect(daily.getByText("Daily Access Paused")).toBeVisible({ timeout: AI_TIMEOUT }); + await expect(daily.getByText("This model counts as 5 uses, and you have 4 left today.", { exact: false })).toBeVisible(); + await evidence.shot("01-opus-weighted-refusal"); + expect(await generateCalls()).toBe(0); + expect(await aiCounter(userId)).toBe(3); + await evidence.step("Opus refused with 4 units left; no provider call; counter unchanged at 3"); + await daily.getByRole("button", { name: "Not now" }).click(); + + await selector.click(); + await page.getByRole("option", { name: /Claude Haiku 5\.5/ }).click(); + const response = aiResponse(page, "generate"); + await page.getByRole("button", { name: "Generate Debate Case" }).click(); + await response; + expect(await generateCalls()).toBe(1); + expect(await aiCounter(userId)).toBe(4); + await evidence.shot("02-haiku-runs"); + await evidence.step("Haiku ran and used 1 unit (counter 4)"); + + // Reach the weekly budget with cap spend logged earlier this week. + const { error: spendError } = await admin.from("ai_requests").insert({ + user_id: userId, feature: "generate", provider: "openrouter", model: "anthropic/claude-opus-5.5", status: "success", cost_usd: 2, cap_cost_usd: 2, + }); + if (spendError) throw spendError; + await page.getByRole("button", { name: "Generate Debate Case" }).click(); + const weekly = page.getByRole("dialog"); + await expect(weekly.getByText("Weekly AI Allowance Used")).toBeVisible({ timeout: AI_TIMEOUT }); + await expect(weekly.getByText("It refreshes on Monday at 00:00 UTC.", { exact: false })).toBeVisible(); + await evidence.shot("03-weekly-cap"); + expect(await generateCalls()).toBe(1); + expect(await aiCounter(userId)).toBe(4); + await evidence.step("Weekly cap refused the request; no provider call; daily counter unchanged"); + await weekly.getByRole("button", { name: "OK" }).click(); + await expect(weekly).toHaveCount(0); +}); + +test("usage-weekly-inline: Sparring shows the translated weekly-cap message, not the server's English reason", async ({ signedIn: page, user, evidence }) => { + const userId = await user.userId(); + const { error } = await admin.from("ai_requests").insert({ + user_id: userId, feature: "sparring", provider: "openrouter", model: "anthropic/claude-opus-5.5", status: "success", cost_usd: 2, cap_cost_usd: 2, + }); + if (error) throw error; + + await page.goto("/app/sparring"); + await page.getByLabel("Motion").fill(motion); + await page.getByRole("combobox", { name: "Round format" }).click(); + await page.getByRole("option", { name: /Lincoln-Douglas · 5 speeches/ }).click(); + await page.getByRole("button", { name: "Start round" }).click(); + await chooseForMotion(page, "create"); + const draft = page.getByRole("textbox", { name: "Draft for Affirmative constructive" }); + await expect(draft).toBeVisible({ timeout: AI_TIMEOUT }); + await draft.fill("Universal access removes the direct price barrier to work, school, and civic life."); + await page.getByRole("button", { name: "Submit speech" }).click(); + + // The client's catalog copy ("You've used ...") differs from the server reason ("You've reached ..."). + await expect(page.getByText("You've used this week's AI allowance.", { exact: false })).toBeVisible({ timeout: AI_TIMEOUT }); + await expect(page.getByText("You've reached this week's AI usage allowance", { exact: false })).toHaveCount(0); + await evidence.shot("04-sparring-weekly-cap"); + expect((await providerCalls()).filter((call) => call.feature === "sparring")).toEqual([]); + await evidence.step("Sparring opponent turn refused by the weekly cap with the translated message; no provider call"); +}); diff --git a/docs/architecture.md b/docs/architecture.md index 1a04234c5..c823f7885 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -146,6 +146,8 @@ Every model call asks for JSON. Calls whose shape the server checks in code (the `Auto Fast` and `Auto Pro` are virtual selections, not provider model IDs. The Edge Functions resolve them through TypeSafe Jev on OpenRouter before calling the concrete writing model. Auto Fast is available to Basic accounts, routes only among the Basic models (Claude Haiku 5.5, GPT-6 Luna, Muse Spark 1.3 Contributor), and normally resolves to Claude Haiku 5.5. Auto Pro remains Pro-only, routes between Claude Sonnet 5.5 and GPT-6.1 Sol, and normally resolves to Claude Sonnet 5.5; it never routes or falls back to Opus. Provider calls, usage rows, and response metadata record the concrete resolved model, while response metadata also retains the requested automatic selection. A Jev router call uses the same OpenRouter key and does not consume a second daily quota unit. +The daily AI quota counts units, not requests. `checkRateLimit` in [`supabase/functions/_shared/entitlements.ts`](../supabase/functions/_shared/entitlements.ts) reserves the requested model's weight through `consume_ai_quota` (Claude Opus 5.5 counts as 5, Sonnet as 2, cheap models as 1; Auto Pro counts as its most expensive candidate because the quota is reserved before routing). The same call refuses the request with `weekly_budget_reached` once the user's weekly AI spend reaches the weekly budget ($2 by default, calendar week from Monday 00:00 UTC). Weekly spend is the sum of `ai_requests.cap_cost_usd`: what OpenRouter charged against our credits plus Exa and ElevenLabs charges. A call billed to our own BYOK provider key (Claude on the Anthropic plan) adds only OpenRouter's fee, usually 0, while a Claude call that falls back to OpenRouter credits counts in full. When part of a request's cost is unknown, the known part counts; rows with no known cap spend, and rows logged before the column existed, do not count. Cost is logged after the provider answers, so the cap can be passed by about one request. The weights, the budget, and the tier defaults live in [`supabase/functions/_shared/usage-limits.ts`](../supabase/functions/_shared/usage-limits.ts). Voice, import, share, and Crossfire signal quotas keep calling `consume_daily_quota`, one unit per call, and are not gated by the weekly budget (voice spend still counts toward it). + ## Observability Client startup loads Sentry and PostHog asynchronously when configured. Product events and exceptions use the privacy-filtering facade in [`src/lib/observability.ts`](../src/lib/observability.ts), so SDK loading or failure cannot block rendering. Sentry events carry the build's commit SHA as the release and the Vercel environment name. Route crashes keep the original error type and stack frames but replace the message, which can carry user text. Vercel Analytics and Speed Insights run only in the web build and load outside the application render boundary. diff --git a/src/components/features/subscription/LimitReachedModal.tsx b/src/components/features/subscription/LimitReachedModal.tsx index 5baa331ae..4776191f3 100644 --- a/src/components/features/subscription/LimitReachedModal.tsx +++ b/src/components/features/subscription/LimitReachedModal.tsx @@ -3,23 +3,36 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } f import { Button } from '@/components/ui/button'; import { AlertTriangle, Zap, Clock } from 'lucide-react'; import { useSubscription } from '@/hooks/use-subscription'; +import { subscriptionLimitMessage } from '@/lib/ai/error-display'; + +export type LimitModalVariant = 'limit_reached' | 'trial_expired' | 'weekly_budget_reached'; interface LimitReachedModalProps { open: boolean; onOpenChange: (open: boolean) => void; - variant: 'limit_reached' | 'trial_expired'; + variant: LimitModalVariant; limit?: number; current?: number; + /** Quota units the refused request needed; above 1 for expensive models. */ + units?: number; onUpgrade: () => void; } -export function LimitReachedModal({ open, onOpenChange, variant, limit, current, onUpgrade }: LimitReachedModalProps) { +export function LimitReachedModal({ open, onOpenChange, variant, limit, current, units, onUpgrade }: LimitReachedModalProps) { const { t } = useTranslation(); const { isPro } = useSubscription(); const isTrialExpired = variant === 'trial_expired'; + const isWeeklyBudget = variant === 'weekly_budget_reached'; // A Pro student who used today's included requests already pays. Selling Pro again is the wrong next step. const paidDailyCap = isPro && !isTrialExpired; + const title = isTrialExpired + ? t('limitModal.trialEnded') + : isWeeklyBudget + ? t('limitModal.weeklyReached') + : t('limitModal.limitReached'); + const description = subscriptionLimitMessage({ code: variant, limit, current, units }); + return ( @@ -31,20 +44,15 @@ export function LimitReachedModal({ open, onOpenChange, variant, limit, current, )} - - {isTrialExpired ? t('limitModal.trialEnded') : t('limitModal.limitReached')} - - - {isTrialExpired - ? t('limitModal.trialEndedDesc') - : t('limitModal.limitReachedDesc', { current: current || 0, limit: limit || 0 })} - + {title} + {description}
- {paidDailyCap ? ( + {isWeeklyBudget || paidDailyCap ? ( + // Upgrading does not lift the spend cap, and a paid plan already has Pro. ) : ( <> diff --git a/src/hooks/use-subscription-error.ts b/src/hooks/use-subscription-error.ts index 403788c0a..7e915b10e 100644 --- a/src/hooks/use-subscription-error.ts +++ b/src/hooks/use-subscription-error.ts @@ -1,11 +1,13 @@ import { useState, useCallback } from 'react'; import { isAISubscriptionError, isAIModelRestrictionError, type AISubscriptionError } from '@/lib/ai/client'; +import type { LimitModalVariant } from '@/components/features/subscription/LimitReachedModal'; interface SubscriptionErrorState { limitModalOpen: boolean; - limitModalVariant: 'limit_reached' | 'trial_expired'; + limitModalVariant: LimitModalVariant; limitModalLimit?: number; limitModalCurrent?: number; + limitModalUnits?: number; modelModalOpen: boolean; pricingModalOpen: boolean; setLimitModalOpen: (open: boolean) => void; @@ -20,22 +22,24 @@ interface SubscriptionErrorState { */ export function useSubscriptionError(): SubscriptionErrorState { const [limitModalOpen, setLimitModalOpen] = useState(false); - const [limitModalVariant, setLimitModalVariant] = useState<'limit_reached' | 'trial_expired'>('limit_reached'); + const [limitModalVariant, setLimitModalVariant] = useState('limit_reached'); const [limitModalLimit, setLimitModalLimit] = useState(); const [limitModalCurrent, setLimitModalCurrent] = useState(); + const [limitModalUnits, setLimitModalUnits] = useState(); const [modelModalOpen, setModelModalOpen] = useState(false); const [pricingModalOpen, setPricingModalOpen] = useState(false); const handleSubscriptionError = useCallback((error: unknown): boolean => { if (isAISubscriptionError(error)) { const subError = error as AISubscriptionError; - if (subError.code === 'trial_expired') { - setLimitModalVariant('trial_expired'); + if (subError.code === 'trial_expired' || subError.code === 'weekly_budget_reached') { + setLimitModalVariant(subError.code); } else { setLimitModalVariant('limit_reached'); } setLimitModalLimit(subError.limit); setLimitModalCurrent(subError.current); + setLimitModalUnits(subError.units); setLimitModalOpen(true); return true; } @@ -53,6 +57,7 @@ export function useSubscriptionError(): SubscriptionErrorState { limitModalVariant, limitModalLimit, limitModalCurrent, + limitModalUnits, modelModalOpen, pricingModalOpen, setLimitModalOpen, diff --git a/src/lib/ai/error-display.ts b/src/lib/ai/error-display.ts index 2e92efc61..17ab03524 100644 --- a/src/lib/ai/error-display.ts +++ b/src/lib/ai/error-display.ts @@ -1,5 +1,6 @@ import i18n from '@/i18n'; import { AIProviderError, isAISubscriptionError } from './client'; +import { subscriptionLimitMessage } from './limit-message'; const PROVIDER_LABELS: Record = { openai: 'OpenAI', @@ -16,12 +17,11 @@ function providerLabel(provider?: string, message = ''): string { return i18n.t('aiErrors.genericProvider', 'AI provider'); } +/** The limit dialog's explanation, also used where a page shows the error inline. */ +export { subscriptionLimitMessage }; + export function getUserFacingAIError(error: unknown): string { - // Out of today's requests: the limit dialog's own words, not the server's English. - if (isAISubscriptionError(error)) { - if (error.code === 'trial_expired') return i18n.t('limitModal.trialEndedDesc'); - return i18n.t('limitModal.limitReachedDesc', { current: error.current ?? 0, limit: error.limit ?? 0 }); - } + if (isAISubscriptionError(error)) return subscriptionLimitMessage(error); if (error instanceof AIProviderError || (error instanceof Error && error.name === 'AIProviderError')) { const provider = providerLabel((error as AIProviderError).provider, error.message); diff --git a/src/lib/ai/errors.ts b/src/lib/ai/errors.ts index 2801609c8..7f01104ca 100644 --- a/src/lib/ai/errors.ts +++ b/src/lib/ai/errors.ts @@ -1,3 +1,5 @@ +import { localizedLimitMessage } from './limit-message'; + /** * Base class for AI-related errors with structured information. */ @@ -56,16 +58,19 @@ export class AISubscriptionError extends AIError { readonly code?: string; readonly limit?: number; readonly current?: number; + /** Quota units the refused request needed; above 1 for expensive models. */ + readonly units?: number; constructor( message: string = 'Subscription required.', - options?: { statusCode?: number; requestId?: string; code?: string; limit?: number; current?: number } + options?: { statusCode?: number; requestId?: string; code?: string; limit?: number; current?: number; units?: number } ) { super(message, { statusCode: options?.statusCode ?? 402, requestId: options?.requestId }); this.name = 'AISubscriptionError'; this.code = options?.code; this.limit = options?.limit; this.current = options?.current; + this.units = options?.units; } } @@ -227,14 +232,13 @@ export function mapAIError( } if (statusCode === 402) { - const code = typeof payload?.code === 'string' ? payload.code : undefined; - return new AISubscriptionError(message, { - statusCode, - requestId, - code, + const details = { + code: typeof payload?.code === 'string' ? payload.code : undefined, limit: typeof payload?.limit === 'number' ? payload.limit : undefined, current: typeof payload?.current === 'number' ? payload.current : undefined, - }); + units: typeof payload?.units === 'number' ? payload.units : undefined, + }; + return new AISubscriptionError(localizedLimitMessage(message, details), { statusCode, requestId, ...details }); } if (statusCode === 403) { diff --git a/src/lib/ai/limit-message.test.ts b/src/lib/ai/limit-message.test.ts new file mode 100644 index 000000000..258c059d1 --- /dev/null +++ b/src/lib/ai/limit-message.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; +import i18n from '@/i18n'; +import { mapAIError, AISubscriptionError } from './errors'; + +describe('quota refusals are translated, not the server reason', () => { + const refuse = (payload: Record) => + mapAIError({ message: 'Edge Function returned a non-2xx status code', context: { status: 402 } }, 'req-1', 'openrouter', 'm', payload); + + it('uses the catalog text for the weekly cap', () => { + const error = refuse({ error: "You've reached this week's AI usage allowance.", code: 'weekly_budget_reached' }); + expect(error).toBeInstanceOf(AISubscriptionError); + expect(error.message).toBe(i18n.t('limitModal.weeklyReachedDesc')); + }); + + it('says a model heavier than the whole daily limit never fits', () => { + const error = refuse({ error: 'This model counts as 5 requests', code: 'limit_reached', limit: 3, current: 0, units: 5 }); + expect(error.message).toBe(i18n.t('limitModal.weightedTooLargeDesc', { units: 5, limit: 3 })); + expect(error.message).not.toMatch(/midnight/); + }); + + it('keeps the weighted message when lighter requests still fit', () => { + const error = refuse({ error: 'x', code: 'limit_reached', limit: 50, current: 47, units: 5 }); + expect(error.message).toBe(i18n.t('limitModal.weightedDesc', { units: 5, remaining: 3 })); + }); + + it('keeps the server text when the refusal carries no code', () => { + expect(refuse({ error: 'Server text' }).message).toBe('Server text'); + }); +}); diff --git a/src/lib/ai/limit-message.ts b/src/lib/ai/limit-message.ts new file mode 100644 index 000000000..9625a0d20 --- /dev/null +++ b/src/lib/ai/limit-message.ts @@ -0,0 +1,32 @@ +import i18n from '@/i18n'; + +export interface LimitDetails { + code?: string; + limit?: number; + current?: number; + units?: number; +} + +/** + * The translated explanation of a quota refusal. AISubscriptionError carries it + * as its message, so a page that shows error.message directly is translated too; + * the server's own reason is English only. + */ +export function subscriptionLimitMessage({ code, limit, current, units }: LimitDetails): string { + if (code === 'trial_expired') return i18n.t('limitModal.trialEndedDesc'); + if (code === 'weekly_budget_reached') return i18n.t('limitModal.weeklyReachedDesc'); + const weight = units ?? 1; + // A model heavier than the whole daily limit never fits, so waiting does not help. + if (weight > 1 && typeof limit === 'number' && weight > limit) { + return i18n.t('limitModal.weightedTooLargeDesc', { units: weight, limit }); + } + const remaining = Math.max(0, (limit || 0) - (current || 0)); + // A weighted model can be refused while lighter requests still fit today. + if (weight > 1 && remaining > 0) return i18n.t('limitModal.weightedDesc', { units: weight, remaining }); + return i18n.t('limitModal.limitReachedDesc', { current: current || 0, limit: limit || 0 }); +} + +/** Translated text for a refusal the server tagged with a code; otherwise the server's text. */ +export function localizedLimitMessage(serverMessage: string, details: LimitDetails): string { + return details.code ? subscriptionLimitMessage(details) : serverMessage; +} diff --git a/src/lib/ai/request.ts b/src/lib/ai/request.ts index 7f4187fc3..9d67305d8 100644 --- a/src/lib/ai/request.ts +++ b/src/lib/ai/request.ts @@ -10,6 +10,7 @@ import { toast } from '@/hooks/use-toast'; import { captureException, captureProductEvent } from '@/lib/observability'; import { isAppLanguage, type AppLanguage } from '@/lib/languages'; import i18n from '@/i18n'; +import { localizedLimitMessage } from './limit-message'; import type { WorkspaceToolContext } from '@/lib/workspace/tool-context'; import { AIError, @@ -254,14 +255,14 @@ export async function callSupabaseAI( const errorCode = data.code as string | undefined; // Subscription errors (402 / trial expired / limit reached) - if (errorCode === 'trial_expired' || errorCode === 'limit_reached') { - throw new AISubscriptionError(errorMessage, { - statusCode: 402, - requestId, + if (errorCode === 'trial_expired' || errorCode === 'limit_reached' || errorCode === 'weekly_budget_reached') { + const details = { code: errorCode, - limit: data.limit as number | undefined, - current: data.current as number | undefined, - }); + limit: typeof data.limit === 'number' ? data.limit : undefined, + current: typeof data.current === 'number' ? data.current : undefined, + units: typeof data.units === 'number' ? data.units : undefined, + }; + throw new AISubscriptionError(localizedLimitMessage(errorMessage, details), { statusCode: 402, requestId, ...details }); } // Model restriction (403 / upgrade_required) diff --git a/src/locales/en.json b/src/locales/en.json index 970af0791..fc2aa04a9 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1344,6 +1344,11 @@ "trialEndedDesc": "Your 3-day free trial has ended. Upgrade to Pro to keep using DebateLab.", "limitReached": "Daily Access Paused", "limitReachedDesc": "You've used {{current}} of {{limit}} included AI uses today. Access refreshes at midnight UTC.", + "weightedDesc": "This model counts as {{units}} uses, and you have {{remaining}} left today. Pick a lighter model or wait until midnight UTC.", + "weightedTooLargeDesc": "This model counts as {{units}} uses, more than your daily limit of {{limit}}. Pick a lighter model.", + "weeklyReached": "Weekly AI Allowance Used", + "weeklyReachedDesc": "You've used this week's AI allowance. It refreshes on Monday at 00:00 UTC. Contact support if you need more.", + "ok": "OK", "upgrade": "Upgrade to Pro - $5/mo", "upgradeNote": "More AI prep access + high reasoning", "notNow": "Not now", diff --git a/src/locales/es.json b/src/locales/es.json index edbef6926..df08042f8 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -1344,6 +1344,11 @@ "trialEndedDesc": "Tu prueba gratuita de 3 días ha terminado. Actualiza a Pro para seguir usando DebateLab.", "limitReached": "Acceso diario pausado", "limitReachedDesc": "Has usado {{current}} de {{limit}} usos de IA incluidos hoy. El acceso se renueva a medianoche UTC.", + "weightedDesc": "Este modelo cuenta como {{units}} usos y te quedan {{remaining}} hoy. Elige un modelo más ligero o espera a la medianoche UTC.", + "weightedTooLargeDesc": "Este modelo cuenta como {{units}} usos, más que tu límite diario de {{limit}}. Elige un modelo más ligero.", + "weeklyReached": "Asignación semanal de IA agotada", + "weeklyReachedDesc": "Has usado la asignación de IA de esta semana. Se renueva el lunes a las 00:00 UTC. Contacta con soporte si necesitas más.", + "ok": "Entendido", "upgrade": "Actualizar a Pro - $5/mes", "upgradeNote": "Más acceso a preparación con IA + razonamiento alto", "notNow": "Ahora no", diff --git a/src/locales/hu.json b/src/locales/hu.json index ca88508d2..a04bc316f 100644 --- a/src/locales/hu.json +++ b/src/locales/hu.json @@ -1344,6 +1344,11 @@ "trialEndedDesc": "A 3 napos ingyenes próbaidőszakod lejárt. Frissíts Pro-ra a DebateLab további használatához.", "limitReached": "Napi hozzáférés szünetel", "limitReachedDesc": "Ma {{current}} / {{limit}} benne foglalt MI használatot használtál fel. A hozzáférés éjfélkor (UTC) frissül.", + "weightedDesc": "Ez a modell {{units}} használatnak számít, és ma még {{remaining}} maradt. Válassz könnyebb modellt, vagy várj éjfélig (UTC).", + "weightedTooLargeDesc": "Ez a modell {{units}} használatnak számít, ami több, mint a napi {{limit}} kereted. Válassz könnyebb modellt.", + "weeklyReached": "Elfogyott a heti MI keret", + "weeklyReachedDesc": "Felhasználtad az e heti MI keretet. Hétfőn 00:00-kor (UTC) frissül. Ha többre van szükséged, írj az ügyfélszolgálatnak.", + "ok": "Rendben", "upgrade": "Frissítés Pro-ra - $5/hó", "upgradeNote": "Több MI felkészülési hozzáférés + magas szintű érvelés", "notNow": "Most nem", diff --git a/src/pages/AskPage.tsx b/src/pages/AskPage.tsx index 39c233e79..e8075530d 100644 --- a/src/pages/AskPage.tsx +++ b/src/pages/AskPage.tsx @@ -498,6 +498,7 @@ export default function AskPage() { variant={subError.limitModalVariant} limit={subError.limitModalLimit} current={subError.limitModalCurrent} + units={subError.limitModalUnits} onUpgrade={() => { subError.setLimitModalOpen(false); subError.setPricingModalOpen(true); }} /> { subError.setLimitModalOpen(false); subError.setPricingModalOpen(true); }} /> { subError.setLimitModalOpen(false); subError.setPricingModalOpen(true); }} /> { subError.setLimitModalOpen(false); subError.setPricingModalOpen(true); }} /> { subError.setLimitModalOpen(false); subError.setPricingModalOpen(true); }} /> { it('uses one server-only atomic quota reservation from every billable entrypoint', () => { expect(migration).toContain('ON CONFLICT (user_id, usage_date) DO UPDATE'); expect(migration).toContain("auth.role() <> 'service_role'"); - for (const source of [entitlements, importer]) expect(source).toContain('consume_daily_quota'); + expect(entitlements).toContain('consume_ai_quota'); + expect(importer).toContain('consume_daily_quota'); expect(entitlements).toContain('Unable to verify your quota'); expect(entitlements).not.toContain('return { allowed: true }; // Fail open'); expect(entitlements).not.toContain('return { allowed: true };\n } catch'); diff --git a/supabase/functions/_shared/cost.test.ts b/supabase/functions/_shared/cost.test.ts index cc7b0de90..447130574 100644 --- a/supabase/functions/_shared/cost.test.ts +++ b/supabase/functions/_shared/cost.test.ts @@ -211,3 +211,48 @@ Deno.test("ElevenLabs cost fails closed for an unlisted model, missing usage, or if (calculateRequestCost({ provider: "elevenlabs", model: "eleven_v4" }).costUsd !== null) throw new Error("missing usage was priced"); if (calculateRequestCost({ provider: "elevenlabs", model: "eleven_v4", usage: { incomplete: true } }).costUsd !== null) throw new Error("incomplete work was priced"); }); + +Deno.test("the weekly cap counts OpenRouter credit spend, not BYOK provider charges", () => { + // A BYOK Claude call: OpenRouter charges only its fee (0 here); the provider bills our own key. + const byok = normalizeOpenRouterUsage({ prompt_tokens: 1500, completion_tokens: 600, cost: 0, is_byok: true }); + // A Claude call that fell back to OpenRouter credits counts in full. + const credits = normalizeOpenRouterUsage({ prompt_tokens: 1500, completion_tokens: 600, cost: 0.012, is_byok: false }); + const gpt = normalizeOpenRouterUsage({ prompt_tokens: 1000, completion_tokens: 500, cost: 0.004 }); + if (calculateRequestCost({ provider: "openrouter", model: "anthropic/claude-opus-5.5", usage: byok }).capCostUsd !== 0) { + throw new Error("a BYOK call counted toward the cap"); + } + if (calculateRequestCost({ provider: "openrouter", model: "anthropic/claude-opus-5.5", usage: credits }).capCostUsd !== 0.012) { + throw new Error("a credit-billed Claude call did not count"); + } + const merged = calculateRequestCost({ provider: "openrouter", model: "openai/gpt-6-luna", usage: mergeNormalizedUsage([gpt, byok]) }); + if (Math.abs((merged.capCostUsd ?? -1) - 0.004) > 1e-12) throw new Error(`merged cap spend wrong: ${merged.capCostUsd}`); +}); + +Deno.test("a usage with a separate provider charge counts it toward the cap in full", () => { + // Exa-style usage: a known charge with no separate cap figure. + const research = { reportedCostUsd: 0.03 }; + const byok = normalizeOpenRouterUsage({ prompt_tokens: 10, completion_tokens: 10, cost: 0, is_byok: true }); + const merged = mergeNormalizedUsage([byok, research]); + const result = calculateRequestCost({ provider: "openrouter", model: "anthropic/claude-haiku-5.5", usage: merged }); + if (Math.abs((result.capCostUsd ?? -1) - 0.03) > 1e-12) throw new Error(`research charge not counted: ${result.capCostUsd}`); +}); + +Deno.test("the cap counts the known part of a partly unknown request", () => { + const known = normalizeOpenRouterUsage({ prompt_tokens: 10, completion_tokens: 10, cost: 0.5 }); + const unknown = normalizeOpenRouterUsage({ prompt_tokens: 10, completion_tokens: 10 }); + const partial = calculateRequestCost({ provider: "openrouter", model: "x", usage: mergeNormalizedUsage([known, unknown]) }); + if (partial.costUsd !== null) throw new Error("a partly unknown request got a full cost"); + if (partial.capCostUsd !== 0.5) throw new Error(`known part missing from the cap: ${partial.capCostUsd}`); + if (calculateRequestCost({ provider: "openrouter", model: "x", usage: markIncompleteUsage(known) }).capCostUsd !== 0.5) { + throw new Error("cancelled work dropped its known charge from the cap"); + } + if (calculateRequestCost({ provider: "openrouter", model: "x", usage: unknown }).capCostUsd !== null) { + throw new Error("a wholly unknown request got a cap figure"); + } +}); + +Deno.test("ElevenLabs voice cost counts toward the cap", () => { + const usage = normalizeElevenLabsUsage({ characters: 1000, characterCostHeader: "1000", requestId: null }); + const result = calculateRequestCost({ provider: "elevenlabs", model: "eleven_v4_turbo", usage }); + if (result.capCostUsd !== result.costUsd || result.costUsd === null) throw new Error("voice cost missing from the cap"); +}); diff --git a/supabase/functions/_shared/cost.ts b/supabase/functions/_shared/cost.ts index ca051b645..89940aa31 100644 --- a/supabase/functions/_shared/cost.ts +++ b/supabase/functions/_shared/cost.ts @@ -34,6 +34,14 @@ export interface NormalizedUsage { reasoningOutputTokens?: number; /** OpenRouter's authoritative account charge, in USD. */ reportedCostUsd?: number; + /** + * The part of reportedCostUsd that counts toward the weekly spend cap. For + * OpenRouter it is `usage.cost`, what OpenRouter took from our credits; a + * BYOK call billed to our own provider key adds only OpenRouter's fee. + * Undefined means "same as reportedCostUsd" (Exa, ElevenLabs, merged calls + * that did not set it). + */ + capCostUsd?: number; /** Characters ElevenLabs billed for a speech request. */ billedCharacters?: number; /** Untouched provider usage object, kept verbatim for audit. */ @@ -48,6 +56,8 @@ export interface CostResult { reasoningOutputTokens: number | null; /** null when the model has no verified pricing in the snapshot. */ costUsd: number | null; + /** USD that counts toward the weekly spend cap; null when unknown. */ + capCostUsd: number | null; pricingSnapshotId: string; pricingSourceUrl: string | null; costCalculationVersion: string; @@ -77,6 +87,10 @@ export function normalizeOpenRouterUsage(usage: Record): Normal outputTokens: intOrUndefined(usage.completion_tokens), reasoningOutputTokens: intOrUndefined(completionDetails?.reasoning_tokens), reportedCostUsd: reportedCostOrUndefined(usage.cost), + // usage.cost is OpenRouter's credit charge. For a BYOK call it is only the + // OpenRouter fee, so Claude on our own Anthropic key stays out of the cap, + // and a call that fell back to OpenRouter credits counts in full. + capCostUsd: reportedCostOrUndefined(usage.cost), raw: usage, }; } @@ -117,14 +131,22 @@ function calculateElevenLabsCost(model: string, usage?: NormalizedUsage): CostRe costUsd: rate === undefined || characters === undefined || usage?.incomplete ? null : Number(((characters / 1_000) * rate).toFixed(6)), + capCostUsd: null, pricingSnapshotId: ELEVENLABS_PRICING_SNAPSHOT_ID, pricingSourceUrl: rate === undefined ? null : ELEVENLABS_PRICING_SOURCE_URL, costCalculationVersion: ELEVENLABS_COST_CALCULATION_VERSION, }; + // ElevenLabs bills our own account, so all of it counts toward the cap. + result.capCostUsd = result.costUsd; if (usage?.raw) result.rawUsage = usage.raw; return result; } +/** Cap spend for one usage record: its own cap figure, else its full reported cost. */ +function capCostOf(usage: NormalizedUsage): number | undefined { + return usage.capCostUsd ?? usage.reportedCostUsd; +} + export function calculateRequestCost(args: { provider: string; model: string; @@ -148,6 +170,9 @@ export function calculateRequestCost(args: { outputTokens: usage?.outputTokens ?? null, reasoningOutputTokens: usage?.reasoningOutputTokens ?? null, costUsd: usage?.incomplete ? null : usage?.reportedCostUsd ?? null, + // The cap counts what is known even when the rest of the cost is not: a + // lower bound stops a user sooner than leaving the request out entirely. + capCostUsd: usage ? capCostOf(usage) ?? null : null, pricingSnapshotId: "openrouter-response-usage", pricingSourceUrl: "https://openrouter.ai/docs/cookbook/administration/usage-accounting", costCalculationVersion: OPENROUTER_COST_CALCULATION_VERSION, @@ -182,6 +207,10 @@ export function mergeNormalizedUsage(usages: Array) reportedCostUsd: defined.every((u) => !u.incomplete && typeof u.reportedCostUsd === "number") ? defined.reduce((total, usage) => total + (usage.reportedCostUsd ?? 0), 0) : undefined, + // Cap spend is a lower bound: calls with an unknown charge add nothing. + capCostUsd: defined.some((u) => typeof capCostOf(u) === "number") + ? defined.reduce((total, usage) => total + (capCostOf(usage) ?? 0), 0) + : undefined, raw: { calls: defined.map((u) => u.raw ?? null) }, }; return merged; diff --git a/supabase/functions/_shared/entitlements.test.ts b/supabase/functions/_shared/entitlements.test.ts index 72b117a18..834816564 100644 --- a/supabase/functions/_shared/entitlements.test.ts +++ b/supabase/functions/_shared/entitlements.test.ts @@ -7,6 +7,7 @@ for (const [key, value] of Object.entries({ })) Deno.env.set(key, value); const { getSubscriptionInfo, checkRateLimit } = await import("./entitlements.ts"); +const { MODEL_QUOTA_UNITS, WEEKLY_AI_BUDGET_USD } = await import("./usage-limits.ts"); function json(body: unknown, status = 200): Response { return Response.json(body, { status }); @@ -22,17 +23,21 @@ interface Routes { subscriptions?: unknown; rate_limits?: unknown; quota?: unknown; + onQuota?: (body: Record) => void; } async function withSupabase(routes: Routes, run: () => Promise): Promise { const originalFetch = globalThis.fetch; - globalThis.fetch = (async (input: Request | URL | string) => { + globalThis.fetch = (async (input: Request | URL | string, init?: RequestInit) => { const url = new URL(input instanceof Request ? input.url : String(input)); if (url.pathname.includes("/rest/v1/user_settings")) return json(routes.user_settings ?? null); if (url.pathname.includes("/rest/v1/user_roles")) return json(routes.user_roles ?? { role: "user" }); if (url.pathname.includes("/rest/v1/subscriptions")) return json(routes.subscriptions ?? []); if (url.pathname.includes("/rest/v1/rate_limits")) return json(routes.rate_limits ?? null); - if (url.pathname.includes("/rpc/consume_daily_quota")) return json(routes.quota ?? [{ allowed: true, current_count: 1 }]); + if (url.pathname.includes("/rpc/consume_ai_quota")) { + routes.onQuota?.(JSON.parse(String(init?.body ?? "{}"))); + return json(routes.quota ?? [{ allowed: true, current_count: 1 }]); + } throw new Error(`Unexpected request: ${url}`); }) as typeof fetch; try { @@ -140,3 +145,48 @@ Deno.test("expired access is refused with the trial-expired code", async () => { assertEquals(result.statusCode, 402, "statusCode"); }); }); + +const proUser = { subscription_tier: "pro", daily_limit_override: null, trial_ends_at: null, grace_period_ends_at: future() }; + +Deno.test("an expensive model reserves its weighted units and the weekly cap", async () => { + let sent: Record = {}; + await withSupabase({ user_settings: proUser, onQuota: (body) => { sent = body; } }, async () => { + const result = await checkRateLimit("user-1", "anthropic/claude-opus-5.5"); + assertEquals(result.allowed, true, "allowed"); + assertEquals(sent.p_units, MODEL_QUOTA_UNITS["anthropic/claude-opus-5.5"], "p_units"); + assertEquals(sent.p_weekly_cap_usd, WEEKLY_AI_BUDGET_USD, "p_weekly_cap_usd"); + }); + await withSupabase({ user_settings: proUser, onQuota: (body) => { sent = body; } }, async () => { + await checkRateLimit("user-1", "openai/gpt-6-luna"); + assertEquals(sent.p_units, 1, "cheap model p_units"); + }); +}); + +Deno.test("a weighted refusal says how many requests remain", async () => { + await withSupabase({ user_settings: proUser, quota: [{ allowed: false, current_count: 47, denied_by: "daily" }] }, async () => { + const result = await checkRateLimit("user-1", "anthropic/claude-opus-5.5"); + assertEquals(result.code, "limit_reached", "code"); + assertEquals(result.units, 5, "units"); + assertEquals(result.reason?.includes("3 left today"), true, "reason mentions remaining requests"); + }); +}); + +Deno.test("the weekly budget refusal has its own code", async () => { + await withSupabase({ user_settings: proUser, quota: [{ allowed: false, current_count: 2, denied_by: "weekly" }] }, async () => { + const result = await checkRateLimit("user-1", "openai/gpt-6-luna"); + assertEquals(result.allowed, false, "allowed"); + assertEquals(result.code, "weekly_budget_reached", "code"); + assertEquals(result.statusCode, 402, "statusCode"); + }); +}); + +Deno.test("a model heavier than the whole daily limit is not told to wait", async () => { + const smallLimit = { ...proUser, daily_limit_override: 3 }; + await withSupabase({ user_settings: smallLimit, quota: [{ allowed: false, current_count: 0, denied_by: "daily" }] }, async () => { + const result = await checkRateLimit("user-1", "anthropic/claude-opus-5.5"); + assertEquals(result.code, "limit_reached", "code"); + assertEquals(result.limit, 3, "limit"); + assertEquals(result.reason?.includes("more than your daily limit of 3"), true, `reason: ${result.reason}`); + assertEquals(result.reason?.includes("midnight"), false, "reason must not suggest waiting"); + }); +}); diff --git a/supabase/functions/_shared/entitlements.ts b/supabase/functions/_shared/entitlements.ts index 1ae2b7afa..97ed7f5d3 100644 --- a/supabase/functions/_shared/entitlements.ts +++ b/supabase/functions/_shared/entitlements.ts @@ -10,6 +10,7 @@ import { isAutoModelSelection, type ModelSelection, } from "./model-catalog.ts"; +import { WEEKLY_AI_BUDGET_USD, PRO_DAILY_LIMIT, quotaUnitsFor, TRIAL_DAILY_LIMIT } from "./usage-limits.ts"; const supabase = createClient( Deno.env.get("SUPABASE_URL")!, @@ -22,7 +23,9 @@ export interface RateLimitResult { reason?: string; limit?: number; current?: number; - code?: "limit_reached" | "trial_expired" | "banned" | "upgrade_required"; + code?: "limit_reached" | "weekly_budget_reached" | "trial_expired" | "banned" | "upgrade_required"; + /** Quota units this request needed; more than 1 for expensive models. */ + units?: number; statusCode?: number; } @@ -62,14 +65,14 @@ export async function getSubscriptionInfo(userId: string): Promise dailyLimit + ? `This model counts as ${units} requests, more than your daily limit of ${dailyLimit}. Pick a lighter model.` + : remaining > 0 + ? `This model counts as ${units} requests and you have ${remaining} left today. Pick a lighter model or wait until midnight UTC.` + : `You've used all ${dailyLimit} of your daily requests. ${sub.tier === "pro" ? "Limit resets at midnight UTC." : `Upgrade to Pro for ${PRO_DAILY_LIMIT} daily requests.`}`; + return { allowed: false, reason, code: "limit_reached", limit: dailyLimit, current, units, statusCode: 402 }; } - return { allowed: true, limit: dailyLimit, current, canAccessProModels: sub.canAccessProModels }; + return { allowed: true, limit: dailyLimit, current, units, canAccessProModels: sub.canAccessProModels }; } catch (err) { console.error("[RATE LIMIT] Check failed:", err); return { allowed: false, reason: "Unable to verify your account access. Please try again.", statusCode: 500 }; @@ -223,6 +240,8 @@ export async function logUsage( status, error_code: errorCode, cost_usd: cost.costUsd, + // Weekly spend cap input (20261022130001_weighted_quota_weekly_budget.sql). + cap_cost_usd: cost.capCostUsd, // Audit columns added by migration 20260822000000_ai_request_cost_audit.sql. cached_input_tokens: cost.cachedInputTokens, cache_write_tokens: cost.cacheWriteTokens, diff --git a/supabase/functions/_shared/usage-limits.ts b/supabase/functions/_shared/usage-limits.ts new file mode 100644 index 000000000..c00280c0e --- /dev/null +++ b/supabase/functions/_shared/usage-limits.ts @@ -0,0 +1,41 @@ +// AI usage limits that are expected to change while pricing is settled. +// Change the numbers here; checkRateLimit in entitlements.ts reads them. +// Per-user daily limits still come from the tier defaults, daily_limit_override +// and rate_limits rows; these values decide how fast a request uses them up. + +import { AUTO_FAST, AUTO_PRO, type ModelSelection } from "./model-catalog.ts"; + +/** Quota units a model without an entry below costs. */ +export const DEFAULT_QUOTA_UNITS = 1; + +/** + * How many daily requests one call to a model counts as. Unlisted models + * cost DEFAULT_QUOTA_UNITS. The quota is reserved before Auto picks a model, + * so Auto selections are charged as their most expensive candidate. + */ +export const MODEL_QUOTA_UNITS: Readonly> = { + "anthropic/claude-opus-5.5": 5, + "openai/gpt-6.1-sol": 3, + "anthropic/claude-sonnet-5.5": 2, + "deepseek/deepseek-v4-pro-0813": 2, + [AUTO_PRO]: 5, + [AUTO_FAST]: 1, +}; + +/** + * Weekly cap on a user's AI spend, in USD, per calendar week starting Monday + * 00:00 UTC. Only ai_requests.cap_cost_usd counts: OpenRouter credit charges + * plus Exa and ElevenLabs. Calls billed to our own BYOK key (Claude on the + * Anthropic Max plan) add only OpenRouter's fee, usually 0. Set to null to + * turn the cap off. + */ +export const WEEKLY_AI_BUDGET_USD: number | null = 2; + +/** Default daily quota units per tier, before overrides and rate_limits rows. */ +export const TRIAL_DAILY_LIMIT = 5; +export const PRO_DAILY_LIMIT = 50; + +export function quotaUnitsFor(model: ModelSelection | string | undefined): number { + if (!model) return DEFAULT_QUOTA_UNITS; + return MODEL_QUOTA_UNITS[model] ?? DEFAULT_QUOTA_UNITS; +} diff --git a/supabase/functions/ai-stream/handler_test.ts b/supabase/functions/ai-stream/handler_test.ts index e0430bc8b..82863f45a 100644 --- a/supabase/functions/ai-stream/handler_test.ts +++ b/supabase/functions/ai-stream/handler_test.ts @@ -76,7 +76,7 @@ Deno.test("streaming Ask forwards the shared prompt and returns provider SSE", a headers: { "Content-Type": "application/json" }, }); } - if (url.pathname.includes("/rest/v1/rpc/consume_daily_quota")) { + if (/\/rest\/v1\/rpc\/consume_(daily|ai)_quota$/.test(url.pathname)) { return new Response(JSON.stringify([{ allowed: true, current_count: 1 }]), { headers: { "Content-Type": "application/json" }, }); diff --git a/supabase/functions/ai-stream/index.ts b/supabase/functions/ai-stream/index.ts index c8eabf8ef..5c9cf4d4d 100644 --- a/supabase/functions/ai-stream/index.ts +++ b/supabase/functions/ai-stream/index.ts @@ -183,6 +183,7 @@ export async function handleRequest(req: Request): Promise { code: rateLimit.code, limit: rateLimit.limit, current: rateLimit.current, + units: rateLimit.units, }, rateLimit.statusCode || 402, corsHeaders); } diff --git a/supabase/functions/ai-stream/index_test.ts b/supabase/functions/ai-stream/index_test.ts index e0e0f6636..20a218519 100644 --- a/supabase/functions/ai-stream/index_test.ts +++ b/supabase/functions/ai-stream/index_test.ts @@ -96,7 +96,7 @@ async function runScenario(scenario: Scenario) { }); } if (url.pathname.includes("/rest/v1/rate_limits")) return jsonResponse({ limit_value: 50 }); - if (url.pathname.includes("/rest/v1/rpc/consume_daily_quota")) { + if (/\/rest\/v1\/rpc\/consume_(daily|ai)_quota$/.test(url.pathname)) { return jsonResponse([{ allowed: true, current_count: 1 }]); } if (url.pathname.includes("/rest/v1/ai_requests")) { diff --git a/supabase/functions/ai/debate-context.test.ts b/supabase/functions/ai/debate-context.test.ts index 9516a7f59..d7632247c 100644 --- a/supabase/functions/ai/debate-context.test.ts +++ b/supabase/functions/ai/debate-context.test.ts @@ -73,7 +73,7 @@ function install(options: { stream?: boolean; tables?: typeof tables; reply?: un if (url.pathname.includes("/rest/v1/user_settings")) return jsonResponse({ subscription_tier: "trial", daily_limit_override: 50, trial_ends_at: "2099-01-01T00:00:00.000Z", grace_period_ends_at: null }); if (url.pathname.includes("/rest/v1/subscriptions")) return jsonResponse([]); if (url.pathname.includes("/rest/v1/rate_limits")) return jsonResponse({ limit_value: 50 }); - if (url.pathname.includes("/rest/v1/rpc/consume_daily_quota")) return jsonResponse([{ allowed: true, current_count: 1 }]); + if (/\/rest\/v1\/rpc\/consume_(daily|ai)_quota$/.test(url.pathname)) return jsonResponse([{ allowed: true, current_count: 1 }]); if (url.pathname.includes("/rest/v1/global_prompts")) return jsonResponse({ prompt_text: null }); if (url.pathname.includes("/rest/v1/ai_requests")) return new Response(null, { status: 201 }); throw new Error(`Unhandled Supabase request: ${url}`); @@ -197,7 +197,7 @@ Deno.test("a forged debate is rejected before quota, routing, or provider work", debateContext: { debateId, side: "pro", sourceIds: [SOURCE], outputs: [{ kind: "case", id: CASE }] }, })); assert(response.status === 404, `forged debate returned ${response.status}`); - assert(!harness.calls.some((path) => path.includes("consume_daily_quota") || path.includes("/chat/completions") || path.includes("/decisions")), `forged debate reached paid work: ${harness.calls.join(", ")}`); + assert(!harness.calls.some((path) => /consume_(daily|ai)_quota/.test(path) || path.includes("/chat/completions") || path.includes("/decisions")), `forged debate reached paid work: ${harness.calls.join(", ")}`); } finally { harness.restore(); } @@ -262,7 +262,7 @@ Deno.test("streamed Ask receives the same debate context and rejects a foreign d debateContext: { debateId: FOREIGN_DEBATE, side: "pro", sourceIds: [], outputs: [] }, }, "ai-stream")); assert(response.status === 404, `foreign stream returned ${response.status}`); - assert(!foreign.calls.some((path) => path.includes("consume_daily_quota") || path.includes("/chat/completions")), "foreign stream reached paid work"); + assert(!foreign.calls.some((path) => /consume_(daily|ai)_quota/.test(path) || path.includes("/chat/completions")), "foreign stream reached paid work"); } finally { foreign.restore(); } diff --git a/supabase/functions/ai/generate-honesty_test.ts b/supabase/functions/ai/generate-honesty_test.ts index ddd064d5a..c9ba178ce 100644 --- a/supabase/functions/ai/generate-honesty_test.ts +++ b/supabase/functions/ai/generate-honesty_test.ts @@ -80,7 +80,7 @@ Deno.test("generate marks every contention when research has no citable sources" if (url.pathname.includes("/rest/v1/rate_limits")) { return jsonResponse({ limit_value: 50 }); } - if (url.pathname.includes("/rest/v1/rpc/consume_daily_quota")) { + if (/\/rest\/v1\/rpc\/consume_(daily|ai)_quota$/.test(url.pathname)) { return jsonResponse([{ allowed: true, current_count: 1 }]); } if (url.pathname.includes("/rest/v1/global_prompts")) { diff --git a/supabase/functions/ai/handler-research.test.ts b/supabase/functions/ai/handler-research.test.ts index 9dcd0d6e0..6c830da2d 100644 --- a/supabase/functions/ai/handler-research.test.ts +++ b/supabase/functions/ai/handler-research.test.ts @@ -199,7 +199,7 @@ Deno.test("factoids handler carries Exa filters and consulted-source metadata ac }); } if (url.pathname.includes("/rest/v1/rate_limits")) return jsonResponse({ limit_value: 50 }); - if (url.pathname.includes("/rest/v1/rpc/consume_daily_quota")) { + if (/\/rest\/v1\/rpc\/consume_(daily|ai)_quota$/.test(url.pathname)) { return jsonResponse([{ allowed: true, current_count: 1 }]); } if (url.pathname.includes("/rest/v1/global_prompts")) return jsonResponse({ prompt_text: null }); @@ -337,7 +337,7 @@ Deno.test("Ask marks only surviving URL and research context as available", asyn }); } if (url.pathname.includes("/rest/v1/rate_limits")) return jsonResponse({ limit_value: 50 }); - if (url.pathname.includes("/rest/v1/rpc/consume_daily_quota")) { + if (/\/rest\/v1\/rpc\/consume_(daily|ai)_quota$/.test(url.pathname)) { return jsonResponse([{ allowed: true, current_count: 1 }]); } if (url.pathname.includes("/rest/v1/global_prompts")) return jsonResponse({ prompt_text: null }); @@ -469,7 +469,7 @@ function supabaseTestResponse(url: URL): Response | undefined { }); if (url.pathname.includes("/rest/v1/subscriptions")) return jsonResponse([{ status: "active", current_period_end: "2099-01-01T00:00:00.000Z" }]); if (url.pathname.includes("/rest/v1/rate_limits")) return jsonResponse({ limit_value: 50 }); - if (url.pathname.includes("/rest/v1/rpc/consume_daily_quota")) return jsonResponse([{ allowed: true, current_count: 1 }]); + if (/\/rest\/v1\/rpc\/consume_(daily|ai)_quota$/.test(url.pathname)) return jsonResponse([{ allowed: true, current_count: 1 }]); if (url.pathname.includes("/rest/v1/global_prompts")) return jsonResponse({ prompt_text: null }); if (url.pathname.includes("/rest/v1/ai_requests")) return new Response(null, { status: 201 }); throw new Error(`Unhandled Supabase request: ${url}`); @@ -566,7 +566,7 @@ Deno.test("Crossfire returns newest-exchange signals without making them a reply const claimedSignalKeys = new Set(); globalThis.fetch = (async (input: Request | URL | string, init?: RequestInit) => { const url = new URL(input instanceof Request ? input.url : String(input)); - if (url.origin === "https://supabase.test" && url.pathname.includes("/rest/v1/rpc/consume_daily_quota")) { + if (url.origin === "https://supabase.test" && /\/rest\/v1\/rpc\/consume_(daily|ai)_quota$/.test(url.pathname)) { const body = JSON.parse(String(init?.body ?? "{}")) as { p_quota_key?: string }; if (body.p_quota_key?.startsWith("crossfire_signal:")) { const allowed = !claimedSignalKeys.has(body.p_quota_key); diff --git a/supabase/functions/ai/index.ts b/supabase/functions/ai/index.ts index 08f283863..596d8f69c 100644 --- a/supabase/functions/ai/index.ts +++ b/supabase/functions/ai/index.ts @@ -540,7 +540,8 @@ export async function handleRequest(req: Request): Promise { error: rateLimit.reason, code: rateLimit.code, limit: rateLimit.limit, - current: rateLimit.current + current: rateLimit.current, + units: rateLimit.units, }), { status: rateLimit.statusCode || 429, headers: { ...corsHeaders, "Content-Type": "application/json" } diff --git a/supabase/functions/ai/provider-errors_test.ts b/supabase/functions/ai/provider-errors_test.ts index 565080ead..1cf6a2445 100644 --- a/supabase/functions/ai/provider-errors_test.ts +++ b/supabase/functions/ai/provider-errors_test.ts @@ -17,7 +17,7 @@ Deno.test("AI JSON and SSE failures never expose provider response bodies", asyn if (url.pathname.endsWith("/subscriptions")) return Response.json([]); if (url.pathname.endsWith("/rate_limits")) return Response.json({ limit_value: 50 }); if (url.pathname.endsWith("/global_prompts")) return Response.json({ prompt_text: null }); - if (url.pathname.endsWith("/consume_daily_quota")) return Response.json([{ allowed: true, current_count: 1 }]); + if (/\/consume_(daily|ai)_quota$/.test(url.pathname)) return Response.json([{ allowed: true, current_count: 1 }]); if (url.pathname.endsWith("/ai_requests")) return new Response(null, { status: 201 }); } if (url.href === "https://openrouter.ai/api/v1/chat/completions") return new Response("PRIVATE_PROVIDER_BODY", { status: 400 }); diff --git a/supabase/functions/ai/stream.test.ts b/supabase/functions/ai/stream.test.ts index 34a2a10fa..43a01ae6b 100644 --- a/supabase/functions/ai/stream.test.ts +++ b/supabase/functions/ai/stream.test.ts @@ -67,7 +67,7 @@ function install(replies: ProviderReply[]): Harness { if (url.pathname.includes("/rest/v1/user_settings")) return jsonResponse({ subscription_tier: "trial", daily_limit_override: 50, trial_ends_at: "2099-01-01T00:00:00.000Z", grace_period_ends_at: null }); if (url.pathname.includes("/rest/v1/subscriptions")) return jsonResponse([]); if (url.pathname.includes("/rest/v1/rate_limits")) return jsonResponse({ limit_value: 50 }); - if (url.pathname.includes("/rest/v1/rpc/consume_daily_quota")) return jsonResponse([{ allowed: true, current_count: 1 }]); + if (/\/rest\/v1\/rpc\/consume_(daily|ai)_quota$/.test(url.pathname)) return jsonResponse([{ allowed: true, current_count: 1 }]); if (url.pathname.includes("/rest/v1/global_prompts")) return jsonResponse({ prompt_text: null }); if (url.pathname.includes("/rest/v1/ai_requests")) { const row = JSON.parse(String(init?.body)); diff --git a/supabase/functions/ai/voice.test.ts b/supabase/functions/ai/voice.test.ts index 268a2f9d5..a06977c80 100644 --- a/supabase/functions/ai/voice.test.ts +++ b/supabase/functions/ai/voice.test.ts @@ -45,7 +45,7 @@ function install(reply: unknown): Harness { if (url.pathname.includes("/rest/v1/user_settings")) return jsonResponse({ subscription_tier: "trial", daily_limit_override: 50, trial_ends_at: "2099-01-01T00:00:00.000Z", grace_period_ends_at: null }); if (url.pathname.includes("/rest/v1/subscriptions")) return jsonResponse([]); if (url.pathname.includes("/rest/v1/rate_limits")) return jsonResponse({ limit_value: 50 }); - if (url.pathname.includes("/rest/v1/rpc/consume_daily_quota")) return jsonResponse([{ allowed: true, current_count: 1 }]); + if (/\/rest\/v1\/rpc\/consume_(daily|ai)_quota$/.test(url.pathname)) return jsonResponse([{ allowed: true, current_count: 1 }]); if (url.pathname.includes("/rest/v1/rpc/sparring_request_started_at")) return jsonResponse(new Date().toISOString()); if (url.pathname.includes("/rest/v1/rpc/claim_sparring_score_session")) { const payload = (JSON.parse(String(init?.body)) as { p_payload: { claim_token: string } }).p_payload; diff --git a/supabase/migrations/20261022130000_index_ai_requests_user_created.sql b/supabase/migrations/20261022130000_index_ai_requests_user_created.sql new file mode 100644 index 000000000..7fafa8e8b --- /dev/null +++ b/supabase/migrations/20261022130000_index_ai_requests_user_created.sql @@ -0,0 +1,6 @@ +-- The weekly AI budget in consume_ai_quota sums a user's ai_requests for +-- the current week on every AI request. Keep this pipeline-incompatible +-- statement isolated: the Supabase CLI runs CREATE INDEX CONCURRENTLY outside +-- its per-file transaction batch. +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_ai_requests_user_created + ON public.ai_requests (user_id, created_at); diff --git a/supabase/migrations/20261022130001_weighted_quota_weekly_budget.sql b/supabase/migrations/20261022130001_weighted_quota_weekly_budget.sql new file mode 100644 index 000000000..0da0ef9db --- /dev/null +++ b/supabase/migrations/20261022130001_weighted_quota_weekly_budget.sql @@ -0,0 +1,109 @@ +-- Weight the daily AI quota by model cost and add a weekly AI spend cap. +-- +-- Additive only. consume_daily_quota is left exactly as it is: import, share, +-- voice and Crossfire signals keep calling it, and the previous ai/ai-stream +-- builds keep working until the new ones are deployed. The AI functions move +-- to a new function, consume_ai_quota, because weighted units need a new +-- argument and the weekly cap a new result column, and Postgres cannot change +-- a function's arguments or result columns in place (CREATE OR REPLACE would +-- add a second overload; changing the result needs DROP). +-- +-- ai_requests.cap_cost_usd is the part of a request's cost that counts +-- toward the weekly cap: what OpenRouter charged against our credits (for a +-- BYOK call that is only OpenRouter's fee, usually 0, because the provider +-- bills our own key), plus non-OpenRouter charges such as Exa and ElevenLabs. +-- So a Claude call on the BYOK Anthropic key adds about nothing, and a Claude +-- call that falls back to OpenRouter credits counts in full. When part of a +-- request's cost is unknown, the known part is recorded (a lower bound). NULL +-- means nothing is known or the row predates this column; it never counts. Rows logged before this +-- migration have NULL, so rewriting their cost_usd (for example the BYOK +-- cost backfill) cannot move anyone toward the cap. +ALTER TABLE public.ai_requests + ADD COLUMN IF NOT EXISTS cap_cost_usd numeric; + +COMMENT ON COLUMN public.ai_requests.cap_cost_usd IS + 'USD that counts toward the weekly AI spend cap: OpenRouter credit charges (BYOK provider charges excluded) plus Exa and ElevenLabs. NULL = unknown, not counted.'; + +-- p_units: quota units this request reserves (an expensive model counts as +-- several requests). +-- p_weekly_cap_usd: when set, refuse once the user's cap_cost_usd since +-- Monday 00:00 UTC (ISO week) reaches the cap. NULL skips the check. Cost is +-- logged after the provider answers, so the cap is soft by about one request. +-- denied_by: 'daily' or 'weekly' when refused, NULL when allowed. +-- The weights and the cap live in supabase/functions/_shared/usage-limits.ts. +-- The week's spend is read through idx_ai_requests_user_created +-- (20261022130000_index_ai_requests_user_created.sql). +CREATE FUNCTION public.consume_ai_quota( + p_user_id uuid, + p_limit integer, + p_units integer DEFAULT 1, + p_weekly_cap_usd numeric DEFAULT NULL, + p_quota_key text DEFAULT 'ai' +) +RETURNS TABLE (allowed boolean, current_count integer, denied_by text) +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_quota_key text := nullif(trim(p_quota_key), ''); + v_current_count integer; + v_week_start timestamptz := date_trunc('week', now() AT TIME ZONE 'UTC') AT TIME ZONE 'UTC'; + v_week_spend numeric; +BEGIN + IF auth.role() <> 'service_role' OR p_user_id IS NULL OR p_limit IS NULL OR p_limit < 1 + OR v_quota_key IS NULL OR p_units IS NULL OR p_units < 1 OR p_weekly_cap_usd < 0 THEN + RAISE EXCEPTION 'quota reservation is restricted to trusted server code'; + END IF; + + SELECT request_count INTO v_current_count + FROM public.daily_usage_counters + WHERE user_id = p_user_id + AND usage_date = CURRENT_DATE + AND quota_key = v_quota_key; + + IF p_weekly_cap_usd IS NOT NULL THEN + SELECT COALESCE(sum(cap_cost_usd), 0) INTO v_week_spend + FROM public.ai_requests + WHERE user_id = p_user_id + AND created_at >= v_week_start; + + IF v_week_spend >= p_weekly_cap_usd THEN + RETURN QUERY SELECT false, COALESCE(v_current_count, 0), 'weekly'::text; + RETURN; + END IF; + END IF; + + -- The insert path below has no limit guard, so refuse a request that could + -- never fit before touching the counter. + IF p_units > p_limit THEN + RETURN QUERY SELECT false, COALESCE(v_current_count, 0), 'daily'::text; + RETURN; + END IF; + + INSERT INTO public.daily_usage_counters (user_id, usage_date, quota_key, request_count) + VALUES (p_user_id, CURRENT_DATE, v_quota_key, p_units) + ON CONFLICT (user_id, usage_date, quota_key) DO UPDATE + SET request_count = public.daily_usage_counters.request_count + p_units + WHERE public.daily_usage_counters.request_count + p_units <= p_limit + RETURNING request_count INTO v_current_count; + + IF FOUND THEN + RETURN QUERY SELECT true, v_current_count, NULL::text; + RETURN; + END IF; + + SELECT request_count INTO v_current_count + FROM public.daily_usage_counters + WHERE user_id = p_user_id + AND usage_date = CURRENT_DATE + AND quota_key = v_quota_key; + + RETURN QUERY SELECT false, COALESCE(v_current_count, 0), 'daily'::text; +END; +$$; + +REVOKE ALL ON FUNCTION public.consume_ai_quota(uuid, integer, integer, numeric, text) + FROM PUBLIC, anon, authenticated; +GRANT EXECUTE ON FUNCTION public.consume_ai_quota(uuid, integer, integer, numeric, text) + TO service_role; diff --git a/supabase/tests/ai_quota_weekly_budget_test.sql b/supabase/tests/ai_quota_weekly_budget_test.sql new file mode 100644 index 000000000..e5d91e296 --- /dev/null +++ b/supabase/tests/ai_quota_weekly_budget_test.sql @@ -0,0 +1,134 @@ +BEGIN; +SELECT plan(17); + +INSERT INTO auth.users (id) +VALUES + ('66666666-6666-4666-8666-666666666666'), + ('77777777-7777-4777-8777-777777777777'); + +SELECT has_column('public', 'ai_requests', 'cap_cost_usd', 'ai_requests records the spend that counts toward the weekly cap'); +SELECT col_is_null('public', 'ai_requests', 'cap_cost_usd', 'cap spend is nullable, so old rows need no backfill'); +SELECT results_eq( + $$SELECT count(*)::bigint FROM pg_proc + WHERE pronamespace = 'public'::regnamespace AND proname = 'consume_daily_quota'$$, + ARRAY[1::bigint], + 'consume_daily_quota is left in place with one overload' +); +SELECT ok( + NOT has_function_privilege('anon', 'public.consume_ai_quota(uuid, integer, integer, numeric, text)', 'EXECUTE'), + 'anonymous callers cannot reserve AI quota' +); +SELECT ok( + NOT has_function_privilege('authenticated', 'public.consume_ai_quota(uuid, integer, integer, numeric, text)', 'EXECUTE'), + 'authenticated callers cannot reserve AI quota' +); +SELECT ok( + has_function_privilege('service_role', 'public.consume_ai_quota(uuid, integer, integer, numeric, text)', 'EXECUTE'), + 'service role can reserve AI quota' +); + +SET LOCAL ROLE service_role; +SELECT set_config('request.jwt.claim.role', 'service_role', true); + +SELECT results_eq( + $$SELECT allowed, current_count, denied_by + FROM public.consume_ai_quota('66666666-6666-4666-8666-666666666666', 10, 6)$$, + $$VALUES (true, 6, NULL::text)$$, + 'a weighted request reserves all of its units' +); +SELECT results_eq( + $$SELECT allowed, current_count, denied_by + FROM public.consume_ai_quota('66666666-6666-4666-8666-666666666666', 10, 5)$$, + $$VALUES (false, 6, 'daily'::text)$$, + 'a weighted request that does not fit is refused and leaves the counter alone' +); +SELECT results_eq( + $$SELECT allowed, current_count, denied_by + FROM public.consume_ai_quota('66666666-6666-4666-8666-666666666666', 10, 4)$$, + $$VALUES (true, 10, NULL::text)$$, + 'a request that exactly fills the limit is allowed' +); +SELECT results_eq( + $$SELECT allowed, current_count, denied_by + FROM public.consume_ai_quota('66666666-6666-4666-8666-666666666666', 3, 5, NULL, 'weighted_first')$$, + $$VALUES (false, 0, 'daily'::text)$$, + 'a first request larger than the whole limit is refused' +); +SELECT throws_ok( + $$SELECT * FROM public.consume_ai_quota('66666666-6666-4666-8666-666666666666', 10, 0)$$, + 'P0001', + 'quota reservation is restricted to trusted server code', + 'zero-unit reservations are rejected' +); +SELECT results_eq( + $$SELECT allowed, current_count + FROM public.consume_daily_quota('66666666-6666-4666-8666-666666666666', 20)$$, + $$VALUES (true, 11)$$, + 'the old one-unit function still works on the same ai counter' +); + +-- Weekly cap. Cap spend: 1.50 this week; BYOK provider charges (cost_usd only), +-- unknown cap spend, last week's spend and another user's spend do not count. +RESET ROLE; +INSERT INTO public.ai_requests (user_id, feature, provider, model, status, cost_usd, cap_cost_usd, created_at) +VALUES + ('66666666-6666-4666-8666-666666666666', 'ask', 'openrouter', 'openai/gpt-6-luna', 'success', 1.50, 1.50, now()), + ('66666666-6666-4666-8666-666666666666', 'ask', 'openrouter', 'anthropic/claude-opus-5.5', 'success', 40.00, 0, now()), + ('66666666-6666-4666-8666-666666666666', 'ask', 'openrouter', 'anthropic/claude-opus-5.5', 'success', 30.00, NULL, now()), + ('66666666-6666-4666-8666-666666666666', 'ask', 'openrouter', 'openai/gpt-6-luna', 'success', 9.00, 9.00, + date_trunc('week', now() AT TIME ZONE 'UTC') AT TIME ZONE 'UTC' - interval '1 second'), + ('77777777-7777-4777-8777-777777777777', 'ask', 'openrouter', 'openai/gpt-6-luna', 'success', 5.00, 5.00, now()); +SET LOCAL ROLE service_role; + +SELECT results_eq( + $$SELECT allowed, current_count, denied_by + FROM public.consume_ai_quota('66666666-6666-4666-8666-666666666666', 100, 1, 2.00)$$, + $$VALUES (true, 12, NULL::text)$$, + 'BYOK charges, unknown spend, last week and other users do not count toward the cap' +); + +RESET ROLE; +INSERT INTO public.ai_requests (user_id, feature, provider, model, status, cost_usd, cap_cost_usd) +VALUES ('66666666-6666-4666-8666-666666666666', 'voice', 'elevenlabs', 'eleven_v4', 'success', 0.50, 0.50); +SET LOCAL ROLE service_role; + +SELECT results_eq( + $$SELECT allowed, current_count, denied_by + FROM public.consume_ai_quota('66666666-6666-4666-8666-666666666666', 100, 1, 2.00)$$, + $$VALUES (false, 12, 'weekly'::text)$$, + 'reaching the weekly cap refuses the request without consuming daily quota' +); +SELECT results_eq( + $$SELECT allowed, denied_by + FROM public.consume_ai_quota('66666666-6666-4666-8666-666666666666', 100, 1, NULL)$$, + $$VALUES (true, NULL::text)$$, + 'a NULL cap turns the weekly check off' +); + +-- Rewriting old rows' cost_usd (the BYOK cost backfill) leaves the cap alone. +RESET ROLE; +UPDATE public.ai_requests SET cost_usd = cost_usd + 100 +WHERE user_id = '77777777-7777-4777-8777-777777777777'; +INSERT INTO public.ai_requests (user_id, feature, provider, model, status, cost_usd, created_at) +VALUES ('77777777-7777-4777-8777-777777777777', 'ask', 'openrouter', 'anthropic/claude-opus-5.5', 'success', 50.00, now()); +SET LOCAL ROLE service_role; +SELECT results_eq( + $$SELECT allowed, denied_by + FROM public.consume_ai_quota('77777777-7777-4777-8777-777777777777', 100, 1, 6.00)$$, + $$VALUES (true, NULL::text)$$, + 'cost_usd alone, including backfilled rows, never counts toward the cap' +); + +RESET ROLE; +SET LOCAL ROLE authenticated; +SELECT set_config('request.jwt.claim.role', 'authenticated', true); +SELECT throws_ok( + $$SELECT * FROM public.consume_ai_quota('66666666-6666-4666-8666-666666666666', 3)$$, + '42501', + NULL, + 'unauthorized callers cannot execute the AI quota RPC' +); + +RESET ROLE; +SELECT * FROM finish(); +ROLLBACK;