From de52e44ca719b2662a51cdbad1aea5ba84758f81 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 22 Oct 2026 13:01:19 +0000 Subject: [PATCH 1/9] Run Claude only on the Anthropic BYOK key, with no fallback provider. Every anthropic/* call now asks OpenRouter for the anthropic provider only, with fallbacks off, so Bedrock, Vertex and other providers are never tried. Other models keep balanced routing. A refused Claude call returns a 503 outage message the client already translates, logs claude_unavailable, and gives the reserved daily request back through a new refund_daily_quota RPC. A Claude response whose usage shows is_byok false or a cost above zero logs a claude_not_byok warning. Co-Authored-By: Claude Opus 5.5 --- docs/architecture.md | 2 +- e2e/full-stack/fake-openrouter.mjs | 11 ++- scripts/prompt-examples.ts | 3 +- supabase/README.md | 2 + supabase/functions/_shared/entitlements.ts | 14 ++++ .../_shared/provider-routing.test.ts | 27 +++++++ .../functions/_shared/provider-routing.ts | 69 ++++++++++++++++ supabase/functions/ai-stream/handler_test.ts | 3 + supabase/functions/ai-stream/index.ts | 32 ++++++-- .../functions/ai/handler-research.test.ts | 18 +++++ supabase/functions/ai/index.ts | 16 +++- supabase/functions/ai/provider-errors_test.ts | 81 +++++++++++++++++++ supabase/functions/ai/provider.ts | 18 +++-- .../20261022120000_refund_daily_quota.sql | 47 +++++++++++ supabase/tests/daily_quota_refund_test.sql | 80 ++++++++++++++++++ 15 files changed, 406 insertions(+), 17 deletions(-) create mode 100644 supabase/functions/_shared/provider-routing.test.ts create mode 100644 supabase/functions/_shared/provider-routing.ts create mode 100644 supabase/migrations/20261022120000_refund_daily_quota.sql create mode 100644 supabase/tests/daily_quota_refund_test.sql diff --git a/docs/architecture.md b/docs/architecture.md index 3e6fe3670..c237d649d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -136,7 +136,7 @@ Supabase Auth owns user sessions. Protected routes provide a client navigation b Checkout and portal functions create Stripe sessions after authenticated ownership and redirect validation. The webhook updates subscription state and records event processing for idempotency. AI handlers record the provider-reported usage and charge for each request. -All model requests use the single OpenRouter backend route and `OPENROUTER_API_KEY`. The shared ten-model catalog, its defaults (Claude Haiku 5.5 for Basic, Claude Opus 5.5 for Pro), Pro-only entries, retired-ID successors, and request controls live in [`supabase/functions/_shared/model-catalog.ts`](../supabase/functions/_shared/model-catalog.ts). OpenRouter usage remains raw in the audit row, and the reported charge is unknown when the provider does not return a complete cost. The opponent's voice is the one exception to the OpenRouter route: the `voice` function calls ElevenLabs with `ELEVENLABS_API_KEY`. ElevenLabs reports billed characters, not dollars, so [`supabase/functions/_shared/cost.ts`](../supabase/functions/_shared/cost.ts) prices those rows from a dated list-price snapshot; promotions and plan credits are not modelled, so a voice row can overstate the charge. +All model requests use the single OpenRouter backend route and `OPENROUTER_API_KEY`. The shared ten-model catalog, its defaults (Claude Haiku 5.5 for Basic, Claude Opus 5.5 for Pro), Pro-only entries, retired-ID successors, and request controls live in [`supabase/functions/_shared/model-catalog.ts`](../supabase/functions/_shared/model-catalog.ts). Claude models run only on DebateLab's Anthropic key (OpenRouter BYOK), with no fallback provider; see [`supabase/README.md`](../supabase/README.md). OpenRouter usage remains raw in the audit row, and the reported charge is unknown when the provider does not return a complete cost. The opponent's voice is the one exception to the OpenRouter route: the `voice` function calls ElevenLabs with `ELEVENLABS_API_KEY`. ElevenLabs reports billed characters, not dollars, so [`supabase/functions/_shared/cost.ts`](../supabase/functions/_shared/cost.ts) prices those rows from a dated list-price snapshot; promotions and plan credits are not modelled, so a voice row can overstate the charge. `Auto Fast` and `Auto Pro` are virtual selections, not provider model IDs. The Edge Functions resolve them through TypeSafe Jev on OpenRouter before calling the concrete writing model. Auto Fast is available to Basic accounts, routes only among the Basic models (Claude Haiku 5.5, GPT-6 Luna, Muse Spark 1.3 Contributor), and normally resolves to Claude Haiku 5.5. Auto Pro remains Pro-only and normally resolves to Claude Opus 5.5. 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. diff --git a/e2e/full-stack/fake-openrouter.mjs b/e2e/full-stack/fake-openrouter.mjs index e28c4fc1a..9b17fd387 100644 --- a/e2e/full-stack/fake-openrouter.mjs +++ b/e2e/full-stack/fake-openrouter.mjs @@ -14,6 +14,15 @@ const port = Number(process.env.PORT ?? 8787); const upstream = process.env.UPSTREAM; const liveModel = process.env.LIVE_MODEL; const chatModel = (requested) => (liveModel === "as-requested" ? requested : liveModel); +// Claude requests pin OpenRouter to the Anthropic provider. A rewritten +// model is served elsewhere, so it keeps only the provider options that +// still apply to it. +const liveChatBody = (body) => { + const model = chatModel(body.model); + if (model === body.model || !body.provider || typeof body.provider !== "object") return { ...body, model }; + const { only: _only, allow_fallbacks: _allowFallbacks, ...provider } = body.provider; + return { ...body, model, provider }; +}; const maxUpstreamCalls = Number(process.env.MAX_UPSTREAM_CALLS ?? 200); if (upstream && !liveModel) throw new Error("LIVE_MODEL is required with UPSTREAM"); const requests = []; @@ -278,7 +287,7 @@ async function proxy(req, res, path) { const response = await fetch(`${upstream}${path.replace(/^\/api/, "")}`, { method: "POST", headers: { authorization: req.headers.authorization ?? "", "content-type": "application/json" }, - body: JSON.stringify(chat ? { ...body, model: chatModel(body.model) } : body), + body: JSON.stringify(chat ? liveChatBody(body) : body), signal: hangUp.signal, }); record.status = response.status; diff --git a/scripts/prompt-examples.ts b/scripts/prompt-examples.ts index 84ab08622..e532c575c 100644 --- a/scripts/prompt-examples.ts +++ b/scripts/prompt-examples.ts @@ -10,6 +10,7 @@ // model, fourteen calls, no web research. import { PROMPT_EXAMPLES } from "../supabase/functions/_shared/prompts/prompt-examples.ts"; +import { openRouterProviderOptions } from "../supabase/functions/_shared/provider-routing.ts"; const [functionsDir, outPath, model = "openai/gpt-6-luna", only] = Deno.args; const selected = only ? new Set(only.split(",")) : null; @@ -64,7 +65,7 @@ for (const example of PROMPT_EXAMPLES.filter((item) => !selected || selected.has max_tokens: 8_000, response_format: { type: "json_object" }, reasoning: { effort: "low", exclude: true }, - provider: { require_parameters: true }, + provider: openRouterProviderOptions(model), }), // Matches the ai function's request budget; one retry covers a stalled route. signal: AbortSignal.timeout(120_000), diff --git a/supabase/README.md b/supabase/README.md index 37e2e17a1..430a08c64 100644 --- a/supabase/README.md +++ b/supabase/README.md @@ -38,6 +38,8 @@ Both endpoints accept an optional `debateContext` of IDs (`debateId`, `side`, `s Both endpoints route every model inference through OpenRouter and enforce the shared ten-model Basic/Pro catalog. Basic accounts get Claude Haiku 5.5 (the default), GPT-6 Luna, and Muse Spark 1.3 Contributor; Claude Opus 5.5 is the Pro default; Pro-only models require active Pro access. Retired catalog IDs from older clients resolve to their successor before validation. +Claude runs only on DebateLab's own Anthropic key, added to OpenRouter as BYOK. [`_shared/provider-routing.ts`](functions/_shared/provider-routing.ts) sends `anthropic/*` requests with `provider: { only: ["anthropic"], allow_fallbacks: false }`, so Bedrock, Vertex and any other provider are never tried; other models keep OpenRouter's balanced routing. The request body cannot stop OpenRouter from serving Anthropic on its own credits when the key fails, so the key's **Shared capacity fallback** in OpenRouter must be set to "Never use shared capacity for any model on this provider". When a Claude call is refused, the endpoints answer 503 `provider_unavailable`, log `claude_unavailable`, and give the reserved daily request back through `refund_daily_quota`. A Claude response whose usage shows `is_byok: false` or a `cost` above zero logs a `claude_not_byok` warning; past the monthly BYOK allowance, OpenRouter's BYOK fee also shows as cost. + The browser boundary is [`../src/lib/ai/client.ts`](../src/lib/ai/client.ts). Shared prompt, research, source-quality, cost, entitlement, CORS, and control logic lives in `_shared`. ### Content and sharing diff --git a/supabase/functions/_shared/entitlements.ts b/supabase/functions/_shared/entitlements.ts index 1ae2b7afa..768494f0c 100644 --- a/supabase/functions/_shared/entitlements.ts +++ b/supabase/functions/_shared/entitlements.ts @@ -197,6 +197,20 @@ export async function checkRateLimit(userId: string | null, model?: ModelSelecti } } +/** + * Gives back the daily request checkRateLimit reserved, for a request the + * provider could not serve. A failed refund is logged, never thrown, so the + * student still gets the error response. + */ +export async function refundDailyQuota(userId: string) { + try { + const { error } = await supabase.rpc("refund_daily_quota", { p_user_id: userId }); + if (error) throw error; + } catch (err) { + console.error("[RATE LIMIT] Quota refund failed:", err); + } +} + export async function logUsage( userId: string | null, feature: string, diff --git a/supabase/functions/_shared/provider-routing.test.ts b/supabase/functions/_shared/provider-routing.test.ts new file mode 100644 index 000000000..05635de73 --- /dev/null +++ b/supabase/functions/_shared/provider-routing.test.ts @@ -0,0 +1,27 @@ +import { assertEquals } from "jsr:@std/assert@1.0.19"; +import { warnIfClaudeNotByok } from "./provider-routing.ts"; + +Deno.test("the BYOK guard warns only for Claude usage that left the key or cost credits", () => { + const originalWarn = console.warn; + const warnings: string[] = []; + console.warn = (message: string) => warnings.push(message); + try { + const claude = "anthropic/claude-haiku-5.5"; + assertEquals(warnIfClaudeNotByok(claude, { is_byok: true, cost: 0 }, "ai"), false); + assertEquals(warnIfClaudeNotByok(claude, undefined, "ai"), false); + assertEquals(warnIfClaudeNotByok("openai/gpt-6-luna", { is_byok: false, cost: 0.02 }, "ai"), false); + assertEquals(warnIfClaudeNotByok(claude, { is_byok: false, cost: 0 }, "ai"), true); + assertEquals(warnIfClaudeNotByok(claude, { is_byok: true, cost: 0.0004, cost_details: { upstream_inference_cost: 0.008 } }, "ai-stream"), true); + assertEquals(warnings.length, 2); + assertEquals(JSON.parse(warnings[1]), { + event: "claude_not_byok", + source: "ai-stream", + model: claude, + is_byok: true, + cost: 0.0004, + upstream_inference_cost: 0.008, + }); + } finally { + console.warn = originalWarn; + } +}); diff --git a/supabase/functions/_shared/provider-routing.ts b/supabase/functions/_shared/provider-routing.ts new file mode 100644 index 000000000..a92e280be --- /dev/null +++ b/supabase/functions/_shared/provider-routing.ts @@ -0,0 +1,69 @@ +// OpenRouter provider preferences per model family, and the Claude BYOK guard. +// +// Claude must run only on DebateLab's own Anthropic key, which is set up as +// BYOK in OpenRouter. `only: ["anthropic"]` keeps Bedrock and Vertex out, and +// `allow_fallbacks: false` stops OpenRouter from trying any other provider. +// OpenRouter tries a BYOK key before its own capacity for the same provider, +// but only the key's "Never use shared capacity" setting in OpenRouter stops it +// from spending OpenRouter credits on Anthropic when the key fails. The usage +// warning below is how a leak would show up. + +export function isClaudeModel(model: string): boolean { + return model.startsWith("anthropic/"); +} + +export function openRouterProviderOptions(model: string): Record { + if (isClaudeModel(model)) return { only: ["anthropic"], allow_fallbacks: false, require_parameters: true }; + // Omitting sort and order keeps OpenRouter's balanced provider load balancing. + return { require_parameters: true }; +} + +/** + * A Claude call OpenRouter refused or failed. With fallbacks off this usually + * means the Anthropic key is out of credit or erroring, so the request is not + * retried elsewhere, the student sees a provider outage, and the reserved + * daily request is given back. + */ +export class ClaudeProviderUnavailableError extends Error { + readonly status: number | null; + + constructor(status: number | null) { + super("AI provider request failed"); + this.name = "ClaudeProviderUnavailableError"; + this.status = status; + } +} + +/** Throws the Claude-specific error for Claude models, the generic one otherwise. */ +export function providerRequestError(model: string, status: number | null): Error { + return isClaudeModel(model) ? new ClaudeProviderUnavailableError(status) : new Error("AI provider request failed"); +} + +export const CLAUDE_UNAVAILABLE_CODE = "provider_unavailable"; +export const CLAUDE_UNAVAILABLE_STATUS = 503; +// The client maps a provider error naming Anthropic and "temporarily +// unavailable" to its translated outage message. +export const CLAUDE_UNAVAILABLE_MESSAGE = "Anthropic Claude is temporarily unavailable. Please try again later."; + +/** + * Warns when a Claude response did not run on the BYOK key (`is_byok` false) + * or OpenRouter charged for it (`cost` above zero). Past the monthly BYOK + * allowance, OpenRouter's BYOK fee also shows up as cost. + */ +export function warnIfClaudeNotByok(model: string, rawUsage: Record | undefined, source: string): boolean { + if (!isClaudeModel(model) || !rawUsage) return false; + const cost = typeof rawUsage.cost === "number" ? rawUsage.cost : undefined; + if (rawUsage.is_byok !== false && !(cost !== undefined && cost > 0)) return false; + const costDetails = rawUsage.cost_details && typeof rawUsage.cost_details === "object" + ? rawUsage.cost_details as Record + : undefined; + console.warn(JSON.stringify({ + event: "claude_not_byok", + source, + model, + is_byok: rawUsage.is_byok ?? null, + cost: cost ?? null, + upstream_inference_cost: costDetails?.upstream_inference_cost ?? null, + })); + return true; +} diff --git a/supabase/functions/ai-stream/handler_test.ts b/supabase/functions/ai-stream/handler_test.ts index e0430bc8b..e57c80313 100644 --- a/supabase/functions/ai-stream/handler_test.ts +++ b/supabase/functions/ai-stream/handler_test.ts @@ -170,6 +170,9 @@ Deno.test("streaming Ask forwards the shared prompt and returns provider SSE", a if (!routing || typeof routing !== "object" || Array.isArray(routing)) throw new Error("provider preferences were missing"); const preferences = routing as Record; if (preferences.require_parameters !== true) throw new Error("parameter support was not required"); + if (JSON.stringify(preferences.only) !== '["anthropic"]' || preferences.allow_fallbacks !== false) { + throw new Error(`streamed Claude was not pinned to Anthropic without fallbacks: ${JSON.stringify(preferences)}`); + } if ("sort" in preferences || "order" in preferences) { throw new Error(`sort/order disables OpenRouter balanced routing: ${JSON.stringify(preferences)}`); } diff --git a/supabase/functions/ai-stream/index.ts b/supabase/functions/ai-stream/index.ts index 7fbf56efe..2ed562837 100644 --- a/supabase/functions/ai-stream/index.ts +++ b/supabase/functions/ai-stream/index.ts @@ -20,7 +20,17 @@ import { } from "../_shared/cost.ts"; import { HttpError, jsonError, requireUser } from "../_shared/auth.ts"; import { getCorsHeaders } from "../_shared/cors.ts"; -import { checkRateLimit, logUsage } from "../_shared/entitlements.ts"; +import { checkRateLimit, logUsage, refundDailyQuota } from "../_shared/entitlements.ts"; +import { + CLAUDE_UNAVAILABLE_CODE, + CLAUDE_UNAVAILABLE_MESSAGE, + CLAUDE_UNAVAILABLE_STATUS, + ClaudeProviderUnavailableError, + isClaudeModel, + openRouterProviderOptions, + providerRequestError, + warnIfClaudeNotByok, +} from "../_shared/provider-routing.ts"; import { OPENROUTER_API_URL } from "../_shared/research.ts"; import { MAX_ASK_HISTORY_MESSAGE_CHARS, MAX_ASK_HISTORY_MESSAGES, withPromptCaching } from "../_shared/prompt-budget.ts"; import { @@ -267,8 +277,7 @@ export async function handleRequest(req: Request): Promise { messages: withPromptCaching(model, messages), max_tokens: Math.min(modelEntry.maxTokens, MAX_REQUEST_OUTPUT_TOKENS), reasoning: { effort: reasoningEffort, exclude: true }, - // Omitting sort and order keeps OpenRouter's balanced provider load balancing. - provider: { require_parameters: true }, + provider: openRouterProviderOptions(model), stream: true, stream_options: { include_usage: true }, }; @@ -281,7 +290,7 @@ export async function handleRequest(req: Request): Promise { }); if (!response.ok || !response.body) { console.error(`[OpenRouter] Stream request failed (${response.status}):`, (await response.text()).slice(0, 2000)); - throw new Error("AI provider request failed"); + throw providerRequestError(model, response.status); } reader = response.body.getReader(); @@ -302,6 +311,10 @@ export async function handleRequest(req: Request): Promise { const message = providerError && typeof providerError === "object" && "message" in providerError ? (providerError as { message?: unknown }).message : providerError; + if (isClaudeModel(model)) { + console.error("[OpenRouter] Stream error:", typeof message === "string" ? message.slice(0, 2000) : "stream error"); + throw providerRequestError(model, null); + } throw new Error(typeof message === "string" ? message : "OpenRouter stream error"); } const delta = (Array.isArray(parsed.choices) ? parsed.choices[0] : undefined) as { delta?: { content?: string }; finish_reason?: unknown } | undefined; @@ -353,12 +366,19 @@ export async function handleRequest(req: Request): Promise { if (!req.signal.aborted) { try { emit("final", { output, meta: { requestedModel, resolvedModel: model, ...(debateMeta ? { debateContext: debateMeta } : {}) } }); } catch { /* client disconnected after provider completion */ } } + warnIfClaudeNotByok(model, streamUsageRaw, "ai-stream"); await logUsage(user.id, "ask", OPENROUTER_PROVIDER, model, collectUsage(), Date.now() - startedAt); } catch (err) { cancelUpstream(); console.error("[AI_STREAM] Stream failed:", (err instanceof Error ? err.message : String(err)).slice(0, 2000)); const isAbort = req.signal.aborted || (err instanceof DOMException && err.name === "AbortError"); - if (!isAbort) { + // Claude runs only on the Anthropic key, with no fallback provider. + // When it fails the student gets an outage message and their request back. + const claudeUnavailable = !isAbort && err instanceof ClaudeProviderUnavailableError; + if (claudeUnavailable) { + try { emit("error", { message: CLAUDE_UNAVAILABLE_MESSAGE, code: CLAUDE_UNAVAILABLE_CODE, status: CLAUDE_UNAVAILABLE_STATUS }); } catch { /* client disconnected */ } + await refundDailyQuota(user.id); + } else if (!isAbort) { try { emit("error", { message: "AI streaming failed. Please try again.", code: "provider_error" }); } catch { /* client disconnected */ } } const observedUsage = collectUsage(); @@ -370,7 +390,7 @@ export async function handleRequest(req: Request): Promise { markIncompleteUsage(observedUsage), Date.now() - startedAt, isAbort ? "cancelled" : "error", - isAbort ? "client_abort" : "stream_error", + isAbort ? "client_abort" : claudeUnavailable ? "claude_unavailable" : "stream_error", ); } finally { req.signal.removeEventListener("abort", clientAbortHandler); diff --git a/supabase/functions/ai/handler-research.test.ts b/supabase/functions/ai/handler-research.test.ts index 8d4dc3fb1..9eea4aa7a 100644 --- a/supabase/functions/ai/handler-research.test.ts +++ b/supabase/functions/ai/handler-research.test.ts @@ -411,6 +411,15 @@ Deno.test("Ask marks only surviving URL and research context as available", asyn if (helperModels.length !== 2 || helperModels.some((model) => model !== BACKGROUND_MODEL)) { throw new Error(`research helpers did not use the background model: ${JSON.stringify(helperModels)}`); } + // The background model is Claude, so research steps on a GPT request + // must stay on the Anthropic key too, while the GPT answer is not pinned. + for (const body of providerBodies) { + const routing = body.provider as Record; + const pinned = JSON.stringify(routing.only) === '["anthropic"]' && routing.allow_fallbacks === false; + if (pinned !== String(body.model).startsWith("anthropic/")) { + throw new Error(`wrong provider routing for ${String(body.model)}: ${JSON.stringify(routing)}`); + } + } if (providerBodies.filter((body) => body.model === "openai/gpt-6-luna").length !== 1) { throw new Error("Ask answer did not use the requested model"); } @@ -756,6 +765,15 @@ Deno.test("AI handlers use strict OpenRouter routing and server-side reasoning t if (reasoning.effort !== (entry.isProOnly ? "high" : "low")) throw new Error(`tier effort was not enforced for ${entry.id}`); if (reasoning.exclude !== true || provider.require_parameters !== true) throw new Error(`strict reasoning route missing for ${entry.id}`); if ("sort" in provider || "order" in provider) throw new Error(`sort/order disabled balanced routing for ${entry.id}`); + // Claude runs only on the Anthropic key (BYOK); every other family keeps + // OpenRouter's balanced routing across providers. + if (entry.id.startsWith("anthropic/")) { + if (JSON.stringify(provider.only) !== '["anthropic"]' || provider.allow_fallbacks !== false) { + throw new Error(`Claude was not pinned to Anthropic without fallbacks: ${JSON.stringify(provider)}`); + } + } else if ("only" in provider || "allow_fallbacks" in provider) { + throw new Error(`non-Claude route was restricted for ${entry.id}: ${JSON.stringify(provider)}`); + } if ((body.response_format as Record)?.type !== "json_object") throw new Error(`JSON response format missing for ${entry.id}`); // With require_parameters, one endpoint without temperature support // turns the whole request into a routing 404 (Claude Opus 5 did). diff --git a/supabase/functions/ai/index.ts b/supabase/functions/ai/index.ts index 44cea4f3d..efdffdef6 100644 --- a/supabase/functions/ai/index.ts +++ b/supabase/functions/ai/index.ts @@ -57,7 +57,14 @@ import { checkUserBanned, logUsage, type RateLimitResult, + refundDailyQuota, } from "../_shared/entitlements.ts"; +import { + CLAUDE_UNAVAILABLE_CODE, + CLAUDE_UNAVAILABLE_MESSAGE, + CLAUDE_UNAVAILABLE_STATUS, + ClaudeProviderUnavailableError, +} from "../_shared/provider-routing.ts"; import { isWorkspaceMember, loadAuthorizedWorkspaceContext } from "../_shared/workspace-context.ts"; import { type DebateContextRequest, parseDebateContextRequest } from "../_shared/debate-context.ts"; import { @@ -798,6 +805,9 @@ export async function handleRequest(req: Request): Promise { // A successful main call takes precedence over usage attached to a later // post-processing error. This avoids counting its repair attempts twice. const mainErrorUsage = completedMainUsage ?? selectObservedUsage(usageError, workSignal?.aborted === true); + // Claude runs only on the Anthropic key, with no fallback provider. When + // it fails the student gets an outage message and their request back. + const claudeUnavailable = err instanceof ClaudeProviderUnavailableError; await logUsage( userId, feature, @@ -809,8 +819,12 @@ export async function handleRequest(req: Request): Promise { ]), latencyMs, "error", - "ai_error", + claudeUnavailable ? "claude_unavailable" : "ai_error", ); + if (claudeUnavailable) { + await refundDailyQuota(userId); + return new Response(JSON.stringify({ error: CLAUDE_UNAVAILABLE_MESSAGE, code: CLAUDE_UNAVAILABLE_CODE }), { status: CLAUDE_UNAVAILABLE_STATUS, headers: { ...corsHeaders, "Content-Type": "application/json" } }); + } return new Response(JSON.stringify({ error: "AI request failed. Please try again.", code: "provider_error" }), { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }); } }; diff --git a/supabase/functions/ai/provider-errors_test.ts b/supabase/functions/ai/provider-errors_test.ts index a47a59677..dddd3fe81 100644 --- a/supabase/functions/ai/provider-errors_test.ts +++ b/supabase/functions/ai/provider-errors_test.ts @@ -40,3 +40,84 @@ Deno.test("AI JSON and SSE failures never expose provider response bodies", asyn } } finally { globalThis.fetch = originalFetch; } }); + +Deno.test("a refused Claude call is not retried elsewhere, shows an outage, and gives the request back", async () => { + const originalFetch = globalThis.fetch; + const originalWarn = console.warn; + let failure: "http" | "chunk" = "http"; + let refunds = 0; + const usageRows: Array> = []; + const providerBodies: Array> = []; + console.warn = () => undefined; + globalThis.fetch = (async (input: Request | URL | string, init?: RequestInit) => { + const url = new URL(input instanceof Request ? input.url : String(input)); + if (url.origin === "https://supabase.test") { + if (url.pathname === "/auth/v1/user") return Response.json({ id: "user-1", aud: "authenticated" }); + if (url.pathname.endsWith("/user_roles")) return Response.json({ role: "user" }); + if (url.pathname.endsWith("/user_settings")) return Response.json({ subscription_tier: "trial", daily_limit_override: 50, trial_ends_at: "2099-01-01", grace_period_ends_at: null }); + if (url.pathname.endsWith("/subscriptions")) return Response.json([]); + if (url.pathname.endsWith("/rate_limits")) return Response.json({ limit_value: 50 }); + if (url.pathname.endsWith("/global_prompts")) return Response.json({ prompt_text: null }); + if (url.pathname.endsWith("/consume_daily_quota")) return Response.json([{ allowed: true, current_count: 1 }]); + if (url.pathname.endsWith("/refund_daily_quota")) { + refunds += 1; + return Response.json(0); + } + if (url.pathname.endsWith("/ai_requests")) { + usageRows.push(JSON.parse(String(init?.body))); + return new Response(null, { status: 201 }); + } + } + if (url.href === "https://openrouter.ai/api/v1/chat/completions") { + providerBodies.push(JSON.parse(String(init?.body))); + if (failure === "http") return new Response("PRIVATE_PROVIDER_BODY", { status: 402 }); + return new Response('data: {"error":{"message":"PRIVATE_PROVIDER_BODY"}}\n\n', { headers: { "Content-Type": "text/event-stream" } }); + } + throw new Error(`Unexpected request: ${url}`); + }) as typeof fetch; + const send = (handle: typeof ai, extra: Record = {}) => handle(new Request("http://127.0.0.1/functions/v1/ai", { + method: "POST", headers: { Authorization: "Bearer test-token", "Content-Type": "application/json" }, + body: JSON.stringify({ feature: "ask", model: "anthropic/claude-haiku-5.5", input: { message: "A question", mode: "general", webSearch: false }, ...extra }), + })); + try { + for (const [label, kind, run] of [ + ["ai", "http", async () => { + const response = await send(ai); + assertEquals(response.status, 503); + return await response.text(); + }], + ...(["http", "chunk"] as const).flatMap((kind) => [ + [`ai live ${kind}`, kind, async () => { + const text = await (await send(ai, { stream: true })).text(); + assertEquals(text.includes('"status":503'), true); + return text; + }], + [`ai-stream ${kind}`, kind, async () => { + const response = await send(stream); + assertEquals(response.status, 200); + return await response.text(); + }], + ] as const), + ] as const) { + failure = kind; + refunds = 0; + usageRows.length = 0; + providerBodies.length = 0; + const text = await run(); + assertEquals(text.includes("PRIVATE_PROVIDER_BODY"), false, label); + assertEquals(text.includes("Anthropic Claude is temporarily unavailable. Please try again later."), true, label); + assertEquals(text.includes('"code":"provider_unavailable"'), true, label); + assertEquals(refunds, 1, `${label} refunds the reserved request once`); + assertEquals(usageRows.map((row) => row.error_code), ["claude_unavailable"], label); + // Every attempt stays on the Anthropic route; nothing is retried on another model or provider. + assertEquals(providerBodies.length > 0, true, label); + for (const body of providerBodies) { + assertEquals(body.model, "anthropic/claude-haiku-5.5", label); + assertEquals(body.provider, { only: ["anthropic"], allow_fallbacks: false, require_parameters: true }, label); + } + } + } finally { + globalThis.fetch = originalFetch; + console.warn = originalWarn; + } +}); diff --git a/supabase/functions/ai/provider.ts b/supabase/functions/ai/provider.ts index 8c40c09ba..a4328fc96 100644 --- a/supabase/functions/ai/provider.ts +++ b/supabase/functions/ai/provider.ts @@ -23,6 +23,7 @@ import { readResponseText, } from "../_shared/research.ts"; import { withPromptCaching } from "../_shared/prompt-budget.ts"; +import { openRouterProviderOptions, providerRequestError, warnIfClaudeNotByok } from "../_shared/provider-routing.ts"; export const OPENROUTER_PROVIDER: Provider = "openrouter"; @@ -239,10 +240,9 @@ export async function callOpenRouter( max_tokens: Math.min(modelEntry.maxTokens, MAX_REQUEST_OUTPUT_TOKENS), response_format: { type: "json_object" }, reasoning: { effort: reasoningEffort, exclude: true }, - // Omitting sort and order keeps OpenRouter's balanced provider load balancing. - provider: { require_parameters: true }, + provider: openRouterProviderOptions(model), }; - if (stream) return await streamOpenRouter(apiKey, payload, stream, signal); + if (stream) return await streamOpenRouter(apiKey, model, payload, stream, signal); const { response, data, errorBody } = await fetchJsonWithTimeout(`${OPENROUTER_API_URL}/v1/chat/completions`, { method: "POST", @@ -251,10 +251,11 @@ export async function callOpenRouter( }, REQUEST_BUDGET_MS, signal); if (!response.ok) { console.error(`[OpenRouter] Request failed (${response.status}):`, errorBody?.slice(0, 2000)); - throw new Error("AI provider request failed"); + throw providerRequestError(model, response.status); } if (!data?.choices || !Array.isArray(data.choices) || data.choices.length === 0) throw new Error("OpenRouter returned no choices"); const usage = normalizeProviderUsage(data.usage) ?? missingOpenRouterUsage("response"); + warnIfClaudeNotByok(model, usage.raw, "ai"); return parseProviderContent(data.choices[0]?.message?.content ?? "", "OpenRouter", usage); } @@ -276,6 +277,7 @@ type OpenRouterStreamChunk = { */ async function streamOpenRouter( apiKey: string, + model: string, payload: Record, stream: GenerationStream, signal?: AbortSignal, @@ -291,10 +293,11 @@ async function streamOpenRouter( ? await readResponseText(response, MAX_PROVIDER_ERROR_BODY_BYTES, MAX_PROVIDER_ERROR_BODY_CHARS, bodySignal, true) : { text: "" }; console.error(`[OpenRouter] Stream request failed (${response.status}):`, body.text.slice(0, 2000)); - throw new Error("AI provider request failed"); + throw providerRequestError(model, response.status); } - return await readOpenRouterStream(response.body, stream, bodySignal); + return await readOpenRouterStream(response.body, stream, bodySignal, model); }); + warnIfClaudeNotByok(model, usage.raw, "ai"); return parseProviderContent(text, "OpenRouter", usage); } @@ -302,6 +305,7 @@ export async function readOpenRouterStream( body: ReadableStream, stream: GenerationStream, signal: AbortSignal, + model = "", ): Promise<{ text: string; usage: NormalizedUsage }> { const reader = body.getReader(); const decoder = new TextDecoder(); @@ -330,7 +334,7 @@ export async function readOpenRouterStream( ? (chunk.error as { message?: unknown }).message : chunk.error; console.error("[OpenRouter] Stream error:", typeof message === "string" ? message.slice(0, 2000) : "stream error"); - throw new Error("AI provider request failed"); + throw providerRequestError(model, null); } const choice = Array.isArray(chunk.choices) ? chunk.choices[0] : undefined; if (typeof choice?.finish_reason === "string" && choice.finish_reason) finished = true; diff --git a/supabase/migrations/20261022120000_refund_daily_quota.sql b/supabase/migrations/20261022120000_refund_daily_quota.sql new file mode 100644 index 000000000..bb632d55f --- /dev/null +++ b/supabase/migrations/20261022120000_refund_daily_quota.sql @@ -0,0 +1,47 @@ +-- Give back a reserved daily request when the provider could not serve it. +-- Edge Functions call this only after a Claude call failed on the Anthropic +-- key, so the student is not charged for an answer they never got. It only +-- lowers today's existing counter and never below zero. +CREATE FUNCTION public.refund_daily_quota( + p_user_id uuid, + p_quota_key text DEFAULT 'ai' +) +RETURNS integer +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_quota_key text := nullif(trim(p_quota_key), ''); + v_current_count integer; +BEGIN + IF auth.role() <> 'service_role' OR p_user_id IS NULL OR v_quota_key IS NULL THEN + RAISE EXCEPTION 'quota refunds are restricted to trusted server code'; + END IF; + + UPDATE public.daily_usage_counters + SET request_count = request_count - 1 + WHERE user_id = p_user_id + AND usage_date = CURRENT_DATE + AND quota_key = v_quota_key + AND request_count > 0 + RETURNING request_count INTO v_current_count; + + IF FOUND THEN + RETURN v_current_count; + END IF; + + SELECT request_count INTO v_current_count + FROM public.daily_usage_counters + WHERE user_id = p_user_id + AND usage_date = CURRENT_DATE + AND quota_key = v_quota_key; + + RETURN COALESCE(v_current_count, 0); +END; +$$; + +REVOKE ALL ON FUNCTION public.refund_daily_quota(uuid, text) + FROM PUBLIC, anon, authenticated; +GRANT EXECUTE ON FUNCTION public.refund_daily_quota(uuid, text) + TO service_role; diff --git a/supabase/tests/daily_quota_refund_test.sql b/supabase/tests/daily_quota_refund_test.sql new file mode 100644 index 000000000..5f7b80594 --- /dev/null +++ b/supabase/tests/daily_quota_refund_test.sql @@ -0,0 +1,80 @@ +BEGIN; +SELECT plan(9); + +INSERT INTO auth.users (id) +VALUES + ('66666666-6666-4666-8666-666666666666'), + ('77777777-7777-4777-8777-777777777777'); + +SELECT ok( + NOT has_function_privilege('anon', 'public.refund_daily_quota(uuid, text)', 'EXECUTE'), + 'anonymous callers cannot refund quota' +); +SELECT ok( + NOT has_function_privilege('authenticated', 'public.refund_daily_quota(uuid, text)', 'EXECUTE'), + 'authenticated callers cannot refund quota' +); + +SET LOCAL ROLE authenticated; +SELECT set_config('request.jwt.claim.role', 'authenticated', true); +SELECT throws_ok( + $$SELECT public.refund_daily_quota('66666666-6666-4666-8666-666666666666')$$, + '42501', + NULL, + 'unauthorized callers still cannot execute the refund RPC' +); + +RESET ROLE; +SET LOCAL ROLE service_role; +SELECT set_config('request.jwt.claim.role', 'service_role', true); + +SELECT results_eq( + $$SELECT current_count FROM public.consume_daily_quota('66666666-6666-4666-8666-666666666666', 3) + UNION ALL SELECT current_count FROM public.consume_daily_quota('66666666-6666-4666-8666-666666666666', 3) + UNION ALL SELECT current_count FROM public.consume_daily_quota('66666666-6666-4666-8666-666666666666', 1, 'import_source')$$, + ARRAY[1, 2, 1], + 'setup reserves two ai requests and one import' +); +SELECT is( + public.refund_daily_quota('66666666-6666-4666-8666-666666666666'), + 1, + 'a refund gives back one ai request' +); +SELECT results_eq( + $$SELECT allowed, current_count + FROM public.consume_daily_quota('66666666-6666-4666-8666-666666666666', 2)$$, + $$VALUES (true, 2)$$, + 'the refunded request can be used again under the same limit' +); +SELECT is( + (SELECT request_count FROM public.daily_usage_counters + WHERE user_id = '66666666-6666-4666-8666-666666666666' AND quota_key = 'import_source'), + 1, + 'an ai refund leaves the import counter alone' +); +SELECT is( + public.refund_daily_quota('77777777-7777-4777-8777-777777777777'), + 0, + 'a refund without a reservation today creates nothing' +); + +RESET ROLE; +INSERT INTO public.daily_usage_counters (user_id, usage_date, request_count) +VALUES + ('77777777-7777-4777-8777-777777777777', CURRENT_DATE, 0), + ('77777777-7777-4777-8777-777777777777', CURRENT_DATE - 1, 5); +SET LOCAL ROLE service_role; +SELECT set_config('request.jwt.claim.role', 'service_role', true); +SELECT public.refund_daily_quota('77777777-7777-4777-8777-777777777777'); +RESET ROLE; +SELECT results_eq( + $$SELECT usage_date = CURRENT_DATE, request_count + FROM public.daily_usage_counters + WHERE user_id = '77777777-7777-4777-8777-777777777777' + ORDER BY usage_date$$, + $$VALUES (false, 5), (true, 0)$$, + 'refunds never go below zero or touch earlier days' +); + +SELECT * FROM finish(); +ROLLBACK; From f2e9b602cfecfe67f50d313f97d84fd568429ff0 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 22 Oct 2026 13:05:28 +0000 Subject: [PATCH 2/9] Refund a failed Claude request only when nothing was written yet. A stream that fails after the model wrote text, or a repair retry after a completed attempt, already spent tokens on the key, so the request stays counted. The student still sees the outage message. Co-Authored-By: Claude Opus 5.5 --- supabase/README.md | 2 +- supabase/functions/_shared/provider-routing.ts | 13 ++++++++----- supabase/functions/ai-stream/index.ts | 7 ++++--- supabase/functions/ai/index.ts | 6 ++++-- supabase/functions/ai/provider-errors_test.ts | 10 ++++++---- supabase/functions/ai/provider.ts | 2 +- 6 files changed, 24 insertions(+), 16 deletions(-) diff --git a/supabase/README.md b/supabase/README.md index 430a08c64..21dc7d67a 100644 --- a/supabase/README.md +++ b/supabase/README.md @@ -38,7 +38,7 @@ Both endpoints accept an optional `debateContext` of IDs (`debateId`, `side`, `s Both endpoints route every model inference through OpenRouter and enforce the shared ten-model Basic/Pro catalog. Basic accounts get Claude Haiku 5.5 (the default), GPT-6 Luna, and Muse Spark 1.3 Contributor; Claude Opus 5.5 is the Pro default; Pro-only models require active Pro access. Retired catalog IDs from older clients resolve to their successor before validation. -Claude runs only on DebateLab's own Anthropic key, added to OpenRouter as BYOK. [`_shared/provider-routing.ts`](functions/_shared/provider-routing.ts) sends `anthropic/*` requests with `provider: { only: ["anthropic"], allow_fallbacks: false }`, so Bedrock, Vertex and any other provider are never tried; other models keep OpenRouter's balanced routing. The request body cannot stop OpenRouter from serving Anthropic on its own credits when the key fails, so the key's **Shared capacity fallback** in OpenRouter must be set to "Never use shared capacity for any model on this provider". When a Claude call is refused, the endpoints answer 503 `provider_unavailable`, log `claude_unavailable`, and give the reserved daily request back through `refund_daily_quota`. A Claude response whose usage shows `is_byok: false` or a `cost` above zero logs a `claude_not_byok` warning; past the monthly BYOK allowance, OpenRouter's BYOK fee also shows as cost. +Claude runs only on DebateLab's own Anthropic key, added to OpenRouter as BYOK. [`_shared/provider-routing.ts`](functions/_shared/provider-routing.ts) sends `anthropic/*` requests with `provider: { only: ["anthropic"], allow_fallbacks: false }`, so Bedrock, Vertex and any other provider are never tried; other models keep OpenRouter's balanced routing. The request body cannot stop OpenRouter from serving Anthropic on its own credits when the key fails, so the key's **Shared capacity fallback** in OpenRouter must be set to "Never use shared capacity for any model on this provider". When a Claude call is refused, the endpoints answer 503 `provider_unavailable`, log `claude_unavailable`, and give the reserved daily request back through `refund_daily_quota` when the failure came before any output or completed provider call. A Claude response whose usage shows `is_byok: false` or a `cost` above zero logs a `claude_not_byok` warning; past the monthly BYOK allowance, OpenRouter's BYOK fee also shows as cost. The browser boundary is [`../src/lib/ai/client.ts`](../src/lib/ai/client.ts). Shared prompt, research, source-quality, cost, entitlement, CORS, and control logic lives in `_shared`. diff --git a/supabase/functions/_shared/provider-routing.ts b/supabase/functions/_shared/provider-routing.ts index a92e280be..9facaf10f 100644 --- a/supabase/functions/_shared/provider-routing.ts +++ b/supabase/functions/_shared/provider-routing.ts @@ -21,22 +21,25 @@ export function openRouterProviderOptions(model: string): Record { : providerError; if (isClaudeModel(model)) { console.error("[OpenRouter] Stream error:", typeof message === "string" ? message.slice(0, 2000) : "stream error"); - throw providerRequestError(model, null); + throw providerRequestError(model, null, accumulated.length > 0); } throw new Error(typeof message === "string" ? message : "OpenRouter stream error"); } @@ -373,11 +373,12 @@ export async function handleRequest(req: Request): Promise { console.error("[AI_STREAM] Stream failed:", (err instanceof Error ? err.message : String(err)).slice(0, 2000)); const isAbort = req.signal.aborted || (err instanceof DOMException && err.name === "AbortError"); // Claude runs only on the Anthropic key, with no fallback provider. - // When it fails the student gets an outage message and their request back. + // When it fails the student gets an outage message, and their request + // back if the model had written nothing yet. const claudeUnavailable = !isAbort && err instanceof ClaudeProviderUnavailableError; if (claudeUnavailable) { try { emit("error", { message: CLAUDE_UNAVAILABLE_MESSAGE, code: CLAUDE_UNAVAILABLE_CODE, status: CLAUDE_UNAVAILABLE_STATUS }); } catch { /* client disconnected */ } - await refundDailyQuota(user.id); + if (!err.afterOutput && !accumulated) await refundDailyQuota(user.id); } else if (!isAbort) { try { emit("error", { message: "AI streaming failed. Please try again.", code: "provider_error" }); } catch { /* client disconnected */ } } diff --git a/supabase/functions/ai/index.ts b/supabase/functions/ai/index.ts index efdffdef6..53b7bbea6 100644 --- a/supabase/functions/ai/index.ts +++ b/supabase/functions/ai/index.ts @@ -806,8 +806,10 @@ export async function handleRequest(req: Request): Promise { // post-processing error. This avoids counting its repair attempts twice. const mainErrorUsage = completedMainUsage ?? selectObservedUsage(usageError, workSignal?.aborted === true); // Claude runs only on the Anthropic key, with no fallback provider. When - // it fails the student gets an outage message and their request back. + // it fails the student gets an outage message, and their request back + // if no provider call had finished or written anything yet. const claudeUnavailable = err instanceof ClaudeProviderUnavailableError; + const refundable = claudeUnavailable && !err.afterOutput && !completedMainUsage && !usageError?.completedUsage; await logUsage( userId, feature, @@ -822,7 +824,7 @@ export async function handleRequest(req: Request): Promise { claudeUnavailable ? "claude_unavailable" : "ai_error", ); if (claudeUnavailable) { - await refundDailyQuota(userId); + if (refundable) await refundDailyQuota(userId); return new Response(JSON.stringify({ error: CLAUDE_UNAVAILABLE_MESSAGE, code: CLAUDE_UNAVAILABLE_CODE }), { status: CLAUDE_UNAVAILABLE_STATUS, headers: { ...corsHeaders, "Content-Type": "application/json" } }); } return new Response(JSON.stringify({ error: "AI request failed. Please try again.", code: "provider_error" }), { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }); diff --git a/supabase/functions/ai/provider-errors_test.ts b/supabase/functions/ai/provider-errors_test.ts index dddd3fe81..8508fae51 100644 --- a/supabase/functions/ai/provider-errors_test.ts +++ b/supabase/functions/ai/provider-errors_test.ts @@ -44,7 +44,7 @@ Deno.test("AI JSON and SSE failures never expose provider response bodies", asyn Deno.test("a refused Claude call is not retried elsewhere, shows an outage, and gives the request back", async () => { const originalFetch = globalThis.fetch; const originalWarn = console.warn; - let failure: "http" | "chunk" = "http"; + let failure: "http" | "chunk" | "chunk-after-text" = "http"; let refunds = 0; const usageRows: Array> = []; const providerBodies: Array> = []; @@ -71,7 +71,8 @@ Deno.test("a refused Claude call is not retried elsewhere, shows an outage, and if (url.href === "https://openrouter.ai/api/v1/chat/completions") { providerBodies.push(JSON.parse(String(init?.body))); if (failure === "http") return new Response("PRIVATE_PROVIDER_BODY", { status: 402 }); - return new Response('data: {"error":{"message":"PRIVATE_PROVIDER_BODY"}}\n\n', { headers: { "Content-Type": "text/event-stream" } }); + const text = failure === "chunk-after-text" ? 'data: {"choices":[{"delta":{"content":"{\\"resp"}}]}\n\n' : ""; + return new Response(`${text}data: {"error":{"message":"PRIVATE_PROVIDER_BODY"}}\n\n`, { headers: { "Content-Type": "text/event-stream" } }); } throw new Error(`Unexpected request: ${url}`); }) as typeof fetch; @@ -86,7 +87,7 @@ Deno.test("a refused Claude call is not retried elsewhere, shows an outage, and assertEquals(response.status, 503); return await response.text(); }], - ...(["http", "chunk"] as const).flatMap((kind) => [ + ...(["http", "chunk", "chunk-after-text"] as const).flatMap((kind) => [ [`ai live ${kind}`, kind, async () => { const text = await (await send(ai, { stream: true })).text(); assertEquals(text.includes('"status":503'), true); @@ -107,7 +108,8 @@ Deno.test("a refused Claude call is not retried elsewhere, shows an outage, and assertEquals(text.includes("PRIVATE_PROVIDER_BODY"), false, label); assertEquals(text.includes("Anthropic Claude is temporarily unavailable. Please try again later."), true, label); assertEquals(text.includes('"code":"provider_unavailable"'), true, label); - assertEquals(refunds, 1, `${label} refunds the reserved request once`); + // A failure after the model wrote text keeps the request: the key already paid for it. + assertEquals(refunds, kind === "chunk-after-text" ? 0 : 1, `${label} refunds only a failure before output`); assertEquals(usageRows.map((row) => row.error_code), ["claude_unavailable"], label); // Every attempt stays on the Anthropic route; nothing is retried on another model or provider. assertEquals(providerBodies.length > 0, true, label); diff --git a/supabase/functions/ai/provider.ts b/supabase/functions/ai/provider.ts index a4328fc96..be25d8679 100644 --- a/supabase/functions/ai/provider.ts +++ b/supabase/functions/ai/provider.ts @@ -334,7 +334,7 @@ export async function readOpenRouterStream( ? (chunk.error as { message?: unknown }).message : chunk.error; console.error("[OpenRouter] Stream error:", typeof message === "string" ? message.slice(0, 2000) : "stream error"); - throw providerRequestError(model, null); + throw providerRequestError(model, null, text.length > 0); } const choice = Array.isArray(chunk.choices) ? chunk.choices[0] : undefined; if (typeof choice?.finish_reason === "string" && choice.finish_reason) finished = true; From ffd860446c029368b4d16a0d6b640be5b891bb79 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 22 Oct 2026 14:01:45 +0000 Subject: [PATCH 3/9] Keep paid research out of Claude refunds and stop the Ask double attempt. From the Opus review of #185: - A request whose research steps already ran on Claude keeps its quota. - Ask no longer resends a provider_unavailable stream failure through the plain endpoint, which tried Claude a second time on a failing key. - ai-stream refunds before it sends the error, so an immediate retry sees it. - Sparring shows the translated provider message instead of the raw one. - Tests cover no refund on success, on a non-Claude failure, after a completed repair attempt, and after research. Co-Authored-By: Claude Opus 5.5 --- src/pages/AskPage.tsx | 6 ++- src/pages/SparringPage.tsx | 3 +- supabase/README.md | 2 +- supabase/functions/ai-stream/index.ts | 4 +- supabase/functions/ai/index.ts | 3 +- supabase/functions/ai/provider-errors_test.ts | 43 +++++++++++++++---- 6 files changed, 48 insertions(+), 13 deletions(-) diff --git a/src/pages/AskPage.tsx b/src/pages/AskPage.tsx index 39c233e79..bb1b40a3a 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, isAskMessageWithinBudget, isTimeoutError } from '@/lib/ai/client'; +import { askChat, isAIProviderError, 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'; @@ -293,6 +293,10 @@ 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. + throw streamError; } else { result = await askChat(fullMessage, history, mode, config, options); } diff --git a/src/pages/SparringPage.tsx b/src/pages/SparringPage.tsx index 8128f7868..f503186e4 100644 --- a/src/pages/SparringPage.tsx +++ b/src/pages/SparringPage.tsx @@ -6,6 +6,7 @@ import { useLocation, useNavigate } from 'react-router-dom'; import { useSparringStore } from '@/lib/stores/sparring-store'; import { useSettingsStore } from '@/lib/stores/settings-store'; import { generateSparringTurn, judgeSparringSession } from '@/lib/ai/client'; +import { getUserFacingAIError } from '@/lib/ai/error-display'; import type { SparringRespondInput, SparringScoreInput, SparringSpeech } from '@/lib/ai/schemas/sparring'; import { SparringSetup, type SparringSetupValues } from '@/components/features/sparring/SparringSetup'; import { SparringRound } from '@/components/features/sparring/SparringRound'; @@ -26,7 +27,7 @@ import type { SpeechDelivery } from '@/lib/voice/delivery'; import { opponentVoice } from '@/lib/voice/opponent-voice'; function readableError(error: unknown, fallback: string) { - if (error instanceof Error && error.message.trim()) return error.message; + if (error instanceof Error && error.message.trim()) return getUserFacingAIError(error); return fallback; } diff --git a/supabase/README.md b/supabase/README.md index 21dc7d67a..a8ff0d2f0 100644 --- a/supabase/README.md +++ b/supabase/README.md @@ -38,7 +38,7 @@ Both endpoints accept an optional `debateContext` of IDs (`debateId`, `side`, `s Both endpoints route every model inference through OpenRouter and enforce the shared ten-model Basic/Pro catalog. Basic accounts get Claude Haiku 5.5 (the default), GPT-6 Luna, and Muse Spark 1.3 Contributor; Claude Opus 5.5 is the Pro default; Pro-only models require active Pro access. Retired catalog IDs from older clients resolve to their successor before validation. -Claude runs only on DebateLab's own Anthropic key, added to OpenRouter as BYOK. [`_shared/provider-routing.ts`](functions/_shared/provider-routing.ts) sends `anthropic/*` requests with `provider: { only: ["anthropic"], allow_fallbacks: false }`, so Bedrock, Vertex and any other provider are never tried; other models keep OpenRouter's balanced routing. The request body cannot stop OpenRouter from serving Anthropic on its own credits when the key fails, so the key's **Shared capacity fallback** in OpenRouter must be set to "Never use shared capacity for any model on this provider". When a Claude call is refused, the endpoints answer 503 `provider_unavailable`, log `claude_unavailable`, and give the reserved daily request back through `refund_daily_quota` when the failure came before any output or completed provider call. A Claude response whose usage shows `is_byok: false` or a `cost` above zero logs a `claude_not_byok` warning; past the monthly BYOK allowance, OpenRouter's BYOK fee also shows as cost. +Claude runs only on DebateLab's own Anthropic key, added to OpenRouter as BYOK. [`_shared/provider-routing.ts`](functions/_shared/provider-routing.ts) sends `anthropic/*` requests with `provider: { only: ["anthropic"], allow_fallbacks: false }`, so Bedrock, Vertex and any other provider are never tried; other models keep OpenRouter's balanced routing. The request body cannot stop OpenRouter from serving Anthropic on its own credits when the key fails, so the key's **Shared capacity fallback** in OpenRouter must be set to "Never use shared capacity for any model on this provider". When a Claude call is refused, the endpoints answer 503 `provider_unavailable`, log `claude_unavailable`, and give the reserved daily request back through `refund_daily_quota` when the failure came before any output or completed provider call, research steps included. The Ask page does not resend a `provider_unavailable` stream failure through the plain endpoint. A Claude response whose usage shows `is_byok: false` or a `cost` above zero logs a `claude_not_byok` warning; past the monthly BYOK allowance, OpenRouter's BYOK fee also shows as cost. The browser boundary is [`../src/lib/ai/client.ts`](../src/lib/ai/client.ts). Shared prompt, research, source-quality, cost, entitlement, CORS, and control logic lives in `_shared`. diff --git a/supabase/functions/ai-stream/index.ts b/supabase/functions/ai-stream/index.ts index efe02491a..e7721004e 100644 --- a/supabase/functions/ai-stream/index.ts +++ b/supabase/functions/ai-stream/index.ts @@ -377,8 +377,10 @@ export async function handleRequest(req: Request): Promise { // back if the model had written nothing yet. const claudeUnavailable = !isAbort && err instanceof ClaudeProviderUnavailableError; if (claudeUnavailable) { - try { emit("error", { message: CLAUDE_UNAVAILABLE_MESSAGE, code: CLAUDE_UNAVAILABLE_CODE, status: CLAUDE_UNAVAILABLE_STATUS }); } catch { /* client disconnected */ } + // Refund before the client hears of the failure, so a retry it + // sends straight away sees the request already given back. if (!err.afterOutput && !accumulated) await refundDailyQuota(user.id); + try { emit("error", { message: CLAUDE_UNAVAILABLE_MESSAGE, code: CLAUDE_UNAVAILABLE_CODE, status: CLAUDE_UNAVAILABLE_STATUS }); } catch { /* client disconnected */ } } else if (!isAbort) { try { emit("error", { message: "AI streaming failed. Please try again.", code: "provider_error" }); } catch { /* client disconnected */ } } diff --git a/supabase/functions/ai/index.ts b/supabase/functions/ai/index.ts index 53b7bbea6..2d93d3871 100644 --- a/supabase/functions/ai/index.ts +++ b/supabase/functions/ai/index.ts @@ -809,7 +809,8 @@ export async function handleRequest(req: Request): Promise { // it fails the student gets an outage message, and their request back // if no provider call had finished or written anything yet. const claudeUnavailable = err instanceof ClaudeProviderUnavailableError; - const refundable = claudeUnavailable && !err.afterOutput && !completedMainUsage && !usageError?.completedUsage; + const refundable = claudeUnavailable && !err.afterOutput && !completedMainUsage && !usageError?.completedUsage && + researchUsage.length === 0 && !researchAccountingIncomplete; await logUsage( userId, feature, diff --git a/supabase/functions/ai/provider-errors_test.ts b/supabase/functions/ai/provider-errors_test.ts index 8508fae51..532e65b66 100644 --- a/supabase/functions/ai/provider-errors_test.ts +++ b/supabase/functions/ai/provider-errors_test.ts @@ -44,7 +44,7 @@ Deno.test("AI JSON and SSE failures never expose provider response bodies", asyn Deno.test("a refused Claude call is not retried elsewhere, shows an outage, and gives the request back", async () => { const originalFetch = globalThis.fetch; const originalWarn = console.warn; - let failure: "http" | "chunk" | "chunk-after-text" = "http"; + let failure: "http" | "chunk" | "chunk-after-text" | "repair" | "after-research" | "ok" = "http"; let refunds = 0; const usageRows: Array> = []; const providerBodies: Array> = []; @@ -69,24 +69,41 @@ Deno.test("a refused Claude call is not retried elsewhere, shows an outage, and } } if (url.href === "https://openrouter.ai/api/v1/chat/completions") { - providerBodies.push(JSON.parse(String(init?.body))); - if (failure === "http") return new Response("PRIVATE_PROVIDER_BODY", { status: 402 }); + const body = JSON.parse(String(init?.body)); + providerBodies.push(body); + const usage = { prompt_tokens: 10, completion_tokens: 5, cost: 0, is_byok: true }; + if (failure === "ok" && body.stream) { + return new Response(`data: {"choices":[{"delta":{"content":"ok"},"finish_reason":"stop"}],"usage":${JSON.stringify(usage)}}\n\ndata: [DONE]\n\n`, { headers: { "Content-Type": "text/event-stream" } }); + } + if (failure === "ok") return Response.json({ choices: [{ message: { content: JSON.stringify({ response: "ok", followUpQuestions: [], uncertainties: [] }) } }], usage }); + // A completed attempt with invalid JSON, then a refused repair retry. + const repairRetry = JSON.stringify(body.messages).includes("JSON REPAIR INSTRUCTION"); + if (failure === "repair" && !repairRetry) return Response.json({ choices: [{ message: { content: "not json" } }], usage }); + // The research search decision (on Haiku) succeeds before the main call is refused. + const searchDecision = JSON.stringify(body.messages).includes("User message:"); + if (failure === "after-research" && searchDecision) return Response.json({ choices: [{ message: { content: JSON.stringify({ shouldSearch: false, query: "" }) } }], usage }); + if (failure === "http" || failure === "repair" || failure === "after-research") return new Response("PRIVATE_PROVIDER_BODY", { status: 402 }); const text = failure === "chunk-after-text" ? 'data: {"choices":[{"delta":{"content":"{\\"resp"}}]}\n\n' : ""; return new Response(`${text}data: {"error":{"message":"PRIVATE_PROVIDER_BODY"}}\n\n`, { headers: { "Content-Type": "text/event-stream" } }); } throw new Error(`Unexpected request: ${url}`); }) as typeof fetch; - const send = (handle: typeof ai, extra: Record = {}) => handle(new Request("http://127.0.0.1/functions/v1/ai", { + const send = (handle: typeof ai, extra: Record = {}, model = "anthropic/claude-haiku-5.5") => handle(new Request("http://127.0.0.1/functions/v1/ai", { method: "POST", headers: { Authorization: "Bearer test-token", "Content-Type": "application/json" }, - body: JSON.stringify({ feature: "ask", model: "anthropic/claude-haiku-5.5", input: { message: "A question", mode: "general", webSearch: false }, ...extra }), + body: JSON.stringify({ feature: "ask", model, input: { message: "A question", mode: "general", webSearch: false }, ...extra }), })); try { for (const [label, kind, run] of [ - ["ai", "http", async () => { + ...(["http", "repair"] as const).map((kind) => [`ai ${kind}`, kind, async () => { const response = await send(ai); assertEquals(response.status, 503); return await response.text(); - }], + }] as const), + ["ai after research", "after-research", async () => { + const response = await send(ai, { input: { message: "What is the latest evidence?", mode: "general", citationMode: true } }); + assertEquals(response.status, 503); + return await response.text(); + }] as const, ...(["http", "chunk", "chunk-after-text"] as const).flatMap((kind) => [ [`ai live ${kind}`, kind, async () => { const text = await (await send(ai, { stream: true })).text(); @@ -109,7 +126,8 @@ Deno.test("a refused Claude call is not retried elsewhere, shows an outage, and assertEquals(text.includes("Anthropic Claude is temporarily unavailable. Please try again later."), true, label); assertEquals(text.includes('"code":"provider_unavailable"'), true, label); // A failure after the model wrote text keeps the request: the key already paid for it. - assertEquals(refunds, kind === "chunk-after-text" ? 0 : 1, `${label} refunds only a failure before output`); + // The repair case already completed a paid attempt before the retry failed. + assertEquals(refunds, kind === "chunk-after-text" || kind === "repair" || kind === "after-research" ? 0 : 1, `${label} refunds only a failure before output`); assertEquals(usageRows.map((row) => row.error_code), ["claude_unavailable"], label); // Every attempt stays on the Anthropic route; nothing is retried on another model or provider. assertEquals(providerBodies.length > 0, true, label); @@ -118,6 +136,15 @@ Deno.test("a refused Claude call is not retried elsewhere, shows an outage, and assertEquals(body.provider, { only: ["anthropic"], allow_fallbacks: false, require_parameters: true }, label); } } + + // Neither a successful Claude answer nor a failure on another model gives a request back. + refunds = 0; + failure = "ok"; + assertEquals((await send(ai)).status, 200); + assertEquals((await (await send(stream)).text()).includes("event: final"), true); + failure = "http"; + assertEquals((await send(ai, {}, "openai/gpt-6-luna")).status, 500); + assertEquals(refunds, 0); } finally { globalThis.fetch = originalFetch; console.warn = originalWarn; From a5087ef51f9dd1875fdd05713664fad08e09b860 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 22 Oct 2026 14:24:57 +0000 Subject: [PATCH 4/9] Let a quota refund give back more than one unit. refund_daily_quota takes p_units (default 1, the reservation every AI request makes today) and never goes below zero. Once the weighted quota in #178 lands, the refund can return what consume_ai_quota reserved for the model. Co-Authored-By: Claude Opus 5.5 --- supabase/functions/_shared/entitlements.ts | 4 ++-- .../20261022120000_refund_daily_quota.sql | 13 ++++++++----- supabase/tests/daily_quota_refund_test.sql | 17 ++++++++++++++--- 3 files changed, 24 insertions(+), 10 deletions(-) diff --git a/supabase/functions/_shared/entitlements.ts b/supabase/functions/_shared/entitlements.ts index 768494f0c..5009bf6b4 100644 --- a/supabase/functions/_shared/entitlements.ts +++ b/supabase/functions/_shared/entitlements.ts @@ -202,9 +202,9 @@ export async function checkRateLimit(userId: string | null, model?: ModelSelecti * provider could not serve. A failed refund is logged, never thrown, so the * student still gets the error response. */ -export async function refundDailyQuota(userId: string) { +export async function refundDailyQuota(userId: string, units = 1) { try { - const { error } = await supabase.rpc("refund_daily_quota", { p_user_id: userId }); + const { error } = await supabase.rpc("refund_daily_quota", { p_user_id: userId, p_units: units }); if (error) throw error; } catch (err) { console.error("[RATE LIMIT] Quota refund failed:", err); diff --git a/supabase/migrations/20261022120000_refund_daily_quota.sql b/supabase/migrations/20261022120000_refund_daily_quota.sql index bb632d55f..73c12b76f 100644 --- a/supabase/migrations/20261022120000_refund_daily_quota.sql +++ b/supabase/migrations/20261022120000_refund_daily_quota.sql @@ -1,9 +1,11 @@ -- Give back a reserved daily request when the provider could not serve it. -- Edge Functions call this only after a Claude call failed on the Anthropic -- key, so the student is not charged for an answer they never got. It only --- lowers today's existing counter and never below zero. +-- lowers today's existing counter and never below zero. p_units is what the +-- request reserved: 1 today, the model's weight once the quota is weighted. CREATE FUNCTION public.refund_daily_quota( p_user_id uuid, + p_units integer DEFAULT 1, p_quota_key text DEFAULT 'ai' ) RETURNS integer @@ -15,12 +17,13 @@ DECLARE v_quota_key text := nullif(trim(p_quota_key), ''); v_current_count integer; BEGIN - IF auth.role() <> 'service_role' OR p_user_id IS NULL OR v_quota_key IS NULL THEN + IF auth.role() <> 'service_role' OR p_user_id IS NULL OR v_quota_key IS NULL + OR p_units IS NULL OR p_units < 1 THEN RAISE EXCEPTION 'quota refunds are restricted to trusted server code'; END IF; UPDATE public.daily_usage_counters - SET request_count = request_count - 1 + SET request_count = GREATEST(request_count - p_units, 0) WHERE user_id = p_user_id AND usage_date = CURRENT_DATE AND quota_key = v_quota_key @@ -41,7 +44,7 @@ BEGIN END; $$; -REVOKE ALL ON FUNCTION public.refund_daily_quota(uuid, text) +REVOKE ALL ON FUNCTION public.refund_daily_quota(uuid, integer, text) FROM PUBLIC, anon, authenticated; -GRANT EXECUTE ON FUNCTION public.refund_daily_quota(uuid, text) +GRANT EXECUTE ON FUNCTION public.refund_daily_quota(uuid, integer, text) TO service_role; diff --git a/supabase/tests/daily_quota_refund_test.sql b/supabase/tests/daily_quota_refund_test.sql index 5f7b80594..c0bce6b1c 100644 --- a/supabase/tests/daily_quota_refund_test.sql +++ b/supabase/tests/daily_quota_refund_test.sql @@ -1,5 +1,5 @@ BEGIN; -SELECT plan(9); +SELECT plan(11); INSERT INTO auth.users (id) VALUES @@ -7,11 +7,11 @@ VALUES ('77777777-7777-4777-8777-777777777777'); SELECT ok( - NOT has_function_privilege('anon', 'public.refund_daily_quota(uuid, text)', 'EXECUTE'), + NOT has_function_privilege('anon', 'public.refund_daily_quota(uuid, integer, text)', 'EXECUTE'), 'anonymous callers cannot refund quota' ); SELECT ok( - NOT has_function_privilege('authenticated', 'public.refund_daily_quota(uuid, text)', 'EXECUTE'), + NOT has_function_privilege('authenticated', 'public.refund_daily_quota(uuid, integer, text)', 'EXECUTE'), 'authenticated callers cannot refund quota' ); @@ -57,6 +57,17 @@ SELECT is( 0, 'a refund without a reservation today creates nothing' ); +SELECT throws_ok( + $$SELECT public.refund_daily_quota('66666666-6666-4666-8666-666666666666', 0)$$, + 'P0001', + NULL, + 'a refund must give back at least one unit' +); +SELECT is( + public.refund_daily_quota('66666666-6666-4666-8666-666666666666', 5), + 0, + 'a weighted refund gives back its units and stops at zero' +); RESET ROLE; INSERT INTO public.daily_usage_counters (user_id, usage_date, request_count) From b493fd65298fc10895e2bd6f4d38133587ec0af6 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 22 Oct 2026 14:30:30 +0000 Subject: [PATCH 5/9] Give the refund migration its own version. #180 added 20261022120000_opus_default_to_sonnet.sql, so the refund migration's identical version failed to apply after the merge from main. Co-Authored-By: Claude Opus 5.5 --- ...fund_daily_quota.sql => 20261022150000_refund_daily_quota.sql} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename supabase/migrations/{20261022120000_refund_daily_quota.sql => 20261022150000_refund_daily_quota.sql} (100%) diff --git a/supabase/migrations/20261022120000_refund_daily_quota.sql b/supabase/migrations/20261022150000_refund_daily_quota.sql similarity index 100% rename from supabase/migrations/20261022120000_refund_daily_quota.sql rename to supabase/migrations/20261022150000_refund_daily_quota.sql From b68e586bb26c32e5fb1c38eb3c80e6189e4101a9 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 22 Oct 2026 14:47:36 +0000 Subject: [PATCH 6/9] Refund the weighted units consume_ai_quota reserved, and keep refunds out of the weekly cap. #178 reserves each request's weight (Opus 5, Sonnet 2, others 1; Auto Pro 5) through consume_ai_quota. A refunded Claude failure now gives back the same units checkRateLimit reserved, taken from its RateLimitResult, and logs cap_cost_usd 0 so it does not count toward the weekly $2 either. Co-Authored-By: Claude Opus 5.5 --- supabase/functions/_shared/cost.ts | 5 ++ supabase/functions/_shared/entitlements.ts | 12 ++-- supabase/functions/ai-stream/index.ts | 9 ++- supabase/functions/ai/index.ts | 15 +++-- supabase/functions/ai/provider-errors_test.ts | 45 +++++++++++---- .../20261022150000_refund_daily_quota.sql | 10 +++- supabase/tests/daily_quota_refund_test.sql | 56 ++++++++++--------- 7 files changed, 100 insertions(+), 52 deletions(-) diff --git a/supabase/functions/_shared/cost.ts b/supabase/functions/_shared/cost.ts index 89940aa31..dbda544ab 100644 --- a/supabase/functions/_shared/cost.ts +++ b/supabase/functions/_shared/cost.ts @@ -232,6 +232,11 @@ export function selectObservedUsage( : usageError?.usage ?? usageError?.completedUsage; } +/** Keeps a refunded request out of the weekly spend cap; its cost stays for audit. */ +export function excludeFromCap(usage?: NormalizedUsage): NormalizedUsage { + return { ...usage, capCostUsd: 0 }; +} + /** Preserve observed fragments without treating partial provider work as a complete bill. */ export function markIncompleteUsage(usage?: NormalizedUsage): NormalizedUsage { return { ...usage, incomplete: true }; diff --git a/supabase/functions/_shared/entitlements.ts b/supabase/functions/_shared/entitlements.ts index d0f37ee11..a7156672c 100644 --- a/supabase/functions/_shared/entitlements.ts +++ b/supabase/functions/_shared/entitlements.ts @@ -215,13 +215,15 @@ export async function checkRateLimit(userId: string | null, model?: ModelSelecti } /** - * Gives back the daily request checkRateLimit reserved, for a request the - * provider could not serve. A failed refund is logged, never thrown, so the - * student still gets the error response. + * Gives back exactly what checkRateLimit reserved through consume_ai_quota for + * a request the provider could not serve: the same `units`, which follow the + * requested selection (Auto Pro reserves 5 even when it resolves to Haiku). A + * failed refund is logged, never thrown, so the student still gets the error. */ -export async function refundDailyQuota(userId: string, units = 1) { +export async function refundQuotaReservation(userId: string, reservation: Pick) { + if (!reservation.allowed || !reservation.units) return; try { - const { error } = await supabase.rpc("refund_daily_quota", { p_user_id: userId, p_units: units }); + const { error } = await supabase.rpc("refund_daily_quota", { p_user_id: userId, p_units: reservation.units }); if (error) throw error; } catch (err) { console.error("[RATE LIMIT] Quota refund failed:", err); diff --git a/supabase/functions/ai-stream/index.ts b/supabase/functions/ai-stream/index.ts index ffe6a7db9..99cb75388 100644 --- a/supabase/functions/ai-stream/index.ts +++ b/supabase/functions/ai-stream/index.ts @@ -14,13 +14,14 @@ import { loadDebateContext, } from "../_shared/debate-context-loader.ts"; import { + excludeFromCap, markIncompleteUsage, normalizeOpenRouterUsage, type NormalizedUsage, } from "../_shared/cost.ts"; import { HttpError, jsonError, requireUser } from "../_shared/auth.ts"; import { getCorsHeaders } from "../_shared/cors.ts"; -import { checkRateLimit, logUsage, refundDailyQuota } from "../_shared/entitlements.ts"; +import { checkRateLimit, logUsage, refundQuotaReservation } from "../_shared/entitlements.ts"; import { CLAUDE_UNAVAILABLE_CODE, CLAUDE_UNAVAILABLE_MESSAGE, @@ -392,10 +393,11 @@ export async function handleRequest(req: Request): Promise { // When it fails the student gets an outage message, and their request // back if the model had written nothing yet. const claudeUnavailable = !isAbort && err instanceof ClaudeProviderUnavailableError; + const refunded = claudeUnavailable && !err.afterOutput && !accumulated; if (claudeUnavailable) { // Refund before the client hears of the failure, so a retry it // sends straight away sees the request already given back. - if (!err.afterOutput && !accumulated) await refundDailyQuota(user.id); + if (refunded) await refundQuotaReservation(user.id, rateLimit); try { emit("error", { message: CLAUDE_UNAVAILABLE_MESSAGE, code: CLAUDE_UNAVAILABLE_CODE, status: CLAUDE_UNAVAILABLE_STATUS }); } catch { /* client disconnected */ } } else if (!isAbort) { try { emit("error", { message: "AI streaming failed. Please try again.", code: "provider_error" }); } catch { /* client disconnected */ } @@ -406,7 +408,8 @@ export async function handleRequest(req: Request): Promise { "ask", OPENROUTER_PROVIDER, model, - markIncompleteUsage(observedUsage), + // A refunded request counts toward neither today's quota nor the weekly cap. + refunded ? excludeFromCap(markIncompleteUsage(observedUsage)) : markIncompleteUsage(observedUsage), Date.now() - startedAt, isAbort ? "cancelled" : "error", isAbort ? "client_abort" : claudeUnavailable ? "claude_unavailable" : "stream_error", diff --git a/supabase/functions/ai/index.ts b/supabase/functions/ai/index.ts index cc5b4b080..84ab24180 100644 --- a/supabase/functions/ai/index.ts +++ b/supabase/functions/ai/index.ts @@ -22,6 +22,7 @@ import { type SparringRequest, } from "./sparring-persistence.ts"; import { + excludeFromCap, mergeNormalizedUsage, selectObservedUsage, type NormalizedUsage, @@ -57,7 +58,7 @@ import { checkUserBanned, logUsage, type RateLimitResult, - refundDailyQuota, + refundQuotaReservation, } from "../_shared/entitlements.ts"; import { CLAUDE_UNAVAILABLE_CODE, @@ -813,21 +814,23 @@ export async function handleRequest(req: Request): Promise { const claudeUnavailable = err instanceof ClaudeProviderUnavailableError; const refundable = claudeUnavailable && !err.afterOutput && !completedMainUsage && !usageError?.completedUsage && researchUsage.length === 0 && !researchAccountingIncomplete; + const errorUsage = mergeNormalizedUsage([ + mainErrorUsage, + ...researchUsageForLog(), + ]); await logUsage( userId, feature, provider, model, - mergeNormalizedUsage([ - mainErrorUsage, - ...researchUsageForLog(), - ]), + // A refunded request counts toward neither today's quota nor the weekly cap. + refundable ? excludeFromCap(errorUsage) : errorUsage, latencyMs, "error", claudeUnavailable ? "claude_unavailable" : "ai_error", ); if (claudeUnavailable) { - if (refundable) await refundDailyQuota(userId); + if (refundable) await refundQuotaReservation(userId, rateLimit); return new Response(JSON.stringify({ error: CLAUDE_UNAVAILABLE_MESSAGE, code: CLAUDE_UNAVAILABLE_CODE }), { status: CLAUDE_UNAVAILABLE_STATUS, headers: { ...corsHeaders, "Content-Type": "application/json" } }); } return new Response(JSON.stringify({ error: "AI request failed. Please try again.", code: "provider_error" }), { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }); diff --git a/supabase/functions/ai/provider-errors_test.ts b/supabase/functions/ai/provider-errors_test.ts index 33d25f7f4..660f86518 100644 --- a/supabase/functions/ai/provider-errors_test.ts +++ b/supabase/functions/ai/provider-errors_test.ts @@ -45,7 +45,10 @@ Deno.test("a refused Claude call is not retried elsewhere, shows an outage, and const originalFetch = globalThis.fetch; const originalWarn = console.warn; let failure: "http" | "chunk" | "chunk-after-text" | "repair" | "after-research" | "ok" = "http"; - let refunds = 0; + let tier = "trial"; + // consume_ai_quota reservations and refund_daily_quota refunds, in units. + const reservedUnits: number[] = []; + const refundedUnits: number[] = []; const usageRows: Array> = []; const providerBodies: Array> = []; console.warn = () => undefined; @@ -54,13 +57,16 @@ Deno.test("a refused Claude call is not retried elsewhere, shows an outage, and if (url.origin === "https://supabase.test") { if (url.pathname === "/auth/v1/user") return Response.json({ id: "user-1", aud: "authenticated" }); if (url.pathname.endsWith("/user_roles")) return Response.json({ role: "user" }); - if (url.pathname.endsWith("/user_settings")) return Response.json({ subscription_tier: "trial", daily_limit_override: 50, trial_ends_at: "2099-01-01", grace_period_ends_at: null }); - if (url.pathname.endsWith("/subscriptions")) return Response.json([]); + if (url.pathname.endsWith("/user_settings")) return Response.json({ subscription_tier: tier, daily_limit_override: 50, trial_ends_at: "2099-01-01", grace_period_ends_at: null }); + if (url.pathname.endsWith("/subscriptions")) return Response.json(tier === "pro" ? [{ status: "active", current_period_end: "2099-01-01T00:00:00.000Z" }] : []); if (url.pathname.endsWith("/rate_limits")) return Response.json({ limit_value: 50 }); if (url.pathname.endsWith("/global_prompts")) return Response.json({ prompt_text: null }); - if (url.pathname.endsWith("/consume_daily_quota")) return Response.json([{ allowed: true, current_count: 1 }]); + if (url.pathname.endsWith("/consume_ai_quota")) { + reservedUnits.push(JSON.parse(String(init?.body)).p_units); + return Response.json([{ allowed: true, current_count: 1, denied_by: null }]); + } if (url.pathname.endsWith("/refund_daily_quota")) { - refunds += 1; + refundedUnits.push(JSON.parse(String(init?.body)).p_units); return Response.json(0); } if (url.pathname.endsWith("/ai_requests")) { @@ -84,7 +90,9 @@ Deno.test("a refused Claude call is not retried elsewhere, shows an outage, and if (failure === "after-research" && searchDecision) return Response.json({ choices: [{ message: { content: JSON.stringify({ shouldSearch: false, query: "" }) } }], usage }); if (failure === "http" || failure === "repair" || failure === "after-research") return new Response("PRIVATE_PROVIDER_BODY", { status: 402 }); const text = failure === "chunk-after-text" ? 'data: {"choices":[{"delta":{"content":"{\\"resp"}}]}\n\n' : ""; - return new Response(`${text}data: {"error":{"message":"PRIVATE_PROVIDER_BODY"}}\n\n`, { headers: { "Content-Type": "text/event-stream" } }); + // OpenRouter reported a charge before the stream failed. + const charge = 'data: {"choices":[],"usage":{"prompt_tokens":10,"completion_tokens":0,"cost":0.25}}\n\n'; + return new Response(`${text}${charge}data: {"error":{"message":"PRIVATE_PROVIDER_BODY"}}\n\n`, { headers: { "Content-Type": "text/event-stream" } }); } throw new Error(`Unexpected request: ${url}`); }) as typeof fetch; @@ -118,7 +126,8 @@ Deno.test("a refused Claude call is not retried elsewhere, shows an outage, and ] as const), ] as const) { failure = kind; - refunds = 0; + reservedUnits.length = 0; + refundedUnits.length = 0; usageRows.length = 0; providerBodies.length = 0; const text = await run(); @@ -127,8 +136,12 @@ Deno.test("a refused Claude call is not retried elsewhere, shows an outage, and assertEquals(text.includes('"code":"provider_unavailable"'), true, label); // A failure after the model wrote text keeps the request: the key already paid for it. // The repair case already completed a paid attempt before the retry failed. - assertEquals(refunds, kind === "chunk-after-text" || kind === "repair" || kind === "after-research" ? 0 : 1, `${label} refunds only a failure before output`); + const refunded = !(kind === "chunk-after-text" || kind === "repair" || kind === "after-research"); + assertEquals(refundedUnits, refunded ? [1] : [], `${label} refunds only a failure before output`); assertEquals(usageRows.map((row) => row.error_code), ["claude_unavailable"], label); + // A refunded request stays out of the weekly cap even when OpenRouter reported a charge. + if (refunded) assertEquals(usageRows[0].cap_cost_usd, 0, label); + if (kind === "chunk-after-text") assertEquals(usageRows[0].cap_cost_usd, 0.25, label); // Every attempt stays on the Anthropic route; nothing is retried on another model or provider. assertEquals(providerBodies.length > 0, true, label); for (const body of providerBodies) { @@ -138,13 +151,25 @@ Deno.test("a refused Claude call is not retried elsewhere, shows an outage, and } // Neither a successful Claude answer nor a failure on another model gives a request back. - refunds = 0; + refundedUnits.length = 0; failure = "ok"; assertEquals((await send(ai)).status, 200); assertEquals((await (await send(stream)).text()).includes("event: final"), true); failure = "http"; assertEquals((await send(ai, {}, "openai/gpt-6-luna")).status, 500); - assertEquals(refunds, 0); + assertEquals(refundedUnits, []); + + // The refund gives back exactly the weighted units consume_ai_quota reserved. + tier = "pro"; + for (const [model, units] of [["anthropic/claude-haiku-5.5", 1], ["anthropic/claude-sonnet-5.5", 2], ["anthropic/claude-opus-5.5", 5]] as const) { + for (const [label, handle] of [["ai", ai], ["ai-stream", stream]] as const) { + reservedUnits.length = 0; + refundedUnits.length = 0; + await (await send(handle, {}, model)).text(); + assertEquals(reservedUnits, [units], `${label} ${model} reserves its weight`); + assertEquals(refundedUnits, [units], `${label} ${model} refunds what it reserved`); + } + } } finally { globalThis.fetch = originalFetch; console.warn = originalWarn; diff --git a/supabase/migrations/20261022150000_refund_daily_quota.sql b/supabase/migrations/20261022150000_refund_daily_quota.sql index 73c12b76f..950d8a35e 100644 --- a/supabase/migrations/20261022150000_refund_daily_quota.sql +++ b/supabase/migrations/20261022150000_refund_daily_quota.sql @@ -1,8 +1,12 @@ -- Give back a reserved daily request when the provider could not serve it. -- Edge Functions call this only after a Claude call failed on the Anthropic --- key, so the student is not charged for an answer they never got. It only --- lowers today's existing counter and never below zero. p_units is what the --- request reserved: 1 today, the model's weight once the quota is weighted. +-- key, so the student is not charged for an answer they never got. p_units +-- must be the p_units that consume_ai_quota +-- (20261022130001_weighted_quota_weekly_budget.sql) reserved for that +-- request: 5 for Opus or Auto Pro, 2 for Sonnet, 1 for a cheap model. It only +-- lowers today's existing counter and never below zero. The refunded +-- request's ai_requests row is logged with cap_cost_usd 0, so it does not +-- count toward the weekly cap either. CREATE FUNCTION public.refund_daily_quota( p_user_id uuid, p_units integer DEFAULT 1, diff --git a/supabase/tests/daily_quota_refund_test.sql b/supabase/tests/daily_quota_refund_test.sql index c0bce6b1c..e8d2017b5 100644 --- a/supabase/tests/daily_quota_refund_test.sql +++ b/supabase/tests/daily_quota_refund_test.sql @@ -1,5 +1,5 @@ BEGIN; -SELECT plan(11); +SELECT plan(12); INSERT INTO auth.users (id) VALUES @@ -28,34 +28,31 @@ RESET ROLE; SET LOCAL ROLE service_role; SELECT set_config('request.jwt.claim.role', 'service_role', true); +-- An Opus request (5 units) and a Haiku request (1 unit) under a limit of 7. SELECT results_eq( - $$SELECT current_count FROM public.consume_daily_quota('66666666-6666-4666-8666-666666666666', 3) - UNION ALL SELECT current_count FROM public.consume_daily_quota('66666666-6666-4666-8666-666666666666', 3) - UNION ALL SELECT current_count FROM public.consume_daily_quota('66666666-6666-4666-8666-666666666666', 1, 'import_source')$$, - ARRAY[1, 2, 1], - 'setup reserves two ai requests and one import' + $$SELECT current_count FROM public.consume_ai_quota('66666666-6666-4666-8666-666666666666', 7, 5)$$, + ARRAY[5], + 'setup reserves an Opus request' ); -SELECT is( - public.refund_daily_quota('66666666-6666-4666-8666-666666666666'), - 1, - 'a refund gives back one ai request' +SELECT results_eq( + $$SELECT current_count FROM public.consume_ai_quota('66666666-6666-4666-8666-666666666666', 7, 1)$$, + ARRAY[6], + 'setup reserves a Haiku request' ); SELECT results_eq( - $$SELECT allowed, current_count - FROM public.consume_daily_quota('66666666-6666-4666-8666-666666666666', 2)$$, - $$VALUES (true, 2)$$, - 'the refunded request can be used again under the same limit' + $$SELECT allowed, denied_by FROM public.consume_ai_quota('66666666-6666-4666-8666-666666666666', 7, 5)$$, + $$VALUES (false, 'daily'::text)$$, + 'a second Opus request does not fit' ); SELECT is( - (SELECT request_count FROM public.daily_usage_counters - WHERE user_id = '66666666-6666-4666-8666-666666666666' AND quota_key = 'import_source'), + public.refund_daily_quota('66666666-6666-4666-8666-666666666666', 5), 1, - 'an ai refund leaves the import counter alone' + 'refunding the failed Opus request gives back its 5 units' ); -SELECT is( - public.refund_daily_quota('77777777-7777-4777-8777-777777777777'), - 0, - 'a refund without a reservation today creates nothing' +SELECT results_eq( + $$SELECT allowed, current_count FROM public.consume_ai_quota('66666666-6666-4666-8666-666666666666', 7, 5)$$, + $$VALUES (true, 6)$$, + 'the refunded units fit another Opus request' ); SELECT throws_ok( $$SELECT public.refund_daily_quota('66666666-6666-4666-8666-666666666666', 0)$$, @@ -64,19 +61,28 @@ SELECT throws_ok( 'a refund must give back at least one unit' ); SELECT is( - public.refund_daily_quota('66666666-6666-4666-8666-666666666666', 5), + public.refund_daily_quota('77777777-7777-4777-8777-777777777777', 5), 0, - 'a weighted refund gives back its units and stops at zero' + 'a refund without a reservation today creates nothing' ); +-- The failed request's row is logged with cap_cost_usd 0 when it is refunded, +-- so even a costly failure leaves the weekly cap alone. RESET ROLE; +INSERT INTO public.ai_requests (user_id, feature, provider, model, status, error_code, cost_usd, cap_cost_usd) +VALUES ('77777777-7777-4777-8777-777777777777', 'ask', 'openrouter', 'anthropic/claude-opus-5.5', 'error', 'claude_unavailable', 3.00, 0); INSERT INTO public.daily_usage_counters (user_id, usage_date, request_count) VALUES - ('77777777-7777-4777-8777-777777777777', CURRENT_DATE, 0), + ('77777777-7777-4777-8777-777777777777', CURRENT_DATE, 2), ('77777777-7777-4777-8777-777777777777', CURRENT_DATE - 1, 5); SET LOCAL ROLE service_role; SELECT set_config('request.jwt.claim.role', 'service_role', true); -SELECT public.refund_daily_quota('77777777-7777-4777-8777-777777777777'); +SELECT results_eq( + $$SELECT allowed, denied_by FROM public.consume_ai_quota('77777777-7777-4777-8777-777777777777', 50, 1, 2)$$, + $$VALUES (true, NULL::text)$$, + 'a refunded failure does not count toward the weekly cap' +); +SELECT public.refund_daily_quota('77777777-7777-4777-8777-777777777777', 5); RESET ROLE; SELECT results_eq( $$SELECT usage_date = CURRENT_DATE, request_count From 2415463de99647cb106cbdececf23517fddfec56 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 22 Oct 2026 14:50:38 +0000 Subject: [PATCH 7/9] Never refund a Claude failure OpenRouter already charged credits for. Zeroing cap_cost_usd on such a row would hide real spend from the weekly cap, so a request with a charge against the cap now keeps both its quota and its cap cost. Only free failures are refunded and logged with cap cost 0. Co-Authored-By: Claude Opus 5.5 --- supabase/functions/_shared/cost.ts | 12 +++++++++++- supabase/functions/ai-stream/index.ts | 6 ++++-- supabase/functions/ai/index.ts | 7 +++++-- supabase/functions/ai/provider-errors_test.ts | 16 +++++++++------- .../20261022150000_refund_daily_quota.sql | 6 +++--- 5 files changed, 32 insertions(+), 15 deletions(-) diff --git a/supabase/functions/_shared/cost.ts b/supabase/functions/_shared/cost.ts index dbda544ab..1d98b98cc 100644 --- a/supabase/functions/_shared/cost.ts +++ b/supabase/functions/_shared/cost.ts @@ -232,7 +232,17 @@ export function selectObservedUsage( : usageError?.usage ?? usageError?.completedUsage; } -/** Keeps a refunded request out of the weekly spend cap; its cost stays for audit. */ +/** True when the observed usage charged anything toward the weekly spend cap. */ +export function chargedAgainstCap(usage?: NormalizedUsage): boolean { + const cap = usage ? capCostOf(usage) : undefined; + return typeof cap === "number" && cap > 0; +} + +/** + * Records a refunded request as adding nothing to the weekly spend cap. Only + * for usage that chargedAgainstCap() cleared: a request OpenRouter charged + * credits for is never refunded, so no real spend is hidden from the cap. + */ export function excludeFromCap(usage?: NormalizedUsage): NormalizedUsage { return { ...usage, capCostUsd: 0 }; } diff --git a/supabase/functions/ai-stream/index.ts b/supabase/functions/ai-stream/index.ts index 99cb75388..e34fc96ab 100644 --- a/supabase/functions/ai-stream/index.ts +++ b/supabase/functions/ai-stream/index.ts @@ -14,6 +14,7 @@ import { loadDebateContext, } from "../_shared/debate-context-loader.ts"; import { + chargedAgainstCap, excludeFromCap, markIncompleteUsage, normalizeOpenRouterUsage, @@ -393,7 +394,9 @@ export async function handleRequest(req: Request): Promise { // When it fails the student gets an outage message, and their request // back if the model had written nothing yet. const claudeUnavailable = !isAbort && err instanceof ClaudeProviderUnavailableError; - const refunded = claudeUnavailable && !err.afterOutput && !accumulated; + const observedUsage = collectUsage(); + // OpenRouter credits already spent on the request keep it counted. + const refunded = claudeUnavailable && !err.afterOutput && !accumulated && !chargedAgainstCap(observedUsage); if (claudeUnavailable) { // Refund before the client hears of the failure, so a retry it // sends straight away sees the request already given back. @@ -402,7 +405,6 @@ export async function handleRequest(req: Request): Promise { } else if (!isAbort) { try { emit("error", { message: "AI streaming failed. Please try again.", code: "provider_error" }); } catch { /* client disconnected */ } } - const observedUsage = collectUsage(); await logUsage( user.id, "ask", diff --git a/supabase/functions/ai/index.ts b/supabase/functions/ai/index.ts index 84ab24180..281ddc08e 100644 --- a/supabase/functions/ai/index.ts +++ b/supabase/functions/ai/index.ts @@ -22,6 +22,7 @@ import { type SparringRequest, } from "./sparring-persistence.ts"; import { + chargedAgainstCap, excludeFromCap, mergeNormalizedUsage, selectObservedUsage, @@ -812,12 +813,14 @@ export async function handleRequest(req: Request): Promise { // it fails the student gets an outage message, and their request back // if no provider call had finished or written anything yet. const claudeUnavailable = err instanceof ClaudeProviderUnavailableError; - const refundable = claudeUnavailable && !err.afterOutput && !completedMainUsage && !usageError?.completedUsage && - researchUsage.length === 0 && !researchAccountingIncomplete; + const researchRan = researchUsage.length > 0 || researchAccountingIncomplete; const errorUsage = mergeNormalizedUsage([ mainErrorUsage, ...researchUsageForLog(), ]); + // OpenRouter credits already spent on the request keep it counted. + const refundable = claudeUnavailable && !err.afterOutput && !completedMainUsage && !usageError?.completedUsage && + !researchRan && !chargedAgainstCap(errorUsage); await logUsage( userId, feature, diff --git a/supabase/functions/ai/provider-errors_test.ts b/supabase/functions/ai/provider-errors_test.ts index 660f86518..c7c7ca4d6 100644 --- a/supabase/functions/ai/provider-errors_test.ts +++ b/supabase/functions/ai/provider-errors_test.ts @@ -44,7 +44,7 @@ Deno.test("AI JSON and SSE failures never expose provider response bodies", asyn Deno.test("a refused Claude call is not retried elsewhere, shows an outage, and gives the request back", async () => { const originalFetch = globalThis.fetch; const originalWarn = console.warn; - let failure: "http" | "chunk" | "chunk-after-text" | "repair" | "after-research" | "ok" = "http"; + let failure: "http" | "chunk" | "chunk-after-text" | "chunk-charged" | "repair" | "after-research" | "ok" = "http"; let tier = "trial"; // consume_ai_quota reservations and refund_daily_quota refunds, in units. const reservedUnits: number[] = []; @@ -90,8 +90,9 @@ Deno.test("a refused Claude call is not retried elsewhere, shows an outage, and if (failure === "after-research" && searchDecision) return Response.json({ choices: [{ message: { content: JSON.stringify({ shouldSearch: false, query: "" }) } }], usage }); if (failure === "http" || failure === "repair" || failure === "after-research") return new Response("PRIVATE_PROVIDER_BODY", { status: 402 }); const text = failure === "chunk-after-text" ? 'data: {"choices":[{"delta":{"content":"{\\"resp"}}]}\n\n' : ""; - // OpenRouter reported a charge before the stream failed. - const charge = 'data: {"choices":[],"usage":{"prompt_tokens":10,"completion_tokens":0,"cost":0.25}}\n\n'; + // Usage arrives before the error: free on the BYOK key, or a charge on OpenRouter credits. + const cost = failure === "chunk-charged" ? 0.25 : 0; + const charge = `data: {"choices":[],"usage":{"prompt_tokens":10,"completion_tokens":0,"cost":${cost},"is_byok":${cost === 0}}}\n\n`; return new Response(`${text}${charge}data: {"error":{"message":"PRIVATE_PROVIDER_BODY"}}\n\n`, { headers: { "Content-Type": "text/event-stream" } }); } throw new Error(`Unexpected request: ${url}`); @@ -112,7 +113,7 @@ Deno.test("a refused Claude call is not retried elsewhere, shows an outage, and assertEquals(response.status, 503); return await response.text(); }] as const, - ...(["http", "chunk", "chunk-after-text"] as const).flatMap((kind) => [ + ...(["http", "chunk", "chunk-after-text", "chunk-charged"] as const).flatMap((kind) => [ [`ai live ${kind}`, kind, async () => { const text = await (await send(ai, { stream: true })).text(); assertEquals(text.includes('"status":503'), true); @@ -136,12 +137,13 @@ Deno.test("a refused Claude call is not retried elsewhere, shows an outage, and assertEquals(text.includes('"code":"provider_unavailable"'), true, label); // A failure after the model wrote text keeps the request: the key already paid for it. // The repair case already completed a paid attempt before the retry failed. - const refunded = !(kind === "chunk-after-text" || kind === "repair" || kind === "after-research"); + // OpenRouter credits already charged also keep the request counted. + const refunded = !(kind === "chunk-after-text" || kind === "chunk-charged" || kind === "repair" || kind === "after-research"); assertEquals(refundedUnits, refunded ? [1] : [], `${label} refunds only a failure before output`); assertEquals(usageRows.map((row) => row.error_code), ["claude_unavailable"], label); - // A refunded request stays out of the weekly cap even when OpenRouter reported a charge. + // A refunded request adds nothing to the weekly cap; a charged one keeps its charge. if (refunded) assertEquals(usageRows[0].cap_cost_usd, 0, label); - if (kind === "chunk-after-text") assertEquals(usageRows[0].cap_cost_usd, 0.25, label); + if (kind === "chunk-charged") assertEquals(usageRows[0].cap_cost_usd, 0.25, label); // Every attempt stays on the Anthropic route; nothing is retried on another model or provider. assertEquals(providerBodies.length > 0, true, label); for (const body of providerBodies) { diff --git a/supabase/migrations/20261022150000_refund_daily_quota.sql b/supabase/migrations/20261022150000_refund_daily_quota.sql index 950d8a35e..1f4c62709 100644 --- a/supabase/migrations/20261022150000_refund_daily_quota.sql +++ b/supabase/migrations/20261022150000_refund_daily_quota.sql @@ -4,9 +4,9 @@ -- must be the p_units that consume_ai_quota -- (20261022130001_weighted_quota_weekly_budget.sql) reserved for that -- request: 5 for Opus or Auto Pro, 2 for Sonnet, 1 for a cheap model. It only --- lowers today's existing counter and never below zero. The refunded --- request's ai_requests row is logged with cap_cost_usd 0, so it does not --- count toward the weekly cap either. +-- lowers today's existing counter and never below zero. A request that +-- OpenRouter already charged credits for is never refunded; a refunded one is +-- logged with cap_cost_usd 0, so it does not count toward the weekly cap. CREATE FUNCTION public.refund_daily_quota( p_user_id uuid, p_units integer DEFAULT 1, From 631d05c45e3e263a92337f565015ac13c9a45e44 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 22 Oct 2026 14:57:58 +0000 Subject: [PATCH 8/9] Refund research tools in a Claude outage too. Research steps run on Haiku, so in an outage they are refused along with the main call. Such a refusal did no paid work and no longer blocks the refund; a research step that finished or was charged still does. Co-Authored-By: Claude Opus 5.5 --- supabase/README.md | 2 +- supabase/functions/ai/index.ts | 10 ++++++++-- supabase/functions/ai/provider-errors_test.ts | 5 +++-- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/supabase/README.md b/supabase/README.md index 991c97d02..903f2131a 100644 --- a/supabase/README.md +++ b/supabase/README.md @@ -39,7 +39,7 @@ Both endpoints accept an optional `debateContext` of IDs (`debateId`, `side`, `s Both endpoints route every model inference through OpenRouter and enforce the shared ten-model Basic/Pro catalog. Basic accounts get Claude Haiku 5.5 (the default), GPT-6 Luna, and Muse Spark 1.3 Contributor; Claude Sonnet 5.5 is the Pro default and Claude Opus 5.5 runs only when picked; Pro-only models require active Pro access. Retired catalog IDs from older clients resolve to their successor before validation. -Claude runs only on DebateLab's own Anthropic key, added to OpenRouter as BYOK. [`_shared/provider-routing.ts`](functions/_shared/provider-routing.ts) sends `anthropic/*` requests with `provider: { only: ["anthropic"], allow_fallbacks: false }`, so Bedrock, Vertex and any other provider are never tried; other models keep OpenRouter's balanced routing. The request body cannot stop OpenRouter from serving Anthropic on its own credits when the key fails, so the key's **Shared capacity fallback** in OpenRouter must be set to "Never use shared capacity for any model on this provider". When a Claude call is refused, the endpoints answer 503 `provider_unavailable`, log `claude_unavailable`, and give the reserved daily request back through `refund_daily_quota` when the failure came before any output or completed provider call, research steps included. The Ask page does not resend a `provider_unavailable` stream failure through the plain endpoint. A Claude response whose usage shows `is_byok: false` or a `cost` above zero logs a `claude_not_byok` warning; past the monthly BYOK allowance, OpenRouter's BYOK fee also shows as cost. +Claude runs only on DebateLab's own Anthropic key, added to OpenRouter as BYOK. [`_shared/provider-routing.ts`](functions/_shared/provider-routing.ts) sends `anthropic/*` requests with `provider: { only: ["anthropic"], allow_fallbacks: false }`, so Bedrock, Vertex, Azure and every other provider are never tried; other models keep OpenRouter's balanced routing. The request body cannot stop OpenRouter from serving Anthropic on its own credits when the key fails, so the key's **Shared capacity fallback** in OpenRouter must be set to "Never use shared capacity for any model on this provider". When a Claude call is refused, the endpoints answer 503 `provider_unavailable` and log `claude_unavailable`. The request gets back exactly the units `consume_ai_quota` reserved for it (`refundQuotaReservation` passes `RateLimitResult.units` to `refund_daily_quota`), and its row is logged with `cap_cost_usd` 0, but only when nothing was paid for: no text written, no finished model call (a research step Claude refused in the same outage does not count), and no OpenRouter charge against the weekly cap. Jev routing and source-ranking rows are logged separately and keep their own cap cost. The Ask page does not resend a `provider_unavailable` stream failure through the plain endpoint. A Claude response whose usage shows `is_byok: false` or a `cost` above zero logs a `claude_not_byok` warning; past the monthly BYOK allowance, OpenRouter's BYOK fee also shows as cost. The browser boundary is [`../src/lib/ai/client.ts`](../src/lib/ai/client.ts). Shared prompt, research, source-quality, cost, entitlement, CORS, and control logic lives in `_shared`. diff --git a/supabase/functions/ai/index.ts b/supabase/functions/ai/index.ts index 281ddc08e..b36304bd1 100644 --- a/supabase/functions/ai/index.ts +++ b/supabase/functions/ai/index.ts @@ -640,6 +640,9 @@ export async function handleRequest(req: Request): Promise { // collected here so the parent request's row accounts for every token. const researchUsage: NormalizedUsage[] = []; let researchAccountingIncomplete = false; + // A research step that finished, or failed after paid work. Claude refusing + // a research step outright (the same outage as the main call) does not count. + let researchDidWork = false; // Research sub-calls are small background steps, so they use the fixed // cheap model instead of the user's choice. const researchDeps: ResearchDeps = { @@ -656,6 +659,7 @@ export async function handleRequest(req: Request): Promise { // A provider that ignores cancellation must not append usage after the // research timeout has already been finalized by the parent request. if (result.usage && !args.signal?.aborted) researchUsage.push(result.usage); + researchDidWork = true; return { output: result.output }; } catch (err) { const usageError = err && typeof err === "object" @@ -663,6 +667,9 @@ export async function handleRequest(req: Request): Promise { : undefined; const researchErrorUsage = selectObservedUsage(usageError, args.signal?.aborted === true); if (researchErrorUsage) researchUsage.push(researchErrorUsage); + const refusedForFree = err instanceof ClaudeProviderUnavailableError && !err.afterOutput && + !usageError?.completedUsage && !chargedAgainstCap(researchErrorUsage); + if (!refusedForFree) researchDidWork = true; throw err; } }, @@ -813,14 +820,13 @@ export async function handleRequest(req: Request): Promise { // it fails the student gets an outage message, and their request back // if no provider call had finished or written anything yet. const claudeUnavailable = err instanceof ClaudeProviderUnavailableError; - const researchRan = researchUsage.length > 0 || researchAccountingIncomplete; const errorUsage = mergeNormalizedUsage([ mainErrorUsage, ...researchUsageForLog(), ]); // OpenRouter credits already spent on the request keep it counted. const refundable = claudeUnavailable && !err.afterOutput && !completedMainUsage && !usageError?.completedUsage && - !researchRan && !chargedAgainstCap(errorUsage); + !researchDidWork && !researchAccountingIncomplete && !chargedAgainstCap(errorUsage); await logUsage( userId, feature, diff --git a/supabase/functions/ai/provider-errors_test.ts b/supabase/functions/ai/provider-errors_test.ts index c7c7ca4d6..bed5a70a0 100644 --- a/supabase/functions/ai/provider-errors_test.ts +++ b/supabase/functions/ai/provider-errors_test.ts @@ -108,11 +108,12 @@ Deno.test("a refused Claude call is not retried elsewhere, shows an outage, and assertEquals(response.status, 503); return await response.text(); }] as const), - ["ai after research", "after-research", async () => { + ...(["after-research", "http"] as const).map((kind) => [`ai research ${kind}`, kind, async () => { + // In a full outage ("http") the Haiku research step is refused too, which is no paid work. const response = await send(ai, { input: { message: "What is the latest evidence?", mode: "general", citationMode: true } }); assertEquals(response.status, 503); return await response.text(); - }] as const, + }] as const), ...(["http", "chunk", "chunk-after-text", "chunk-charged"] as const).flatMap((kind) => [ [`ai live ${kind}`, kind, async () => { const text = await (await send(ai, { stream: true })).text(); From 6fa3f2fcb153cb15bcc168f6beb049cf38b46be9 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 22 Oct 2026 15:19:40 +0000 Subject: [PATCH 9/9] Bill usage on a stream's error frame, and never refund a cancelled request. From Codex review round 2 of #185: - Both stream readers threw on an error frame before reading its usage, so a charge reported only there was dropped, and the failure could then be refunded with cap cost 0. Usage is now kept before the error is thrown. - ai/index.ts refunded a Claude refusal that raced a client abort. ai-stream already refused that; the plain handler now does too. Co-Authored-By: Claude Opus 5.5 --- supabase/functions/ai-stream/index.ts | 7 ++-- supabase/functions/ai/index.ts | 3 +- supabase/functions/ai/provider-errors_test.ts | 33 ++++++++++++++++--- supabase/functions/ai/provider.ts | 7 ++-- 4 files changed, 39 insertions(+), 11 deletions(-) diff --git a/supabase/functions/ai-stream/index.ts b/supabase/functions/ai-stream/index.ts index e34fc96ab..8ed2cacb8 100644 --- a/supabase/functions/ai-stream/index.ts +++ b/supabase/functions/ai-stream/index.ts @@ -310,6 +310,10 @@ export async function handleRequest(req: Request): Promise { } let parsed: Record; try { parsed = JSON.parse(data); } catch { return; } + // Usage can ride on the error frame itself; keep it so the charge is billed. + if (parsed.usage && typeof parsed.usage === "object" && !Array.isArray(parsed.usage)) { + streamUsageRaw = { ...streamUsageRaw, ...(parsed.usage as Record) }; + } if (parsed.error) { const providerError = parsed.error; const message = providerError && typeof providerError === "object" && "message" in providerError @@ -330,9 +334,6 @@ export async function handleRequest(req: Request): Promise { accumulated += delta.delta.content; emit("delta", { text: delta.delta.content }); } - if (parsed.usage && typeof parsed.usage === "object" && !Array.isArray(parsed.usage)) { - streamUsageRaw = { ...streamUsageRaw, ...(parsed.usage as Record) }; - } }; while (true) { if (req.signal.aborted) throw new DOMException("Client aborted", "AbortError"); diff --git a/supabase/functions/ai/index.ts b/supabase/functions/ai/index.ts index b36304bd1..b60b1ce7d 100644 --- a/supabase/functions/ai/index.ts +++ b/supabase/functions/ai/index.ts @@ -825,7 +825,8 @@ export async function handleRequest(req: Request): Promise { ...researchUsageForLog(), ]); // OpenRouter credits already spent on the request keep it counted. - const refundable = claudeUnavailable && !err.afterOutput && !completedMainUsage && !usageError?.completedUsage && + // A cancelled request is never refunded, even when the refusal raced the abort. + const refundable = claudeUnavailable && !workSignal?.aborted && !err.afterOutput && !completedMainUsage && !usageError?.completedUsage && !researchDidWork && !researchAccountingIncomplete && !chargedAgainstCap(errorUsage); await logUsage( userId, diff --git a/supabase/functions/ai/provider-errors_test.ts b/supabase/functions/ai/provider-errors_test.ts index bed5a70a0..31d515759 100644 --- a/supabase/functions/ai/provider-errors_test.ts +++ b/supabase/functions/ai/provider-errors_test.ts @@ -44,12 +44,13 @@ Deno.test("AI JSON and SSE failures never expose provider response bodies", asyn Deno.test("a refused Claude call is not retried elsewhere, shows an outage, and gives the request back", async () => { const originalFetch = globalThis.fetch; const originalWarn = console.warn; - let failure: "http" | "chunk" | "chunk-after-text" | "chunk-charged" | "repair" | "after-research" | "ok" = "http"; + let failure: "http" | "chunk" | "chunk-after-text" | "chunk-charged" | "error-frame-charged" | "repair" | "after-research" | "ok" = "http"; let tier = "trial"; // consume_ai_quota reservations and refund_daily_quota refunds, in units. const reservedUnits: number[] = []; const refundedUnits: number[] = []; const usageRows: Array> = []; + let clientAbort: AbortController | undefined; const providerBodies: Array> = []; console.warn = () => undefined; globalThis.fetch = (async (input: Request | URL | string, init?: RequestInit) => { @@ -91,6 +92,10 @@ Deno.test("a refused Claude call is not retried elsewhere, shows an outage, and if (failure === "http" || failure === "repair" || failure === "after-research") return new Response("PRIVATE_PROVIDER_BODY", { status: 402 }); const text = failure === "chunk-after-text" ? 'data: {"choices":[{"delta":{"content":"{\\"resp"}}]}\n\n' : ""; // Usage arrives before the error: free on the BYOK key, or a charge on OpenRouter credits. + if (failure === "error-frame-charged") { + // The charge arrives on the error frame itself, not in a frame before it. + return new Response('data: {"error":{"message":"PRIVATE_PROVIDER_BODY"},"usage":{"prompt_tokens":10,"completion_tokens":0,"cost":0.25,"is_byok":false}}\n\n', { headers: { "Content-Type": "text/event-stream" } }); + } const cost = failure === "chunk-charged" ? 0.25 : 0; const charge = `data: {"choices":[],"usage":{"prompt_tokens":10,"completion_tokens":0,"cost":${cost},"is_byok":${cost === 0}}}\n\n`; return new Response(`${text}${charge}data: {"error":{"message":"PRIVATE_PROVIDER_BODY"}}\n\n`, { headers: { "Content-Type": "text/event-stream" } }); @@ -114,7 +119,7 @@ Deno.test("a refused Claude call is not retried elsewhere, shows an outage, and assertEquals(response.status, 503); return await response.text(); }] as const), - ...(["http", "chunk", "chunk-after-text", "chunk-charged"] as const).flatMap((kind) => [ + ...(["http", "chunk", "chunk-after-text", "chunk-charged", "error-frame-charged"] as const).flatMap((kind) => [ [`ai live ${kind}`, kind, async () => { const text = await (await send(ai, { stream: true })).text(); assertEquals(text.includes('"status":503'), true); @@ -139,12 +144,12 @@ Deno.test("a refused Claude call is not retried elsewhere, shows an outage, and // A failure after the model wrote text keeps the request: the key already paid for it. // The repair case already completed a paid attempt before the retry failed. // OpenRouter credits already charged also keep the request counted. - const refunded = !(kind === "chunk-after-text" || kind === "chunk-charged" || kind === "repair" || kind === "after-research"); + const refunded = !(kind === "chunk-after-text" || kind === "chunk-charged" || kind === "error-frame-charged" || kind === "repair" || kind === "after-research"); assertEquals(refundedUnits, refunded ? [1] : [], `${label} refunds only a failure before output`); assertEquals(usageRows.map((row) => row.error_code), ["claude_unavailable"], label); // A refunded request adds nothing to the weekly cap; a charged one keeps its charge. if (refunded) assertEquals(usageRows[0].cap_cost_usd, 0, label); - if (kind === "chunk-charged") assertEquals(usageRows[0].cap_cost_usd, 0.25, label); + if (kind === "chunk-charged" || kind === "error-frame-charged") assertEquals(usageRows[0].cap_cost_usd, 0.25, label); // Every attempt stays on the Anthropic route; nothing is retried on another model or provider. assertEquals(providerBodies.length > 0, true, label); for (const body of providerBodies) { @@ -162,6 +167,26 @@ Deno.test("a refused Claude call is not retried elsewhere, shows an outage, and assertEquals((await send(ai, {}, "openai/gpt-6-luna")).status, 500); assertEquals(refundedUnits, []); + // A cancelled request is never refunded, even when the refusal raced the abort: + // the client goes away after the 402 was read, as the handler starts its error path. + failure = "http"; + clientAbort = new AbortController(); + const originalError = console.error; + console.error = (...args: unknown[]) => { + if (String(args[0]).startsWith("[ERROR]")) clientAbort?.abort(); + originalError(...args); + }; + await (await ai(new Request("http://127.0.0.1/functions/v1/ai", { + method: "POST", headers: { Authorization: "Bearer test-token", "Content-Type": "application/json" }, + body: JSON.stringify({ feature: "ask", model: "anthropic/claude-haiku-5.5", input: { message: "A question", mode: "general", webSearch: false } }), + signal: clientAbort.signal, + }))).text().catch(() => ""); + console.error = originalError; + assertEquals(clientAbort.signal.aborted, true); + assertEquals(usageRows.at(-1)?.error_code, "claude_unavailable"); + assertEquals(refundedUnits, []); + clientAbort = undefined; + // The refund gives back exactly the weighted units consume_ai_quota reserved. tier = "pro"; for (const [model, units] of [["anthropic/claude-haiku-5.5", 1], ["anthropic/claude-sonnet-5.5", 2], ["anthropic/claude-opus-5.5", 5]] as const) { diff --git a/supabase/functions/ai/provider.ts b/supabase/functions/ai/provider.ts index fe5f6ee5a..b2e17c11f 100644 --- a/supabase/functions/ai/provider.ts +++ b/supabase/functions/ai/provider.ts @@ -483,6 +483,10 @@ export async function readOpenRouterStream( } let chunk: OpenRouterStreamChunk; try { chunk = JSON.parse(data); } catch { return; } + // Usage can ride on the error frame itself; keep it so the charge is billed. + if (chunk.usage && typeof chunk.usage === "object" && !Array.isArray(chunk.usage)) { + usageRaw = { ...usageRaw, ...(chunk.usage as Record) }; + } if (chunk.error) { const message = typeof chunk.error === "object" && chunk.error !== null && "message" in chunk.error ? (chunk.error as { message?: unknown }).message @@ -499,9 +503,6 @@ export async function readOpenRouterStream( if (textBytes > MAX_PROVIDER_RESPONSE_BYTES) throw new Error(`Provider response exceeds ${MAX_PROVIDER_RESPONSE_BYTES} bytes`); stream.delta(content); } - if (chunk.usage && typeof chunk.usage === "object" && !Array.isArray(chunk.usage)) { - usageRaw = { ...usageRaw, ...(chunk.usage as Record) }; - } }; try {