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/usage-limits.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,4 @@ Crossfire and Sparring are charged per round, not per call: the round's first ca

- Demo users are Pro with Claude Sonnet 5.5 as the default model, so every Generate in other recipes uses 2 units of their 999. The weighted step picks Opus itself.
- 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.
- The weekly step relies on the demo user having no weekly override. `user_settings.weekly_budget_unlimited = true` or a `weekly_budget_override_usd` above the inserted spend lets the request through instead of opening the dialog.
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ 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. Auto routes only to Claude, which runs on DebateLab's Anthropic key (BYOK in OpenRouter); other families bill OpenRouter credits, so they run only when a student picks one by hand. Auto Fast is available to Basic accounts and resolves to Claude Haiku 5.5. Auto Pro remains Pro-only and resolves to Claude Sonnet 5.5; it never routes or falls back to Opus. With one candidate per pool the Edge Functions skip the TypeSafe Jev routing call; a pool with more than one candidate (all `anthropic/`, which `src/test/ai/anthropic-only-guard.test.ts` enforces) is resolved through Jev on OpenRouter before calling the concrete writing model. 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; an Auto selection counts as its most expensive candidate because the quota is reserved before routing. The Auto weights are derived from the candidate pools, so Auto Pro counts as Sonnet's 2 while Sonnet is its only candidate). 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`: each request's full logged charge, including Claude calls billed to our own BYOK Anthropic key, plus Exa and ElevenLabs charges. A BYOK response without the upstream charge counts OpenRouter's fee, the part that is known. 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).
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; an Auto selection counts as its most expensive candidate because the quota is reserved before routing. The Auto weights are derived from the candidate pools, so Auto Pro counts as Sonnet's 2 while Sonnet is its only candidate). 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). An admin can change one account's budget in `user_settings`, which the user cannot write: `weekly_budget_override_usd` replaces the $2 and `weekly_budget_unlimited = true` skips the weekly check; the daily quota still applies. The `quotaCheck` peek uses the same per-account budget. Weekly spend is the sum of `ai_requests.cap_cost_usd`: each request's full logged charge, including Claude calls billed to our own BYOK Anthropic key, plus Exa and ElevenLabs charges. A BYOK response without the upstream charge counts OpenRouter's fee, the part that is known. 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).

Crossfire and Sparring are charged per round, not per call. The round's first call reserves the model's units through `claim_ai_session_quota` ([`20261022220000_ai_quota_sessions.sql`](../supabase/migrations/20261022220000_ai_quota_sessions.sql)), keyed by the Sparring `sessionId` or the Crossfire round id the client sends as `quotaSession` beside `input`; later turns, the debrief, and the judge in that round reserve nothing. A round past its call cap or lifetime, or switched to a heavier model, pays again (`SESSION_QUOTA` in `usage-limits.ts`), so a reused id cannot buy unlimited AI. A Crossfire request without `quotaSession` (an older client) is charged per call. Before a round starts, both pages send a `quotaCheck` request, which reserves nothing and answers with the round's cost and today's count, so a round the server would refuse never starts.

Expand Down
59 changes: 56 additions & 3 deletions supabase/functions/_shared/entitlements.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ for (const [key, value] of Object.entries({
SUPABASE_SERVICE_ROLE_KEY: "test-service-role-key",
})) Deno.env.set(key, value);

const { getSubscriptionInfo, checkRateLimit, getConfiguredDailyLimit, checkUserBanned, normalizeErrorCode } = await import("./entitlements.ts");
const { getSubscriptionInfo, checkRateLimit, peekRateLimit, getConfiguredDailyLimit, checkUserBanned, normalizeErrorCode } = await import("./entitlements.ts");
const { MODEL_QUOTA_UNITS, WEEKLY_AI_BUDGET_USD } = await import("./usage-limits.ts");

function json(body: unknown, status = 200): Response {
Expand All @@ -18,11 +18,12 @@ const future = () => new Date(Date.now() + DAY).toISOString();
const past = () => new Date(Date.now() - DAY).toISOString();

interface Routes {
user_settings?: unknown;
user_settings?: unknown | ((url: URL) => Response);
user_roles?: unknown;
subscriptions?: unknown | ((url: URL) => Response);
rate_limits?: unknown | ((url: URL) => Response);
quota?: unknown;
peek?: unknown;
onQuota?: (body: Record<string, unknown>) => void;
requests?: string[];
}
Expand All @@ -31,7 +32,9 @@ async function withSupabase<T>(routes: Routes, run: () => Promise<T>): Promise<T
const originalFetch = globalThis.fetch;
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_settings")) {
return typeof routes.user_settings === "function" ? routes.user_settings(url) : 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 typeof routes.subscriptions === "function" ? routes.subscriptions(url) : json(routes.subscriptions ?? []);
Expand All @@ -49,6 +52,7 @@ async function withSupabase<T>(routes: Routes, run: () => Promise<T>): Promise<T
routes.onQuota?.(JSON.parse(String(init?.body ?? "{}")));
return json(routes.quota ?? [{ allowed: true, current_count: 1 }]);
}
if (url.pathname.includes("/rpc/peek_ai_quota")) return json(routes.peek ?? [{ current_count: 0, week_spend_usd: 0 }]);
throw new Error(`Unexpected request: ${url}`);
}) as typeof fetch;
try {
Expand Down Expand Up @@ -284,6 +288,55 @@ Deno.test("the weekly budget refusal has its own code", async () => {
});
});

async function sentWeeklyCap(settings: Routes["user_settings"]): Promise<unknown> {
let sent: Record<string, unknown> = {};
await withSupabase({ user_settings: settings, onQuota: (body) => { sent = body; } }, async () => {
assertEquals((await checkRateLimit("user-1", "openai/gpt-6-luna")).allowed, true, "allowed");
});
return sent.p_weekly_cap_usd;
}

Deno.test("an account without a weekly override gets the global cap", async () => {
assertEquals(await sentWeeklyCap({ ...proUser, weekly_budget_override_usd: null, weekly_budget_unlimited: false }), WEEKLY_AI_BUDGET_USD, "p_weekly_cap_usd");
});

Deno.test("a weekly override replaces the global cap", async () => {
// PostgREST sends numeric as a number or a string.
assertEquals(await sentWeeklyCap({ ...proUser, weekly_budget_override_usd: 25, weekly_budget_unlimited: false }), 25, "number");
assertEquals(await sentWeeklyCap({ ...proUser, weekly_budget_override_usd: "7.5", weekly_budget_unlimited: false }), 7.5, "string");
assertEquals(await sentWeeklyCap({ ...proUser, weekly_budget_override_usd: 0, weekly_budget_unlimited: false }), 0, "zero");
});

Deno.test("an unlimited account skips the weekly cap, even with an override set", async () => {
assertEquals(await sentWeeklyCap({ ...proUser, weekly_budget_override_usd: 1, weekly_budget_unlimited: true }), null, "p_weekly_cap_usd");
});

Deno.test("a failed override read keeps the global cap instead of lifting it", async () => {
const originalWarn = console.warn;
console.warn = () => {};
try {
const settings = (url: URL) => (url.searchParams.get("select") ?? "").includes("weekly_budget")
? json({ message: "column user_settings.weekly_budget_unlimited does not exist", code: "42703" }, 400)
: json(proUser);
assertEquals(await sentWeeklyCap(settings), WEEKLY_AI_BUDGET_USD, "p_weekly_cap_usd");
} finally {
console.warn = originalWarn;
}
});

Deno.test("the pre-round quota check uses the account's weekly cap", async () => {
const spent = [{ current_count: 1, week_spend_usd: (WEEKLY_AI_BUDGET_USD ?? 0) + 3 }];
await withSupabase({ user_settings: proUser, peek: spent }, async () => {
assertEquals((await peekRateLimit("user-1", "openai/gpt-6-luna")).code, "weekly_budget_reached", "global cap");
});
await withSupabase({ user_settings: { ...proUser, weekly_budget_unlimited: true }, peek: spent }, async () => {
assertEquals((await peekRateLimit("user-1", "openai/gpt-6-luna")).allowed, true, "unlimited");
});
await withSupabase({ user_settings: { ...proUser, weekly_budget_override_usd: 100 }, peek: spent }, async () => {
assertEquals((await peekRateLimit("user-1", "openai/gpt-6-luna")).allowed, true, "raised cap");
});
});

Deno.test("configured daily limits prefer the account row, then the global row", async () => {
const requests: string[] = [];
const limits = (url: URL) => {
Expand Down
39 changes: 32 additions & 7 deletions supabase/functions/_shared/entitlements.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,31 @@ export async function getConfiguredDailyLimit(userId: string, fallbackLimit: num
return fallbackLimit;
}

/**
* The weekly AI spend cap for this account in USD, or null for no cap.
* user_settings.weekly_budget_unlimited lifts it, a non-NULL
* weekly_budget_override_usd replaces it (20261028120000_weekly_budget_override.sql),
* and otherwise WEEKLY_AI_BUDGET_USD applies. Read apart from
* getSubscriptionInfo so a database without the columns only loses the
* override instead of failing every request. A failed read keeps the global cap.
*/
export async function getWeeklyBudgetUsd(userId: string): Promise<number | null> {
const { data, error } = await supabase
.from("user_settings")
.select("weekly_budget_override_usd, weekly_budget_unlimited")
.eq("user_id", userId)
.maybeSingle();
if (error) {
console.warn("[RATE LIMIT] weekly budget override lookup failed:", error.message);
return WEEKLY_AI_BUDGET_USD;
}
if (data?.weekly_budget_unlimited === true) return null;
// numeric arrives from PostgREST as a number or a string.
const override = data?.weekly_budget_override_usd;
const value = override === null || override === undefined ? NaN : Number(override);
return Number.isFinite(value) && value >= 0 ? value : WEEKLY_AI_BUDGET_USD;
}

// Logged and returned for API callers; the browser shows its own translation of the code.
const ACCESS_REFUSAL_REASONS: Record<AccessRefusalCode, string> = {
trial_ended: "Your free trial has ended. Upgrade to Pro to continue using DebateOwl.",
Expand All @@ -166,7 +191,7 @@ const ACCESS_REFUSAL_REASONS: Record<AccessRefusalCode, string> = {

type AccessCheck =
| { denied: RateLimitResult }
| { denied?: undefined; tier: string; dailyLimit: number; canAccessProModels: boolean };
| { denied?: undefined; tier: string; dailyLimit: number; weeklyBudgetUsd: number | null; canAccessProModels: boolean };

// Ban, plan, and model access: everything that refuses a request before quota is read.
async function checkAccess(userId: string, model?: ModelSelection): Promise<AccessCheck> {
Expand All @@ -191,7 +216,7 @@ async function checkAccess(userId: string, model?: ModelSelection): Promise<Acce
return { denied: { allowed: false, reason: "Your daily request limit is currently set to zero. Contact support if this seems wrong.", code: "limit_reached", limit: 0, current: 0, statusCode: 402 } };
}

return { tier: sub.tier, dailyLimit, canAccessProModels: sub.canAccessProModels };
return { tier: sub.tier, dailyLimit, weeklyBudgetUsd: await getWeeklyBudgetUsd(userId), canAccessProModels: sub.canAccessProModels };
}

function quotaDenial(deniedBy: unknown, tier: string, dailyLimit: number, current: number, units: number): RateLimitResult {
Expand Down Expand Up @@ -220,7 +245,7 @@ export async function checkRateLimit(userId: string | null, model?: ModelSelecti
try {
const access = await checkAccess(userId, model);
if (access.denied) return access.denied;
const { tier, dailyLimit, canAccessProModels } = access;
const { tier, dailyLimit, weeklyBudgetUsd, canAccessProModels } = access;

// Atomically reserve today's quota units server-side so concurrent requests
// can't race past the limit. Expensive models reserve several units, and
Expand All @@ -235,15 +260,15 @@ export async function checkRateLimit(userId: string | null, model?: ModelSelecti
p_session_key: perSession.key,
p_limit: dailyLimit,
p_units: units,
p_weekly_cap_usd: WEEKLY_AI_BUDGET_USD,
p_weekly_cap_usd: weeklyBudgetUsd,
p_max_calls: SESSION_QUOTA[perSession.feature].maxCalls,
p_ttl_seconds: SESSION_QUOTA[perSession.feature].ttlSeconds,
})
: supabase.rpc("consume_ai_quota", {
p_user_id: userId,
p_limit: dailyLimit,
p_units: units,
p_weekly_cap_usd: WEEKLY_AI_BUDGET_USD,
p_weekly_cap_usd: weeklyBudgetUsd,
});
let { data: quota, error: quotaError } = await reserve(session);
// A database without 20261022220000_ai_quota_sessions.sql (functions
Expand Down Expand Up @@ -281,14 +306,14 @@ export async function peekRateLimit(userId: string, model?: ModelSelection): Pro
try {
const access = await checkAccess(userId, model);
if (access.denied) return { ...access.denied, units: quotaUnitsFor(model) };
const { tier, dailyLimit } = access;
const { tier, dailyLimit, weeklyBudgetUsd } = access;
const units = quotaUnitsFor(model);
const { data, error } = await supabase.rpc("peek_ai_quota", { p_user_id: userId });
if (error) throw error;
const row = Array.isArray(data) ? data[0] : data;
const current = typeof row?.current_count === "number" ? row.current_count : 0;
const weekSpend = Number(row?.week_spend_usd ?? 0);
if (WEEKLY_AI_BUDGET_USD !== null && weekSpend >= WEEKLY_AI_BUDGET_USD) {
if (weeklyBudgetUsd !== null && weekSpend >= weeklyBudgetUsd) {
return quotaDenial("weekly", tier, dailyLimit, current, units);
}
if (current + units > dailyLimit) return quotaDenial("daily", tier, dailyLimit, current, units);
Expand Down
4 changes: 3 additions & 1 deletion supabase/functions/_shared/usage-limits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,9 @@ export const MODEL_QUOTA_UNITS: Readonly<Record<string, number>> = {
* 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: the full logged cost of
* each request, including Claude calls billed to our own BYOK Anthropic key,
* plus Exa and ElevenLabs. Set to null to turn the cap off.
* plus Exa and ElevenLabs. Set to null to turn the cap off for everyone. One
* account's cap is changed or lifted in user_settings (weekly_budget_override_usd,
* weekly_budget_unlimited); see getWeeklyBudgetUsd in entitlements.ts.
*/
export const WEEKLY_AI_BUDGET_USD: number | null = 2;

Expand Down
40 changes: 40 additions & 0 deletions supabase/migrations/20261028120000_weekly_budget_override.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
-- Per-account override of the weekly AI spend cap (WEEKLY_AI_BUDGET_USD in
-- supabase/functions/_shared/usage-limits.ts), set the same way as
-- daily_limit_override: by an admin or service-role SQL, never by the user.
--
-- Additive only. Both columns default to "no override", so every existing
-- account keeps the global cap. checkRateLimit and peekRateLimit in
-- _shared/entitlements.ts read them and pass the effective cap to
-- consume_ai_quota and claim_ai_session_quota, which are unchanged.
--
-- Precedence: weekly_budget_unlimited = true skips the weekly check; otherwise
-- a non-NULL weekly_budget_override_usd replaces the global cap (0 refuses
-- every request that week); otherwise the global cap applies.
--
-- No grant change: 20260819000000_security_hardening.sql revoked table-level
-- INSERT and UPDATE on user_settings from anon and authenticated and granted
-- UPDATE only on named preference columns, so new columns are server-only.
ALTER TABLE public.user_settings
ADD COLUMN IF NOT EXISTS weekly_budget_override_usd numeric,
ADD COLUMN IF NOT EXISTS weekly_budget_unlimited boolean NOT NULL DEFAULT false;

-- consume_ai_quota raises on a negative cap, which would fail every request
-- for the account instead of refusing it.
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conrelid = 'public.user_settings'::regclass
AND conname = 'user_settings_weekly_budget_override_usd_check'
) THEN
ALTER TABLE public.user_settings
ADD CONSTRAINT user_settings_weekly_budget_override_usd_check
CHECK (weekly_budget_override_usd IS NULL OR weekly_budget_override_usd >= 0);
END IF;
END;
$$;

COMMENT ON COLUMN public.user_settings.weekly_budget_override_usd IS
'Weekly AI spend cap in USD for this account, replacing WEEKLY_AI_BUDGET_USD. NULL = global cap; 0 = refused all week. Admin or service role only.';
COMMENT ON COLUMN public.user_settings.weekly_budget_unlimited IS
'When true, this account skips the weekly AI spend cap; the daily limit still applies. Admin or service role only.';
Loading
Loading