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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude/skills/verify-debatelab/features/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ Each feature file starts with an H1 title and one paragraph describing the user-
- [AI tools](./ai-tools.md): Attack, Brainstorm, Factoids, and Ask streamed, cited, and with web search (`recipes/ai-tools.spec.ts`).
- [Live generation](./live-generation.md): every AI tool shows its answer while the model writes it, Cancel mid-stream, and the event stream's shape (`recipes/live-generation.spec.ts`); Crossfire and Sparring requests stream too (`recipes/practice.spec.ts`).
- [Current Debate](./current-debate.md): start, the first-debate offer and the different-motion choice, sources and results scoped to a debate, switching, and the first onboarding step (`recipes/current-debate.spec.ts`); debate context review and pins (`e2e/full-stack/debate-context.spec.ts`).
- [Model defaults](./model-defaults.md): Pro accounts start on Claude Opus 5.5, Basic accounts on Claude Haiku 5.5 with Pro models locked, and retired model IDs move to Opus 5.5 (`recipes/model-defaults.spec.ts`).
- [Model defaults](./model-defaults.md): Pro accounts start on Claude Sonnet 5.5 with Opus 5.5 as an opt-in, Basic accounts on Claude Haiku 5.5 with Pro models locked, and retired model IDs move to their successor (`recipes/model-defaults.spec.ts`).
- [Practice](./practice.md): a full Lincoln-Douglas sparring round with verdict, and Crossfire in both directions with debrief, each filed in its debate (`recipes/practice.spec.ts`).
- [Voice rounds](./voice-rounds.md): spoken Sparring and Crossfire on the browser voice, with no ElevenLabs key (`recipes/voice-rounds.spec.ts`).
- [Workspace autosave](./workspace-autosave.md): a slow Crossfire or Sparring save from a Workspace never overwrites newer text (`recipes/workspace-autosave.spec.ts`). Needs `up --collab`; skips without it.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,5 +42,5 @@ Preconditions:
- `Save` appears both on the result toolbar and in the dialog. Scope the second click to `getByRole("dialog")`. The toolbar `Save` exists only for one-off results.
- A case filed in a debate is titled with the motion, which the header control also shows (truncated and hidden). Look for it inside the History panel, not with a page-wide `getByText`.
- The client files a result after it renders. Wait for the result on screen (and the row, if the next step reloads) before a `page.goto`, or the reload can drop it.
- The model label in the result header (for example `anthropic/claude-opus-5.5`) is the model the function requested. The answer itself came from the fake provider.
- The model label in the result header (for example `anthropic/claude-sonnet-5.5`) is the model the function requested. The answer itself came from the fake provider.
- `Google Docs` export needs Google OAuth and cannot be proven locally.
8 changes: 4 additions & 4 deletions .claude/skills/verify-debatelab/features/model-defaults.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
# Model defaults and the model selector

Every AI tool has a model selector. Basic accounts can pick Claude Haiku 5.5, GPT-6 Luna, or Muse Spark 1.3 (`meta/muse-spark-1.3-contributor`); everything else is Pro. A new Pro account starts every tool on Claude Opus 5.5, and a Basic (trial) account on Claude Haiku 5.5. Pro-only models show a `Pro` badge and are disabled for Basic accounts. A setting saved with a retired catalog ID is rewritten on the next load: GPT-5.6 Luna moves to Claude Haiku 5.5, Claude Sonnet 5 and Opus 5 to Claude Opus 5.5, GPT-5.6 Sol and Terra to GPT-6.1 Sol, DeepSeek V4 Flash to Claude Haiku 5.5, and Muse Spark 1.3 to its contributor tier. The Edge Functions resolve a retired ID from an older client the same way. A retired Basic ID never resolves to a Pro model. The catalog and defaults live in `supabase/functions/_shared/model-catalog.ts`.
Every AI tool has a model selector. Basic accounts can pick Claude Haiku 5.5, GPT-6 Luna, or Muse Spark 1.3 (`meta/muse-spark-1.3-contributor`); everything else is Pro. A new Pro account starts every tool on Claude Sonnet 5.5, and a Basic (trial) account on Claude Haiku 5.5. Claude Opus 5.5 is opt-in only: it runs when someone picks it, and Auto Pro never routes or falls back to it. Pro-only models show a `Pro` badge and are disabled for Basic accounts. A setting saved with a retired catalog ID is rewritten on the next load: GPT-5.6 Luna moves to Claude Haiku 5.5, Claude Sonnet 5 to Claude Sonnet 5.5, Claude Opus 5 to Claude Opus 5.5, GPT-5.6 Sol and Terra to GPT-6.1 Sol, DeepSeek V4 Flash to Claude Haiku 5.5, and Muse Spark 1.3 to its contributor tier. The Edge Functions resolve a retired ID from an older client the same way. A retired Basic ID never resolves to a Pro model. The catalog and defaults live in `supabase/functions/_shared/model-catalog.ts`.

## Sub-features

- `model-pro-default`: a new Pro account's `user_settings.feature_configs` hold `anthropic/claude-opus-5.5` for every tool, the selector shows `Claude Opus 5.5`, and a Generate run records `model: anthropic/claude-opus-5.5` in `ai_requests`.
- `model-pro-default`: a new Pro account's `user_settings.feature_configs` hold `anthropic/claude-sonnet-5.5` for every tool, the selector shows `Claude Sonnet 5.5` with `Claude Opus 5.5` still enabled, and a Generate run records `model: anthropic/claude-sonnet-5.5` in `ai_requests`.
- `model-basic-default`: a trial account with no saved settings gets `anthropic/claude-haiku-5.5` everywhere. `Claude Opus 5.5` and `Claude Sonnet 5.5` are disabled options, and a Generate run records Haiku 5.5.
- `model-retired`: saved `anthropic/claude-sonnet-5` configs become `anthropic/claude-opus-5.5` on screen and in `user_settings`.
- `model-retired`: saved `anthropic/claude-sonnet-5` configs become `anthropic/claude-sonnet-5.5` on screen and in `user_settings`.

## How to get to it (user POV)

Expand All @@ -21,4 +21,4 @@ Every AI tool has a model selector. Basic accounts can pick Claude Haiku 5.5, GP
## Gotchas

- Fake mode records the requested model but answers with fake text, and live mode rewrites every chat call to the Muse Spark contributor pin. Neither proves that OpenRouter accepts the Claude 5.5 IDs; that needs a direct OpenRouter call.
- Existing saved choices of a model still in the catalog are kept. Only retired IDs and unsaved settings move to the new defaults.
- Existing saved choices of a model still in the catalog are kept. Only retired IDs and unsaved settings move to the new defaults, so a Pro account that saved Opus 5.5 under the old default keeps it.
28 changes: 14 additions & 14 deletions .claude/skills/verify-debatelab/recipes/model-defaults.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { admin, AI_TIMEOUT, aiResponse, chooseForMotion, expect, providerCalls,

// Feature map: features/model-defaults.md.
const motion = "This House would make public transport free at the point of use";
const OPUS = "anthropic/claude-opus-5.5";
const SONNET = "anthropic/claude-sonnet-5.5";
const HAIKU = "anthropic/claude-haiku-5.5";

async function settings(userId: string) {
Expand All @@ -25,29 +25,29 @@ async function generateOnce(page: Page) {
return response;
}

test("model-pro-default: a new Pro account starts on Claude Opus 5.5 and generates with it", async ({ signedIn: page, user, evidence }) => {
test("model-pro-default: a new Pro account starts on Claude Sonnet 5.5, keeps Opus as an opt-in, and generates with Sonnet", async ({ signedIn: page, user, evidence }) => {
const userId = await user.userId();
await expect.poll(async () => (await settings(userId)).generate?.model, { timeout: AI_TIMEOUT }).toBe(OPUS);
await expect.poll(async () => (await settings(userId)).generate?.model, { timeout: AI_TIMEOUT }).toBe(SONNET);

await page.goto("/app/generate");
const selector = page.getByRole("combobox", { name: "Select AI model for generate" });
await expect(selector).toContainText("Claude Opus 5.5");
await expect(selector).toContainText("Claude Sonnet 5.5");
await selector.click();
await expect(page.getByRole("option", { name: /Claude Haiku 5\.5/ })).toBeEnabled();
await expect(page.getByRole("option", { name: /Claude Sonnet 5\.5/ })).toBeEnabled();
await expect(page.getByRole("option", { name: /Claude Opus 5\.5/ })).toBeEnabled();
await evidence.shot("01-pro-model-list");
await page.keyboard.press("Escape");
await evidence.step("New Pro account: selector shows Claude Opus 5.5, all Claude 5.5 models enabled");
await evidence.step("New Pro account: selector shows Claude Sonnet 5.5; Opus 5.5 stays enabled as an opt-in");

const body = await generateOnce(page);
expect((body.meta as { resolvedModel?: string } | undefined)?.resolvedModel ?? OPUS).toBe(OPUS);
expect((body.meta as { resolvedModel?: string } | undefined)?.resolvedModel ?? SONNET).toBe(SONNET);
const calls = await providerCalls();
// Live mode rewrites the upstream model to the pin, so read what the app asked for.
expect(calls.filter((call) => call.feature === "generate").map((call) => call.requestedModel ?? call.model)).toContain(OPUS);
expect(calls.filter((call) => call.feature === "generate").map((call) => call.requestedModel ?? call.model)).toContain(SONNET);
const { data: usage } = await admin.from("ai_requests").select("feature,status,model").eq("user_id", userId);
expect(usage).toContainEqual(expect.objectContaining({ feature: "generate", status: "success", model: OPUS }));
expect(usage).toContainEqual(expect.objectContaining({ feature: "generate", status: "success", model: SONNET }));
await evidence.json("02-ai-requests", usage);
await evidence.shot("02-generated-with-opus");
await evidence.shot("02-generated-with-sonnet");
});

test("model-basic-default: a Basic account starts on Claude Haiku 5.5 with only Haiku, Luna and Muse Spark unlocked", async ({ signedIn: page, user, evidence }) => {
Expand Down Expand Up @@ -81,7 +81,7 @@ test("model-basic-default: a Basic account starts on Claude Haiku 5.5 with only
await evidence.json("02-ai-requests", usage);
});

test("model-retired: a saved Claude Sonnet 5 setting moves to Claude Opus 5.5", async ({ signedIn: page, user, evidence }) => {
test("model-retired: a saved Claude Sonnet 5 setting moves to Claude Sonnet 5.5", async ({ signedIn: page, user, evidence }) => {
const userId = await user.userId();
const retired = Object.fromEntries(["generate", "attack", "brainstorm", "factoids", "ask", "sparring"].map((feature) => [
feature,
Expand All @@ -91,9 +91,9 @@ test("model-retired: a saved Claude Sonnet 5 setting moves to Claude Opus 5.5",
await page.evaluate(() => localStorage.removeItem("debate-trainer-settings"));
await page.goto("/app/generate");

await expect(page.getByRole("combobox", { name: "Select AI model for generate" })).toContainText("Claude Opus 5.5");
await expect(page.getByRole("combobox", { name: "Select AI model for generate" })).toContainText("Claude Sonnet 5.5");
await expect.poll(async () => Object.values(await settings(userId)).map((config) => config.model), { timeout: AI_TIMEOUT })
.toEqual(Array(6).fill(OPUS));
.toEqual(Array(6).fill(SONNET));
await evidence.shot("01-retired-migrated");
await evidence.step("Saved Sonnet 5 configs were rewritten to Opus 5.5 in user_settings");
await evidence.step("Saved Sonnet 5 configs were rewritten to Sonnet 5.5 in user_settings");
});
4 changes: 2 additions & 2 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,9 +136,9 @@ Supabase Auth owns user sessions. Protected routes provide a client navigation b

Checkout and portal functions create Stripe sessions after authenticated ownership and redirect validation. The webhook updates subscription state and records event processing for idempotency. AI handlers record the provider-reported usage and charge for each request.

All model requests use the single OpenRouter backend route and `OPENROUTER_API_KEY`. The shared ten-model catalog, its defaults (Claude Haiku 5.5 for Basic, Claude Opus 5.5 for Pro), Pro-only entries, retired-ID successors, and request controls live in [`supabase/functions/_shared/model-catalog.ts`](../supabase/functions/_shared/model-catalog.ts). OpenRouter usage remains raw in the audit row, and the reported charge is unknown when the provider does not return a complete cost. The opponent's voice is the one exception to the OpenRouter route: the `voice` function calls ElevenLabs with `ELEVENLABS_API_KEY`. ElevenLabs reports billed characters, not dollars, so [`supabase/functions/_shared/cost.ts`](../supabase/functions/_shared/cost.ts) prices those rows from a dated list-price snapshot; promotions and plan credits are not modelled, so a voice row can overstate the charge.
All model requests use the single OpenRouter backend route and `OPENROUTER_API_KEY`. The shared ten-model catalog, its defaults (Claude Haiku 5.5 for Basic, Claude 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). OpenRouter usage remains raw in the audit row, and the reported charge is unknown when the provider does not return a complete cost. The opponent's voice is the one exception to the OpenRouter route: the `voice` function calls ElevenLabs with `ELEVENLABS_API_KEY`. ElevenLabs reports billed characters, not dollars, so [`supabase/functions/_shared/cost.ts`](../supabase/functions/_shared/cost.ts) prices those rows from a dated list-price snapshot; promotions and plan credits are not modelled, so a voice row can overstate the charge.

`Auto Fast` and `Auto Pro` are virtual selections, not provider model IDs. The Edge Functions resolve them through TypeSafe Jev on OpenRouter before calling the concrete writing model. Auto Fast is available to Basic accounts, routes only among the Basic models (Claude Haiku 5.5, GPT-6 Luna, Muse Spark 1.3 Contributor), and normally resolves to Claude Haiku 5.5. Auto Pro remains Pro-only and normally resolves to Claude Opus 5.5. Provider calls, usage rows, and response metadata record the concrete resolved model, while response metadata also retains the requested automatic selection. A Jev router call uses the same OpenRouter key and does not consume a second daily quota unit.
`Auto Fast` and `Auto Pro` are virtual selections, not provider model IDs. The Edge Functions resolve them through TypeSafe Jev on OpenRouter before calling the concrete writing model. Auto Fast is available to Basic accounts, routes only among the Basic models (Claude Haiku 5.5, GPT-6 Luna, Muse Spark 1.3 Contributor), and normally resolves to Claude Haiku 5.5. Auto Pro remains Pro-only, routes between Claude Sonnet 5.5 and GPT-6.1 Sol, and normally resolves to Claude Sonnet 5.5; it never routes or falls back to Opus. Provider calls, usage rows, and response metadata record the concrete resolved model, while response metadata also retains the requested automatic selection. A Jev router call uses the same OpenRouter key and does not consume a second daily quota unit.

## Observability

Expand Down
2 changes: 1 addition & 1 deletion src/lib/stores/settings-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ const LEGACY_MODEL_SUCCESSORS: Record<string, string> = {
'gpt-5.4-mini': DEFAULT_MODEL,
'gpt-4o': DEFAULT_MODEL,
'gpt-4o-mini': DEFAULT_MODEL,
'claude-opus-4-7': DEFAULT_PRO_MODEL,
'claude-opus-4-7': 'anthropic/claude-opus-5.5',
'claude-sonnet-4-6': DEFAULT_PRO_MODEL,
'claude-haiku-4-5-20251001': DEFAULT_MODEL,
'gemini-3.1-pro-preview': 'google/gemini-3.8-flash',
Expand Down
2 changes: 1 addition & 1 deletion supabase/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ supabase/tests/ pgTAP database and policy tests

Both endpoints accept an optional `debateContext` of IDs (`debateId`, `side`, `sourceIds`, `outputs`). `_shared/debate-context-loader.ts` loads the matching material with the service role, filtering every query by the caller's user ID and the one debate, and returns 404 for a debate the caller does not own before any quota is reserved. Requests without it still accept the legacy `sourceIds` field.

Both endpoints route every model inference through OpenRouter and enforce the shared ten-model Basic/Pro catalog. Basic accounts get Claude Haiku 5.5 (the default), GPT-6 Luna, and Muse Spark 1.3 Contributor; Claude Opus 5.5 is the Pro default; Pro-only models require active Pro access. Retired catalog IDs from older clients resolve to their successor before validation.
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.

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`.

Expand Down
28 changes: 26 additions & 2 deletions supabase/functions/_shared/auto-model-router.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ Deno.test("Auto Pro rejects Basic before calling Jev", async () => {
});

Deno.test("Auto Pro accepts only its approved smart-model pool", async () => {
for (const model of ["anthropic/claude-opus-5.5", "anthropic/claude-sonnet-5.5", "openai/gpt-6.1-sol"]) {
for (const model of ["anthropic/claude-sonnet-5.5", "openai/gpt-6.1-sol"]) {
const resolved = await resolveModelSelection({
requestedModel: AUTO_PRO,
feature: "generate",
Expand All @@ -124,11 +124,35 @@ Deno.test("Auto Pro accepts only its approved smart-model pool", async () => {
canAccessProModels: true,
callJevImpl: jevReturning(choiceResult("z-ai/glm-5.3-flash")),
});
if (outsidePool.resolvedModel !== "anthropic/claude-opus-5.5" || !outsidePool.usedFallback) {
if (outsidePool.resolvedModel !== "anthropic/claude-sonnet-5.5" || !outsidePool.usedFallback) {
throw new Error("Auto Pro accepted a model outside its pool");
}
});

Deno.test("Auto Pro never routes or falls back to Opus", async () => {
const routedToOpus = await resolveModelSelection({
requestedModel: AUTO_PRO,
feature: "generate",
input: { motion: "Complex international law motion" },
canAccessProModels: true,
callJevImpl: jevReturning(choiceResult("anthropic/claude-opus-5.5")),
});
if (routedToOpus.resolvedModel !== "anthropic/claude-sonnet-5.5" || !routedToOpus.usedFallback) {
throw new Error(`Auto Pro accepted Opus: ${JSON.stringify(routedToOpus)}`);
}

const unavailable = await resolveModelSelection({
requestedModel: AUTO_PRO,
feature: "generate",
input: { motion: "Complex international law motion" },
canAccessProModels: true,
callJevImpl: () => Promise.reject(new Error("provider unavailable")),
});
if (unavailable.resolvedModel !== "anthropic/claude-sonnet-5.5" || !unavailable.usedFallback) {
throw new Error(`Auto Pro fell back to the wrong model: ${JSON.stringify(unavailable)}`);
}
});

Deno.test("manual model selections bypass Jev", async () => {
const observed = { calls: 0 };
const resolved = await resolveModelSelection({
Expand Down
6 changes: 3 additions & 3 deletions supabase/functions/_shared/auto-model-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,9 @@ const FAST_CANDIDATES = {
"meta/muse-spark-1.3-contributor": "Best for very long input, multilingual output, or broad context handling.",
} as const;

// Opus is opt-in only: Auto Pro never routes or falls back to it.
const PRO_CANDIDATES = {
"anthropic/claude-opus-5.5": "Default for professional DebateLab work: nuanced rhetoric, adversarial critique, and long-form writing.",
"anthropic/claude-sonnet-5.5": "Best for quick, simpler Pro requests where speed matters more than depth.",
"anthropic/claude-sonnet-5.5": "Default for professional DebateLab work: nuanced rhetoric, adversarial critique, and long-form writing.",
"openai/gpt-6.1-sol": "Best for complex structured reasoning and reliable multi-part analysis.",
} as const;

Expand Down Expand Up @@ -72,7 +72,7 @@ function routingState(args: ResolveModelSelectionArgs) {
}

function fallbackFor(selection: typeof AUTO_FAST | typeof AUTO_PRO): AutoCandidate {
return selection === AUTO_FAST ? "anthropic/claude-haiku-5.5" : "anthropic/claude-opus-5.5";
return selection === AUTO_FAST ? "anthropic/claude-haiku-5.5" : "anthropic/claude-sonnet-5.5";
}

function isAbort(error: unknown, signal?: AbortSignal): boolean {
Expand Down
Loading
Loading