Skip to content

Commit 17c00f9

Browse files
Programcodeclaude
andauthored
Run Claude only on the Anthropic BYOK key, with no fallback provider (#185)
* 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent d749051 commit 17c00f9

18 files changed

Lines changed: 577 additions & 32 deletions

‎docs/architecture.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ Supabase Auth owns user sessions. Protected routes provide a client navigation b
140140

141141
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.
142142

143-
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.
143+
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.
144144

145145
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.
146146

‎e2e/full-stack/fake-openrouter.mjs‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,15 @@ const port = Number(process.env.PORT ?? 8787);
1414
const upstream = process.env.UPSTREAM;
1515
const liveModel = process.env.LIVE_MODEL;
1616
const chatModel = (requested) => (liveModel === "as-requested" ? requested : liveModel);
17+
// Claude requests pin OpenRouter to the Anthropic provider. A rewritten
18+
// model is served elsewhere, so it keeps only the provider options that
19+
// still apply to it.
20+
const liveChatBody = (body) => {
21+
const model = chatModel(body.model);
22+
if (model === body.model || !body.provider || typeof body.provider !== "object") return { ...body, model };
23+
const { only: _only, allow_fallbacks: _allowFallbacks, ...provider } = body.provider;
24+
return { ...body, model, provider };
25+
};
1726
const maxUpstreamCalls = Number(process.env.MAX_UPSTREAM_CALLS ?? 200);
1827
if (upstream && !liveModel) throw new Error("LIVE_MODEL is required with UPSTREAM");
1928
const requests = [];
@@ -280,7 +289,7 @@ async function proxy(req, res, path) {
280289
const response = await fetch(`${upstream}${path.replace(/^\/api/, "")}`, {
281290
method: "POST",
282291
headers: { authorization: req.headers.authorization ?? "", "content-type": "application/json" },
283-
body: JSON.stringify(chat ? { ...body, model: chatModel(body.model) } : body),
292+
body: JSON.stringify(chat ? liveChatBody(body) : body),
284293
signal: hangUp.signal,
285294
});
286295
record.status = response.status;

‎scripts/prompt-examples.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
// model, fourteen calls, no web research.
1111

1212
import { PROMPT_EXAMPLES } from "../supabase/functions/_shared/prompts/prompt-examples.ts";
13+
import { openRouterProviderOptions } from "../supabase/functions/_shared/provider-routing.ts";
1314

1415
const [functionsDir, outPath, model = "openai/gpt-6-luna", only] = Deno.args;
1516
const selected = only ? new Set(only.split(",")) : null;
@@ -64,7 +65,7 @@ for (const example of PROMPT_EXAMPLES.filter((item) => !selected || selected.has
6465
max_tokens: 8_000,
6566
response_format: { type: "json_object" },
6667
reasoning: { effort: "low", exclude: true },
67-
provider: { require_parameters: true },
68+
provider: openRouterProviderOptions(model),
6869
}),
6970
// Matches the ai function's request budget; one retry covers a stalled route.
7071
signal: AbortSignal.timeout(120_000),

‎src/pages/AskPage.tsx‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { useSessionStore } from '@/lib/stores/session-store';
44
import { useAuth } from '@/lib/supabase/AuthProvider';
55
import { useSettingsStore } from '@/lib/stores/settings-store';
66
import { AskChat } from '@/components/features/ask/AskChat';
7-
import { askChat, isAskMessageWithinBudget, isTimeoutError } from '@/lib/ai/client';
7+
import { askChat, isAIProviderError, isAskMessageWithinBudget, isTimeoutError } from '@/lib/ai/client';
88
import { isAskStreamingSupported, streamAskChat } from '@/lib/ai/streaming';
99
import { useSubscriptionError } from '@/hooks/use-subscription-error';
1010
import { getUserFacingAIError } from '@/lib/ai/error-display';
@@ -293,6 +293,10 @@ export default function AskPage() {
293293
followUpQuestions: [],
294294
uncertainties: [t('ask.streamInterrupted', 'The streamed response was interrupted before completion.')],
295295
};
296+
} else if (isAIProviderError(streamError) && streamError.details?.code === 'provider_unavailable') {
297+
// The model's provider is down, not the stream: asking again
298+
// through the plain endpoint would only fail the same way.
299+
throw streamError;
296300
} else {
297301
result = await askChat(fullMessage, history, mode, config, options);
298302
}

‎src/pages/SparringPage.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,8 @@ import { useSubscriptionError } from '@/hooks/use-subscription-error';
3030
import { SubscriptionErrorModals } from '@/components/features/subscription';
3131

3232
function readableError(error: unknown, fallback: string) {
33-
if (isAISubscriptionError(error)) return getUserFacingAIError(error);
34-
if (error instanceof Error && error.message.trim()) return error.message;
33+
// Subscription errors have their own text; provider errors get the translated outage message.
34+
if (isAISubscriptionError(error) || (error instanceof Error && error.message.trim())) return getUserFacingAIError(error);
3535
return fallback;
3636
}
3737

‎supabase/README.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ Both endpoints accept an optional `debateContext` of IDs (`debateId`, `side`, `s
3939

4040
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.
4141

42+
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.
43+
4244
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`.
4345

4446
### Content and sharing

‎supabase/functions/_shared/cost.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,21 @@ export function selectObservedUsage(
274274
: usageError?.usage ?? usageError?.completedUsage;
275275
}
276276

277+
/** True when the observed usage charged anything toward the weekly spend cap. */
278+
export function chargedAgainstCap(usage?: NormalizedUsage): boolean {
279+
const cap = usage ? capCostOf(usage) : undefined;
280+
return typeof cap === "number" && cap > 0;
281+
}
282+
283+
/**
284+
* Records a refunded request as adding nothing to the weekly spend cap. Only
285+
* for usage that chargedAgainstCap() cleared: a request OpenRouter charged
286+
* credits for is never refunded, so no real spend is hidden from the cap.
287+
*/
288+
export function excludeFromCap(usage?: NormalizedUsage): NormalizedUsage {
289+
return { ...usage, capCostUsd: 0 };
290+
}
291+
277292
/** Preserve observed fragments without treating partial provider work as a complete bill. */
278293
export function markIncompleteUsage(usage?: NormalizedUsage): NormalizedUsage {
279294
return { ...usage, incomplete: true };

‎supabase/functions/_shared/entitlements.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,22 @@ export async function checkRateLimit(userId: string | null, model?: ModelSelecti
214214
}
215215
}
216216

217+
/**
218+
* Gives back exactly what checkRateLimit reserved through consume_ai_quota for
219+
* a request the provider could not serve: the same `units`, which follow the
220+
* requested selection (Auto Pro reserves 5 even when it resolves to Haiku). A
221+
* failed refund is logged, never thrown, so the student still gets the error.
222+
*/
223+
export async function refundQuotaReservation(userId: string, reservation: Pick<RateLimitResult, "allowed" | "units">) {
224+
if (!reservation.allowed || !reservation.units) return;
225+
try {
226+
const { error } = await supabase.rpc("refund_daily_quota", { p_user_id: userId, p_units: reservation.units });
227+
if (error) throw error;
228+
} catch (err) {
229+
console.error("[RATE LIMIT] Quota refund failed:", err);
230+
}
231+
}
232+
217233
export async function logUsage(
218234
userId: string | null,
219235
feature: string,
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { assertEquals } from "jsr:@std/assert@1.0.19";
2+
import { warnIfClaudeNotByok } from "./provider-routing.ts";
3+
4+
Deno.test("the BYOK guard warns only for Claude usage that left the key or cost credits", () => {
5+
const originalWarn = console.warn;
6+
const warnings: string[] = [];
7+
console.warn = (message: string) => warnings.push(message);
8+
try {
9+
const claude = "anthropic/claude-haiku-5.5";
10+
assertEquals(warnIfClaudeNotByok(claude, { is_byok: true, cost: 0 }, "ai"), false);
11+
assertEquals(warnIfClaudeNotByok(claude, undefined, "ai"), false);
12+
assertEquals(warnIfClaudeNotByok("openai/gpt-6-luna", { is_byok: false, cost: 0.02 }, "ai"), false);
13+
assertEquals(warnIfClaudeNotByok(claude, { is_byok: false, cost: 0 }, "ai"), true);
14+
assertEquals(warnIfClaudeNotByok(claude, { is_byok: true, cost: 0.0004, cost_details: { upstream_inference_cost: 0.008 } }, "ai-stream"), true);
15+
assertEquals(warnings.length, 2);
16+
assertEquals(JSON.parse(warnings[1]), {
17+
event: "claude_not_byok",
18+
source: "ai-stream",
19+
model: claude,
20+
is_byok: true,
21+
cost: 0.0004,
22+
upstream_inference_cost: 0.008,
23+
});
24+
} finally {
25+
console.warn = originalWarn;
26+
}
27+
});
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
// OpenRouter provider preferences per model family, and the Claude BYOK guard.
2+
//
3+
// Claude must run only on DebateLab's own Anthropic key, which is set up as
4+
// BYOK in OpenRouter. `only: ["anthropic"]` keeps Bedrock and Vertex out, and
5+
// `allow_fallbacks: false` stops OpenRouter from trying any other provider.
6+
// OpenRouter tries a BYOK key before its own capacity for the same provider,
7+
// but only the key's "Never use shared capacity" setting in OpenRouter stops it
8+
// from spending OpenRouter credits on Anthropic when the key fails. The usage
9+
// warning below is how a leak would show up.
10+
11+
export function isClaudeModel(model: string): boolean {
12+
return model.startsWith("anthropic/");
13+
}
14+
15+
export function openRouterProviderOptions(model: string): Record<string, unknown> {
16+
if (isClaudeModel(model)) return { only: ["anthropic"], allow_fallbacks: false, require_parameters: true };
17+
// Omitting sort and order keeps OpenRouter's balanced provider load balancing.
18+
return { require_parameters: true };
19+
}
20+
21+
/**
22+
* A Claude call OpenRouter refused or failed. With fallbacks off this usually
23+
* means the Anthropic key is out of credit or erroring, so the request is not
24+
* retried elsewhere and the student sees a provider outage. The reserved daily
25+
* request is given back only when the failure came before any output.
26+
*/
27+
export class ClaudeProviderUnavailableError extends Error {
28+
readonly status: number | null;
29+
/** The model had already written text when the failure arrived. */
30+
readonly afterOutput: boolean;
31+
32+
constructor(status: number | null, afterOutput = false) {
33+
super("AI provider request failed");
34+
this.name = "ClaudeProviderUnavailableError";
35+
this.status = status;
36+
this.afterOutput = afterOutput;
37+
}
38+
}
39+
40+
/** Throws the Claude-specific error for Claude models, the generic one otherwise. */
41+
export function providerRequestError(model: string, status: number | null, afterOutput = false): Error {
42+
return isClaudeModel(model) ? new ClaudeProviderUnavailableError(status, afterOutput) : new Error("AI provider request failed");
43+
}
44+
45+
export const CLAUDE_UNAVAILABLE_CODE = "provider_unavailable";
46+
export const CLAUDE_UNAVAILABLE_STATUS = 503;
47+
// The client maps a provider error naming Anthropic and "temporarily
48+
// unavailable" to its translated outage message.
49+
export const CLAUDE_UNAVAILABLE_MESSAGE = "Anthropic Claude is temporarily unavailable. Please try again later.";
50+
51+
/**
52+
* Warns when a Claude response did not run on the BYOK key (`is_byok` false)
53+
* or OpenRouter charged for it (`cost` above zero). Past the monthly BYOK
54+
* allowance, OpenRouter's BYOK fee also shows up as cost.
55+
*/
56+
export function warnIfClaudeNotByok(model: string, rawUsage: Record<string, unknown> | undefined, source: string): boolean {
57+
if (!isClaudeModel(model) || !rawUsage) return false;
58+
const cost = typeof rawUsage.cost === "number" ? rawUsage.cost : undefined;
59+
if (rawUsage.is_byok !== false && !(cost !== undefined && cost > 0)) return false;
60+
const costDetails = rawUsage.cost_details && typeof rawUsage.cost_details === "object"
61+
? rawUsage.cost_details as Record<string, unknown>
62+
: undefined;
63+
console.warn(JSON.stringify({
64+
event: "claude_not_byok",
65+
source,
66+
model,
67+
is_byok: rawUsage.is_byok ?? null,
68+
cost: cost ?? null,
69+
upstream_inference_cost: costDetails?.upstream_inference_cost ?? null,
70+
}));
71+
return true;
72+
}

0 commit comments

Comments
 (0)