diff --git a/docs/architecture.md b/docs/architecture.md index 67a76a7a4..b4d6b739e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -140,7 +140,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 Sonnet 5.5 for Pro; Claude Opus 5.5 runs only when picked), Pro-only entries, retired-ID successors, and request controls live in [`supabase/functions/_shared/model-catalog.ts`](../supabase/functions/_shared/model-catalog.ts). Each feature's reply length cap lives in [`supabase/functions/_shared/output-caps.ts`](../supabase/functions/_shared/output-caps.ts); `max_tokens` adds room for the reasoning effort on top. A JSON reply cut off at its cap is retried once with twice the budget, and a streamed Ask answer cut off there ends with a notice under it. OpenRouter usage remains raw in the audit row, and the reported charge is unknown when the provider does not return a complete cost. For a call billed to our own provider key (BYOK, which the Anthropic models use), OpenRouter's `cost` is only its fee, so the logged cost adds `cost_details.upstream_inference_cost`; the weekly cap still counts only `cost`. Exa searches and page reads report their charge as `costDollars.total`; it joins the request's row, with one `raw_usage.calls` entry per Exa call. 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 Sonnet 5.5 for Pro; Claude Opus 5.5 runs only when picked), 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). Each feature's reply length cap lives in [`supabase/functions/_shared/output-caps.ts`](../supabase/functions/_shared/output-caps.ts); `max_tokens` adds room for the reasoning effort on top. A JSON reply cut off at its cap is retried once with twice the budget, and a streamed Ask answer cut off there ends with a notice under it. OpenRouter usage remains raw in the audit row, and the reported charge is unknown when the provider does not return a complete cost. For a call billed to our own provider key (BYOK, which the Anthropic models use), OpenRouter's `cost` is only its fee, so the logged cost adds `cost_details.upstream_inference_cost`; the weekly cap still counts only `cost`. Exa searches and page reads report their charge as `costDollars.total`; it joins the request's row, with one `raw_usage.calls` entry per Exa call. 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. Every model call asks for JSON. Calls whose shape the server checks in code (the Crossfire next turn and debrief, the Ask search decision, and research follow-up queries) send a strict JSON Schema, so OpenRouter routes them only to endpoints that enforce it; the other features use plain JSON mode. [`supabase/functions/ai/provider.ts`](../supabase/functions/ai/provider.ts) fixes common slips in a reply locally (markdown fences, prose around the object, trailing commas, raw line breaks in strings) before it pays for one repair retry, and a reply the provider cut off at its cap is retried once with twice the budget, not with the repair prompt. diff --git a/e2e/full-stack/fake-openrouter.mjs b/e2e/full-stack/fake-openrouter.mjs index c5fe22c79..b61101bdd 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 = []; @@ -280,7 +289,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/src/pages/AskPage.tsx b/src/pages/AskPage.tsx index e8075530d..a1456181c 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 6c5955a0d..eed818596 100644 --- a/src/pages/SparringPage.tsx +++ b/src/pages/SparringPage.tsx @@ -30,8 +30,8 @@ import { useSubscriptionError } from '@/hooks/use-subscription-error'; import { SubscriptionErrorModals } from '@/components/features/subscription'; function readableError(error: unknown, fallback: string) { - if (isAISubscriptionError(error)) return getUserFacingAIError(error); - if (error instanceof Error && error.message.trim()) return error.message; + // Subscription errors have their own text; provider errors get the translated outage message. + if (isAISubscriptionError(error) || (error instanceof Error && error.message.trim())) return getUserFacingAIError(error); return fallback; } diff --git a/supabase/README.md b/supabase/README.md index d6c48592c..903f2131a 100644 --- a/supabase/README.md +++ b/supabase/README.md @@ -39,6 +39,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 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, 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`. ### Content and sharing diff --git a/supabase/functions/_shared/cost.ts b/supabase/functions/_shared/cost.ts index de5097ad7..5c709fbe9 100644 --- a/supabase/functions/_shared/cost.ts +++ b/supabase/functions/_shared/cost.ts @@ -274,6 +274,21 @@ export function selectObservedUsage( : usageError?.usage ?? usageError?.completedUsage; } +/** 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 }; +} + /** 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 97ed7f5d3..a7156672c 100644 --- a/supabase/functions/_shared/entitlements.ts +++ b/supabase/functions/_shared/entitlements.ts @@ -214,6 +214,22 @@ export async function checkRateLimit(userId: string | null, model?: ModelSelecti } } +/** + * 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 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: reservation.units }); + 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..9facaf10f --- /dev/null +++ b/supabase/functions/_shared/provider-routing.ts @@ -0,0 +1,72 @@ +// 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 and the student sees a provider outage. The reserved daily + * request is given back only when the failure came before any output. + */ +export class ClaudeProviderUnavailableError extends Error { + readonly status: number | null; + /** The model had already written text when the failure arrived. */ + readonly afterOutput: boolean; + + constructor(status: number | null, afterOutput = false) { + super("AI provider request failed"); + this.name = "ClaudeProviderUnavailableError"; + this.status = status; + this.afterOutput = afterOutput; + } +} + +/** Throws the Claude-specific error for Claude models, the generic one otherwise. */ +export function providerRequestError(model: string, status: number | null, afterOutput = false): Error { + return isClaudeModel(model) ? new ClaudeProviderUnavailableError(status, afterOutput) : 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 82863f45a..f37791e2b 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 5c9cf4d4d..8ed2cacb8 100644 --- a/supabase/functions/ai-stream/index.ts +++ b/supabase/functions/ai-stream/index.ts @@ -14,13 +14,25 @@ import { loadDebateContext, } from "../_shared/debate-context-loader.ts"; import { + chargedAgainstCap, + 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 } from "../_shared/entitlements.ts"; +import { checkRateLimit, logUsage, refundQuotaReservation } 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 { @@ -269,8 +281,7 @@ export async function handleRequest(req: Request): Promise { messages: withPromptCaching(model, messages), max_tokens: outputTokenLimit(REPLY_TOKEN_CAPS.ask, reasoningEffort, modelEntry.maxTokens), 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 }, }; @@ -283,7 +294,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(); @@ -299,11 +310,19 @@ 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 ? (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, accumulated.length > 0); + } 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; @@ -315,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"); @@ -369,24 +385,37 @@ 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 if the model had written nothing yet. + const claudeUnavailable = !isAbort && err instanceof ClaudeProviderUnavailableError; + 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. + 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 */ } } - const observedUsage = collectUsage(); await logUsage( user.id, "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" : "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 6c830da2d..50f2ccb44 100644 --- a/supabase/functions/ai/handler-research.test.ts +++ b/supabase/functions/ai/handler-research.test.ts @@ -412,6 +412,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"); } @@ -758,6 +767,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 159335597..c4537809d 100644 --- a/supabase/functions/ai/index.ts +++ b/supabase/functions/ai/index.ts @@ -22,6 +22,8 @@ import { type SparringRequest, } from "./sparring-persistence.ts"; import { + chargedAgainstCap, + excludeFromCap, mergeNormalizedUsage, selectObservedUsage, type NormalizedUsage, @@ -57,7 +59,14 @@ import { checkUserBanned, logUsage, type RateLimitResult, + refundQuotaReservation, } 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 { @@ -632,6 +641,9 @@ export async function handleRequest(req: Request): Promise { // accounts for every token and every paid call. 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 = { @@ -651,6 +663,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" @@ -658,6 +671,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; } }, @@ -804,19 +820,33 @@ 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 + // if no provider call had finished or written anything yet. + const claudeUnavailable = err instanceof ClaudeProviderUnavailableError; + const errorUsage = mergeNormalizedUsage([ + mainErrorUsage, + ...researchUsageForLog(), + ]); + // OpenRouter credits already spent on the request keep it counted. + // 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, 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", - "ai_error", + claudeUnavailable ? "claude_unavailable" : "ai_error", ); + if (claudeUnavailable) { + 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 1cf6a2445..31d515759 100644 --- a/supabase/functions/ai/provider-errors_test.ts +++ b/supabase/functions/ai/provider-errors_test.ts @@ -41,6 +41,169 @@ 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" | "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) => { + 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: 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_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")) { + refundedUnits.push(JSON.parse(String(init?.body)).p_units); + 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") { + 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' : ""; + // 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" } }); + } + throw new Error(`Unexpected request: ${url}`); + }) as typeof fetch; + 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, input: { message: "A question", mode: "general", webSearch: false }, ...extra }), + })); + try { + for (const [label, kind, run] of [ + ...(["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), + ...(["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), + ...(["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); + 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; + reservedUnits.length = 0; + refundedUnits.length = 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); + // 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 === "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" || 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) { + assertEquals(body.model, "anthropic/claude-haiku-5.5", label); + 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. + 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(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) { + 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; + } +}); + Deno.test("common JSON slips are repaired locally instead of paying for a retry", async () => { const { parseJsonFromText } = await import("./provider.ts"); const parse = (text: string) => parseJsonFromText(text, "OpenRouter"); diff --git a/supabase/functions/ai/provider.ts b/supabase/functions/ai/provider.ts index f8776f074..b2e17c11f 100644 --- a/supabase/functions/ai/provider.ts +++ b/supabase/functions/ai/provider.ts @@ -29,6 +29,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"; @@ -391,10 +392,9 @@ export async function callOpenRouter( ? { type: "json_schema", json_schema: { name: outputSchema.name, strict: true, schema: outputSchema.schema } } : { 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", @@ -403,10 +403,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"); if (data.choices[0]?.finish_reason === "length") throw new OutputTruncatedError(usage); return parseProviderContent(data.choices[0]?.message?.content ?? "", "OpenRouter", usage); } @@ -429,6 +430,7 @@ type OpenRouterStreamChunk = { */ async function streamOpenRouter( apiKey: string, + model: string, payload: Record, stream: GenerationStream, signal?: AbortSignal, @@ -444,10 +446,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"); if (finishReason === "length") throw new OutputTruncatedError(usage); return parseProviderContent(text, "OpenRouter", usage); } @@ -456,6 +459,7 @@ export async function readOpenRouterStream( body: ReadableStream, stream: GenerationStream, signal: AbortSignal, + model = "", ): Promise<{ text: string; usage: NormalizedUsage; finishReason?: string }> { const reader = body.getReader(); const decoder = new TextDecoder(); @@ -479,12 +483,16 @@ 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 : 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, text.length > 0); } const choice = Array.isArray(chunk.choices) ? chunk.choices[0] : undefined; if (typeof choice?.finish_reason === "string" && choice.finish_reason) finishReason = choice.finish_reason; @@ -495,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 { diff --git a/supabase/migrations/20261022170000_refund_daily_quota.sql b/supabase/migrations/20261022170000_refund_daily_quota.sql new file mode 100644 index 000000000..1f4c62709 --- /dev/null +++ b/supabase/migrations/20261022170000_refund_daily_quota.sql @@ -0,0 +1,54 @@ +-- 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. 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. 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, + 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 + 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 = GREATEST(request_count - p_units, 0) + 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, integer, text) + FROM PUBLIC, anon, authenticated; +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 new file mode 100644 index 000000000..e8d2017b5 --- /dev/null +++ b/supabase/tests/daily_quota_refund_test.sql @@ -0,0 +1,97 @@ +BEGIN; +SELECT plan(12); + +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, integer, text)', 'EXECUTE'), + 'anonymous callers cannot refund quota' +); +SELECT ok( + NOT has_function_privilege('authenticated', 'public.refund_daily_quota(uuid, integer, 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); + +-- 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_ai_quota('66666666-6666-4666-8666-666666666666', 7, 5)$$, + ARRAY[5], + 'setup reserves an Opus 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, 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( + public.refund_daily_quota('66666666-6666-4666-8666-666666666666', 5), + 1, + 'refunding the failed Opus request gives back its 5 units' +); +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)$$, + 'P0001', + NULL, + 'a refund must give back at least one unit' +); +SELECT is( + public.refund_daily_quota('77777777-7777-4777-8777-777777777777', 5), + 0, + '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, 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 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 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;