Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .claude/skills/verify-debatelab/features/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ Each feature file starts with an H1 title and one paragraph describing the user-
- [Workspace autosave](./workspace-autosave.md): a slow Crossfire or Sparring save from a Workspace never overwrites newer text (`recipes/workspace-autosave.spec.ts`). Needs `up --collab`; skips without it.
- [Account data](./account-data.md): Download my data and Delete account, each proven against a bystander (`recipes/account-data.spec.ts`); Delete All My Data scoped against a bystander (`e2e/full-stack/account-data.spec.ts`).
- Account: output language reaching the model from a new device, flowchart node delete (at once, with an `Undo` toast; no confirm dialog), save and load, and the speech timer (`R` resets only while paused) (`recipes/account.spec.ts`).
- [Usage limits](./usage-limits.md): Opus refused when its weight does not fit today while Haiku still runs, and the weekly AI budget dialog (`recipes/usage-limits.spec.ts`).
- Billing: expired account blocked, promo code restores access (`recipes/billing.spec.ts`); signed, forged, and replayed Stripe webhooks (`e2e/full-stack/billing.spec.ts`).
- Admin announcements and the non-admin block (`e2e/full-stack/admin.spec.ts`), public share links (`e2e/full-stack/public.spec.ts`), and Workspaces with live collaboration and invites (`e2e/workspace-collaboration.spec.ts`).

Expand Down
25 changes: 25 additions & 0 deletions .claude/skills/verify-debatelab/features/usage-limits.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Usage limits: weighted daily quota and weekly AI budget

The daily AI allowance counts units, not requests. An expensive model uses several units per request (Claude Opus 5.5 counts as 5, Sonnet 5.5 as 2, cheap models as 1), so a request that does not fit in what is left today is refused with a message that names the model's weight and the units left, while a lighter model still runs. Separately, once a user's AI spend for the current week (from Monday 00:00 UTC) reaches the weekly budget ($2 by default), every AI request is refused with its own dialog until Monday. Only `ai_requests.cap_cost_usd` counts: OpenRouter credit charges plus Exa and ElevenLabs; calls billed to our own BYOK key add only OpenRouter's fee. The weights, the budget and the tier defaults live in `supabase/functions/_shared/usage-limits.ts`; the reservation is `consume_ai_quota` in `supabase/migrations/20261022130001_weighted_quota_weekly_budget.sql`.

## Sub-features

- `usage-weighted`: with 4 units left, Opus is refused (`Daily Access Paused`, "This model counts as 5 uses, and you have 4 left today.") without a provider call or a counter change; switching to Claude Haiku 5.5 runs and uses 1 unit.
- `usage-weekly`: with `ai_requests.cap_cost_usd` for this week at or above the budget, the next request opens `Weekly AI Allowance Used` with an `OK` button and no upgrade offer, makes no provider call, and leaves the daily counter alone.

- `usage-weekly-inline`: Sparring shows refusals inline rather than in the dialog. With the budget reached, the opponent's turn shows the translated weekly message from the catalog, not the server's English reason, and no provider call is made.

## How to get to it (user POV)

- Any AI tool, for example `/app/generate`, once the day's allowance or the week's budget is used up. The dialog opens on submit.

## Driving it with verify.sh + Playwright

- `bin/verify.sh drive recipes/usage-limits` covers the dialog sub-features in one test and the Sparring inline message in a second.
- The recipe sets `daily_limit_override: 7` on the demo user and inserts a `daily_usage_counters` row (`quota_key: "ai"`, today's UTC date, `request_count: 3`), then inserts an `ai_requests` row with `cap_cost_usd: 2` for the weekly step.
- End states are read from `daily_usage_counters.request_count` and from the recorded provider calls.

## Gotchas

- Demo users are Pro with Claude Opus 5.5 as the default model, so every Generate in other recipes uses 5 units of their 999.
- The weekly sum counts `cap_cost_usd` of every `ai_requests` row since Monday 00:00 UTC, including voice and Jev routing rows; NULL (unknown or logged before the column existed) counts as nothing, and `cost_usd` alone never counts. Cost is logged after the provider answers, so the cap can be passed by about one request.
98 changes: 98 additions & 0 deletions .claude/skills/verify-debatelab/recipes/usage-limits.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import type { Page } from "@playwright/test";
import { admin, AI_TIMEOUT, aiResponse, chooseForMotion, expect, providerCalls, startDebate, test } from "./fixtures";

// Feature map: features/usage-limits.md.
const motion = "This House would make public transport free at the point of use";

async function openGenerate(page: Page) {
await startDebate(page, motion);
await page.goto("/app/generate");
await expect(page.getByRole("heading", { name: "Ready to Generate" })).toBeVisible();
}

async function aiCounter(userId: string): Promise<number> {
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<number> {
return (await providerCalls()).filter((call) => call.feature === "generate").length;
}

test("usage-weighted: Opus counts as 5 requests, a lighter model still runs, and the weekly cap stops everything", async ({ signedIn: page, user, evidence }) => {
const userId = await user.userId();
// Demo users are Pro on Claude Opus 5.5 with a limit of 999. Leave room for
// 4 more units today: too few for Opus, enough for Haiku.
await admin.from("user_settings").update({ daily_limit_override: 7 }).eq("user_id", userId);
const { error: seedError } = await admin.from("daily_usage_counters").insert({
user_id: userId, usage_date: new Date().toISOString().slice(0, 10), quota_key: "ai", request_count: 3,
});
if (seedError) throw seedError;

await openGenerate(page);
const selector = page.getByRole("combobox", { name: "Select AI model for generate" });
await expect(selector).toContainText("Claude Opus 5.5");
await page.getByRole("button", { name: "Generate Debate Case" }).click();
const daily = page.getByRole("dialog");
await expect(daily.getByText("Daily Access Paused")).toBeVisible({ timeout: AI_TIMEOUT });
await expect(daily.getByText("This model counts as 5 uses, and you have 4 left today.", { exact: false })).toBeVisible();
await evidence.shot("01-opus-weighted-refusal");
expect(await generateCalls()).toBe(0);
expect(await aiCounter(userId)).toBe(3);
await evidence.step("Opus refused with 4 units left; no provider call; counter unchanged at 3");
await daily.getByRole("button", { name: "Not now" }).click();

await selector.click();
await page.getByRole("option", { name: /Claude Haiku 5\.5/ }).click();
const response = aiResponse(page, "generate");
await page.getByRole("button", { name: "Generate Debate Case" }).click();
await response;
expect(await generateCalls()).toBe(1);
expect(await aiCounter(userId)).toBe(4);
await evidence.shot("02-haiku-runs");
await evidence.step("Haiku ran and used 1 unit (counter 4)");

// Reach the weekly budget with cap spend logged earlier this week.
const { error: spendError } = await admin.from("ai_requests").insert({
user_id: userId, feature: "generate", provider: "openrouter", model: "anthropic/claude-opus-5.5", status: "success", cost_usd: 2, cap_cost_usd: 2,
});
if (spendError) throw spendError;
await page.getByRole("button", { name: "Generate Debate Case" }).click();
const weekly = page.getByRole("dialog");
await expect(weekly.getByText("Weekly AI Allowance Used")).toBeVisible({ timeout: AI_TIMEOUT });
await expect(weekly.getByText("It refreshes on Monday at 00:00 UTC.", { exact: false })).toBeVisible();
await evidence.shot("03-weekly-cap");
expect(await generateCalls()).toBe(1);
expect(await aiCounter(userId)).toBe(4);
await evidence.step("Weekly cap refused the request; no provider call; daily counter unchanged");
await weekly.getByRole("button", { name: "OK" }).click();
await expect(weekly).toHaveCount(0);
});

test("usage-weekly-inline: Sparring shows the translated weekly-cap message, not the server's English reason", async ({ signedIn: page, user, evidence }) => {
const userId = await user.userId();
const { error } = await admin.from("ai_requests").insert({
user_id: userId, feature: "sparring", provider: "openrouter", model: "anthropic/claude-opus-5.5", status: "success", cost_usd: 2, cap_cost_usd: 2,
});
if (error) throw error;

await page.goto("/app/sparring");
await page.getByLabel("Motion").fill(motion);
await page.getByRole("combobox", { name: "Round format" }).click();
await page.getByRole("option", { name: /Lincoln-Douglas · 5 speeches/ }).click();
await page.getByRole("button", { name: "Start round" }).click();
await chooseForMotion(page, "create");
const draft = page.getByRole("textbox", { name: "Draft for Affirmative constructive" });
await expect(draft).toBeVisible({ timeout: AI_TIMEOUT });
await draft.fill("Universal access removes the direct price barrier to work, school, and civic life.");
await page.getByRole("button", { name: "Submit speech" }).click();

// The client's catalog copy ("You've used ...") differs from the server reason ("You've reached ...").
await expect(page.getByText("You've used this week's AI allowance.", { exact: false })).toBeVisible({ timeout: AI_TIMEOUT });
await expect(page.getByText("You've reached this week's AI usage allowance", { exact: false })).toHaveCount(0);
await evidence.shot("04-sparring-weekly-cap");
expect((await providerCalls()).filter((call) => call.feature === "sparring")).toEqual([]);
await evidence.step("Sparring opponent turn refused by the weekly cap with the translated message; no provider call");
});
2 changes: 2 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,8 @@ Every model call asks for JSON. Calls whose shape the server checks in code (the

`Auto Fast` and `Auto Pro` are virtual selections, not provider model IDs. The Edge Functions resolve them through TypeSafe Jev on OpenRouter before calling the concrete writing model. Auto Fast is available to Basic accounts, routes only among the Basic models (Claude Haiku 5.5, GPT-6 Luna, Muse Spark 1.3 Contributor), and normally resolves to Claude Haiku 5.5. Auto Pro remains Pro-only, routes between Claude Sonnet 5.5 and GPT-6.1 Sol, and normally resolves to Claude Sonnet 5.5; it never routes or falls back to Opus. Provider calls, usage rows, and response metadata record the concrete resolved model, while response metadata also retains the requested automatic selection. A Jev router call uses the same OpenRouter key and does not consume a second daily quota unit.

The daily AI quota counts units, not requests. `checkRateLimit` in [`supabase/functions/_shared/entitlements.ts`](../supabase/functions/_shared/entitlements.ts) reserves the requested model's weight through `consume_ai_quota` (Claude Opus 5.5 counts as 5, Sonnet as 2, cheap models as 1; Auto Pro counts as its most expensive candidate because the quota is reserved before routing). The same call refuses the request with `weekly_budget_reached` once the user's weekly AI spend reaches the weekly budget ($2 by default, calendar week from Monday 00:00 UTC). Weekly spend is the sum of `ai_requests.cap_cost_usd`: what OpenRouter charged against our credits plus Exa and ElevenLabs charges. A call billed to our own BYOK provider key (Claude on the Anthropic plan) adds only OpenRouter's fee, usually 0, while a Claude call that falls back to OpenRouter credits counts in full. When part of a request's cost is unknown, the known part counts; rows with no known cap spend, and rows logged before the column existed, do not count. Cost is logged after the provider answers, so the cap can be passed by about one request. The weights, the budget, and the tier defaults live in [`supabase/functions/_shared/usage-limits.ts`](../supabase/functions/_shared/usage-limits.ts). Voice, import, share, and Crossfire signal quotas keep calling `consume_daily_quota`, one unit per call, and are not gated by the weekly budget (voice spend still counts toward it).

## Observability

Client startup loads Sentry and PostHog asynchronously when configured. Product events and exceptions use the privacy-filtering facade in [`src/lib/observability.ts`](../src/lib/observability.ts), so SDK loading or failure cannot block rendering. Sentry events carry the build's commit SHA as the release and the Vercel environment name. Route crashes keep the original error type and stack frames but replace the message, which can carry user text. Vercel Analytics and Speed Insights run only in the web build and load outside the application render boundary.
Expand Down
32 changes: 20 additions & 12 deletions src/components/features/subscription/LimitReachedModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,36 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } f
import { Button } from '@/components/ui/button';
import { AlertTriangle, Zap, Clock } from 'lucide-react';
import { useSubscription } from '@/hooks/use-subscription';
import { subscriptionLimitMessage } from '@/lib/ai/error-display';

export type LimitModalVariant = 'limit_reached' | 'trial_expired' | 'weekly_budget_reached';

interface LimitReachedModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
variant: 'limit_reached' | 'trial_expired';
variant: LimitModalVariant;
limit?: number;
current?: number;
/** Quota units the refused request needed; above 1 for expensive models. */
units?: number;
onUpgrade: () => void;
}

export function LimitReachedModal({ open, onOpenChange, variant, limit, current, onUpgrade }: LimitReachedModalProps) {
export function LimitReachedModal({ open, onOpenChange, variant, limit, current, units, onUpgrade }: LimitReachedModalProps) {
const { t } = useTranslation();
const { isPro } = useSubscription();
const isTrialExpired = variant === 'trial_expired';
const isWeeklyBudget = variant === 'weekly_budget_reached';
// A Pro student who used today's included requests already pays. Selling Pro again is the wrong next step.
const paidDailyCap = isPro && !isTrialExpired;

const title = isTrialExpired
? t('limitModal.trialEnded')
: isWeeklyBudget
? t('limitModal.weeklyReached')
: t('limitModal.limitReached');
const description = subscriptionLimitMessage({ code: variant, limit, current, units });

return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[420px]">
Expand All @@ -31,20 +44,15 @@ export function LimitReachedModal({ open, onOpenChange, variant, limit, current,
<AlertTriangle className="h-6 w-6 text-amber-600 dark:text-amber-400" />
)}
</div>
<DialogTitle className="text-center">
{isTrialExpired ? t('limitModal.trialEnded') : t('limitModal.limitReached')}
</DialogTitle>
<DialogDescription className="text-center">
{isTrialExpired
? t('limitModal.trialEndedDesc')
: t('limitModal.limitReachedDesc', { current: current || 0, limit: limit || 0 })}
</DialogDescription>
<DialogTitle className="text-center">{title}</DialogTitle>
<DialogDescription className="text-center">{description}</DialogDescription>
</DialogHeader>

<div className="flex flex-col gap-2 mt-4">
{paidDailyCap ? (
{isWeeklyBudget || paidDailyCap ? (
// Upgrading does not lift the spend cap, and a paid plan already has Pro.
<Button onClick={() => onOpenChange(false)} size="lg" className="w-full">
{t('limitModal.gotIt')}
{isWeeklyBudget ? t('limitModal.ok') : t('limitModal.gotIt')}
</Button>
) : (
<>
Expand Down
13 changes: 9 additions & 4 deletions src/hooks/use-subscription-error.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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<LimitModalVariant>('limit_reached');
const [limitModalLimit, setLimitModalLimit] = useState<number>();
const [limitModalCurrent, setLimitModalCurrent] = useState<number>();
const [limitModalUnits, setLimitModalUnits] = useState<number>();
const [modelModalOpen, setModelModalOpen] = useState(false);
const [pricingModalOpen, setPricingModalOpen] = useState(false);

const handleSubscriptionError = useCallback((error: unknown): boolean => {
if (isAISubscriptionError(error)) {
const subError = error as AISubscriptionError;
if (subError.code === 'trial_expired') {
setLimitModalVariant('trial_expired');
if (subError.code === 'trial_expired' || subError.code === 'weekly_budget_reached') {
setLimitModalVariant(subError.code);
} else {
setLimitModalVariant('limit_reached');
}
setLimitModalLimit(subError.limit);
setLimitModalCurrent(subError.current);
setLimitModalUnits(subError.units);
setLimitModalOpen(true);
return true;
}
Expand All @@ -53,6 +57,7 @@ export function useSubscriptionError(): SubscriptionErrorState {
limitModalVariant,
limitModalLimit,
limitModalCurrent,
limitModalUnits,
modelModalOpen,
pricingModalOpen,
setLimitModalOpen,
Expand Down
10 changes: 5 additions & 5 deletions src/lib/ai/error-display.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import i18n from '@/i18n';
import { AIProviderError, isAISubscriptionError } from './client';
import { subscriptionLimitMessage } from './limit-message';

const PROVIDER_LABELS: Record<string, string> = {
openai: 'OpenAI',
Expand All @@ -16,12 +17,11 @@ function providerLabel(provider?: string, message = ''): string {
return i18n.t('aiErrors.genericProvider', 'AI provider');
}

/** The limit dialog's explanation, also used where a page shows the error inline. */
export { subscriptionLimitMessage };

export function getUserFacingAIError(error: unknown): string {
// Out of today's requests: the limit dialog's own words, not the server's English.
if (isAISubscriptionError(error)) {
if (error.code === 'trial_expired') return i18n.t('limitModal.trialEndedDesc');
return i18n.t('limitModal.limitReachedDesc', { current: error.current ?? 0, limit: error.limit ?? 0 });
}
if (isAISubscriptionError(error)) return subscriptionLimitMessage(error);

if (error instanceof AIProviderError || (error instanceof Error && error.name === 'AIProviderError')) {
const provider = providerLabel((error as AIProviderError).provider, error.message);
Expand Down
Loading
Loading