From 1df6a684f6f44f82dd637f8ee80b57c0b3eacc0f Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 22 Oct 2026 11:14:41 +0000 Subject: [PATCH 1/7] Weight the daily AI quota by model cost and cap monthly AI spend Expensive models use several daily quota units per request (Opus 5, Sonnet 2, cheap models 1), and a user whose logged AI spend this UTC month reaches the budget ($3) is refused until next month. Weights, budget and tier defaults live in _shared/usage-limits.ts. The limit dialog explains a weighted refusal and has its own monthly variant. Co-Authored-By: Claude Opus 5.5 --- .../verify-debatelab/features/README.md | 1 + .../verify-debatelab/features/usage-limits.md | 23 +++++ .../recipes/usage-limits.spec.ts | 72 ++++++++++++++ docs/architecture.md | 2 + .../subscription/LimitReachedModal.tsx | 67 ++++++++----- src/hooks/use-subscription-error.ts | 13 ++- src/lib/ai/errors.ts | 6 +- src/lib/ai/request.ts | 3 +- src/locales/en.json | 4 + src/locales/es.json | 4 + src/locales/hu.json | 4 + src/pages/AskPage.tsx | 1 + src/pages/AttackPage.tsx | 1 + src/pages/BrainstormPage.tsx | 1 + src/pages/FactoidsPage.tsx | 1 + src/pages/GeneratePage.tsx | 1 + .../functions/_shared/entitlements.test.ts | 43 +++++++- supabase/functions/_shared/entitlements.ts | 35 +++++-- supabase/functions/_shared/usage-limits.ts | 38 ++++++++ supabase/functions/ai-stream/index.ts | 1 + supabase/functions/ai/index.ts | 3 +- ...2120000_index_ai_requests_user_created.sql | 6 ++ ...22120001_weighted_quota_monthly_budget.sql | 97 +++++++++++++++++++ supabase/tests/daily_usage_quota_test.sql | 71 +++++++++++++- 24 files changed, 453 insertions(+), 45 deletions(-) create mode 100644 .claude/skills/verify-debatelab/features/usage-limits.md create mode 100644 .claude/skills/verify-debatelab/recipes/usage-limits.spec.ts create mode 100644 supabase/functions/_shared/usage-limits.ts create mode 100644 supabase/migrations/20261022120000_index_ai_requests_user_created.sql create mode 100644 supabase/migrations/20261022120001_weighted_quota_monthly_budget.sql diff --git a/.claude/skills/verify-debatelab/features/README.md b/.claude/skills/verify-debatelab/features/README.md index 565c095c3..58833d4f9 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 monthly 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..d48d2b966 --- /dev/null +++ b/.claude/skills/verify-debatelab/features/usage-limits.md @@ -0,0 +1,23 @@ +# Usage limits: weighted daily quota and monthly 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 logged AI spend for the current UTC month reaches the monthly budget ($3 by default), every AI request is refused with its own dialog until the 1st of next month. The weights, the budget and the tier defaults live in `supabase/functions/_shared/usage-limits.ts`; the reservation is `consume_daily_quota` in `supabase/migrations/20261022120001_weighted_quota_monthly_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-monthly`: with `ai_requests.cost_usd` for this month at or above the budget, the next request opens `Monthly AI Allowance Used` with an `OK` button and no upgrade offer, makes no provider call, and leaves the daily counter alone. + +## How to get to it (user POV) + +- Any AI tool, for example `/app/generate`, once the day's allowance or the month's budget is used up. The dialog opens on submit. + +## Driving it with verify.sh + Playwright + +- `bin/verify.sh drive recipes/usage-limits` covers both sub-features in one test. +- 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 `cost_usd: 3` for the monthly 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 monthly sum counts every `ai_requests` row of the month with a known cost, including voice and Jev routing rows. 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..a6f8bb2db --- /dev/null +++ b/.claude/skills/verify-debatelab/recipes/usage-limits.spec.ts @@ -0,0 +1,72 @@ +import type { Page } from "@playwright/test"; +import { admin, AI_TIMEOUT, aiResponse, 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 monthly 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 monthly budget with spend logged earlier this month. + 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: 3, + }); + if (spendError) throw spendError; + await page.getByRole("button", { name: "Generate Debate Case" }).click(); + const monthly = page.getByRole("dialog"); + await expect(monthly.getByText("Monthly AI Allowance Used")).toBeVisible({ timeout: AI_TIMEOUT }); + await expect(monthly.getByText("It refreshes on the 1st of next month (UTC).", { exact: false })).toBeVisible(); + await evidence.shot("03-monthly-cap"); + expect(await generateCalls()).toBe(1); + expect(await aiCounter(userId)).toBe(4); + await evidence.step("Monthly cap refused the request; no provider call; daily counter unchanged"); + await monthly.getByRole("button", { name: "OK" }).click(); + await expect(monthly).toHaveCount(0); +}); diff --git a/docs/architecture.md b/docs/architecture.md index 30b508c00..6851c18c0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -136,6 +136,8 @@ All model requests use the single OpenRouter backend route and `OPENROUTER_API_K `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 and normally resolves to Claude Opus 5.5. 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_daily_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 `monthly_budget_reached` once the user's `ai_requests.cost_usd` for the current UTC month reaches the monthly AI budget. Every logged row counts toward that sum, voice and Jev routing included, and rows with unknown cost do not. 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 one unit per call and are not gated by the monthly budget. + ## 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 616e2e324..dc4655761 100644 --- a/src/components/features/subscription/LimitReachedModal.tsx +++ b/src/components/features/subscription/LimitReachedModal.tsx @@ -3,18 +3,39 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } f import { Button } from '@/components/ui/button'; import { AlertTriangle, Zap, Clock } from 'lucide-react'; +export type LimitModalVariant = 'limit_reached' | 'trial_expired' | 'monthly_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 isTrialExpired = variant === 'trial_expired'; + const isMonthlyBudget = variant === 'monthly_budget_reached'; + const remaining = Math.max(0, (limit || 0) - (current || 0)); + // A weighted model can be refused while lighter requests still fit today. + const isWeightedRefusal = !isTrialExpired && !isMonthlyBudget && (units ?? 1) > 1 && remaining > 0; + + const title = isTrialExpired + ? t('limitModal.trialEnded') + : isMonthlyBudget + ? t('limitModal.monthlyReached') + : t('limitModal.limitReached'); + const description = isTrialExpired + ? t('limitModal.trialEndedDesc') + : isMonthlyBudget + ? t('limitModal.monthlyReachedDesc') + : isWeightedRefusal + ? t('limitModal.weightedDesc', { units, remaining }) + : t('limitModal.limitReachedDesc', { current: current || 0, limit: limit || 0 }); return ( @@ -27,28 +48,30 @@ 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} -
- - -

- {t('limitModal.upgradeNote')} -

-
+ {isMonthlyBudget ? ( +
+ +
+ ) : ( +
+ + +

+ {t('limitModal.upgradeNote')} +

+
+ )}
); diff --git a/src/hooks/use-subscription-error.ts b/src/hooks/use-subscription-error.ts index 403788c0a..e521a47c4 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 === 'monthly_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/errors.ts b/src/lib/ai/errors.ts index 2801609c8..232a00acb 100644 --- a/src/lib/ai/errors.ts +++ b/src/lib/ai/errors.ts @@ -56,16 +56,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; } } @@ -234,6 +237,7 @@ export function mapAIError( code, limit: typeof payload?.limit === 'number' ? payload.limit : undefined, current: typeof payload?.current === 'number' ? payload.current : undefined, + units: typeof payload?.units === 'number' ? payload.units : undefined, }); } diff --git a/src/lib/ai/request.ts b/src/lib/ai/request.ts index 7f4187fc3..6bd75a7a9 100644 --- a/src/lib/ai/request.ts +++ b/src/lib/ai/request.ts @@ -254,13 +254,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') { + if (errorCode === 'trial_expired' || errorCode === 'limit_reached' || errorCode === 'monthly_budget_reached') { throw new AISubscriptionError(errorMessage, { statusCode: 402, requestId, code: errorCode, limit: data.limit as number | undefined, current: data.current as number | undefined, + units: data.units as number | undefined, }); } diff --git a/src/locales/en.json b/src/locales/en.json index 7a2b4ac96..d2407d51d 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1330,6 +1330,10 @@ "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.", + "monthlyReached": "Monthly AI Allowance Used", + "monthlyReachedDesc": "You've used this month's AI allowance. It refreshes on the 1st of next month (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 e03254f8e..81e3c6b0a 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -1330,6 +1330,10 @@ "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.", + "monthlyReached": "Asignación mensual de IA agotada", + "monthlyReachedDesc": "Has usado la asignación de IA de este mes. Se renueva el día 1 del próximo mes (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 46a0e4953..ef9239d81 100644 --- a/src/locales/hu.json +++ b/src/locales/hu.json @@ -1330,6 +1330,10 @@ "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).", + "monthlyReached": "Elfogyott a havi MI keret", + "monthlyReachedDesc": "Felhasználtad az e havi MI keretet. A következő hónap 1-jén (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 52c2d31a9..70cef42d8 100644 --- a/src/pages/AskPage.tsx +++ b/src/pages/AskPage.tsx @@ -469,6 +469,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); }} /> ) => 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_daily_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,37 @@ 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 monthly 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_monthly_cap_usd, MONTHLY_AI_BUDGET_USD, "p_monthly_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 monthly budget refusal has its own code", async () => { + await withSupabase({ user_settings: proUser, quota: [{ allowed: false, current_count: 2, denied_by: "monthly" }] }, async () => { + const result = await checkRateLimit("user-1", "openai/gpt-6-luna"); + assertEquals(result.allowed, false, "allowed"); + assertEquals(result.code, "monthly_budget_reached", "code"); + assertEquals(result.statusCode, 402, "statusCode"); + }); +}); diff --git a/supabase/functions/_shared/entitlements.ts b/supabase/functions/_shared/entitlements.ts index 1ae2b7afa..75c358e1a 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 { MONTHLY_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" | "monthly_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 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 }; diff --git a/supabase/functions/_shared/usage-limits.ts b/supabase/functions/_shared/usage-limits.ts new file mode 100644 index 000000000..1fc520be3 --- /dev/null +++ b/supabase/functions/_shared/usage-limits.ts @@ -0,0 +1,38 @@ +// 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, +}; + +/** + * Monthly cap on a user's logged AI spend (ai_requests.cost_usd), in USD, + * per calendar month in UTC. Set to null to turn the cap off. + */ +export const MONTHLY_AI_BUDGET_USD: number | null = 3; + +/** 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/index.ts b/supabase/functions/ai-stream/index.ts index f595da149..08d15571e 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/index.ts b/supabase/functions/ai/index.ts index 786ccb8f2..d67021111 100644 --- a/supabase/functions/ai/index.ts +++ b/supabase/functions/ai/index.ts @@ -538,7 +538,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/migrations/20261022120000_index_ai_requests_user_created.sql b/supabase/migrations/20261022120000_index_ai_requests_user_created.sql new file mode 100644 index 000000000..7a360c1aa --- /dev/null +++ b/supabase/migrations/20261022120000_index_ai_requests_user_created.sql @@ -0,0 +1,6 @@ +-- The monthly AI budget in consume_daily_quota sums a user's ai_requests for +-- the current month 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/20261022120001_weighted_quota_monthly_budget.sql b/supabase/migrations/20261022120001_weighted_quota_monthly_budget.sql new file mode 100644 index 000000000..acd5d03c2 --- /dev/null +++ b/supabase/migrations/20261022120001_weighted_quota_monthly_budget.sql @@ -0,0 +1,97 @@ +-- Weight the daily AI quota by model cost and add a monthly AI spend cap. +-- +-- p_units: how many quota units this request reserves (an expensive model +-- counts as several requests). Defaults to 1 so existing callers (import, +-- share, voice, crossfire signals) keep their behaviour. +-- p_monthly_cap_usd: when set, refuse the reservation once the user's logged +-- ai_requests.cost_usd for the current UTC month reaches the cap. NULL skips +-- the check. Rows with unknown pricing (cost_usd IS NULL) are not counted, +-- and cost is logged after the provider call, so the cap is soft by about +-- one request. +-- +-- denied_by reports which limit refused the call: 'daily' or 'monthly'. +-- The weights and the cap live in supabase/functions/_shared/usage-limits.ts. +-- The month's spend is read through idx_ai_requests_user_created +-- (20261022120000_index_ai_requests_user_created.sql). + +-- New parameters and a new result column change the signature, so remove the +-- old overload first and keep exactly one. Only service-role Edge Functions +-- call it, by named arguments, and the new parameters have defaults, so calls +-- from the previous function build still resolve to the replacement. +-- squawk-ignore ban-drop-function +DROP FUNCTION public.consume_daily_quota(uuid, integer, text); + +CREATE FUNCTION public.consume_daily_quota( + p_user_id uuid, + p_limit integer, + p_quota_key text DEFAULT 'ai', + p_units integer DEFAULT 1, + p_monthly_cap_usd numeric DEFAULT NULL +) +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_month_start timestamptz := date_trunc('month', now() AT TIME ZONE 'UTC') AT TIME ZONE 'UTC'; + v_month_spend numeric; +BEGIN + IF auth.role() <> 'service_role' OR p_user_id IS NULL OR p_limit < 1 OR v_quota_key IS NULL + OR p_units IS NULL OR p_units < 1 OR p_monthly_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_monthly_cap_usd IS NOT NULL THEN + SELECT COALESCE(sum(cost_usd), 0) INTO v_month_spend + FROM public.ai_requests + WHERE user_id = p_user_id + AND created_at >= v_month_start; + + IF v_month_spend >= p_monthly_cap_usd THEN + RETURN QUERY SELECT false, COALESCE(v_current_count, 0), 'monthly'::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_daily_quota(uuid, integer, text, integer, numeric) + FROM PUBLIC, anon, authenticated; +GRANT EXECUTE ON FUNCTION public.consume_daily_quota(uuid, integer, text, integer, numeric) + TO service_role; diff --git a/supabase/tests/daily_usage_quota_test.sql b/supabase/tests/daily_usage_quota_test.sql index 14cb94513..c17479ba5 100644 --- a/supabase/tests/daily_usage_quota_test.sql +++ b/supabase/tests/daily_usage_quota_test.sql @@ -1,5 +1,5 @@ BEGIN; -SELECT plan(14); +SELECT plan(21); INSERT INTO auth.users (id) VALUES @@ -36,15 +36,15 @@ SELECT results_eq( 'the migration leaves one consume_daily_quota overload' ); SELECT ok( - NOT has_function_privilege('anon', 'public.consume_daily_quota(uuid, integer, text)', 'EXECUTE'), + NOT has_function_privilege('anon', 'public.consume_daily_quota(uuid, integer, text, integer, numeric)', 'EXECUTE'), 'anonymous callers cannot reserve quota' ); SELECT ok( - NOT has_function_privilege('authenticated', 'public.consume_daily_quota(uuid, integer, text)', 'EXECUTE'), + NOT has_function_privilege('authenticated', 'public.consume_daily_quota(uuid, integer, text, integer, numeric)', 'EXECUTE'), 'authenticated callers cannot reserve quota' ); SELECT ok( - has_function_privilege('service_role', 'public.consume_daily_quota(uuid, integer, text)', 'EXECUTE'), + has_function_privilege('service_role', 'public.consume_daily_quota(uuid, integer, text, integer, numeric)', 'EXECUTE'), 'service role can reserve quota' ); @@ -106,5 +106,68 @@ SELECT results_eq( 'rows without an explicit quota key retain their count under ai' ); +-- Weighted reservations and the monthly AI budget. +INSERT INTO auth.users (id) VALUES ('66666666-6666-4666-8666-666666666666'); +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_daily_quota('66666666-6666-4666-8666-666666666666', 10, 'ai', 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_daily_quota('66666666-6666-4666-8666-666666666666', 10, 'ai', 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_daily_quota('66666666-6666-4666-8666-666666666666', 10, 'ai', 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_daily_quota('66666666-6666-4666-8666-666666666666', 3, 'weighted_first', 5)$$, + $$VALUES (false, 0, 'daily'::text)$$, + 'a first request larger than the whole limit is refused' +); +SELECT throws_ok( + $$SELECT * FROM public.consume_daily_quota('66666666-6666-4666-8666-666666666666', 10, 'ai', 0)$$, + 'P0001', + 'quota reservation is restricted to trusted server code', + 'zero-unit reservations are rejected' +); + +RESET ROLE; +INSERT INTO public.ai_requests (user_id, feature, provider, model, status, cost_usd, created_at) +VALUES + ('66666666-6666-4666-8666-666666666666', 'ask', 'openrouter', 'anthropic/claude-opus-5.5', 'success', 2.50, now()), + ('66666666-6666-4666-8666-666666666666', 'ask', 'openrouter', 'anthropic/claude-opus-5.5', 'success', 9.00, now() - interval '40 days'); +SET LOCAL ROLE service_role; + +SELECT results_eq( + $$SELECT allowed, denied_by + FROM public.consume_daily_quota('66666666-6666-4666-8666-666666666666', 100, 'ai', 1, 3.00)$$, + $$VALUES (true, NULL::text)$$, + 'spend under the monthly cap is allowed and earlier months do not count' +); + +RESET ROLE; +INSERT INTO public.ai_requests (user_id, feature, provider, model, status, cost_usd) +VALUES ('66666666-6666-4666-8666-666666666666', 'ask', 'openrouter', 'anthropic/claude-opus-5.5', 'success', 0.50); +SET LOCAL ROLE service_role; + +SELECT results_eq( + $$SELECT allowed, current_count, denied_by + FROM public.consume_daily_quota('66666666-6666-4666-8666-666666666666', 100, 'ai', 1, 3.00)$$, + $$VALUES (false, 11, 'monthly'::text)$$, + 'reaching the monthly cap refuses the request without consuming daily quota' +); + +RESET ROLE; SELECT * FROM finish(); ROLLBACK; From 60e3d069ddbd533b09368231fc55e833ed39fbdf Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 22 Oct 2026 11:23:42 +0000 Subject: [PATCH 2/7] Point the legacy quota replay at the new consume_daily_quota signature Co-Authored-By: Claude Opus 5.5 --- scripts/test-daily-quota.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/test-daily-quota.sh b/scripts/test-daily-quota.sh index 224dc13cc..a03efd27d 100644 --- a/scripts/test-daily-quota.sh +++ b/scripts/test-daily-quota.sh @@ -29,7 +29,7 @@ trap cleanup EXIT BEGIN; INSERT INTO auth.users (id) VALUES ('77777777-7777-4777-8777-777777777777'); -DROP FUNCTION public.consume_daily_quota(uuid, integer, text); +DROP FUNCTION public.consume_daily_quota(uuid, integer, text, integer, numeric); CREATE FUNCTION public.consume_daily_quota(uuid, integer) RETURNS TABLE (allowed boolean, current_count integer) LANGUAGE sql From df127d411c81faefeb77f200ff735a27caef5817 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 22 Oct 2026 11:29:31 +0000 Subject: [PATCH 3/7] Move the quota migrations past the cost-logging migration's timestamp Co-Authored-By: Claude Opus 5.5 --- .claude/skills/verify-debatelab/features/usage-limits.md | 2 +- supabase/functions/_shared/entitlements.ts | 2 +- ...ed.sql => 20261022130000_index_ai_requests_user_created.sql} | 0 ...get.sql => 20261022130001_weighted_quota_monthly_budget.sql} | 2 +- 4 files changed, 3 insertions(+), 3 deletions(-) rename supabase/migrations/{20261022120000_index_ai_requests_user_created.sql => 20261022130000_index_ai_requests_user_created.sql} (100%) rename supabase/migrations/{20261022120001_weighted_quota_monthly_budget.sql => 20261022130001_weighted_quota_monthly_budget.sql} (98%) diff --git a/.claude/skills/verify-debatelab/features/usage-limits.md b/.claude/skills/verify-debatelab/features/usage-limits.md index d48d2b966..7bc6ea8c4 100644 --- a/.claude/skills/verify-debatelab/features/usage-limits.md +++ b/.claude/skills/verify-debatelab/features/usage-limits.md @@ -1,6 +1,6 @@ # Usage limits: weighted daily quota and monthly 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 logged AI spend for the current UTC month reaches the monthly budget ($3 by default), every AI request is refused with its own dialog until the 1st of next month. The weights, the budget and the tier defaults live in `supabase/functions/_shared/usage-limits.ts`; the reservation is `consume_daily_quota` in `supabase/migrations/20261022120001_weighted_quota_monthly_budget.sql`. +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 logged AI spend for the current UTC month reaches the monthly budget ($3 by default), every AI request is refused with its own dialog until the 1st of next month. The weights, the budget and the tier defaults live in `supabase/functions/_shared/usage-limits.ts`; the reservation is `consume_daily_quota` in `supabase/migrations/20261022130001_weighted_quota_monthly_budget.sql`. ## Sub-features diff --git a/supabase/functions/_shared/entitlements.ts b/supabase/functions/_shared/entitlements.ts index 75c358e1a..1e1e2d3fc 100644 --- a/supabase/functions/_shared/entitlements.ts +++ b/supabase/functions/_shared/entitlements.ts @@ -177,7 +177,7 @@ export async function checkRateLimit(userId: string | null, model?: ModelSelecti // Atomically reserve today's quota units server-side so concurrent requests // can't race past the limit. Expensive models reserve several units, and // the same call refuses users over the monthly AI budget (see - // 20261022120001_weighted_quota_monthly_budget.sql). + // 20261022130001_weighted_quota_monthly_budget.sql). const units = quotaUnitsFor(model); const { data: quota, error: quotaError } = await supabase.rpc("consume_daily_quota", { p_user_id: userId, diff --git a/supabase/migrations/20261022120000_index_ai_requests_user_created.sql b/supabase/migrations/20261022130000_index_ai_requests_user_created.sql similarity index 100% rename from supabase/migrations/20261022120000_index_ai_requests_user_created.sql rename to supabase/migrations/20261022130000_index_ai_requests_user_created.sql diff --git a/supabase/migrations/20261022120001_weighted_quota_monthly_budget.sql b/supabase/migrations/20261022130001_weighted_quota_monthly_budget.sql similarity index 98% rename from supabase/migrations/20261022120001_weighted_quota_monthly_budget.sql rename to supabase/migrations/20261022130001_weighted_quota_monthly_budget.sql index acd5d03c2..180115c97 100644 --- a/supabase/migrations/20261022120001_weighted_quota_monthly_budget.sql +++ b/supabase/migrations/20261022130001_weighted_quota_monthly_budget.sql @@ -12,7 +12,7 @@ -- denied_by reports which limit refused the call: 'daily' or 'monthly'. -- The weights and the cap live in supabase/functions/_shared/usage-limits.ts. -- The month's spend is read through idx_ai_requests_user_created --- (20261022120000_index_ai_requests_user_created.sql). +-- (20261022130000_index_ai_requests_user_created.sql). -- New parameters and a new result column change the signature, so remove the -- old overload first and keep exactly one. Only service-role Edge Functions From 59d49c7fec498e3da8ea339ed30a7d7f3ca3a396 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 22 Oct 2026 11:43:09 +0000 Subject: [PATCH 4/7] Translate quota refusals where Sparring and Crossfire show them inline Codex review: those pages printed the server's English reason. The limit dialog's text now comes from one helper that getUserFacingAIError and Sparring also use. Co-Authored-By: Claude Opus 5.5 --- .../verify-debatelab/features/usage-limits.md | 4 ++- .../recipes/usage-limits.spec.ts | 28 ++++++++++++++++++- .../subscription/LimitReachedModal.tsx | 12 ++------ src/lib/ai/error-display.ts | 14 +++++++++- src/pages/SparringPage.tsx | 4 ++- 5 files changed, 48 insertions(+), 14 deletions(-) diff --git a/.claude/skills/verify-debatelab/features/usage-limits.md b/.claude/skills/verify-debatelab/features/usage-limits.md index 7bc6ea8c4..5cc268767 100644 --- a/.claude/skills/verify-debatelab/features/usage-limits.md +++ b/.claude/skills/verify-debatelab/features/usage-limits.md @@ -7,13 +7,15 @@ The daily AI allowance counts units, not requests. An expensive model uses sever - `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-monthly`: with `ai_requests.cost_usd` for this month at or above the budget, the next request opens `Monthly AI Allowance Used` with an `OK` button and no upgrade offer, makes no provider call, and leaves the daily counter alone. +- `usage-monthly-inline`: Sparring shows refusals inline rather than in the dialog. With the budget reached, the opponent's turn shows the translated monthly 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 month's budget is used up. The dialog opens on submit. ## Driving it with verify.sh + Playwright -- `bin/verify.sh drive recipes/usage-limits` covers both sub-features in one test. +- `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 `cost_usd: 3` for the monthly step. - End states are read from `daily_usage_counters.request_count` and from the recorded provider calls. diff --git a/.claude/skills/verify-debatelab/recipes/usage-limits.spec.ts b/.claude/skills/verify-debatelab/recipes/usage-limits.spec.ts index a6f8bb2db..d503dfc9a 100644 --- a/.claude/skills/verify-debatelab/recipes/usage-limits.spec.ts +++ b/.claude/skills/verify-debatelab/recipes/usage-limits.spec.ts @@ -1,5 +1,5 @@ import type { Page } from "@playwright/test"; -import { admin, AI_TIMEOUT, aiResponse, expect, providerCalls, startDebate, test } from "./fixtures"; +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"; @@ -70,3 +70,29 @@ test("usage-weighted: Opus counts as 5 requests, a lighter model still runs, and await monthly.getByRole("button", { name: "OK" }).click(); await expect(monthly).toHaveCount(0); }); + +test("usage-monthly-inline: Sparring shows the translated monthly-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: 3, + }); + 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 month's AI allowance.", { exact: false })).toBeVisible({ timeout: AI_TIMEOUT }); + await expect(page.getByText("You've reached this month's AI usage allowance", { exact: false })).toHaveCount(0); + await evidence.shot("04-sparring-monthly-cap"); + expect((await providerCalls()).filter((call) => call.feature === "sparring")).toEqual([]); + await evidence.step("Sparring opponent turn refused by the monthly cap with the translated message; no provider call"); +}); diff --git a/src/components/features/subscription/LimitReachedModal.tsx b/src/components/features/subscription/LimitReachedModal.tsx index dc4655761..edc1deb4d 100644 --- a/src/components/features/subscription/LimitReachedModal.tsx +++ b/src/components/features/subscription/LimitReachedModal.tsx @@ -2,6 +2,7 @@ import { useTranslation } from 'react-i18next'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; import { AlertTriangle, Zap, Clock } from 'lucide-react'; +import { subscriptionLimitMessage } from '@/lib/ai/error-display'; export type LimitModalVariant = 'limit_reached' | 'trial_expired' | 'monthly_budget_reached'; @@ -20,22 +21,13 @@ export function LimitReachedModal({ open, onOpenChange, variant, limit, current, const { t } = useTranslation(); const isTrialExpired = variant === 'trial_expired'; const isMonthlyBudget = variant === 'monthly_budget_reached'; - const remaining = Math.max(0, (limit || 0) - (current || 0)); - // A weighted model can be refused while lighter requests still fit today. - const isWeightedRefusal = !isTrialExpired && !isMonthlyBudget && (units ?? 1) > 1 && remaining > 0; const title = isTrialExpired ? t('limitModal.trialEnded') : isMonthlyBudget ? t('limitModal.monthlyReached') : t('limitModal.limitReached'); - const description = isTrialExpired - ? t('limitModal.trialEndedDesc') - : isMonthlyBudget - ? t('limitModal.monthlyReachedDesc') - : isWeightedRefusal - ? t('limitModal.weightedDesc', { units, remaining }) - : t('limitModal.limitReachedDesc', { current: current || 0, limit: limit || 0 }); + const description = subscriptionLimitMessage({ code: variant, limit, current, units }); return ( diff --git a/src/lib/ai/error-display.ts b/src/lib/ai/error-display.ts index 1289949a1..f45509cf2 100644 --- a/src/lib/ai/error-display.ts +++ b/src/lib/ai/error-display.ts @@ -1,5 +1,5 @@ import i18n from '@/i18n'; -import { AIProviderError } from './client'; +import { AIProviderError, isAISubscriptionError } from './client'; const PROVIDER_LABELS: Record = { openai: 'OpenAI', @@ -16,7 +16,19 @@ 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 function subscriptionLimitMessage({ code, limit, current, units }: { code?: string; limit?: number; current?: number; units?: number }): string { + if (code === 'trial_expired') return i18n.t('limitModal.trialEndedDesc'); + if (code === 'monthly_budget_reached') return i18n.t('limitModal.monthlyReachedDesc'); + const remaining = Math.max(0, (limit || 0) - (current || 0)); + // A weighted model can be refused while lighter requests still fit today. + if ((units ?? 1) > 1 && remaining > 0) return i18n.t('limitModal.weightedDesc', { units, remaining }); + return i18n.t('limitModal.limitReachedDesc', { current: current || 0, limit: limit || 0 }); +} + export function getUserFacingAIError(error: unknown): string { + 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); const raw = error.message; diff --git a/src/pages/SparringPage.tsx b/src/pages/SparringPage.tsx index 8128f7868..f168d4e3f 100644 --- a/src/pages/SparringPage.tsx +++ b/src/pages/SparringPage.tsx @@ -5,7 +5,8 @@ import { ArrowLeft, CircleHelp } from 'lucide-react'; import { useLocation, useNavigate } from 'react-router-dom'; import { useSparringStore } from '@/lib/stores/sparring-store'; import { useSettingsStore } from '@/lib/stores/settings-store'; -import { generateSparringTurn, judgeSparringSession } from '@/lib/ai/client'; +import { generateSparringTurn, isAISubscriptionError, judgeSparringSession } from '@/lib/ai/client'; +import { getUserFacingAIError } from '@/lib/ai/error-display'; import type { SparringRespondInput, SparringScoreInput, SparringSpeech } from '@/lib/ai/schemas/sparring'; import { SparringSetup, type SparringSetupValues } from '@/components/features/sparring/SparringSetup'; import { SparringRound } from '@/components/features/sparring/SparringRound'; @@ -26,6 +27,7 @@ import type { SpeechDelivery } from '@/lib/voice/delivery'; import { opponentVoice } from '@/lib/voice/opponent-voice'; function readableError(error: unknown, fallback: string) { + if (isAISubscriptionError(error)) return getUserFacingAIError(error); if (error instanceof Error && error.message.trim()) return error.message; return fallback; } From e2591e26d743a887b62ebd044aeca23d5f301700 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 22 Oct 2026 12:51:31 +0000 Subject: [PATCH 5/7] Replace the monthly AI budget with a $2 weekly cap that excludes BYOK spend Adds ai_requests.cap_cost_usd (OpenRouter credit charges plus Exa and ElevenLabs) and a new consume_ai_quota function for the weighted daily quota and the weekly cap. consume_daily_quota is left unchanged, so the migration is additive. Old rows have NULL cap spend and never count. --- .../verify-debatelab/features/README.md | 2 +- .../verify-debatelab/features/usage-limits.md | 14 +- .../recipes/usage-limits.spec.ts | 32 ++--- docs/architecture.md | 2 +- scripts/test-daily-quota.sh | 2 +- .../subscription/LimitReachedModal.tsx | 12 +- src/hooks/use-subscription-error.ts | 2 +- src/lib/ai/error-display.ts | 2 +- src/lib/ai/request.ts | 2 +- src/locales/en.json | 4 +- src/locales/es.json | 4 +- src/locales/hu.json | 4 +- src/test/security-hardening.test.ts | 3 +- supabase/functions/_shared/cost.test.ts | 42 ++++++ supabase/functions/_shared/cost.ts | 26 ++++ .../functions/_shared/entitlements.test.ts | 14 +- supabase/functions/_shared/entitlements.ts | 18 +-- supabase/functions/_shared/usage-limits.ts | 9 +- supabase/functions/ai-stream/handler_test.ts | 2 +- supabase/functions/ai-stream/index_test.ts | 2 +- supabase/functions/ai/debate-context.test.ts | 6 +- .../functions/ai/generate-honesty_test.ts | 2 +- .../functions/ai/handler-research.test.ts | 8 +- supabase/functions/ai/provider-errors_test.ts | 2 +- supabase/functions/ai/stream.test.ts | 2 +- supabase/functions/ai/voice.test.ts | 2 +- ...2130000_index_ai_requests_user_created.sql | 4 +- ...22130001_weighted_quota_monthly_budget.sql | 97 ------------- ...022130001_weighted_quota_weekly_budget.sql | 108 ++++++++++++++ .../tests/ai_quota_weekly_budget_test.sql | 134 ++++++++++++++++++ supabase/tests/daily_usage_quota_test.sql | 71 +--------- 31 files changed, 395 insertions(+), 239 deletions(-) delete mode 100644 supabase/migrations/20261022130001_weighted_quota_monthly_budget.sql create mode 100644 supabase/migrations/20261022130001_weighted_quota_weekly_budget.sql create mode 100644 supabase/tests/ai_quota_weekly_budget_test.sql diff --git a/.claude/skills/verify-debatelab/features/README.md b/.claude/skills/verify-debatelab/features/README.md index 58833d4f9..9b93f3082 100644 --- a/.claude/skills/verify-debatelab/features/README.md +++ b/.claude/skills/verify-debatelab/features/README.md @@ -51,7 +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 monthly AI budget dialog (`recipes/usage-limits.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 index 5cc268767..45c8d46d3 100644 --- a/.claude/skills/verify-debatelab/features/usage-limits.md +++ b/.claude/skills/verify-debatelab/features/usage-limits.md @@ -1,25 +1,25 @@ -# Usage limits: weighted daily quota and monthly AI budget +# 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 logged AI spend for the current UTC month reaches the monthly budget ($3 by default), every AI request is refused with its own dialog until the 1st of next month. The weights, the budget and the tier defaults live in `supabase/functions/_shared/usage-limits.ts`; the reservation is `consume_daily_quota` in `supabase/migrations/20261022130001_weighted_quota_monthly_budget.sql`. +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-monthly`: with `ai_requests.cost_usd` for this month at or above the budget, the next request opens `Monthly AI Allowance Used` with an `OK` button and no upgrade offer, makes no provider call, and leaves the daily counter alone. +- `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-monthly-inline`: Sparring shows refusals inline rather than in the dialog. With the budget reached, the opponent's turn shows the translated monthly message from the catalog, not the server's English reason, and no provider call is made. +- `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 month's budget is used up. The dialog opens on submit. +- 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 `cost_usd: 3` for the monthly step. +- 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 monthly sum counts every `ai_requests` row of the month with a known cost, including voice and Jev routing rows. Cost is logged after the provider answers, so the cap can be passed by about one request. +- 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 index d503dfc9a..b3cd94846 100644 --- a/.claude/skills/verify-debatelab/recipes/usage-limits.spec.ts +++ b/.claude/skills/verify-debatelab/recipes/usage-limits.spec.ts @@ -21,7 +21,7 @@ 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 monthly cap stops everything", async ({ signedIn: page, user, evidence }) => { +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. @@ -54,27 +54,27 @@ test("usage-weighted: Opus counts as 5 requests, a lighter model still runs, and await evidence.shot("02-haiku-runs"); await evidence.step("Haiku ran and used 1 unit (counter 4)"); - // Reach the monthly budget with spend logged earlier this month. + // 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: 3, + 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 monthly = page.getByRole("dialog"); - await expect(monthly.getByText("Monthly AI Allowance Used")).toBeVisible({ timeout: AI_TIMEOUT }); - await expect(monthly.getByText("It refreshes on the 1st of next month (UTC).", { exact: false })).toBeVisible(); - await evidence.shot("03-monthly-cap"); + 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("Monthly cap refused the request; no provider call; daily counter unchanged"); - await monthly.getByRole("button", { name: "OK" }).click(); - await expect(monthly).toHaveCount(0); + 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-monthly-inline: Sparring shows the translated monthly-cap message, not the server's English reason", async ({ signedIn: page, user, evidence }) => { +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: 3, + 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; @@ -90,9 +90,9 @@ test("usage-monthly-inline: Sparring shows the translated monthly-cap message, n 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 month's AI allowance.", { exact: false })).toBeVisible({ timeout: AI_TIMEOUT }); - await expect(page.getByText("You've reached this month's AI usage allowance", { exact: false })).toHaveCount(0); - await evidence.shot("04-sparring-monthly-cap"); + 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 monthly cap with the translated message; no provider call"); + 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 68dacd00e..ff3661b27 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -140,7 +140,7 @@ All model requests use the single OpenRouter backend route and `OPENROUTER_API_K `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 and normally resolves to Claude Opus 5.5. 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_daily_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 `monthly_budget_reached` once the user's `ai_requests.cost_usd` for the current UTC month reaches the monthly AI budget. Every logged row counts toward that sum, voice and Jev routing included, and rows with unknown cost do not. 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 one unit per call and are not gated by the monthly budget. +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. Rows with unknown 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 diff --git a/scripts/test-daily-quota.sh b/scripts/test-daily-quota.sh index a03efd27d..224dc13cc 100644 --- a/scripts/test-daily-quota.sh +++ b/scripts/test-daily-quota.sh @@ -29,7 +29,7 @@ trap cleanup EXIT BEGIN; INSERT INTO auth.users (id) VALUES ('77777777-7777-4777-8777-777777777777'); -DROP FUNCTION public.consume_daily_quota(uuid, integer, text, integer, numeric); +DROP FUNCTION public.consume_daily_quota(uuid, integer, text); CREATE FUNCTION public.consume_daily_quota(uuid, integer) RETURNS TABLE (allowed boolean, current_count integer) LANGUAGE sql diff --git a/src/components/features/subscription/LimitReachedModal.tsx b/src/components/features/subscription/LimitReachedModal.tsx index d8c87f132..4776191f3 100644 --- a/src/components/features/subscription/LimitReachedModal.tsx +++ b/src/components/features/subscription/LimitReachedModal.tsx @@ -5,7 +5,7 @@ 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' | 'monthly_budget_reached'; +export type LimitModalVariant = 'limit_reached' | 'trial_expired' | 'weekly_budget_reached'; interface LimitReachedModalProps { open: boolean; @@ -22,14 +22,14 @@ export function LimitReachedModal({ open, onOpenChange, variant, limit, current, const { t } = useTranslation(); const { isPro } = useSubscription(); const isTrialExpired = variant === 'trial_expired'; - const isMonthlyBudget = variant === 'monthly_budget_reached'; + 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') - : isMonthlyBudget - ? t('limitModal.monthlyReached') + : isWeeklyBudget + ? t('limitModal.weeklyReached') : t('limitModal.limitReached'); const description = subscriptionLimitMessage({ code: variant, limit, current, units }); @@ -49,10 +49,10 @@ export function LimitReachedModal({ open, onOpenChange, variant, limit, current,
- {isMonthlyBudget || 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 e521a47c4..7e915b10e 100644 --- a/src/hooks/use-subscription-error.ts +++ b/src/hooks/use-subscription-error.ts @@ -32,7 +32,7 @@ export function useSubscriptionError(): SubscriptionErrorState { const handleSubscriptionError = useCallback((error: unknown): boolean => { if (isAISubscriptionError(error)) { const subError = error as AISubscriptionError; - if (subError.code === 'trial_expired' || subError.code === 'monthly_budget_reached') { + if (subError.code === 'trial_expired' || subError.code === 'weekly_budget_reached') { setLimitModalVariant(subError.code); } else { setLimitModalVariant('limit_reached'); diff --git a/src/lib/ai/error-display.ts b/src/lib/ai/error-display.ts index f45509cf2..151f1ea6a 100644 --- a/src/lib/ai/error-display.ts +++ b/src/lib/ai/error-display.ts @@ -19,7 +19,7 @@ function providerLabel(provider?: string, message = ''): string { /** The limit dialog's explanation, also used where a page shows the error inline. */ export function subscriptionLimitMessage({ code, limit, current, units }: { code?: string; limit?: number; current?: number; units?: number }): string { if (code === 'trial_expired') return i18n.t('limitModal.trialEndedDesc'); - if (code === 'monthly_budget_reached') return i18n.t('limitModal.monthlyReachedDesc'); + if (code === 'weekly_budget_reached') return i18n.t('limitModal.weeklyReachedDesc'); const remaining = Math.max(0, (limit || 0) - (current || 0)); // A weighted model can be refused while lighter requests still fit today. if ((units ?? 1) > 1 && remaining > 0) return i18n.t('limitModal.weightedDesc', { units, remaining }); diff --git a/src/lib/ai/request.ts b/src/lib/ai/request.ts index 6bd75a7a9..a5e0352e3 100644 --- a/src/lib/ai/request.ts +++ b/src/lib/ai/request.ts @@ -254,7 +254,7 @@ 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' || errorCode === 'monthly_budget_reached') { + if (errorCode === 'trial_expired' || errorCode === 'limit_reached' || errorCode === 'weekly_budget_reached') { throw new AISubscriptionError(errorMessage, { statusCode: 402, requestId, diff --git a/src/locales/en.json b/src/locales/en.json index 7501f6ed4..663abe92e 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1338,8 +1338,8 @@ "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.", - "monthlyReached": "Monthly AI Allowance Used", - "monthlyReachedDesc": "You've used this month's AI allowance. It refreshes on the 1st of next month (UTC). Contact support if you need more.", + "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", diff --git a/src/locales/es.json b/src/locales/es.json index 791a6f776..1746df7b7 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -1338,8 +1338,8 @@ "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.", - "monthlyReached": "Asignación mensual de IA agotada", - "monthlyReachedDesc": "Has usado la asignación de IA de este mes. Se renueva el día 1 del próximo mes (UTC). Contacta con soporte si necesitas más.", + "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", diff --git a/src/locales/hu.json b/src/locales/hu.json index 40eb99932..9988fbbd2 100644 --- a/src/locales/hu.json +++ b/src/locales/hu.json @@ -1338,8 +1338,8 @@ "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).", - "monthlyReached": "Elfogyott a havi MI keret", - "monthlyReachedDesc": "Felhasználtad az e havi MI keretet. A következő hónap 1-jén (UTC) frissül. Ha többre van szükséged, írj az ügyfélszolgálatnak.", + "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", diff --git a/src/test/security-hardening.test.ts b/src/test/security-hardening.test.ts index c6a3c59b6..be1d2b1f0 100644 --- a/src/test/security-hardening.test.ts +++ b/src/test/security-hardening.test.ts @@ -36,7 +36,8 @@ describe('security hardening regressions', () => { 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..876ff02ca 100644 --- a/supabase/functions/_shared/cost.test.ts +++ b/supabase/functions/_shared/cost.test.ts @@ -211,3 +211,45 @@ 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("unknown or incomplete spend never counts toward the cap", () => { + const known = normalizeOpenRouterUsage({ prompt_tokens: 10, completion_tokens: 10, cost: 0.5 }); + const unknown = normalizeOpenRouterUsage({ prompt_tokens: 10, completion_tokens: 10 }); + if (calculateRequestCost({ provider: "openrouter", model: "x", usage: mergeNormalizedUsage([known, unknown]) }).capCostUsd !== null) { + throw new Error("a partly unknown request got a cap figure"); + } + if (calculateRequestCost({ provider: "openrouter", model: "x", usage: markIncompleteUsage(known) }).capCostUsd !== null) { + throw new Error("incomplete work 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..82d993ae5 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,7 @@ export function calculateRequestCost(args: { outputTokens: usage?.outputTokens ?? null, reasoningOutputTokens: usage?.reasoningOutputTokens ?? null, costUsd: usage?.incomplete ? null : usage?.reportedCostUsd ?? null, + capCostUsd: usage && !usage.incomplete ? capCostOf(usage) ?? null : null, pricingSnapshotId: "openrouter-response-usage", pricingSourceUrl: "https://openrouter.ai/docs/cookbook/administration/usage-accounting", costCalculationVersion: OPENROUTER_COST_CALCULATION_VERSION, @@ -182,6 +205,9 @@ 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, + capCostUsd: defined.every((u) => !u.incomplete && 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 f1d9fa5ac..727e77fce 100644 --- a/supabase/functions/_shared/entitlements.test.ts +++ b/supabase/functions/_shared/entitlements.test.ts @@ -7,7 +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, MONTHLY_AI_BUDGET_USD } = await import("./usage-limits.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 }); @@ -34,7 +34,7 @@ async function withSupabase(routes: Routes, run: () => Promise): Promise { 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 monthly cap", async () => { +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_monthly_cap_usd, MONTHLY_AI_BUDGET_USD, "p_monthly_cap_usd"); + 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"); @@ -171,11 +171,11 @@ Deno.test("a weighted refusal says how many requests remain", async () => { }); }); -Deno.test("the monthly budget refusal has its own code", async () => { - await withSupabase({ user_settings: proUser, quota: [{ allowed: false, current_count: 2, denied_by: "monthly" }] }, async () => { +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, "monthly_budget_reached", "code"); + assertEquals(result.code, "weekly_budget_reached", "code"); assertEquals(result.statusCode, 402, "statusCode"); }); }); diff --git a/supabase/functions/_shared/entitlements.ts b/supabase/functions/_shared/entitlements.ts index 1e1e2d3fc..9d8b2ea58 100644 --- a/supabase/functions/_shared/entitlements.ts +++ b/supabase/functions/_shared/entitlements.ts @@ -10,7 +10,7 @@ import { isAutoModelSelection, type ModelSelection, } from "./model-catalog.ts"; -import { MONTHLY_AI_BUDGET_USD, PRO_DAILY_LIMIT, quotaUnitsFor, TRIAL_DAILY_LIMIT } from "./usage-limits.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")!, @@ -23,7 +23,7 @@ export interface RateLimitResult { reason?: string; limit?: number; current?: number; - code?: "limit_reached" | "monthly_budget_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; @@ -176,14 +176,14 @@ export async function checkRateLimit(userId: string | null, model?: ModelSelecti // Atomically reserve today's quota units server-side so concurrent requests // can't race past the limit. Expensive models reserve several units, and - // the same call refuses users over the monthly AI budget (see - // 20261022130001_weighted_quota_monthly_budget.sql). + // the same call refuses users over the weekly AI budget (see + // 20261022130001_weighted_quota_weekly_budget.sql). const units = quotaUnitsFor(model); - const { data: quota, error: quotaError } = await supabase.rpc("consume_daily_quota", { + const { data: quota, error: quotaError } = await supabase.rpc("consume_ai_quota", { p_user_id: userId, p_limit: dailyLimit, p_units: units, - p_monthly_cap_usd: MONTHLY_AI_BUDGET_USD, + p_weekly_cap_usd: WEEKLY_AI_BUDGET_USD, }); if (quotaError) { @@ -195,8 +195,8 @@ export async function checkRateLimit(userId: string | null, model?: ModelSelecti const current = quotaRow?.current_count ?? 0; if (!quotaRow?.allowed) { - if (quotaRow?.denied_by === "monthly") { - return { allowed: false, reason: "You've reached this month's AI usage allowance. It resets on the 1st of next month (UTC). Contact support if you need more.", code: "monthly_budget_reached", limit: dailyLimit, current, units, statusCode: 402 }; + if (quotaRow?.denied_by === "weekly") { + return { allowed: false, reason: "You've reached this week's AI usage allowance. It resets on Monday at 00:00 UTC. Contact support if you need more.", code: "weekly_budget_reached", limit: dailyLimit, current, units, statusCode: 402 }; } const remaining = Math.max(0, dailyLimit - current); const reason = remaining > 0 @@ -238,6 +238,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 index 1fc520be3..c00280c0e 100644 --- a/supabase/functions/_shared/usage-limits.ts +++ b/supabase/functions/_shared/usage-limits.ts @@ -23,10 +23,13 @@ export const MODEL_QUOTA_UNITS: Readonly> = { }; /** - * Monthly cap on a user's logged AI spend (ai_requests.cost_usd), in USD, - * per calendar month in UTC. Set to null to turn the cap off. + * 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 MONTHLY_AI_BUDGET_USD: number | null = 3; +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; 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_test.ts b/supabase/functions/ai-stream/index_test.ts index 59dbe5dad..4386e49d3 100644 --- a/supabase/functions/ai-stream/index_test.ts +++ b/supabase/functions/ai-stream/index_test.ts @@ -87,7 +87,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 8d4dc3fb1..18c3aeca0 100644 --- a/supabase/functions/ai/handler-research.test.ts +++ b/supabase/functions/ai/handler-research.test.ts @@ -198,7 +198,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 }); @@ -336,7 +336,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 }); @@ -468,7 +468,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}`); @@ -565,7 +565,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/provider-errors_test.ts b/supabase/functions/ai/provider-errors_test.ts index a47a59677..1e495bf02 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 1aa85929f..3b43406e7 100644 --- a/supabase/functions/ai/stream.test.ts +++ b/supabase/functions/ai/stream.test.ts @@ -66,7 +66,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 index 7a360c1aa..7fafa8e8b 100644 --- a/supabase/migrations/20261022130000_index_ai_requests_user_created.sql +++ b/supabase/migrations/20261022130000_index_ai_requests_user_created.sql @@ -1,5 +1,5 @@ --- The monthly AI budget in consume_daily_quota sums a user's ai_requests for --- the current month on every AI request. Keep this pipeline-incompatible +-- 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 diff --git a/supabase/migrations/20261022130001_weighted_quota_monthly_budget.sql b/supabase/migrations/20261022130001_weighted_quota_monthly_budget.sql deleted file mode 100644 index 180115c97..000000000 --- a/supabase/migrations/20261022130001_weighted_quota_monthly_budget.sql +++ /dev/null @@ -1,97 +0,0 @@ --- Weight the daily AI quota by model cost and add a monthly AI spend cap. --- --- p_units: how many quota units this request reserves (an expensive model --- counts as several requests). Defaults to 1 so existing callers (import, --- share, voice, crossfire signals) keep their behaviour. --- p_monthly_cap_usd: when set, refuse the reservation once the user's logged --- ai_requests.cost_usd for the current UTC month reaches the cap. NULL skips --- the check. Rows with unknown pricing (cost_usd IS NULL) are not counted, --- and cost is logged after the provider call, so the cap is soft by about --- one request. --- --- denied_by reports which limit refused the call: 'daily' or 'monthly'. --- The weights and the cap live in supabase/functions/_shared/usage-limits.ts. --- The month's spend is read through idx_ai_requests_user_created --- (20261022130000_index_ai_requests_user_created.sql). - --- New parameters and a new result column change the signature, so remove the --- old overload first and keep exactly one. Only service-role Edge Functions --- call it, by named arguments, and the new parameters have defaults, so calls --- from the previous function build still resolve to the replacement. --- squawk-ignore ban-drop-function -DROP FUNCTION public.consume_daily_quota(uuid, integer, text); - -CREATE FUNCTION public.consume_daily_quota( - p_user_id uuid, - p_limit integer, - p_quota_key text DEFAULT 'ai', - p_units integer DEFAULT 1, - p_monthly_cap_usd numeric DEFAULT NULL -) -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_month_start timestamptz := date_trunc('month', now() AT TIME ZONE 'UTC') AT TIME ZONE 'UTC'; - v_month_spend numeric; -BEGIN - IF auth.role() <> 'service_role' OR p_user_id IS NULL OR p_limit < 1 OR v_quota_key IS NULL - OR p_units IS NULL OR p_units < 1 OR p_monthly_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_monthly_cap_usd IS NOT NULL THEN - SELECT COALESCE(sum(cost_usd), 0) INTO v_month_spend - FROM public.ai_requests - WHERE user_id = p_user_id - AND created_at >= v_month_start; - - IF v_month_spend >= p_monthly_cap_usd THEN - RETURN QUERY SELECT false, COALESCE(v_current_count, 0), 'monthly'::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_daily_quota(uuid, integer, text, integer, numeric) - FROM PUBLIC, anon, authenticated; -GRANT EXECUTE ON FUNCTION public.consume_daily_quota(uuid, integer, text, integer, numeric) - TO service_role; 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..5d2e67964 --- /dev/null +++ b/supabase/migrations/20261022130001_weighted_quota_weekly_budget.sql @@ -0,0 +1,108 @@ +-- 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. NULL means +-- unknown or not recorded; 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; diff --git a/supabase/tests/daily_usage_quota_test.sql b/supabase/tests/daily_usage_quota_test.sql index c17479ba5..14cb94513 100644 --- a/supabase/tests/daily_usage_quota_test.sql +++ b/supabase/tests/daily_usage_quota_test.sql @@ -1,5 +1,5 @@ BEGIN; -SELECT plan(21); +SELECT plan(14); INSERT INTO auth.users (id) VALUES @@ -36,15 +36,15 @@ SELECT results_eq( 'the migration leaves one consume_daily_quota overload' ); SELECT ok( - NOT has_function_privilege('anon', 'public.consume_daily_quota(uuid, integer, text, integer, numeric)', 'EXECUTE'), + NOT has_function_privilege('anon', 'public.consume_daily_quota(uuid, integer, text)', 'EXECUTE'), 'anonymous callers cannot reserve quota' ); SELECT ok( - NOT has_function_privilege('authenticated', 'public.consume_daily_quota(uuid, integer, text, integer, numeric)', 'EXECUTE'), + NOT has_function_privilege('authenticated', 'public.consume_daily_quota(uuid, integer, text)', 'EXECUTE'), 'authenticated callers cannot reserve quota' ); SELECT ok( - has_function_privilege('service_role', 'public.consume_daily_quota(uuid, integer, text, integer, numeric)', 'EXECUTE'), + has_function_privilege('service_role', 'public.consume_daily_quota(uuid, integer, text)', 'EXECUTE'), 'service role can reserve quota' ); @@ -106,68 +106,5 @@ SELECT results_eq( 'rows without an explicit quota key retain their count under ai' ); --- Weighted reservations and the monthly AI budget. -INSERT INTO auth.users (id) VALUES ('66666666-6666-4666-8666-666666666666'); -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_daily_quota('66666666-6666-4666-8666-666666666666', 10, 'ai', 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_daily_quota('66666666-6666-4666-8666-666666666666', 10, 'ai', 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_daily_quota('66666666-6666-4666-8666-666666666666', 10, 'ai', 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_daily_quota('66666666-6666-4666-8666-666666666666', 3, 'weighted_first', 5)$$, - $$VALUES (false, 0, 'daily'::text)$$, - 'a first request larger than the whole limit is refused' -); -SELECT throws_ok( - $$SELECT * FROM public.consume_daily_quota('66666666-6666-4666-8666-666666666666', 10, 'ai', 0)$$, - 'P0001', - 'quota reservation is restricted to trusted server code', - 'zero-unit reservations are rejected' -); - -RESET ROLE; -INSERT INTO public.ai_requests (user_id, feature, provider, model, status, cost_usd, created_at) -VALUES - ('66666666-6666-4666-8666-666666666666', 'ask', 'openrouter', 'anthropic/claude-opus-5.5', 'success', 2.50, now()), - ('66666666-6666-4666-8666-666666666666', 'ask', 'openrouter', 'anthropic/claude-opus-5.5', 'success', 9.00, now() - interval '40 days'); -SET LOCAL ROLE service_role; - -SELECT results_eq( - $$SELECT allowed, denied_by - FROM public.consume_daily_quota('66666666-6666-4666-8666-666666666666', 100, 'ai', 1, 3.00)$$, - $$VALUES (true, NULL::text)$$, - 'spend under the monthly cap is allowed and earlier months do not count' -); - -RESET ROLE; -INSERT INTO public.ai_requests (user_id, feature, provider, model, status, cost_usd) -VALUES ('66666666-6666-4666-8666-666666666666', 'ask', 'openrouter', 'anthropic/claude-opus-5.5', 'success', 0.50); -SET LOCAL ROLE service_role; - -SELECT results_eq( - $$SELECT allowed, current_count, denied_by - FROM public.consume_daily_quota('66666666-6666-4666-8666-666666666666', 100, 'ai', 1, 3.00)$$, - $$VALUES (false, 11, 'monthly'::text)$$, - 'reaching the monthly cap refuses the request without consuming daily quota' -); - -RESET ROLE; SELECT * FROM finish(); ROLLBACK; From e4af8d0f8d6c9c763fdab65c7bb5cea1223cc6ee Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 22 Oct 2026 13:19:56 +0000 Subject: [PATCH 6/7] Translate quota refusals and stop advising a wait that cannot help AISubscriptionError now carries the catalog text for coded refusals, so pages that print error.message no longer show the server English reason on Spanish and Hungarian screens. A model heavier than the whole daily limit gets its own message (server and client) instead of wait until midnight. --- src/lib/ai/error-display.ts | 10 ++---- src/lib/ai/errors.ts | 12 +++---- src/lib/ai/limit-message.test.ts | 29 +++++++++++++++++ src/lib/ai/limit-message.ts | 32 +++++++++++++++++++ src/lib/ai/request.ts | 14 ++++---- src/locales/en.json | 1 + src/locales/es.json | 1 + src/locales/hu.json | 1 + .../functions/_shared/entitlements.test.ts | 11 +++++++ supabase/functions/_shared/entitlements.ts | 4 ++- 10 files changed, 93 insertions(+), 22 deletions(-) create mode 100644 src/lib/ai/limit-message.test.ts create mode 100644 src/lib/ai/limit-message.ts diff --git a/src/lib/ai/error-display.ts b/src/lib/ai/error-display.ts index 151f1ea6a..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', @@ -17,14 +18,7 @@ function providerLabel(provider?: string, message = ''): string { } /** The limit dialog's explanation, also used where a page shows the error inline. */ -export function subscriptionLimitMessage({ code, limit, current, units }: { code?: string; limit?: number; current?: number; units?: number }): string { - if (code === 'trial_expired') return i18n.t('limitModal.trialEndedDesc'); - if (code === 'weekly_budget_reached') return i18n.t('limitModal.weeklyReachedDesc'); - const remaining = Math.max(0, (limit || 0) - (current || 0)); - // A weighted model can be refused while lighter requests still fit today. - if ((units ?? 1) > 1 && remaining > 0) return i18n.t('limitModal.weightedDesc', { units, remaining }); - return i18n.t('limitModal.limitReachedDesc', { current: current || 0, limit: limit || 0 }); -} +export { subscriptionLimitMessage }; export function getUserFacingAIError(error: unknown): string { if (isAISubscriptionError(error)) return subscriptionLimitMessage(error); diff --git a/src/lib/ai/errors.ts b/src/lib/ai/errors.ts index 232a00acb..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. */ @@ -230,15 +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 a5e0352e3..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, @@ -255,14 +256,13 @@ export async function callSupabaseAI( // Subscription errors (402 / trial expired / limit reached) if (errorCode === 'trial_expired' || errorCode === 'limit_reached' || errorCode === 'weekly_budget_reached') { - throw new AISubscriptionError(errorMessage, { - statusCode: 402, - requestId, + const details = { code: errorCode, - limit: data.limit as number | undefined, - current: data.current as number | undefined, - units: data.units 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 663abe92e..5e409a8ab 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1338,6 +1338,7 @@ "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", diff --git a/src/locales/es.json b/src/locales/es.json index 1746df7b7..94c65d259 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -1338,6 +1338,7 @@ "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", diff --git a/src/locales/hu.json b/src/locales/hu.json index 9988fbbd2..8e7096342 100644 --- a/src/locales/hu.json +++ b/src/locales/hu.json @@ -1338,6 +1338,7 @@ "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", diff --git a/supabase/functions/_shared/entitlements.test.ts b/supabase/functions/_shared/entitlements.test.ts index 727e77fce..834816564 100644 --- a/supabase/functions/_shared/entitlements.test.ts +++ b/supabase/functions/_shared/entitlements.test.ts @@ -179,3 +179,14 @@ Deno.test("the weekly budget refusal has its own code", async () => { 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 9d8b2ea58..97ed7f5d3 100644 --- a/supabase/functions/_shared/entitlements.ts +++ b/supabase/functions/_shared/entitlements.ts @@ -199,7 +199,9 @@ export async function checkRateLimit(userId: string | null, model?: ModelSelecti return { allowed: false, reason: "You've reached this week's AI usage allowance. It resets on Monday at 00:00 UTC. Contact support if you need more.", code: "weekly_budget_reached", limit: dailyLimit, current, units, statusCode: 402 }; } const remaining = Math.max(0, dailyLimit - current); - const reason = remaining > 0 + const reason = units > 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 }; From 50498c12678b62e36dd91a85573caf2b04639252 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 22 Oct 2026 13:21:02 +0000 Subject: [PATCH 7/7] Count the known part of a partly priced request toward the weekly cap --- docs/architecture.md | 2 +- supabase/functions/_shared/cost.test.ts | 13 ++++++++----- supabase/functions/_shared/cost.ts | 7 +++++-- .../20261022130001_weighted_quota_weekly_budget.sql | 5 +++-- 4 files changed, 17 insertions(+), 10 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index ff3661b27..43201b4f1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -140,7 +140,7 @@ All model requests use the single OpenRouter backend route and `OPENROUTER_API_K `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 and normally resolves to Claude Opus 5.5. 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. Rows with unknown 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). +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 diff --git a/supabase/functions/_shared/cost.test.ts b/supabase/functions/_shared/cost.test.ts index 876ff02ca..447130574 100644 --- a/supabase/functions/_shared/cost.test.ts +++ b/supabase/functions/_shared/cost.test.ts @@ -237,14 +237,17 @@ Deno.test("a usage with a separate provider charge counts it toward the cap in f if (Math.abs((result.capCostUsd ?? -1) - 0.03) > 1e-12) throw new Error(`research charge not counted: ${result.capCostUsd}`); }); -Deno.test("unknown or incomplete spend never counts toward the cap", () => { +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 }); - if (calculateRequestCost({ provider: "openrouter", model: "x", usage: mergeNormalizedUsage([known, unknown]) }).capCostUsd !== null) { - throw new Error("a partly unknown request got a cap figure"); + 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: markIncompleteUsage(known) }).capCostUsd !== null) { - throw new Error("incomplete work got a cap figure"); + if (calculateRequestCost({ provider: "openrouter", model: "x", usage: unknown }).capCostUsd !== null) { + throw new Error("a wholly unknown request got a cap figure"); } }); diff --git a/supabase/functions/_shared/cost.ts b/supabase/functions/_shared/cost.ts index 82d993ae5..89940aa31 100644 --- a/supabase/functions/_shared/cost.ts +++ b/supabase/functions/_shared/cost.ts @@ -170,7 +170,9 @@ export function calculateRequestCost(args: { outputTokens: usage?.outputTokens ?? null, reasoningOutputTokens: usage?.reasoningOutputTokens ?? null, costUsd: usage?.incomplete ? null : usage?.reportedCostUsd ?? null, - capCostUsd: usage && !usage.incomplete ? capCostOf(usage) ?? null : 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, @@ -205,7 +207,8 @@ 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, - capCostUsd: defined.every((u) => !u.incomplete && typeof capCostOf(u) === "number") + // 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) }, diff --git a/supabase/migrations/20261022130001_weighted_quota_weekly_budget.sql b/supabase/migrations/20261022130001_weighted_quota_weekly_budget.sql index 5d2e67964..0da0ef9db 100644 --- a/supabase/migrations/20261022130001_weighted_quota_weekly_budget.sql +++ b/supabase/migrations/20261022130001_weighted_quota_weekly_budget.sql @@ -13,8 +13,9 @@ -- 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. NULL means --- unknown or not recorded; it never counts. Rows logged before this +-- 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