Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
de52e44
Run Claude only on the Anthropic BYOK key, with no fallback provider.
Programcode Oct 22, 2026
f2e9b60
Refund a failed Claude request only when nothing was written yet.
Programcode Oct 22, 2026
ffd8604
Keep paid research out of Claude refunds and stop the Ask double atte…
Programcode Oct 22, 2026
21ee09b
Merge remote-tracking branch 'origin/main' into cost/claude-byok-only
Programcode Oct 22, 2026
91c6fe2
Merge remote-tracking branch 'origin/main' into cost/claude-byok-only
Programcode Oct 22, 2026
630db01
Merge remote-tracking branch 'origin/main' into cost/claude-byok-only
Programcode Oct 22, 2026
a5087ef
Let a quota refund give back more than one unit.
Programcode Oct 22, 2026
b493fd6
Give the refund migration its own version.
Programcode Oct 22, 2026
35f0a2c
Merge remote-tracking branch 'origin/main' into cost/claude-byok-only
Programcode Oct 22, 2026
c93eabd
Merge remote-tracking branch 'origin/main' into cost/claude-byok-only
Programcode Oct 22, 2026
b68e586
Refund the weighted units consume_ai_quota reserved, and keep refunds…
Programcode Oct 22, 2026
2415463
Never refund a Claude failure OpenRouter already charged credits for.
Programcode Oct 22, 2026
631d05c
Refund research tools in a Claude outage too.
Programcode Oct 22, 2026
6fa3f2f
Bill usage on a stream's error frame, and never refund a cancelled re…
Programcode Oct 22, 2026
e74563d
Merge main into cost/claude-byok-only
Programcode Oct 22, 2026
b25bc99
Merge remote-tracking branch 'origin/main' into s185
Programcode Oct 22, 2026
File filter

Filter by extension

Filter by extension

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

Expand Down
11 changes: 10 additions & 1 deletion e2e/full-stack/fake-openrouter.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [];
Expand Down Expand Up @@ -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;
Expand Down
3 changes: 2 additions & 1 deletion scripts/prompt-examples.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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),
Expand Down
6 changes: 5 additions & 1 deletion src/pages/AskPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
}
Expand Down
4 changes: 2 additions & 2 deletions src/pages/SparringPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
2 changes: 2 additions & 0 deletions supabase/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions supabase/functions/_shared/cost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down
16 changes: 16 additions & 0 deletions supabase/functions/_shared/entitlements.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<RateLimitResult, "allowed" | "units">) {
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,
Expand Down
27 changes: 27 additions & 0 deletions supabase/functions/_shared/provider-routing.test.ts
Original file line number Diff line number Diff line change
@@ -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;
}
});
72 changes: 72 additions & 0 deletions supabase/functions/_shared/provider-routing.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> {
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<string, unknown> | 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<string, unknown>
: 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;
}
3 changes: 3 additions & 0 deletions supabase/functions/ai-stream/handler_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
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)}`);
}
Expand Down
Loading
Loading