diff --git a/.claude/skills/verify-debatelab/features/practice.md b/.claude/skills/verify-debatelab/features/practice.md index 484933dd3..adc073092 100644 --- a/.claude/skills/verify-debatelab/features/practice.md +++ b/.claude/skills/verify-debatelab/features/practice.md @@ -11,7 +11,7 @@ Live practice modes. Sparring runs a multi-speech round against an AI opponent a - `cross-ai-questions` reverses the roles: the AI asks (`mode-ai-questions-user`), the user answers, and the AI follows up. - `cross-daily-limit` with no AI requests left, the first AI turn is refused: the daily-limit dialog opens and the page stays in setup with no clock and no `end-crossfire`. - `cross-end-early` ending a round before any exchange returns to setup with no error. -- `spar-daily-limit` Sparring setup warns (test id `sparring-request-shortfall`) when today's remaining requests are fewer than the round needs. +- `spar-daily-limit` Sparring setup warns (test id `sparring-request-shortfall`) when today's remaining requests, as the server reports them, are fewer than the round costs. A round costs one use (by model weight) however many replies it takes; `session-cost` says so before Start. ## How to get to it (user POV) @@ -30,7 +30,7 @@ Preconditions: - **Start Crossfire.** On `/app/crossfire`, fill test id `crossfire-resolution` and `crossfire-argument`. Click `mode-question-ai` (or `mode-ai-questions-user`), then `start-crossfire`, then `chooseForMotion(page, "create")`. - **Crossfire turn.** Fill `crossfire-input` and click `submit-turn`. The text `FAKE-PROVIDER crossfire answer` and `Direct answer` appear. `providerCall("crossfire")` and `providerCall("jev")` both fired. - **Debrief.** Click `end-crossfire`. Test id `crossfire-debrief` is visible. `crossfires` for the user equals `[{title: , debates: {motion: }}]`. -- **Out of requests.** Through `admin`, set `user_settings.daily_limit_override` to N, upsert today's `daily_usage_counters` row (`quota_key: "ai"`, `request_count: N`; what the `ai` function checks), and insert N `ai_requests` rows (what the browser counts). Start Crossfire in `mode-ai-questions-user`: a `dialog` containing `N of N` opens and `start-crossfire` is back. On `/app/sparring`, `sparring-request-shortfall` says `You have no AI requests left today`. +- **Out of requests.** Through `admin`, set `user_settings.daily_limit_override` to N, upsert today's `daily_usage_counters` row (`quota_key: "ai"`, `request_count: N`; what the `ai` function checks), and insert N `ai_requests` rows (the fallback the Settings usage line counts against a database without `ai_usage_today`; Crossfire and Sparring ask the server). Start Crossfire in `mode-ai-questions-user`: a `dialog` containing `N of N` opens and `start-crossfire` is back. On `/app/sparring`, `sparring-request-shortfall` says `You have no AI requests left today`. ## Gotchas diff --git a/.claude/skills/verify-debatelab/features/usage-limits.md b/.claude/skills/verify-debatelab/features/usage-limits.md index 45c8d46d3..3448546da 100644 --- a/.claude/skills/verify-debatelab/features/usage-limits.md +++ b/.claude/skills/verify-debatelab/features/usage-limits.md @@ -2,12 +2,18 @@ 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`. +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` (`supabase/migrations/20261022220000_ai_quota_sessions.sql`), and every later turn, reply, debrief and judge call in the same round is free. A round that passes its call cap or lifetime (`SESSION_QUOTA` in `usage-limits.ts`) pays again. Both setups ask the server what a round costs before Start (a `quotaCheck` request that reserves nothing) and show it in `session-cost`; a round the server would refuse never starts. A run that fails, is cancelled or is lost to a reload before the student saw `REFUND_MAX_DELIVERED_CHARS` of streamed output gets its units back, and the error says "You were not charged". + ## 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. +- `usage-weekly-sparring`: with the budget reached, the Sparring setup's `session-cost` shows the translated weekly message from the catalog (not the server's English reason) before Start, and Start opens `Weekly AI Allowance Used` without opening a round or calling the provider. +- `usage-session-sparring` (QB-01): with 1 request left of 5, a Lincoln-Douglas round on Claude Haiku 5.5 runs both opponent replies and the judge; the counter moves 4 to 5 once and `ai_quota_sessions` shows `calls_used: 3`. +- `usage-session-crossfire` (QB-05, MD-03): an AI-led Crossfire round with two answers and the debrief makes four calls and one charge; the setup hint is gone once the round runs. +- `usage-refund-reload` (QB-04, MD-13): reloading while Brainstorm streams logs the run `cancelled`/`aborted` and gives its units back. Fake mode only. +- `usage-ask-not-sent` (MD-01): an Ask question refused by the daily limit stays in the chat under a `Not sent: daily limit reached` alert with Retry; after the limit is raised, Retry sends it once. ## How to get to it (user POV) @@ -15,11 +21,11 @@ The daily AI allowance counts units, not requests. An expensive model uses sever ## 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. +- `bin/verify.sh drive recipes/usage-limits` covers each sub-feature in its own 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 `cap_cost_usd: 2` for the weekly step. -- End states are read from `daily_usage_counters.request_count` and from the recorded provider calls. +- End states are read from `daily_usage_counters.request_count`, `ai_quota_sessions`, `ai_requests` and 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. +- 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. diff --git a/.claude/skills/verify-debatelab/recipes/practice.spec.ts b/.claude/skills/verify-debatelab/recipes/practice.spec.ts index c24431ef6..303f3028f 100644 --- a/.claude/skills/verify-debatelab/recipes/practice.spec.ts +++ b/.claude/skills/verify-debatelab/recipes/practice.spec.ts @@ -112,8 +112,9 @@ test("cross-ai-questions: the AI questions the user, answers are judged, and the /** * Today's AI requests are used up: the server's counter (what the ai function - * checks) and the usage rows (what the browser counts). A refused call and a - * Jev helper row are on the list too, and must not count as used. + * checks, and what Crossfire and Sparring ask it for before a round) and the + * usage rows (the Settings usage line's fallback on an older database). A refused call and a Jev + * helper row are on the list too, and must not count as used. */ async function useUpDailyRequests(userId: string, limit: number) { await admin.from("user_settings").update({ daily_limit_override: limit }).eq("user_id", userId); diff --git a/.claude/skills/verify-debatelab/recipes/usage-limits.spec.ts b/.claude/skills/verify-debatelab/recipes/usage-limits.spec.ts index b3cd94846..aa7b14ae3 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, chooseForMotion, expect, providerCalls, startDebate, test } from "./fixtures"; +import { admin, AI_TIMEOUT, aiResponse, chooseForMotion, expect, fake, LIVE, 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"; @@ -33,6 +33,9 @@ test("usage-weighted: Opus counts as 5 requests, a lighter model still runs, and await openGenerate(page); const selector = page.getByRole("combobox", { name: "Select AI model for generate" }); + // Demo users default to Sonnet (20261022120000_opus_default_to_sonnet.sql); pick Opus. + await selector.click(); + await page.getByRole("option", { name: /Claude Opus 5\.5/ }).click(); await expect(selector).toContainText("Claude Opus 5.5"); await page.getByRole("button", { name: "Generate Debate Case" }).click(); const daily = page.getByRole("dialog"); @@ -42,7 +45,9 @@ test("usage-weighted: Opus counts as 5 requests, a lighter model still runs, and 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(); + // Pro accounts get OK, others Not now; Escape closes either. + await page.keyboard.press("Escape"); + await expect(daily).toHaveCount(0); await selector.click(); await page.getByRole("option", { name: /Claude Haiku 5\.5/ }).click(); @@ -71,28 +76,157 @@ test("usage-weighted: Opus counts as 5 requests, a lighter model still runs, and 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 }) => { +test("usage-weekly-sparring: with the week's allowance used, Sparring says so before a round and never opens one", 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); + // The setup asks the server first and shows the catalog's words, not the server's English reason. + await expect(page.getByTestId("session-cost")).toContainText("You've used this week's AI allowance.", { 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-before-start"); + await page.getByRole("button", { name: "Start round" }).click(); + await chooseForMotion(page, "create"); + await expect(page.getByRole("dialog").getByText("Weekly AI Allowance Used")).toBeVisible({ timeout: AI_TIMEOUT }); + // No round opened, so no speech can be written into a round the server would refuse. + await expect(page.getByRole("textbox", { name: /^Draft for / })).toHaveCount(0); + expect((await providerCalls()).filter((call) => call.feature === "sparring")).toEqual([]); + await evidence.step("Sparring refused at Start by the weekly cap with the translated message; no round, no provider call"); +}); + +async function sessionRow(userId: string, feature: string) { + const { data, error } = await admin.from("ai_quota_sessions").select("charged,units,calls_used").eq("user_id", userId).eq("feature", feature); + if (error) throw error; + return data; +} + +test("usage-session-sparring: with one request left, a whole Sparring round (every reply and the judge) runs for one use", async ({ signedIn: page, user, evidence }) => { + const userId = await user.userId(); + // QB-01: a trial-sized day (5) with 4 already used. Per call, the round would + // stop at the second reply; per round it finishes. + await admin.from("user_settings").update({ daily_limit_override: 5 }).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: 4, + }); + if (seedError) throw seedError; + 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("combobox", { name: "Select AI model for sparring" }).click(); + await page.getByRole("option", { name: /Claude Haiku 5\.5/ }).click(); + const cost = page.getByTestId("session-cost"); + await expect(cost).toHaveText("This round costs 1 AI request, however many turns it takes. You have 1 left today.", { timeout: AI_TIMEOUT }); + await evidence.shot("01-sparring-cost-before-start"); 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"); + for (const [turn, content] of [ + ["Affirmative constructive", "Universal access removes the direct price barrier to participation in work, school, and civic life."], + ["First affirmative rebuttal", "Targeted discounts leave eligibility gaps and add administrative friction that keeps poor riders out."], + ["Second affirmative rebuttal", "Weigh reliability: the universal policy covers every marginal trip, while targeting misses many of them."], + ] as const) { + const draft = page.getByRole("textbox", { name: `Draft for ${turn}` }); + await expect(draft).toBeVisible({ timeout: AI_TIMEOUT }); + await draft.fill(content); + await page.getByRole("button", { name: "Submit speech" }).click(); + } + await expect(page.getByRole("heading", { name: "Here is what happened in the room." })).toBeVisible({ timeout: AI_TIMEOUT * 2 }); + await evidence.shot("02-sparring-judged-on-one-use"); + const calls = (await providerCalls()).filter((call) => call.feature === "sparring"); + expect(calls.length).toBe(3); + expect(await aiCounter(userId)).toBe(5); + expect(await sessionRow(userId, "sparring")).toEqual([{ charged: true, units: 1, calls_used: 3 }]); + await evidence.step("Two opponent replies and the judge ran on the last request of the day; the counter moved 4 -> 5 once"); +}); + +test("usage-session-crossfire: a Crossfire round (opening, answers, debrief) is charged once", async ({ signedIn: page, user, evidence }) => { + const userId = await user.userId(); + await page.goto("/app/crossfire"); + await page.getByTestId("crossfire-resolution").fill(motion); + await page.getByTestId("crossfire-argument").fill("Free fares remove the price barrier for the poorest riders."); + await page.getByTestId("mode-ai-questions-user").click(); + await expect(page.getByTestId("session-cost")).toContainText("however many turns it takes.", { timeout: AI_TIMEOUT }); + await evidence.shot("01-crossfire-cost-before-start"); + const opening = aiResponse(page, "crossfire", "next-turn"); + await page.getByTestId("start-crossfire").click(); + await chooseForMotion(page, "create"); + await opening; + // MD-03: once the round runs, the setup hint is gone. + await expect(page.getByText("Set a resolution and an argument to start a focused cross-examination.")).toHaveCount(0); + const units = (await sessionRow(userId, "crossfire"))?.[0]?.units ?? 0; + expect(units).toBeGreaterThan(0); + expect(await aiCounter(userId)).toBe(units); + + for (const answer of ["Fare revenue covers a small share of operating cost in most systems.", "Ridership gains offset congestion costs downtown."]) { + await page.getByTestId("crossfire-input").fill(answer); + const next = aiResponse(page, "crossfire", "next-turn"); + await page.getByTestId("submit-turn").click(); + await next; + } + const debrief = aiResponse(page, "crossfire", "debrief"); + await page.getByTestId("end-crossfire").click(); + await debrief; + await expect(page.getByTestId("crossfire-debrief")).toBeVisible({ timeout: AI_TIMEOUT }); + await evidence.shot("02-crossfire-debrief"); + expect(await aiCounter(userId)).toBe(units); + expect(await sessionRow(userId, "crossfire")).toEqual([{ charged: true, units, calls_used: 4 }]); + await evidence.step(`Opening question, two answers and the debrief: four calls, one charge of ${units}`); +}); + +test("usage-refund-reload: reloading while Brainstorm streams costs nothing", async ({ signedIn: page, user, evidence }) => { + test.skip(LIVE, "only the fake provider can hold a reply mid-stream"); + const userId = await user.userId(); + await fake.streamDelay(1_500); + await page.goto("/app/brainstorm"); + await page.getByLabel("Motion / Topic *").fill(motion); + await page.getByRole("button", { name: "Start Brainstorming" }).click(); + await chooseForMotion(page, "create"); + // The run is reserved and streaming: the counter shows it. + await expect.poll(() => aiCounter(userId), { timeout: AI_TIMEOUT }).toBeGreaterThan(0); + await expect.poll(async () => (await providerCalls()).some((call) => call.feature === "brainstorm"), { timeout: AI_TIMEOUT }).toBe(true); + await evidence.shot("01-brainstorm-streaming"); + await page.reload(); + await expect.poll(async () => { + const { data } = await admin.from("ai_requests").select("status,error_code").eq("user_id", userId).eq("feature", "brainstorm"); + return data; + }, { timeout: AI_TIMEOUT }).toEqual([{ status: "cancelled", error_code: "aborted" }]); + expect(await aiCounter(userId)).toBe(0); + await evidence.step("Reload mid-stream: the run was logged cancelled/aborted and its units given back (counter 0)"); +}); + +test("usage-ask-not-sent: a question refused by the daily limit stays, marked Not sent, and Retry sends it once", async ({ signedIn: page, user, evidence }) => { + const userId = await user.userId(); + await admin.from("user_settings").update({ daily_limit_override: 2 }).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: 2, + }); + if (seedError) throw seedError; + const question = "What is the strongest objection to free public transport?"; + await page.goto("/app/ask"); + await page.getByRole("textbox", { name: "Message to your debate coach" }).fill(question); + await page.keyboard.press("Enter"); + const dialog = page.getByRole("dialog"); + await expect(dialog).toBeVisible({ timeout: AI_TIMEOUT }); + await page.keyboard.press("Escape"); + await expect(dialog).toHaveCount(0); + const notSent = page.getByRole("alert").filter({ hasText: "Not sent: daily limit reached" }); + await expect(notSent).toBeVisible(); + await expect(page.getByText(question).first()).toBeVisible(); + await evidence.shot("01-ask-not-sent"); + await evidence.aria("01-ask-not-sent", notSent); + + await admin.from("user_settings").update({ daily_limit_override: 20 }).eq("user_id", userId); + // Plain Ask answers stream from ai-stream. + const answer = page.waitForResponse((response) => response.url().endsWith("/functions/v1/ai-stream"), { timeout: AI_TIMEOUT }); + await notSent.getByRole("button", { name: "Retry" }).click(); + await answer; + await expect(notSent).toHaveCount(0); + await expect(page.getByText(question)).toHaveCount(1); + await evidence.shot("02-ask-retried"); }); diff --git a/docs/architecture.md b/docs/architecture.md index b4d6b739e..aceef15c4 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -148,6 +148,10 @@ Every model call asks for JSON. Calls whose shape the server checks in code (the 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). +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. + +A run that produces nothing costs nothing. When a request fails, times out, or the browser goes away (reload, closed tab, Stop) before `REFUND_MAX_DELIVERED_CHARS` of streamed output reached it and before its answer was saved, `ai` and `ai-stream` give its units back (`refund_daily_quota`, or `refund_ai_session_quota` for the call that paid for a round) and answer with `charged: false`, which the client shows as "You were not charged". The weekly cap still counts what the run cost. The Settings usage line reads the same counter through `ai_usage_today()` (the caller's own units today), so per-round charging and refunds show there; against an older database it falls back to counting today's `ai_requests` rows. `ai_requests.error_code` holds a short code (`provider_error`, `timeout`, `aborted`, `claude_unavailable`, the refusal codes); `logUsage` maps anything else to one of those and sends the raw text, truncated, to the function log only. + ## 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/e2e/crossfire.spec.ts b/e2e/crossfire.spec.ts index d07ec863e..5fdd42899 100644 --- a/e2e/crossfire.spec.ts +++ b/e2e/crossfire.spec.ts @@ -1,5 +1,6 @@ import { expect, test, type Page } from '@playwright/test'; import { seedCurrentDebate, setLocale, stubSupabaseSession } from './helpers/auth'; +import { fulfillQuotaCheck } from './helpers/quota'; const MOTION = 'Resolved: cities should ban private cars downtown.'; const SEEDED_DEBATE_ID = '00000000-0000-4000-8000-00000000d001'; @@ -56,6 +57,7 @@ async function installDeterministicBackend(page: Page, requests: CrossfireReques } if (url.pathname.endsWith('/functions/v1/ai')) { + if (await fulfillQuotaCheck(route, {})) return; const body = route.request().postDataJSON() as CrossfireRequest; requests.push(body); if (body.input.operation === 'signal') { diff --git a/e2e/fixtures/ai-responses.ts b/e2e/fixtures/ai-responses.ts index b24d447c5..40f32b9c7 100644 --- a/e2e/fixtures/ai-responses.ts +++ b/e2e/fixtures/ai-responses.ts @@ -5,6 +5,7 @@ * which bypassed both. */ import type { Page } from '@playwright/test'; +import { fulfillQuotaCheck } from '../helpers/quota'; const CORS_HEADERS = { 'access-control-allow-origin': '*', @@ -162,6 +163,7 @@ export async function stubAIResponses(page: Page) { await route.fulfill({ status: 200, headers: CORS_HEADERS, body: 'ok' }); return; } + if (await fulfillQuotaCheck(route, CORS_HEADERS)) return; const body = (route.request().postDataJSON() ?? {}) as AiRequest; const motion = typeof body.input?.motion === 'string' ? body.input.motion : 'the motion'; const message = typeof body.input?.message === 'string' ? body.input.message : 'this'; diff --git a/e2e/helpers/quota.ts b/e2e/helpers/quota.ts new file mode 100644 index 000000000..1bb7e7205 --- /dev/null +++ b/e2e/helpers/quota.ts @@ -0,0 +1,22 @@ +import type { Route } from '@playwright/test'; + +/** + * Answers the read-only quota check Crossfire and Sparring send to the `ai` + * function before a round starts (`quotaCheck: true`, no `input`): a round + * that fits. Returns true when the request was one; mocks call it first. + */ +export async function fulfillQuotaCheck(route: Route, headers: Record): Promise { + let body: { quotaCheck?: unknown } | null = null; + try { + body = route.request().postDataJSON() as { quotaCheck?: unknown } | null; + } catch { + return false; + } + if (body?.quotaCheck !== true) return false; + await route.fulfill({ + status: 200, + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify({ quota: { allowed: true, limit: 50, current: 0, units: 1 } }), + }); + return true; +} diff --git a/e2e/sparring.spec.ts b/e2e/sparring.spec.ts index 059ed6004..e69bcac41 100644 --- a/e2e/sparring.spec.ts +++ b/e2e/sparring.spec.ts @@ -1,5 +1,6 @@ import { expect, test } from '@playwright/test'; import { setLocale, stubSupabaseSession } from './helpers/auth'; +import { fulfillQuotaCheck } from './helpers/quota'; const corsHeaders = { 'access-control-allow-origin': '*', @@ -50,6 +51,7 @@ test('completes an LD sparring round through mocked opponent turns and judging', await route.fulfill({ status: 200, headers: corsHeaders, body: 'ok' }); return; } + if (await fulfillQuotaCheck(route, corsHeaders)) return; const body = route.request().postDataJSON() as { input?: { action?: string } }; const action = body.input?.action ?? 'unknown'; @@ -106,6 +108,7 @@ test('uses the school 4-6-6-3 roles and coaching in the default round', async ({ await route.fulfill({ status: 200, headers: corsHeaders, body: 'ok' }); return; } + if (await fulfillQuotaCheck(route, corsHeaders)) return; const body = route.request().postDataJSON() as { input?: { action?: string } }; actions.push(body.input?.action ?? 'unknown'); await route.fulfill({ status: 200, headers: corsHeaders, body: JSON.stringify({ output: opponentSpeech(1) }) }); diff --git a/e2e/voice-rounds.spec.ts b/e2e/voice-rounds.spec.ts index 22a9ed7ab..5d2280289 100644 --- a/e2e/voice-rounds.spec.ts +++ b/e2e/voice-rounds.spec.ts @@ -1,5 +1,6 @@ import { expect, test, type Page, type Route } from '@playwright/test'; import { setLocale, stubSupabaseSession } from './helpers/auth'; +import { fulfillQuotaCheck } from './helpers/quota'; import { say, spokenByBrowser, stubSpeech } from './helpers/voice'; // Voice rounds without a microphone, a speech service, or an ElevenLabs key. @@ -81,6 +82,7 @@ async function routeSparring(page: Page, bodies: SparringBody[]) { await route.fulfill({ status: 200, headers: corsHeaders, body: 'ok' }); return; } + if (await fulfillQuotaCheck(route, corsHeaders)) return; const body = route.request().postDataJSON() as SparringBody; bodies.push(body); if (body.input.action === 'score') { @@ -193,6 +195,7 @@ test('a spoken Crossfire exchange plays ElevenLabs audio through the voice funct await route.fulfill({ status: 200, headers: corsHeaders, body: 'ok' }); return; } + if (await fulfillQuotaCheck(route, corsHeaders)) return; const body = route.request().postDataJSON() as { voice?: boolean; input: { transcript: Array> } }; aiVoiceFlags.push(body.voice); // The server accepts only the turn fields it knows; measurements stay in the browser. diff --git a/src/components/features/sparring/SparringSetup.tsx b/src/components/features/sparring/SparringSetup.tsx index df651d15e..e80b9c08e 100644 --- a/src/components/features/sparring/SparringSetup.tsx +++ b/src/components/features/sparring/SparringSetup.tsx @@ -21,6 +21,7 @@ import { import type { SparringDifficulty, SparringFormat, SparringSide } from '@/lib/ai/schemas/sparring'; import { VoiceModeToggle } from '@/components/features/voice/VoiceModeToggle'; import { useVoiceAvailability } from '@/lib/voice/availability'; +import { SessionCostNote } from '@/components/features/subscription/SessionCostNote'; export interface SparringSetupValues { motion: string; @@ -39,8 +40,15 @@ interface SparringSetupProps { /** Fill the motion from the Current Debate. */ debatePrefill?: boolean; onStart: (values: SparringSetupValues) => void; - /** AI requests left today, or null when unknown. A round short of requests gets a warning. */ + /** + * AI requests the whole round costs (every reply and the judge), from the + * server. Null when unknown: an older server charges each reply and the judge. + */ + roundCost?: number | null; + /** AI requests left today, or null when unknown. A round that does not fit gets a warning. */ remainingRequests?: number | null; + /** Why the server would refuse a new round (weekly allowance, trial ended), translated. */ + blockedReason?: string | null; isStarting?: boolean; initialValues?: Partial; } @@ -51,7 +59,7 @@ const DIFFICULTIES: Array<{ id: SparringDifficulty; name: string }> = [ { id: 'hard', name: 'Ruthless' }, ]; -export function SparringSetup({ onStart, remainingRequests = null, isStarting = false, initialValues, banner, debatePrefill = false, voiceAllowed = true }: SparringSetupProps) { +export function SparringSetup({ onStart, roundCost = null, remainingRequests = null, blockedReason = null, isStarting = false, initialValues, banner, debatePrefill = false, voiceAllowed = true }: SparringSetupProps) { const { t } = useTranslation(); const [motion, setMotion] = useState(initialValues?.motion ?? ''); const motionRef = useRef(motion); @@ -67,9 +75,10 @@ export function SparringSetup({ onStart, remainingRequests = null, isStarting = const selectedFormat = useMemo(() => SPARRING_FORMATS.find((item) => item.id === format) ?? SPARRING_FORMATS[0], [format]); const plannedTurns = useMemo(() => buildSparringTurns(format, userSide), [format, userSide]); const aiRequests = plannedTurns.filter((turn) => turn.speaker === 'ai').length; - const totalRequests = aiRequests + 1; - // An estimate (the server can raise a limit the browser cannot see), so it warns rather than blocks. - const requestShortfall = remainingRequests !== null && remainingRequests < totalRequests; + // The whole round (every reply and the judge) is charged once, by model weight. + // Unknown means an older server may be charging per call, so count that way. + const cost = roundCost ?? aiRequests + 1; + const requestShortfall = remainingRequests !== null && remainingRequests < cost; const canStart = motion.trim().length >= 8 && !isStarting && isOnline; const handleSubmit = (event: React.FormEvent) => { @@ -243,8 +252,8 @@ export function SparringSetup({ onStart, remainingRequests = null, isStarting =

{t('sparring.setup.aiRequests', 'AI requests')}

-

{totalRequests}

-

{t('sparring.setup.aiRequestBreakdown', '{{count}} replies + 1 judge', { count: aiRequests })}

+

{cost}

+

{t('sparring.setup.aiRequestBreakdown', { count: aiRequests })}

@@ -263,9 +272,11 @@ export function SparringSetup({ onStart, remainingRequests = null, isStarting =

{remainingRequests === 0 ? t('dailyLimit.sparringNone') - : t('dailyLimit.sparringShort', { needed: totalRequests, remaining: remainingRequests })} + : t('dailyLimit.sparringShort', { needed: cost, remaining: remainingRequests })}

)} + {/* The shortfall note above already explains a day that is used up. */} + {!isOnline &&

{t('sparring.setup.offlineCannotStart', 'Reconnect to start a round. Your setup is kept while you are offline.')}

}

{t('sparring.setup.draftNotice', 'Your draft is saved locally as you type. You can pause without losing your place.')}

diff --git a/src/components/features/subscription/SessionCostNote.tsx b/src/components/features/subscription/SessionCostNote.tsx new file mode 100644 index 000000000..b796dcac0 --- /dev/null +++ b/src/components/features/subscription/SessionCostNote.tsx @@ -0,0 +1,32 @@ +import { useTranslation } from 'react-i18next'; +import { cn } from '@/lib/utils'; + +interface SessionCostNoteProps { + units: number | null; + remaining: number | null; + /** Why a new round would be refused, already translated. Shown instead of the cost. */ + blocked?: string | null; + className?: string; +} + +/** + * "This round costs 1 AI request, however many turns it takes. You have N left today." + * Says nothing while the cost is unknown: an older server charges per call. + */ +export function SessionCostNote({ units, remaining, blocked = null, className }: SessionCostNoteProps) { + const { t } = useTranslation(); + if (!blocked && units === null) return null; + if (blocked) { + return ( +

+ {blocked} +

+ ); + } + return ( +

+ {t('sessionQuota.cost', { count: units })} + {remaining !== null && ` ${t('sessionQuota.remaining', { count: remaining })}`} +

+ ); +} diff --git a/src/components/features/subscription/index.ts b/src/components/features/subscription/index.ts index a5ac7f632..9c55cd365 100644 --- a/src/components/features/subscription/index.ts +++ b/src/components/features/subscription/index.ts @@ -2,3 +2,4 @@ export { PricingModal } from './PricingModal'; export { LimitReachedModal } from './LimitReachedModal'; export { ModelRestrictedModal } from './ModelRestrictedModal'; export { SubscriptionErrorModals } from './SubscriptionErrorModals'; +export { SessionCostNote } from './SessionCostNote'; diff --git a/src/hooks/use-session-quota.ts b/src/hooks/use-session-quota.ts new file mode 100644 index 000000000..00e2c90c2 --- /dev/null +++ b/src/hooks/use-session-quota.ts @@ -0,0 +1,41 @@ +import { useCallback } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { checkSessionQuota, isAISubscriptionError, sessionQuotaError, sessionQuotaRemaining, type AIError } from '@/lib/ai/client'; +import type { ProviderConfig } from '@/lib/ai/providers/types'; +import { useAuth } from '@/lib/supabase/AuthProvider'; + +export interface SessionQuotaState { + /** Quota units a whole round costs on this model, or null while unknown. */ + units: number | null; + /** AI requests left today, or null while unknown. */ + remaining: number | null; + /** Why a new round would be refused (daily limit, weekly allowance, trial ended), translated; null when it fits or is unknown. */ + blocked: string | null; + /** Reads the quota again; the error a new round would meet, or null when it fits or is unknown. */ + checkBeforeStart: () => Promise; +} + +/** + * What a new Crossfire or Sparring round costs on this model and whether + * today's quota covers it, from the server. Never blocks on an unknown answer: + * the round's own requests are still checked. + */ +export function useSessionQuota(feature: 'crossfire' | 'sparring', config: ProviderConfig, enabled = true): SessionQuotaState { + const { user } = useAuth(); + const query = useQuery({ + queryKey: ['session-quota', user?.id, feature, config.model], + queryFn: () => checkSessionQuota(feature, config), + enabled: enabled && Boolean(user?.id), + staleTime: 15_000, + }); + const { refetch } = query; + const checkBeforeStart = useCallback(async () => sessionQuotaError((await refetch()).data ?? null), [refetch]); + const quota = query.data ?? null; + const refusal = sessionQuotaError(quota); + return { + units: quota?.units ?? null, + remaining: sessionQuotaRemaining(quota), + blocked: isAISubscriptionError(refusal) ? refusal.message : null, + checkBeforeStart, + }; +} diff --git a/src/hooks/use-subscription.test.tsx b/src/hooks/use-subscription.test.tsx index bfcc03cee..23fd530eb 100644 --- a/src/hooks/use-subscription.test.tsx +++ b/src/hooks/use-subscription.test.tsx @@ -9,6 +9,9 @@ let rateLimitResult: { data: { limit_value: number } | null; error: { message: s error: null, }; +// ai_usage_today: the server's counter. Missing (an older server) by default. +let usageToday: { data: number | null; error: { message: string } | null } = { data: null, error: { message: 'function not found' } }; + function builder(table: string) { let column = ''; const api = { @@ -42,7 +45,10 @@ vi.mock('@/lib/supabase/AuthProvider', () => ({ })); vi.mock('@/lib/supabase/client', () => ({ - supabase: { from: (table: string) => builder(table) }, + supabase: { + from: (table: string) => builder(table), + rpc: (name: string) => Promise.resolve(name === 'ai_usage_today' ? usageToday : { data: null, error: { message: 'unexpected rpc' } }), + }, })); import { useSubscription } from './use-subscription'; @@ -56,6 +62,7 @@ describe('useSubscription rate limits', () => { beforeEach(() => { orders.length = 0; rateLimitResult = { data: { limit_value: 12 }, error: null }; + usageToday = { data: null, error: { message: 'function not found' } }; }); it('reads the account limit ordered by created_at and uses it', async () => { @@ -80,4 +87,14 @@ describe('useSubscription rate limits', () => { expect(result.current.remainingToday).toBeNull(); warn.mockRestore(); }); + + it('counts what the server charged, not one per logged call', async () => { + // A Sparring round logs a row per call (1 here) but was charged as 3 units + // on a weighted model; refunds and per-round charging only show in the counter. + usageToday = { data: 3, error: null }; + const { result } = renderHook(() => useSubscription(), { wrapper }); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.dailyUsed).toBe(3); + expect(result.current.remainingToday).toBe(9); + }); }); diff --git a/src/hooks/use-subscription.ts b/src/hooks/use-subscription.ts index 8e4ce40f7..4b18b0b80 100644 --- a/src/hooks/use-subscription.ts +++ b/src/hooks/use-subscription.ts @@ -139,11 +139,17 @@ async function fetchSubscription(userId: string): Promise else if (typeof ownLimit?.limit_value === 'number') snapshot.dailyLimit = ownLimit.limit_value; } + // The server's own counter: model-weighted units, one charge per Crossfire + // or Sparring round, refunds given back. + const counted = await readAIUsageToday(); + if (counted !== null) { + return { ...snapshot, dailyUsed: counted, usageKnown: limitKnown && Boolean(data) }; + } + + // A server from before ai_usage_today: count today's usage rows. Refused + // calls and the jev_* helper steps inside a call log rows too, but use no quota. const todayStart = new Date(); todayStart.setUTCHours(0, 0, 0, 0); - // The server counts one request per AI call it lets through (consume_daily_quota). - // Each of those logs one row under its feature; refused calls and the - // jev_* helper steps inside a call log rows too, but use no quota. const { count, error: usageError } = await supabase .from('ai_requests') .select('*', { count: 'exact', head: true }) @@ -162,6 +168,16 @@ async function fetchSubscription(userId: string): Promise } } +/** Today's charged AI units from the server's counter, or null when it cannot be read. */ +async function readAIUsageToday(): Promise { + try { + const { data, error } = await supabase.rpc('ai_usage_today'); + return !error && typeof data === 'number' ? data : null; + } catch { + return null; + } +} + function remainingRequests(snapshot: SubscriptionSnapshot, usageKnown: boolean): number | null { if (!usageKnown) return null; return Math.max(0, snapshot.dailyLimit - snapshot.dailyUsed); diff --git a/src/lib/ai/client.ts b/src/lib/ai/client.ts index ecf60ea6b..db7b827c1 100644 --- a/src/lib/ai/client.ts +++ b/src/lib/ai/client.ts @@ -43,4 +43,6 @@ export { factoids } from './features/factoids'; export { askChat, boundAskMessage, boundAskHistory, isAskMessageWithinBudget } from './features/ask'; export type { AskChatOptions } from './features/ask'; export { generateSparringTurn, judgeSparringSession } from './features/sparring'; +export { checkSessionQuota, sessionQuotaError, sessionQuotaRemaining } from './features/session-quota'; +export type { SessionQuota } from './features/session-quota'; export type { SparringTurnResult } from './features/sparring'; diff --git a/src/lib/ai/error-display.ts b/src/lib/ai/error-display.ts index 186a2af74..f900af39d 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, isAINetworkError, isAISubscriptionError } from './errors'; +import { AIError, AIProviderError, isAINetworkError, isAISubscriptionError } from './errors'; import { subscriptionLimitMessage } from './limit-message'; const PROVIDER_LABELS: Record = { @@ -21,6 +21,12 @@ function providerLabel(provider?: string, message = ''): string { export { subscriptionLimitMessage }; export function getUserFacingAIError(error: unknown): string { + const message = describeAIError(error); + // The server gave the request back, so say so: a failed run costs nothing. + return error instanceof AIError && error.notCharged ? `${message} ${i18n.t('aiErrors.notCharged')}` : message; +} + +function describeAIError(error: unknown): string { if (isAINetworkError(error)) return i18n.t('aiErrors.offline'); if (isAISubscriptionError(error)) return subscriptionLimitMessage(error); diff --git a/src/lib/ai/errors.ts b/src/lib/ai/errors.ts index d3996e304..cb8159743 100644 --- a/src/lib/ai/errors.ts +++ b/src/lib/ai/errors.ts @@ -8,6 +8,8 @@ export class AIError extends Error { readonly statusCode?: number; readonly requestId?: string; readonly details?: Record; + /** The server gave this failed request's quota back, or it ran in a session that had already paid. */ + notCharged = false; constructor( message: string, @@ -295,6 +297,19 @@ export function mapAIError( provider: string, model: string, payload?: Record | null +): AIError { + const mapped = mapAIErrorPayload(error, requestId, provider, model, payload); + // The server says when a failed request cost nothing (it gave the quota back). + if (payload?.charged === false) mapped.notCharged = true; + return mapped; +} + +function mapAIErrorPayload( + error: { message: string; context?: unknown }, + requestId: string, + provider: string, + model: string, + payload?: Record | null ): AIError { const payloadMessage = typeof payload?.error === 'string' ? payload.error : undefined; const message = payloadMessage || error.message || 'Unknown error'; diff --git a/src/lib/ai/features/crossfire.ts b/src/lib/ai/features/crossfire.ts index 9b20d59da..70cf76167 100644 --- a/src/lib/ai/features/crossfire.ts +++ b/src/lib/ai/features/crossfire.ts @@ -31,7 +31,7 @@ function validateCrossfireTurnReferences(input: CrossfireAIInput, debrief: Cross export async function crossfire( rawInput: CrossfireAIInput, config: ProviderConfig, - options: Pick = {}, + options: Pick = {}, ): Promise<(CrossfireReply & { signalToken?: string; voice?: VoicePayload }) | CrossfireDebrief> { const input = CrossfireAIInputSchema.safeParse(rawInput); if (!input.success) { @@ -52,6 +52,8 @@ export async function crossfire( debateId: options.debateId, voice: options.voice && input.data.operation === 'next-turn', live: options.live, + // The round is charged once: every turn and the debrief name it. + quotaSession: options.quotaSession, // Turns keep the round's Review choices; the debrief ends the round. keepContextChoices: input.data.operation === 'next-turn', transformResponse: (rawData) => { diff --git a/src/lib/ai/features/session-quota.test.ts b/src/lib/ai/features/session-quota.test.ts new file mode 100644 index 000000000..906a17abe --- /dev/null +++ b/src/lib/ai/features/session-quota.test.ts @@ -0,0 +1,26 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ signal: null as AbortSignal | null })); + +vi.mock('@/lib/supabase/client', () => ({ + getActiveAccessToken: () => 'token', + // A quota check that never answers. + invokeAuthenticatedFunction: vi.fn((_name: string, options: { signal?: AbortSignal }) => { + mocks.signal = options.signal ?? null; + return new Promise(() => undefined); + }), +})); + +import { checkSessionQuota, QUOTA_CHECK_TIMEOUT_MS } from './session-quota'; + +describe('checkSessionQuota', () => { + afterEach(() => vi.useRealTimers()); + + it('gives up on a stalled check, so Start goes ahead and the request is cancelled', async () => { + vi.useFakeTimers(); + const result = checkSessionQuota('sparring', { provider: 'openrouter', model: 'anthropic/claude-haiku-5.5' }); + await vi.advanceTimersByTimeAsync(QUOTA_CHECK_TIMEOUT_MS); + await expect(result).resolves.toBeNull(); + expect(mocks.signal?.aborted).toBe(true); + }); +}); diff --git a/src/lib/ai/features/session-quota.ts b/src/lib/ai/features/session-quota.ts new file mode 100644 index 000000000..f90355f3b --- /dev/null +++ b/src/lib/ai/features/session-quota.ts @@ -0,0 +1,82 @@ +import type { ProviderConfig } from '../providers/types'; +import { getActiveAccessToken, invokeAuthenticatedFunction } from '@/lib/supabase/client'; +import { AIModelRestrictionError, AISubscriptionError, type AIError } from '../errors'; +import { localizedLimitMessage } from '../limit-message'; + +/** What a new Crossfire or Sparring round costs, and whether today's quota covers it. */ +export interface SessionQuota { + allowed: boolean; + /** Why the server would refuse: limit_reached, weekly_budget_reached, trial_expired, upgrade_required, banned. */ + code?: string; + limit?: number; + current?: number; + /** Quota units the whole round costs: one use, weighted by the model. */ + units: number; +} + +/** How long Start waits for the quota check before going ahead without it. */ +export const QUOTA_CHECK_TIMEOUT_MS = 8_000; + +function numberOrUndefined(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) ? value : undefined; +} + +/** + * Asks the server what a round on this model costs and what is left today, + * without reserving anything. Null when it cannot tell (signed out, offline, + * or a server from before session charging); the round's own requests are + * still checked, so null never blocks a start. + */ +export async function checkSessionQuota(feature: 'crossfire' | 'sparring', config: ProviderConfig): Promise { + const accessToken = getActiveAccessToken(); + if (!accessToken) return null; + const controller = new AbortController(); + let timer: ReturnType | undefined; + // A stalled check must not hold Start: past the timeout the answer is unknown. + const timedOut = new Promise((resolve) => { + timer = setTimeout(() => { + controller.abort(); + resolve(null); + }, QUOTA_CHECK_TIMEOUT_MS); + }); + try { + const answer = await Promise.race([ + invokeAuthenticatedFunction>('ai', { + headers: { Authorization: `Bearer ${accessToken}` }, + body: { feature, model: config.model, quotaCheck: true }, + signal: controller.signal, + }), + timedOut, + ]); + if (!answer) return null; + const { data, error } = answer; + const quota = !error && data?.quota && typeof data.quota === 'object' ? data.quota as Record : null; + if (!quota || typeof quota.allowed !== 'boolean') return null; + return { + allowed: quota.allowed, + code: typeof quota.code === 'string' ? quota.code : undefined, + limit: numberOrUndefined(quota.limit), + current: numberOrUndefined(quota.current), + units: numberOrUndefined(quota.units) ?? 1, + }; + } catch { + return null; + } finally { + clearTimeout(timer); + } +} + +/** Requests left today by this check, or null when unknown. */ +export function sessionQuotaRemaining(quota: SessionQuota | null | undefined): number | null { + if (!quota || quota.limit === undefined || quota.current === undefined) return null; + return Math.max(0, quota.limit - quota.current); +} + +/** The error a refused round would have met, for the same limit dialog every tool uses. */ +export function sessionQuotaError(quota: SessionQuota | null | undefined): AIError | null { + if (!quota || quota.allowed) return null; + if (quota.code === 'upgrade_required') return new AIModelRestrictionError('This model requires a Pro subscription.'); + if (quota.code !== 'limit_reached' && quota.code !== 'weekly_budget_reached' && quota.code !== 'trial_expired') return null; + const details = { code: quota.code, limit: quota.limit, current: quota.current, units: quota.units }; + return new AISubscriptionError(localizedLimitMessage('', details), { statusCode: 402, ...details }); +} diff --git a/src/lib/ai/request.ts b/src/lib/ai/request.ts index 06dc7be68..6d6da1ff0 100644 --- a/src/lib/ai/request.ts +++ b/src/lib/ai/request.ts @@ -41,6 +41,8 @@ import { readLiveResponse, type LiveGeneration } from './live'; export const REQUEST_TIMEOUT_MS = 200 * 1000; const MAX_CLIENT_ERROR_BODY_BYTES = 16_000; const MAX_CLIENT_REQUEST_BODY_BYTES = 190_000; +// The session ids the ai function accepts (parseQuotaSessionKey). +const QUOTA_SESSION_KEY = /^[A-Za-z0-9._:-]{1,128}$/; export interface AIRequestOptions { includeActiveSources?: boolean; @@ -60,6 +62,12 @@ export interface AIRequestOptions { voice?: boolean; /** Show the answer while the model writes it. */ live?: LiveGeneration; + /** + * The Crossfire round this request belongs to. The server charges a round + * once, however many turns it takes (Sparring rounds are named by their + * input's sessionId instead). + */ + quotaSession?: string; } /** @@ -184,6 +192,8 @@ export async function callSupabaseAI( }); }; + // The server said this failure cost nothing (refunded, or a paid session). + let notCharged = false; const controller = new AbortController(); const timeoutId = setTimeout(() => { controller.abort(); @@ -222,6 +232,10 @@ export async function callSupabaseAI( workspaceContext: options.workspaceContext, // Beside `input`, so a server from before voice rounds ignores it. ...(options.voice ? { voice: true } : {}), + // Beside `input` for the same reason: older servers charge per call. An id + // the server would reject (a round restored from an old draft) is left + // out, and that round is charged per call. + ...(options.quotaSession && QUOTA_SESSION_KEY.test(options.quotaSession) ? { quotaSession: options.quotaSession } : {}), }; if (utf8ByteLength(JSON.stringify(requestBody)) > MAX_CLIENT_REQUEST_BODY_BYTES) { @@ -262,6 +276,7 @@ export async function callSupabaseAI( // Handle response with error field (from edge function) if (data?.error) { + notCharged = data.charged === false; const errorMessage = data.error as string; const errorCode = data.code as string | undefined; @@ -353,6 +368,7 @@ export async function callSupabaseAI( } catch (err) { // Re-throw our custom errors if (err instanceof AIError) { + if (notCharged) err.notCharged = true; refreshDebatesIfMissing(err, debateContext); reportFailure(err); throw err; diff --git a/src/locales/en.json b/src/locales/en.json index cb82baf3b..74a71f1a4 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -424,7 +424,10 @@ "saveFailed": "Save failed", "saveFailedDesc": "The chat could not be saved. Please try again.", "sourceSaveFailedDesc": "The source could not be saved. Please try again.", - "stopped": "You stopped this answer before it finished." + "stopped": "You stopped this answer before it finished.", + "notSent": "Not sent", + "notSentDailyLimit": "Not sent: daily limit reached", + "notSentModelDesc": "This model needs Pro. Pick another model, then retry." }, "chat": {}, "settings": { @@ -1374,7 +1377,7 @@ "dailyLimit": { "usage": "Today: {{used}} of {{limit}} AI requests used. They refresh at midnight UTC ({{time}} your time).", "sparringNone": "You have no AI requests left today, so the opponent can't answer. They refresh at midnight UTC.", - "sparringShort": "This round needs {{needed}} AI requests and you have {{remaining}} left today, so it would stop before the judge. They refresh at midnight UTC." + "sparringShort": "This round costs {{needed}} AI requests on this model and you have {{remaining}} left today. Pick a lighter model or wait until midnight UTC." }, "pricingModal": { "title": "Upgrade to Pro", @@ -1972,7 +1975,7 @@ "turns": "{{count}} turns", "opponentTurns": "Opponent turns", "aiRequests": "AI requests", - "aiRequestBreakdown": "{{count}} replies + 1 judge", + "aiRequestBreakdown": "Covers {{count}} replies + the judge", "aiModel": "AI model", "openingRoom": "Opening room...", "startRound": "Start round", @@ -1981,8 +1984,8 @@ "speakingOrder": "Speaking order", "aboutMinutes_one": "About {{count}} minute", "aboutMinutes_other": "About {{count}} minutes", - "aiRequestBreakdown_one": "{{count}} reply + 1 judge", - "aiRequestBreakdown_other": "{{count}} replies + 1 judge", + "aiRequestBreakdown_one": "Covers {{count}} reply + the judge", + "aiRequestBreakdown_other": "Covers {{count}} replies + the judge", "speeches_one": "{{count}} speech", "speeches_other": "{{count}} speeches", "turns_one": "{{count}} turn", @@ -2450,7 +2453,8 @@ "safety": "{{provider}} blocked this request for safety reasons. Try rephrasing your input.", "unexpectedProvider": "The AI provider returned an unexpected error. Please try again later.", "unexpected": "An unexpected error occurred", - "offline": "Check your connection and try again." + "offline": "Check your connection and try again.", + "notCharged": "You were not charged for this request." }, "clock": { "timeUp": "Time is up.", @@ -2460,5 +2464,11 @@ "question": "What is the best AI debate practice tool?", "answer": "It depends on what you need. As of {{asOf}}, DebateLab fits students and competitive debaters who want one place to build cases, find weak points, check evidence and run timed practice rounds, in English, Spanish or Hungarian: a 3-day free trial with no card, then $5/month + tax. If you want free practice against an AI opponent in World Schools or Asian Parliamentary style, debatelab.ai, a separate product, says it is free. If you mostly need brainstorming or writing help on any subject, a general assistant such as ChatGPT is more flexible.", "compare": "Compare AI debate tools" + }, + "sessionQuota": { + "cost_one": "This round costs {{count}} AI request, however many turns it takes.", + "cost_other": "This round costs {{count}} AI requests, however many turns it takes.", + "remaining_one": "You have {{count}} left today.", + "remaining_other": "You have {{count}} left today." } } diff --git a/src/locales/es.json b/src/locales/es.json index 4218a6b2f..11b8bfb57 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -424,7 +424,10 @@ "saveFailed": "No se pudo guardar", "saveFailedDesc": "No se pudo guardar el chat. Inténtalo de nuevo.", "sourceSaveFailedDesc": "No se pudo guardar la fuente. Inténtalo de nuevo.", - "stopped": "Detuviste esta respuesta antes de que terminara." + "stopped": "Detuviste esta respuesta antes de que terminara.", + "notSent": "No enviado", + "notSentDailyLimit": "No enviado: has alcanzado el límite diario", + "notSentModelDesc": "Este modelo requiere Pro. Elige otro modelo y vuelve a intentarlo." }, "chat": {}, "settings": { @@ -1374,7 +1377,7 @@ "dailyLimit": { "usage": "Hoy: {{used}} de {{limit}} solicitudes de IA usadas. Se renuevan a medianoche UTC ({{time}} en tu hora).", "sparringNone": "No te quedan solicitudes de IA hoy, así que el oponente no podrá responder. Se renuevan a medianoche UTC.", - "sparringShort": "Esta ronda necesita {{needed}} solicitudes de IA y hoy te quedan {{remaining}}, así que se detendría antes del juez. Se renuevan a medianoche UTC." + "sparringShort": "Con este modelo, esta ronda cuesta {{needed}} solicitudes de IA y hoy te quedan {{remaining}}. Elige un modelo más ligero o espera a la medianoche UTC." }, "pricingModal": { "title": "Actualizar a Pro", @@ -1972,7 +1975,7 @@ "turns": "{{count}} turnos", "opponentTurns": "Turnos del oponente", "aiRequests": "Solicitudes a la IA", - "aiRequestBreakdown": "{{count}} respuestas + 1 juez", + "aiRequestBreakdown": "Cubre {{count}} respuestas + el juez", "aiModel": "Modelo de IA", "openingRoom": "Abriendo la sala...", "startRound": "Iniciar ronda", @@ -1981,8 +1984,8 @@ "speakingOrder": "Orden de intervención", "aboutMinutes_one": "Aproximadamente {{count}} minuto", "aboutMinutes_other": "Aproximadamente {{count}} minutos", - "aiRequestBreakdown_one": "{{count}} respuesta + 1 juez", - "aiRequestBreakdown_other": "{{count}} respuestas + 1 juez", + "aiRequestBreakdown_one": "Cubre {{count}} respuesta + el juez", + "aiRequestBreakdown_other": "Cubre {{count}} respuestas + el juez", "speeches_one": "{{count}} intervención", "speeches_other": "{{count}} intervenciones", "turns_one": "{{count}} turno", @@ -2450,7 +2453,8 @@ "safety": "{{provider}} bloqueó esta solicitud por seguridad. Prueba a reformularla.", "unexpectedProvider": "El proveedor de IA devolvió un error inesperado. Inténtalo más tarde.", "unexpected": "Se produjo un error inesperado", - "offline": "Comprueba tu conexión e inténtalo de nuevo." + "offline": "Comprueba tu conexión e inténtalo de nuevo.", + "notCharged": "No se te ha cobrado esta solicitud." }, "clock": { "timeUp": "Se acabó el tiempo.", @@ -2460,5 +2464,11 @@ "question": "¿Cuál es la mejor herramienta de IA para practicar debate?", "answer": "Depende de lo que necesites. En {{asOf}}, DebateLab encaja con estudiantes y debatientes de competición que quieren un solo lugar para construir casos, encontrar puntos débiles, comprobar evidencia y hacer rondas de práctica cronometradas, en inglés, español o húngaro: prueba gratis de 3 días sin tarjeta y después $5/mes + impuestos. Si quieres practicar gratis contra un oponente de IA en estilo World Schools o Parlamentario Asiático, debatelab.ai, un producto distinto, dice ser gratuito. Si sobre todo necesitas ideas o ayuda para escribir sobre cualquier tema, un asistente general como ChatGPT es más flexible.", "compare": "Comparar herramientas de IA para debate" + }, + "sessionQuota": { + "cost_one": "Esta ronda cuesta {{count}} solicitud de IA, dure los turnos que dure.", + "cost_other": "Esta ronda cuesta {{count}} solicitudes de IA, dure los turnos que dure.", + "remaining_one": "Hoy te queda {{count}}.", + "remaining_other": "Hoy te quedan {{count}}." } } diff --git a/src/locales/hu.json b/src/locales/hu.json index 3f4dd2bf7..efb55c40d 100644 --- a/src/locales/hu.json +++ b/src/locales/hu.json @@ -424,7 +424,10 @@ "saveFailed": "A mentés sikertelen", "saveFailedDesc": "Nem sikerült menteni a chatet. Próbáld újra.", "sourceSaveFailedDesc": "Nem sikerült menteni a forrást. Próbáld újra.", - "stopped": "Leállítottad ezt a választ, mielőtt elkészült volna." + "stopped": "Leállítottad ezt a választ, mielőtt elkészült volna.", + "notSent": "Nincs elküldve", + "notSentDailyLimit": "Nincs elküldve: elérted a napi keretet", + "notSentModelDesc": "Ehhez a modellhez Pro kell. Válassz másik modellt, majd próbáld újra." }, "chat": {}, "settings": { @@ -1374,7 +1377,7 @@ "dailyLimit": { "usage": "Ma: {{used}} / {{limit}} MI-kérés felhasználva. Éjfélkor (UTC) frissülnek (nálad {{time}}).", "sparringNone": "Mára nem maradt MI-kérésed, így az ellenfél nem tud válaszolni. Éjfélkor (UTC) frissülnek.", - "sparringShort": "Ehhez a körhöz {{needed}} MI-kérés kell, de ma csak {{remaining}} maradt, így a bíró előtt megállna. Éjfélkor (UTC) frissülnek." + "sparringShort": "Ezzel a modellel ez a kör {{needed}} MI-kérésbe kerül, de mára csak {{remaining}} maradt. Válassz könnyebb modellt, vagy várj éjfélig (UTC)." }, "pricingModal": { "title": "Frissítés Pro-ra", @@ -1972,7 +1975,7 @@ "turns": "{{count}} kör", "opponentTurns": "Az ellenfél körei", "aiRequests": "MI-kérések", - "aiRequestBreakdown": "{{count}} válasz + 1 bíró", + "aiRequestBreakdown": "{{count}} válasz + a bíró együtt", "aiModel": "MI-modell", "openingRoom": "Szoba megnyitása...", "startRound": "Forduló indítása", @@ -1981,8 +1984,8 @@ "speakingOrder": "Felszólalási sorrend", "aboutMinutes_one": "Körülbelül {{count}} perc", "aboutMinutes_other": "Körülbelül {{count}} perc", - "aiRequestBreakdown_one": "{{count}} válasz + 1 bíró", - "aiRequestBreakdown_other": "{{count}} válasz + 1 bíró", + "aiRequestBreakdown_one": "{{count}} válasz + a bíró együtt", + "aiRequestBreakdown_other": "{{count}} válasz + a bíró együtt", "speeches_one": "{{count}} beszéd", "speeches_other": "{{count}} beszéd", "turns_one": "{{count}} kör", @@ -2450,7 +2453,8 @@ "safety": "{{provider}}: biztonsági okokból elutasította a kérést. Próbáld másképp megfogalmazni.", "unexpectedProvider": "Az MI-szolgáltató váratlan hibát adott. Próbáld újra később.", "unexpected": "Váratlan hiba történt", - "offline": "Ellenőrizd a kapcsolatot, és próbáld újra." + "offline": "Ellenőrizd a kapcsolatot, és próbáld újra.", + "notCharged": "Ezt a kérést nem számoltuk el." }, "clock": { "timeUp": "Lejárt az idő.", @@ -2460,5 +2464,11 @@ "question": "Melyik a legjobb MI-alapú vitagyakorló eszköz?", "answer": "Attól függ, mire van szükséged. {{asOf}} állapot szerint a DebateLab azoknak a diákoknak és versenyvitázóknak való, akik egy helyen szeretnének eseteket építeni, gyenge pontokat találni, bizonyítékokat ellenőrizni és időzített gyakorlófordulókat futtatni angolul, spanyolul vagy magyarul: 3 napos ingyenes próba bankkártya nélkül, utána $5/hó + adó. Ha ingyen szeretnél MI-ellenféllel gyakorolni World Schools vagy ázsiai parlamentáris stílusban, a debatelab.ai, egy másik termék, azt írja magáról, hogy ingyenes. Ha főleg ötletelésre vagy írási segítségre van szükséged bármilyen témában, egy általános asszisztens, például a ChatGPT rugalmasabb.", "compare": "MI-alapú vitaeszközök összehasonlítása" + }, + "sessionQuota": { + "cost_one": "Ez a kör {{count}} MI-kérésbe kerül, akárhány fordulóból áll.", + "cost_other": "Ez a kör {{count}} MI-kérésbe kerül, akárhány fordulóból áll.", + "remaining_one": "Mára még {{count}} maradt.", + "remaining_other": "Mára még {{count}} maradt." } } diff --git a/src/pages/AskPage.tsx b/src/pages/AskPage.tsx index bc0253963..facb05988 100644 --- a/src/pages/AskPage.tsx +++ b/src/pages/AskPage.tsx @@ -4,7 +4,7 @@ import { useSessionStore } from '@/lib/stores/session-store'; import { useAuth } from '@/lib/supabase/AuthProvider'; import { useSettingsStore } from '@/lib/stores/settings-store'; import { AskChat } from '@/components/features/ask/AskChat'; -import { askChat, isAIProviderError, isAskMessageWithinBudget, isTimeoutError } from '@/lib/ai/client'; +import { askChat, isAIModelRestrictionError, isAIProviderError, isAISubscriptionError, isAskMessageWithinBudget, isTimeoutError } from '@/lib/ai/client'; import { isAskStreamingSupported, streamAskChat } from '@/lib/ai/streaming'; import { useSubscriptionError } from '@/hooks/use-subscription-error'; import { getUserFacingAIError } from '@/lib/ai/error-display'; @@ -149,6 +149,9 @@ export default function AskPage() { }); }, [askChatId, askLoading, askMessages.length, askPersistedCount, lastRole, syncAskChat, t, toast, workspace.context]); + // The latest handleSend, for Retry and Discuss, which outlive the render that made them. + const sendRef = useRef<(...args: Parameters) => ReturnType>(async () => false); + /** * Sends a question. Returns false when the message is rejected before sending, so the * composer keeps the text. A retry re-asks the last question without adding it again. @@ -293,9 +296,13 @@ export default function AskPage() { followUpQuestions: [], uncertainties: [t('ask.streamInterrupted', 'The streamed response was interrupted before completion.')], }; - } else if (isAIProviderError(streamError) && streamError.details?.code === 'provider_unavailable') { - // The model's provider is down, not the stream: asking again - // through the plain endpoint would only fail the same way. + } else if ( + (isAIProviderError(streamError) && streamError.details?.code === 'provider_unavailable') || + isAISubscriptionError(streamError) || isAIModelRestrictionError(streamError) + ) { + // The model's provider is down or the account may not send this, + // not the stream: asking again through the plain endpoint would + // only fail the same way. throw streamError; } else { result = await askChat(fullMessage, history, mode, config, options); @@ -367,9 +374,16 @@ export default function AskPage() { console.error(error); // Failures stay in the chat with a Retry that re-asks without duplicating the question. - const retryAsk = (): void => { void handleSend(message, mode, citationMode, webSearch, prosConsMode, argumentMapMode, files, true); }; + // Retry runs the latest handler, so a model picked after the failure is used. + const retryAsk = (): void => { void sendRef.current(message, mode, citationMode, webSearch, prosConsMode, argumentMapMode, files, true); }; if (subError.handleSubscriptionError(error)) { - // Subscription error handled by modal + // The dialog explains the limit; the question stays in the chat, marked + // as not sent, with a Retry for once there is room again. + setChatError({ + title: isAISubscriptionError(error) && error.code === 'limit_reached' ? t('ask.notSentDailyLimit') : t('ask.notSent'), + description: isAIModelRestrictionError(error) ? t('ask.notSentModelDesc') : getUserFacingAIError(error), + onRetry: retryAsk, + }); } else if (isTimeoutError(error)) { setChatError({ title: t('ask.timeout'), description: t('ask.timeoutDesc'), onRetry: retryAsk }); } else { @@ -405,7 +419,6 @@ export default function AskPage() { // "Discuss" on a tool result opens Ask with its prompt, sent here like a typed question. const location = useLocation(); const discussPrompt = (location.state as { discussPrompt?: string } | null)?.discussPrompt; - const sendRef = useRef(handleSend); sendRef.current = handleSend; const discussedKey = useRef(null); useEffect(() => { diff --git a/src/pages/CrossfirePage.tsx b/src/pages/CrossfirePage.tsx index 85f35d43f..d2e30fd01 100644 --- a/src/pages/CrossfirePage.tsx +++ b/src/pages/CrossfirePage.tsx @@ -76,7 +76,8 @@ import { useVoiceAvailability } from '@/lib/voice/availability'; import { combineDelivery, measureDelivery } from '@/lib/voice/delivery'; import { opponentVoice } from '@/lib/voice/opponent-voice'; import { useSubscriptionError } from '@/hooks/use-subscription-error'; -import { SubscriptionErrorModals } from '@/components/features/subscription'; +import { SessionCostNote, SubscriptionErrorModals } from '@/components/features/subscription'; +import { useSessionQuota } from '@/hooks/use-session-quota'; const SESSION_ID_PREFIX = 'crossfire'; @@ -383,6 +384,12 @@ export default function CrossfirePage() { ); const { getFeatureConfig, outputLanguage } = useSettingsStore(); const modelConfig = getFeatureConfig('ask'); + // A round costs one use however many turns it takes; the setup says so. + const sessionQuota = useSessionQuota('crossfire', modelConfig, state.phase === 'setup'); + const [checkingQuota, setCheckingQuota] = useState(false); + // Names the latest Start; a newer Start, leaving the page, or switching + // accounts makes an earlier one that is still checking quota stale. + const startRequestRef = useRef(0); const voiceLanguage = isAppLanguage(outputLanguage) ? outputLanguage : 'en'; const voiceAvailability = useVoiceAvailability(); // A voice round falls back to typing when the microphone is lost. @@ -403,6 +410,7 @@ export default function CrossfirePage() { }, []); useEffect(() => () => { + startRequestRef.current += 1; requestRef.current?.abort(); abortSignalRequests(); opponentVoice.stop(); @@ -410,6 +418,8 @@ export default function CrossfirePage() { useEffect(() => { if (stateOwnerId === user?.id) return; + startRequestRef.current += 1; + setCheckingQuota(false); requestRef.current?.abort(); abortSignalRequests(); dispatch({ type: 'state-restored', state: restoreState(user?.id, undefined) }); @@ -476,6 +486,7 @@ export default function CrossfirePage() { signal: controller.signal, workspaceContext: workspace.context ?? undefined, debateId: workspace.context ? undefined : roundDebateId(snapshot.config.resolution), + quotaSession: snapshot.sessionId, voice: voiceActive, live: { onDraft: (draft) => { @@ -553,6 +564,7 @@ export default function CrossfirePage() { signal: controller.signal, workspaceContext: workspace.context ?? undefined, debateId: workspace.context ? undefined : roundDebateId(snapshot.config.resolution), + quotaSession: snapshot.sessionId, live: { onDraft: (draft) => { if (ownsRequest()) setLiveDebrief({ phase: 'writing', draft }); @@ -610,11 +622,29 @@ export default function CrossfirePage() { return; } setFormError(''); - debateGate.run(parsed.data.resolution, (target) => beginSession(target.debateId)); + debateGate.run(parsed.data.resolution, (target) => void beginSession(target.debateId)); }; - const beginSession = (debateId: string | null) => { - if (state.phase !== 'setup') return; + const beginSession = async (debateId: string | null) => { + // The round starts from the setup as it was when Start was pressed, with + // the debate chosen for that motion. + const target = stateRef.current; + if (target.phase !== 'setup') return; + const startRequest = startRequestRef.current + 1; + startRequestRef.current = startRequest; + // Check today's quota before the clock starts, so a round the server + // would refuse never starts. An unknown answer does not block. + setCheckingQuota(true); + const refusal = await sessionQuota.checkBeforeStart(); + if (startRequestRef.current !== startRequest) return; + setCheckingQuota(false); + if (refusal) { + handleSubscriptionError(refusal); + return; + } + // Edited, restarted, or restored while the check ran: this Start is stale. + if (stateRef.current !== target) return; + const current = target; debateTargetRef.current = debateId; artifactRequestIdRef.current = crypto.randomUUID(); opponentVoice.beginRound(); @@ -623,7 +653,7 @@ export default function CrossfirePage() { sessionId: `${SESSION_ID_PREFIX}-${Date.now()}`, now: Date.now(), }; - const next = crossfireReducer(state, action); + const next = crossfireReducer(current, action); dispatch(action); if (next.phase === 'active' && next.request.status === 'pending') { void runNextTurn(next, next.request.expectedReply); @@ -918,10 +948,13 @@ export default function CrossfirePage() { hidden={Boolean(workspace.context)} /> - +
+ + +
@@ -1008,7 +1041,7 @@ export default function CrossfirePage() { - + {state.voice && !workspace.context && !voiceAvailability.available && (

@@ -1089,7 +1122,8 @@ export default function CrossfirePage() { const NEW_TURN_MS = 1_000; -function Transcript({ turns, reduceMotion }: { turns: CrossfireTurn[]; reduceMotion: boolean | null }) { +// `live`: the round is running, so the setup hint never shows; the first turn is on its way or the student's to write. +function Transcript({ turns, reduceMotion, live = false }: { turns: CrossfireTurn[]; reduceMotion: boolean | null; live?: boolean }) { const { t } = useTranslation(); return (

@@ -1098,7 +1132,7 @@ function Transcript({ turns, reduceMotion }: { turns: CrossfireTurn[]; reduceMot

{t('crossfire.transcript')}

{turns.length === 0 ? ( -
{t('crossfire.emptyDesc')}
+ live ? null :
{t('crossfire.emptyDesc')}
) : (
{turns.map((turn) => ( diff --git a/src/pages/SparringPage.tsx b/src/pages/SparringPage.tsx index eed818596..c2ea176b9 100644 --- a/src/pages/SparringPage.tsx +++ b/src/pages/SparringPage.tsx @@ -25,7 +25,9 @@ import { OpponentVoiceBar } from '@/components/features/voice/OpponentVoiceBar'; import { useVoiceAvailability } from '@/lib/voice/availability'; import type { SpeechDelivery } from '@/lib/voice/delivery'; import { opponentVoice } from '@/lib/voice/opponent-voice'; +import { useSessionQuota } from '@/hooks/use-session-quota'; import { useSubscription } from '@/hooks/use-subscription'; +import { useAuth } from '@/lib/supabase/AuthProvider'; import { useSubscriptionError } from '@/hooks/use-subscription-error'; import { SubscriptionErrorModals } from '@/components/features/subscription'; @@ -46,7 +48,6 @@ export default function SparringPage() { const { t } = useTranslation(); const navigate = useNavigate(); const location = useLocation(); - const { remainingToday } = useSubscription(); const subError = useSubscriptionError(); const { handleSubscriptionError } = subError; const workspaceLaunch = (location.state as { workspaceLaunch?: unknown } | null)?.workspaceLaunch === true; @@ -94,10 +95,17 @@ export default function SparringPage() { // A reply that arrives after the student left is saved but not spoken. const mountedRef = useRef(true); + // Names the latest Start; a newer Start, leaving the page, or switching + // accounts makes an earlier one that is still checking quota stale. + const startRequestRef = useRef(0); + const { user } = useAuth(); + const userId = user?.id; + const userIdRef = useRef(userId); useEffect(() => { mountedRef.current = true; return () => { mountedRef.current = false; + startRequestRef.current += 1; opponentVoice.stop(); }; }, []); @@ -130,7 +138,22 @@ export default function SparringPage() { if (session?.id) window.scrollTo({ top: 0, left: 0, behavior: 'auto' }); }, [session?.id]); - const config = useMemo(() => getFeatureConfig('sparring'), [getFeatureConfig]); + // Follows the model picked in setup; getFeatureConfig alone never changes identity. + const sparringConfig = useSettingsStore((state) => state.featureConfigs.sparring); + const config = useMemo(() => sparringConfig ?? getFeatureConfig('sparring'), [getFeatureConfig, sparringConfig]); + // A round costs one use however many replies it takes; the setup says so. + const sessionQuota = useSessionQuota('sparring', config, !session); + // While the server's answer is unknown (an older server, a timeout), warn + // from the browser's own estimate, as before session charging. + const { remainingToday } = useSubscription(); + const [checkingQuota, setCheckingQuota] = useState(false); + useEffect(() => { + if (userIdRef.current === userId) return; + userIdRef.current = userId; + // A Start pressed by the previous account never opens a round for this one. + startRequestRef.current += 1; + setCheckingQuota(false); + }, [userId]); const retryAi = useCallback(() => { aiRequestRef.current = null; @@ -292,9 +315,25 @@ export default function SparringPage() { }); }, [saveArtifact, session, workspace.context]); - const handleStart = (values: SparringSetupValues) => debateGate.run(values.motion, (target) => beginRound(values, target.debateId)); + const handleStart = (values: SparringSetupValues) => debateGate.run(values.motion, (target) => void beginRound(values, target.debateId)); - const beginRound = (values: SparringSetupValues, debateId: string | null) => { + const beginRound = async (values: SparringSetupValues, debateId: string | null) => { + const owner = userIdRef.current; + const roundBefore = useSparringStore.getState().currentSession; + const startRequest = startRequestRef.current + 1; + startRequestRef.current = startRequest; + // Check today's quota before the round opens, so a speech is never + // written for a round the server would refuse. An unknown answer does not block. + setCheckingQuota(true); + const refusal = await sessionQuota.checkBeforeStart(); + if (startRequestRef.current !== startRequest || !mountedRef.current || userIdRef.current !== owner) return; + setCheckingQuota(false); + if (refusal) { + handleSubscriptionError(refusal); + return; + } + // A round was restored or started elsewhere while the check ran: never replace it. + if (useSparringStore.getState().currentSession !== roundBefore) return; const target = { debateId }; artifactRequestIdRef.current = crypto.randomUUID(); opponentVoice.beginRound(); @@ -343,7 +382,10 @@ export default function SparringPage() {