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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions src/components/features/sparring/SparringRound.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ export function SparringRound({
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2 text-xs font-medium text-muted-foreground">
<Badge variant="outline" className="rounded-md">{t(`sparring.format.${session.format}`, session.format.toUpperCase())}</Badge>
<span>{t('sparring.round.inProgress', 'Round in progress')}</span>
<span>{judging && judgingError ? t('sparring.round.scoringStopped', 'Scoring stopped') : t('sparring.round.inProgress', 'Round in progress')}</span>
<span aria-hidden="true">·</span>
<span>{session.userSide === 'pro' ? t('sparring.round.youArePro', 'You are Pro') : t('sparring.round.youAreCon', 'You are Con')}</span>
</div>
Expand All @@ -188,7 +188,7 @@ export function SparringRound({
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-muted-foreground">{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')}</p>
<p className="mt-1 text-lg font-semibold text-foreground">{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')}</p>
<p className={cn('mt-1 text-lg font-semibold', judging && judgingError ? 'text-destructive' : 'text-foreground')}>{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')}</p>
</div>
<div className="flex items-center gap-2 text-3xl">
<Clock3 className="h-6 w-6 text-muted-foreground" aria-hidden="true" />
Expand Down
1 change: 1 addition & 0 deletions src/lib/ai/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export {
isAINetworkError,
isAIRateLimitError,
isAIProviderError,
isInvalidOutputError,
isTimeoutError,
isAISubscriptionError,
isAIModelRestrictionError,
Expand Down
20 changes: 20 additions & 0 deletions src/lib/ai/errors.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { describe, expect, it } from 'vitest';
import i18n from '@/i18n';
import {
AIAuthError,
AIModelRestrictionError,
Expand Down Expand Up @@ -37,6 +38,25 @@ describe('classifyAIError', () => {
expect(classifyAIError(new TimeoutError())).toMatchObject({ errorName: 'TimeoutError', shouldCaptureException: true });
});

it('reads invalid_output as a malformed answer, not a provider failure', async () => {
const { isInvalidOutputError } = await import('./errors');
const error = mapAIError({ message: 'failed', context: { status: 502 } }, 'req_1', 'openrouter', 'auto-pro', { error: 'The AI answer came back malformed. Please try again.', code: 'invalid_output', charged: false });
expect(error.name).toBe('AIResponseError');
expect(isInvalidOutputError(error)).toBe(true);
expect(error.notCharged).toBe(true);
expect(error.message).toBe(i18n.t('aiErrors.invalidOutput'));
});

it('keeps transient provider failures out of exception reports but still reports the rest', () => {
const mapped = (payload: Record<string, unknown>, status: number) =>
classifyAIError(mapAIError({ message: 'failed', context: { status } }, 'req_1', 'openrouter', 'auto-pro', payload));
// A Claude outage, and a failure the server marked worth retrying.
expect(mapped({ error: 'Anthropic Claude is temporarily unavailable.', code: 'provider_unavailable' }, 503)).toMatchObject({ errorName: 'AIProviderError', shouldCaptureException: false });
expect(mapped({ error: 'AI request failed. Please try again.', code: 'provider_error', retryable: true }, 500)).toMatchObject({ errorName: 'AIProviderError', shouldCaptureException: false });
// An answer that failed validation may be a bug.
expect(mapped({ error: 'AI request failed. Please try again.', code: 'provider_error' }, 500)).toMatchObject({ errorName: 'AIProviderError', shouldCaptureException: true });
});

it('recognizes the other browser network messages and an offline navigator', () => {
expect(classifyAIError(new TypeError('NetworkError when attempting to fetch resource')).errorName).toBe('AINetworkError');
expect(classifyAIError(new TypeError('Load failed')).errorName).toBe('AINetworkError');
Expand Down
25 changes: 23 additions & 2 deletions src/lib/ai/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,18 @@ export function isAIProviderError(error: unknown): error is AIProviderError {
return error instanceof AIProviderError || (error instanceof Error && error.name === 'AIProviderError');
}

/** The server could not use the model's answer (`invalid_output`). */
export function isInvalidOutputError(error: unknown): boolean {
return error instanceof AIResponseError && error.details?.code === 'invalid_output';
}

/** A provider failure the server marked worth retrying as is (an outage, a dropped stream, the time budget). */
export function isTransientProviderError(error: unknown): boolean {
if (!isAIProviderError(error)) return false;
const details = (error as AIError).details;
return details?.retryable === true || details?.code === 'provider_unavailable';
}

export function isTimeoutError(error: unknown): error is TimeoutError {
return error instanceof TimeoutError || (error instanceof Error && error.name === 'TimeoutError');
}
Expand Down Expand Up @@ -263,7 +275,10 @@ export function classifyAIError(error: unknown, userInitiatedAbort = false): AIE
if (error instanceof AIResponseError || (error instanceof Error && error.name === 'AIResponseError')) {
return { errorName: 'AIResponseError', statusCode, shouldCaptureException: true };
}
if (isAIProviderError(error)) return { errorName: 'AIProviderError', statusCode, shouldCaptureException: true };
// An outage or a dropped provider stream is expected now and then and the
// student is told to try again; ai_request_failed still records it. Other
// provider failures may be bugs and are reported.
if (isAIProviderError(error)) return { errorName: 'AIProviderError', statusCode, shouldCaptureException: !isTransientProviderError(error) };
if (error instanceof AIServerError || (error instanceof Error && error.name === 'AIServerError')) {
return { errorName: 'AIServerError', statusCode, shouldCaptureException: true };
}
Expand Down Expand Up @@ -378,12 +393,18 @@ function mapAIErrorPayload(
});
}

// The model answered, but in a shape the feature could not use, even after
// the server's repair retry. Trying again usually works.
if (errorCode === 'invalid_output') {
return new AIResponseError(i18n.t('aiErrors.invalidOutput'), { requestId, details: { code: errorCode } });
}

if (isProviderErrorMessage(message) || isProviderErrorCode(errorCode)) {
return new AIProviderError(message, {
statusCode: statusCode || 500,
requestId,
provider,
details: { model, code: errorCode },
details: { model, code: errorCode, ...(payload?.retryable === true ? { retryable: true } : {}) },
});
}

Expand Down
Loading
Loading