diff --git a/docs/architecture.md b/docs/architecture.md index 81951378e..7f4a0175a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -78,6 +78,10 @@ Client-side environment selection is a developer convenience, not authorization. Every structured answer is shown while the model writes it. A page passes a `live` callback to its feature wrapper, and the request adds `stream: true`. Every check before the feature handler (authentication, input, debate context, quota, model routing, source ranking) still answers with plain JSON and its real status. After that, [`supabase/functions/ai/stream.ts`](../supabase/functions/ai/stream.ts) answers with server-sent events: `phase` (`research`, `writing`, `checking`), `delta` with the model's raw text, `reset` when a JSON repair retry starts the answer again, and one final `result` holding the status and body a plain request returns. Only the call whose output the user sees streams; research sub-calls and Jev decisions do not. The joined text is parsed exactly as a non-streamed reply, and streamed usage is billed the same way, marked incomplete if the stream breaks. On the client, [`src/lib/ai/live.ts`](../src/lib/ai/live.ts) reads the stream, [`packages/core/src/ai/partial-json.ts`](../packages/core/src/ai/partial-json.ts) turns the JSON written so far into a draft, and the page renders it with the result's own labels. A JSON body is accepted too, so the client works against a server from before live generation. +A streamed run keeps its worker alive with `EdgeRuntime.waitUntil` ([`_shared/background.ts`](../supabase/functions/_shared/background.ts)) from the first event until the HTTP layer has read the last one. Supabase otherwise counts the response as sent once its headers are out and may retire the worker mid-run (`EarlyDrop`). Streamed Ask (`ai-stream`) is held the same way until the browser has read its final event. A run still unanswered about 142 s after its request arrived is aborted and gets a retryable timeout `result` instead of a connection cut at the 150 s wall clock; when the aborted run answers within 2 s, that answer is sent instead, so it says `charged: false` when the units went back. Storing the result and waiting for the browser to read it are cut short to finish before the wall clock. The request sends its id as `resultKey`. The function then writes a row to `ai_run_results` when the run starts, and stores the `result` there before sending it ([`ai/run-results.ts`](../supabase/functions/ai/run-results.ts); one row per user and feature, readable only by its owner). When the stream drops, stalls (no keepalive for 45 s), or delivers a result it cannot read, [`src/lib/ai/run-recovery.ts`](../src/lib/ai/run-recovery.ts) polls that row until the run's result appears, then resolves or fails exactly as if the event had arrived. A row still without a status long after it started is a run its worker never finished. A browser Stop, reload, or closed tab still cancels the run. The server cannot tell those from a lost connection, so it stops spending as soon as the connection goes. + +A reply that hits its token cap is written again once with twice the cap, after a `reset`. The Generate cap (12,000 tokens) leaves room for a full comprehensive case on Claude Haiku, so a long case finishes on its first attempt. Claude rejects an assistant prefill, so a cut-off reply cannot be continued where it stopped. Sparring calls send a strict `json_schema` (`sparringOutputSchema` in `ai/sparring-persistence.ts`). Anthropic's structured outputs take no numeric or array-size limits, so the 0-25 range, the four categories, and the 12-item lists are enforced by the parser. When a reply parses but has the wrong shape, the log records only the failing field path, never the model's text. The call is then retried once with a repair prompt that names the field. A second failure answers `invalid_output` (502, logged as `invalid_output`), which the browser shows as a malformed answer. The judge also retries once when Claude is briefly unavailable or its stream breaks. The round has already paid, so retries cost the student nothing. + For a validated Crossfire next turn, the server returns the reply first with a short-lived signed sidecar token. The client then requests a Jev classification of only the newest completed question-answer exchange. The token is atomically claimed through its own one-use counter, separate from the product's daily AI quota. The optional directness, concession, and follow-up signal is attached by turn ID to the answer; a missing, slow, replayed, forged, or failed sidecar never delays or invalidates the main reply. Draft version 1 remains compatible because turn signals are optional. ### Voice rounds @@ -154,11 +158,11 @@ 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; 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`: 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). 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. -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 usage line in Settings and on every tool page reads the same counter through `ai_usage_today()` (the caller's own units today), so per-round charging and refunds show there, and it is read again after each AI call (`refreshAIUsage` in `src/lib/query-client.ts`). When the counter cannot be read (an older database), the line is hidden rather than estimated from `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. +A run that produces nothing costs nothing. When an `ai` request fails on the server (a provider failure, a reply cut off at its cap, an answer that failed validation, the time budget running out) and its answer was not saved, the function gives its units back, however much of it had streamed. When the browser goes away (reload, closed tab, Stop, a dropped connection; the server cannot tell these apart), the run is refunded only before `REFUND_MAX_DELIVERED_CHARS` of streamed output reached the student and before the model finished its answer. Otherwise a student could read most of an answer and press Stop to get the unit back. For the plain-text Ask stream the streamed text is the answer, so `ai-stream` refunds only before `REFUND_MAX_DELIVERED_CHARS` reached the reader, whatever ended the run. A refund goes through `refund_daily_quota`, or `refund_ai_session_quota` for the call that paid for a round, at most once per run, and the answer carries `charged: false`, which the client shows as "You were not charged". The weekly cap still counts what the run cost. The usage line in Settings and on every tool page reads the same counter through `ai_usage_today()` (the caller's own units today), so per-round charging and refunds show there, and it is read again after each AI call (`refreshAIUsage` in `src/lib/query-client.ts`). When the counter cannot be read (an older database), the line is hidden rather than estimated from `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 diff --git a/src/components/features/sparring/SparringRound.tsx b/src/components/features/sparring/SparringRound.tsx index 47f1eb044..797a543d8 100644 --- a/src/components/features/sparring/SparringRound.tsx +++ b/src/components/features/sparring/SparringRound.tsx @@ -167,7 +167,7 @@ export function SparringRound({
{t(`sparring.format.${session.format}`, session.format.toUpperCase())} - {t('sparring.round.inProgress', 'Round in progress')} + {judging && judgingError ? t('sparring.round.scoringStopped', 'Scoring stopped') : t('sparring.round.inProgress', 'Round in progress')} {session.userSide === 'pro' ? t('sparring.round.youArePro', 'You are Pro') : t('sparring.round.youAreCon', 'You are Con')}
@@ -188,7 +188,7 @@ export function SparringRound({

{judging ? t('sparring.round.complete', 'Round complete') : currentTurn ? t('sparring.round.turnOf', 'Turn {{current}} of {{total}}', { current: session.currentTurnIndex + 1, total: session.turns.length }) : t('sparring.round.preparingResult', 'Preparing your result')}

-

{judging ? t('sparring.round.judgeReading', 'The judge is reading the transcript.') : currentTurn ? t(`sparring.turns.${currentTurn.id}`, currentTurn.label) : t('sparring.round.scoring', 'Scoring the round')}

+

{judging ? (judgingError ? t('sparring.round.judgeFailed', 'The judge could not finish scoring.') : t('sparring.round.judgeReading', 'The judge is reading the transcript.')) : currentTurn ? t(`sparring.turns.${currentTurn.id}`, currentTurn.label) : t('sparring.round.scoring', 'Scoring the round')}