diff --git a/.claude/skills/verify-debatelab/features/README.md b/.claude/skills/verify-debatelab/features/README.md index 89c97d1a2..9884fed72 100644 --- a/.claude/skills/verify-debatelab/features/README.md +++ b/.claude/skills/verify-debatelab/features/README.md @@ -44,6 +44,7 @@ Each feature file starts with an H1 title and one paragraph describing the user- - [Library sources](./library-sources.md): add, pause, delete, grounding, and URL import (`recipes/library.spec.ts`). - [AI tools](./ai-tools.md): Attack, Brainstorm, Factoids, and Ask streamed, cited, and with web search (`recipes/ai-tools.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`). - [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. diff --git a/.claude/skills/verify-debatelab/features/case-generation.md b/.claude/skills/verify-debatelab/features/case-generation.md index 6c83cee79..0078f0704 100644 --- a/.claude/skills/verify-debatelab/features/case-generation.md +++ b/.claude/skills/verify-debatelab/features/case-generation.md @@ -41,5 +41,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-sonnet-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-opus-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. diff --git a/.claude/skills/verify-debatelab/features/model-defaults.md b/.claude/skills/verify-debatelab/features/model-defaults.md new file mode 100644 index 000000000..00ab98457 --- /dev/null +++ b/.claude/skills/verify-debatelab/features/model-defaults.md @@ -0,0 +1,24 @@ +# 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`. + +## 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-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`. + +## How to get to it (user POV) + +- Any tool page, for example `/app/generate`: the combobox named `Select AI model for generate` sits beside the tool's inputs. + +## Driving it with verify.sh + Playwright + +- `bin/verify.sh drive recipes/model-defaults` covers all three sub-features. +- Demo sign-in grants Pro. For the Basic path the recipe sets `subscription_tier: "trial"`, a future `trial_ends_at`, clears `grace_period_ends_at` and `daily_limit_override`, and empties `feature_configs`, then reloads so the client re-hydrates as a new trial account. +- End states are read from `user_settings.feature_configs` and `ai_requests.model` through `admin`, and from the fake provider's recorded `model`. + +## 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. diff --git a/.claude/skills/verify-debatelab/recipes/model-defaults.spec.ts b/.claude/skills/verify-debatelab/recipes/model-defaults.spec.ts new file mode 100644 index 000000000..07d11abf3 --- /dev/null +++ b/.claude/skills/verify-debatelab/recipes/model-defaults.spec.ts @@ -0,0 +1,99 @@ +import type { Page } from "@playwright/test"; +import { admin, AI_TIMEOUT, aiResponse, chooseForMotion, expect, providerCalls, test } from "./fixtures"; + +// 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 HAIKU = "anthropic/claude-haiku-5.5"; + +async function settings(userId: string) { + const { data, error } = await admin.from("user_settings").select("feature_configs").eq("user_id", userId).single(); + if (error) throw error; + return data.feature_configs as Record; +} + +async function updateSettings(userId: string, values: Record) { + const { error } = await admin.from("user_settings").update(values).eq("user_id", userId); + if (error) throw error; +} + +async function generateOnce(page: Page) { + await page.getByRole("textbox", { name: "Motion / Topic *" }).fill(motion); + const response = aiResponse(page, "generate"); + await page.getByRole("button", { name: "Generate Debate Case" }).click(); + await chooseForMotion(page, "once"); + 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 }) => { + const userId = await user.userId(); + await expect.poll(async () => (await settings(userId)).generate?.model, { timeout: AI_TIMEOUT }).toBe(OPUS); + + 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 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 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"); + + const body = await generateOnce(page); + expect((body.meta as { resolvedModel?: string } | undefined)?.resolvedModel ?? OPUS).toBe(OPUS); + 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); + 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 })); + await evidence.json("02-ai-requests", usage); + await evidence.shot("02-generated-with-opus"); +}); + +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 }) => { + const userId = await user.userId(); + // Demo sign-in grants Pro; turn the account into a fresh trial with nothing saved. + await updateSettings(userId, { + subscription_tier: "trial", + trial_ends_at: new Date(Date.now() + 2 * 86_400_000).toISOString(), + grace_period_ends_at: null, + daily_limit_override: null, + feature_configs: {}, + }); + await page.goto("/app/generate"); + await expect.poll(async () => (await settings(userId)).generate?.model, { timeout: AI_TIMEOUT }).toBe(HAIKU); + + const selector = page.getByRole("combobox", { name: "Select AI model for generate" }); + await expect(selector).toContainText("Claude Haiku 5.5"); + await selector.click(); + await expect(page.getByRole("option", { name: /Claude Opus 5\.5/ })).toBeDisabled(); + await expect(page.getByRole("option", { name: /Claude Sonnet 5\.5/ })).toBeDisabled(); + await expect(page.getByRole("option", { name: /Gemini 3\.8 Flash/ })).toBeDisabled(); + await expect(page.getByRole("option", { name: /GPT-6 Luna/ })).toBeEnabled(); + await expect(page.getByRole("option", { name: /Muse Spark 1\.3/ })).toBeEnabled(); + await evidence.shot("01-basic-model-list"); + await page.keyboard.press("Escape"); + await evidence.step("Basic account: selector shows Claude Haiku 5.5; Luna and Muse Spark enabled; Opus, Sonnet and Gemini locked behind Pro"); + + await generateOnce(page); + 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: HAIKU })); + 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 }) => { + const userId = await user.userId(); + const retired = Object.fromEntries(["generate", "attack", "brainstorm", "factoids", "ask", "sparring"].map((feature) => [ + feature, + { provider: "openrouter", model: "anthropic/claude-sonnet-5", temperature: 0.7, reasoningEffort: "low" }, + ])); + await updateSettings(userId, { feature_configs: retired }); + 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.poll(async () => Object.values(await settings(userId)).map((config) => config.model), { timeout: AI_TIMEOUT }) + .toEqual(Array(6).fill(OPUS)); + await evidence.shot("01-retired-migrated"); + await evidence.step("Saved Sonnet 5 configs were rewritten to Opus 5.5 in user_settings"); +}); diff --git a/docs/architecture.md b/docs/architecture.md index bfc5088db..840ab4028 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -128,9 +128,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, Luna Basic default, Pro-only entries, and request controls live in [`supabase/functions/_shared/model-catalog.ts`](../supabase/functions/_shared/model-catalog.ts). OpenRouter usage remains raw in the audit row, and the reported charge is unknown when the provider does not return a complete cost. The opponent's voice is the one exception to the OpenRouter route: the `voice` function calls ElevenLabs with `ELEVENLABS_API_KEY`. ElevenLabs reports billed characters, not dollars, so [`supabase/functions/_shared/cost.ts`](../supabase/functions/_shared/cost.ts) prices those rows from a dated list-price snapshot; promotions and plan credits are not modelled, so a voice row can overstate the charge. +All model requests use the single OpenRouter backend route and `OPENROUTER_API_KEY`. The shared ten-model catalog, its defaults (Claude Haiku 5.5 for Basic, Claude Opus 5.5 for Pro), Pro-only entries, retired-ID successors, and request controls live in [`supabase/functions/_shared/model-catalog.ts`](../supabase/functions/_shared/model-catalog.ts). 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 and normally resolves to GLM 5.3 Flash; manual GLM selection keeps its Pro restriction. Auto Pro remains Pro-only. 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 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. ## Observability diff --git a/docs/testing.md b/docs/testing.md index 6141d8969..33dab4898 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -86,7 +86,7 @@ OPENROUTER_API_KEY=... deno run --allow-read --allow-write --allow-env=OPENROUTE --allow-net=openrouter.ai scripts/prompt-examples.ts supabase/functions /tmp/after.json ``` -This sends the fourteen prompt examples to one real model (GPT-5.6 Luna by default) and writes the raw outputs for manual review. Point the first argument at a directory holding an older `_shared/prompts.ts` and `ai/prompt-builder.ts` to compare revisions. It runs no web research and is a bounded manual check, not a model matrix or an automated quality gate. +This sends the fourteen prompt examples to one real model (GPT-6 Luna by default) and writes the raw outputs for manual review. Point the first argument at a directory holding an older `_shared/prompts.ts` and `ai/prompt-builder.ts` to compare revisions. It runs no web research and is a bounded manual check, not a model matrix or an automated quality gate. ### Live Jev contract smoke @@ -215,7 +215,7 @@ To check one feature by hand, or keep evidence for a change, use the same stack LIVE_PROVIDERS=1 LIVE_OPENROUTER_API_KEY=... LIVE_EXA_API_KEY=... ./.superset/db-gate.sh ``` -Live mode uses the same locked stack but skips pgTAP and the fake-provider specs. It runs `e2e/live/` on desktop Chromium against real OpenRouter, Jev, and Exa. Imported web pages are fetched live too. The fake provider becomes a recording proxy. It forwards every chat call to OpenRouter with the model pinned to `LIVE_MODEL` (default `meta/muse-spark-1.3-contributor`; CI reads the `LIVE_MODEL` repository variable, which is unset), passes Jev decisions through unchanged, and refuses calls past `MAX_UPSTREAM_CALLS` (200). The default is the cheapest model that supports every request parameter the app sends, including `response_format` and reasoning effort. It is test-only and not in the app catalog, so users cannot pick it. Meta is its only provider, so an outage there fails the gate instead of failing over. +Live mode uses the same locked stack but skips pgTAP and the fake-provider specs. It runs `e2e/live/` on desktop Chromium against real OpenRouter, Jev, and Exa. Imported web pages are fetched live too. The fake provider becomes a recording proxy. It forwards every chat call to OpenRouter with the model pinned to `LIVE_MODEL` (default `meta/muse-spark-1.3-contributor`; CI reads the `LIVE_MODEL` repository variable, which is unset), passes Jev decisions through unchanged, and refuses calls past `MAX_UPSTREAM_CALLS` (200). The default is the cheapest model that supports every request parameter the app sends, including `response_format` and reasoning effort. It is also the Basic catalog model "Muse Spark 1.3", so a live run that selects it cannot be told apart from the pin by the upstream model alone; recipes check the requested model instead. Meta is its only provider, so an outage there fails the gate instead of failing over. The specs cover web-researched case generation, verified Factoids citations, Ask with web search, URL import, streamed Ask, Attack, Brainstorm, a judged sparring round, and Crossfire with live Jev signals. Outputs vary, so tests assert structure rather than text: @@ -288,8 +288,8 @@ npx playwright test --config=playwright.production.config.ts - every browser-facing Edge Function answers a preflight that allows the site origin (`workspace-invites` is excluded until production enables Workspaces); - `ai` returns 401, not a 5xx, to an unauthenticated call; - the canary account signs in, opens Generate, and Settings shows its Pro plan (a row-level-security read), without spending an AI request; -- the dedicated canary account gets a streamed GPT-5.6 Luna answer from `ai-stream`; -- the canary account generates a three-contention case with GPT-5.6 Luna through `ai`. +- the dedicated canary account gets a streamed Claude Haiku 5.5 answer from `ai-stream`; +- the canary account generates a three-contention case with Claude Haiku 5.5 through `ai`. The last two checks are the only production writes, and they touch only the canary account: its `ai_requests` usage rows and whatever the Generate page saves for it. The dedicated canary account has Pro promo access capped at 10 requests a day. Captures are disabled because they could retain its session. diff --git a/e2e/production/canary.spec.ts b/e2e/production/canary.spec.ts index b036e8c15..6a1686175 100644 --- a/e2e/production/canary.spec.ts +++ b/e2e/production/canary.spec.ts @@ -32,10 +32,10 @@ async function signInAsCanary(page: Page, { aiRequest }: { aiRequest: boolean }) }, data.session); } -// GPT-5.6 Luna is the Basic default, so this is the path most users take. -async function pickLuna(page: Page, feature: string) { +// Claude Haiku 5.5 is the Basic default, so this is the path most users take. +async function pickBasicDefault(page: Page, feature: string) { await page.getByRole("combobox", { name: `Select AI model for ${feature}` }).click(); - await page.getByRole("option", { name: /GPT-5\.6 Luna/ }).first().click(); + await page.getByRole("option", { name: /Claude Haiku 5\.5/ }).first().click(); } test("the production site loads and guards the app", async ({ page, baseURL }) => { @@ -89,7 +89,7 @@ test("the canary account signs in and loads its account", async ({ page }) => { test("the canary account gets a streamed answer from a real model", async ({ page }) => { await signInAsCanary(page, { aiRequest: true }); await page.goto("/app/ask"); - await pickLuna(page, "ask"); + await pickBasicDefault(page, "ask"); await page.getByPlaceholder("Ask your debate coach...").fill("Reply with one short sentence: what is a rebuttal?"); const stream = page.waitForResponse((response) => response.url().endsWith("/functions/v1/ai-stream"), { timeout: 90_000 }); await page.getByRole("button", { name: "Send message" }).click(); @@ -111,7 +111,7 @@ test("the canary account generates a case", async ({ page }) => { test.setTimeout(180_000); await signInAsCanary(page, { aiRequest: true }); await page.goto("/app/generate"); - await pickLuna(page, "generate"); + await pickBasicDefault(page, "generate"); await page.getByRole("textbox", { name: "Motion / Topic *" }).fill("This House would make public transport free"); const generated = page.waitForResponse((response) => response.url().endsWith("/functions/v1/ai") && response.request().method() === "POST", { timeout: 150_000 }); await page.getByRole("button", { name: "Generate Debate Case" }).click(); diff --git a/scripts/prompt-examples.ts b/scripts/prompt-examples.ts index 87a8d9fab..84ab08622 100644 --- a/scripts/prompt-examples.ts +++ b/scripts/prompt-examples.ts @@ -11,7 +11,7 @@ import { PROMPT_EXAMPLES } from "../supabase/functions/_shared/prompts/prompt-examples.ts"; -const [functionsDir, outPath, model = "openai/gpt-5.6-luna", only] = Deno.args; +const [functionsDir, outPath, model = "openai/gpt-6-luna", only] = Deno.args; const selected = only ? new Set(only.split(",")) : null; if (!functionsDir || !outPath) { console.error("usage: prompt-examples.ts [model]"); diff --git a/src/lib/stores/settings-store.ts b/src/lib/stores/settings-store.ts index 9de5d6884..4d96bd91d 100644 --- a/src/lib/stores/settings-store.ts +++ b/src/lib/stores/settings-store.ts @@ -10,6 +10,7 @@ import { getModelCatalogEntry, isAutoModelSelection, isReasoningEffort, + retiredModelSuccessor, } from '../../../supabase/functions/_shared/model-catalog'; import type { ProviderConfig } from '../ai/providers/types'; import { supabase } from '@/lib/supabase/client'; @@ -80,7 +81,7 @@ function normalizeModelId(value: unknown, fallback: string = DEFAULT_MODEL): str const model = value.trim(); if (isAutoModelSelection(model)) return model; if (getModelCatalogEntry(model)) return model; - return LEGACY_MODEL_SUCCESSORS[model] ?? DEFAULT_MODEL; + return retiredModelSuccessor(model) ?? LEGACY_MODEL_SUCCESSORS[model] ?? DEFAULT_MODEL; } function normalizeFeatureConfig( diff --git a/src/locales/en.json b/src/locales/en.json index 9aa0ab66e..a38fa34c7 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -259,7 +259,7 @@ "factoidsTitle": "Factoids" }, "modelSelector": { - "autoFastDescription": "Fast routing, usually GLM", + "autoFastDescription": "Fast routing, usually Claude Haiku", "autoProDescription": "Routes among the strongest models", "reasoning": "Reasoning", "reasoningLow": "Low", @@ -932,7 +932,7 @@ "trialFeature4": "3-day access", "proFeature1": "Expanded AI prep access", "proFeature2": "All 11 prep modes", - "proFeature3": "All 10 models, including Sonnet, Opus, Muse Spark, and GLM Flash", + "proFeature3": "All 10 models, including Claude Opus and Sonnet, GPT-6.1 Sol, and Gemini", "proFeature4": "Priority support", "proFeature5": "Active Grounding™ research", "startTrial": "Start Free Trial", @@ -948,7 +948,7 @@ "q1": "How much does DebateLab cost?", "a1": "DebateLab offers a 3-day free trial. After that, Pro is $5/month + tax for expanded access and all 10 models.", "q2": "What do I get with Pro?", - "a2": "Expanded AI prep access, all 10 models including Sonnet, Opus, Muse Spark, and GLM Flash, Active Grounding™ research, and priority support.", + "a2": "Expanded AI prep access, all 10 models including Claude Opus and Sonnet, GPT-6.1 Sol, and Gemini, Active Grounding™ research, and priority support.", "q3": "Can I cancel my subscription?", "a3": "Yes, you can cancel anytime from your Settings page. You'll retain access until the end of your billing period.", "q4": "Does the AI make things up?", @@ -1370,7 +1370,7 @@ "freeFeature3": "All debate features", "freeFeature4": "3-day free trial", "proFeature1": "Expanded AI prep access", - "proFeature2": "All 10 models, including Sonnet, Opus, Muse Spark, and GLM Flash", + "proFeature2": "All 10 models, including Claude Opus and Sonnet, GPT-6.1 Sol, and Gemini", "proFeature3": "Deep research with Exa", "proFeature4": "Priority processing", "proFeature5": "All debate features", diff --git a/src/locales/es.json b/src/locales/es.json index 552f50135..c7cab3d13 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -259,7 +259,7 @@ "factoidsTitle": "Datos" }, "modelSelector": { - "autoFastDescription": "Enrutamiento rápido, normalmente con GLM", + "autoFastDescription": "Enrutamiento rápido, normalmente con Claude Haiku", "autoProDescription": "Elige entre los modelos más capaces", "reasoning": "Razonamiento", "reasoningLow": "Bajo", @@ -932,7 +932,7 @@ "trialFeature4": "Acceso por 3 días", "proFeature1": "Acceso ampliado a preparación con IA", "proFeature2": "Las 11 herramientas de preparación", - "proFeature3": "Los 10 modelos, incluidos Sonnet, Opus, Muse Spark y GLM Flash", + "proFeature3": "Los 10 modelos, incluidos Claude Opus y Sonnet, GPT-6.1 Sol y Gemini", "proFeature4": "Soporte prioritario", "proFeature5": "Investigación Active Grounding™", "startTrial": "Comenzar Prueba Gratis", @@ -948,7 +948,7 @@ "q1": "¿Cuánto cuesta DebateLab?", "a1": "DebateLab ofrece una prueba gratis de 3 días. Después, Pro cuesta $5/mes + impuestos para acceso ampliado y los 10 modelos.", "q2": "¿Qué obtengo con Pro?", - "a2": "Acceso ampliado a preparación con IA, los 10 modelos incluidos Sonnet, Opus, Muse Spark y GLM Flash, investigación Active Grounding™ y soporte prioritario.", + "a2": "Acceso ampliado a preparación con IA, los 10 modelos incluidos Claude Opus y Sonnet, GPT-6.1 Sol y Gemini, investigación Active Grounding™ y soporte prioritario.", "q3": "¿Puedo cancelar mi suscripción?", "a3": "Sí, puedes cancelar en cualquier momento desde tu página de Ajustes. Mantendrás el acceso hasta el final de tu período de facturación.", "q4": "¿La IA inventa cosas?", @@ -1370,7 +1370,7 @@ "freeFeature3": "Todas las funciones de debate", "freeFeature4": "Prueba gratis de 3 días", "proFeature1": "Acceso ampliado a preparación con IA", - "proFeature2": "Los 10 modelos, incluidos Sonnet, Opus, Muse Spark y GLM Flash", + "proFeature2": "Los 10 modelos, incluidos Claude Opus y Sonnet, GPT-6.1 Sol y Gemini", "proFeature3": "Investigación profunda con Exa", "proFeature4": "Procesamiento prioritario", "proFeature5": "Todas las funciones de debate", diff --git a/src/locales/hu.json b/src/locales/hu.json index 6cf3ba0fb..baec19ca5 100644 --- a/src/locales/hu.json +++ b/src/locales/hu.json @@ -259,7 +259,7 @@ "factoidsTitle": "Tények" }, "modelSelector": { - "autoFastDescription": "Gyors útválasztás, általában GLM-mel", + "autoFastDescription": "Gyors útválasztás, általában Claude Haikuval", "autoProDescription": "A legerősebb modellek közül választ", "reasoning": "Érvelés", "reasoningLow": "Alacsony", @@ -932,7 +932,7 @@ "trialFeature4": "3 napos hozzáférés", "proFeature1": "Bővített MI felkészülési hozzáférés", "proFeature2": "Mind a 11 felkészülési eszköz", - "proFeature3": "Mind a 10 modell, köztük a Sonnet, Opus, Muse Spark és GLM Flash", + "proFeature3": "Mind a 10 modell, köztük a Claude Opus és Sonnet, a GPT-6.1 Sol és a Gemini", "proFeature4": "Elsőbbségi támogatás", "proFeature5": "Active Grounding™ kutatás", "startTrial": "Ingyenes Próba Indítása", @@ -948,7 +948,7 @@ "q1": "Mennyibe kerül a DebateLab?", "a1": "A DebateLab 3 napos ingyenes próbát kínál. Ezután a Pro $5/hó + adó bővített hozzáférést és mind a 10 modellt kínálja.", "q2": "Mit kapok a Pro-val?", - "a2": "Bővített MI felkészülési hozzáférés mind a 10 modellel, köztük a Sonnet, Opus, Muse Spark és GLM Flash modellekkel, Active Grounding™ kutatással és elsőbbségi támogatással.", + "a2": "Bővített MI felkészülési hozzáférés mind a 10 modellel, köztük a Claude Opus és Sonnet, a GPT-6.1 Sol és a Gemini modellekkel, Active Grounding™ kutatással és elsőbbségi támogatással.", "q3": "Lemondhatom az előfizetésem?", "a3": "Igen, bármikor lemondhatod a Beállítások oldalon. A számlázási időszak végéig megtartod a hozzáférést.", "q4": "Az MI kitalál dolgokat?", @@ -1370,7 +1370,7 @@ "freeFeature3": "Minden vitafunkció", "freeFeature4": "3 napos ingyenes próba", "proFeature1": "Bővített MI felkészülési hozzáférés", - "proFeature2": "Mind a 10 modell, köztük a Sonnet, Opus, Muse Spark és GLM Flash", + "proFeature2": "Mind a 10 modell, köztük a Claude Opus és Sonnet, a GPT-6.1 Sol és a Gemini", "proFeature3": "Mélyreható kutatás Exa-val", "proFeature4": "Elsőbbségi feldolgozás", "proFeature5": "Minden vitafunkció", diff --git a/src/test/ai/streaming.test.ts b/src/test/ai/streaming.test.ts index 49b34ad04..ec0707804 100644 --- a/src/test/ai/streaming.test.ts +++ b/src/test/ai/streaming.test.ts @@ -88,38 +88,38 @@ describe('AI streaming helpers', () => { }); it('enables streaming for basic OpenRouter ask chats', () => { - expect(isAskStreamingSupported({ provider: 'openrouter', model: 'openai/gpt-5.6-luna' })).toBe(true); + expect(isAskStreamingSupported({ provider: 'openrouter', model: 'openai/gpt-6-luna' })).toBe(true); expect(isAskStreamingSupported({ provider: 'openai', model: 'gpt-5.4-mini' })).toBe(false); expect(isAskStreamingSupported({ provider: 'google', model: 'gemini-3-flash-preview' })).toBe(false); expect(isAskStreamingSupported({ provider: 'anthropic', model: 'claude-haiku-4-5-20251001' })).toBe(false); expect(isAskStreamingSupported({ provider: 'unknown' as string, model: 'x' })).toBe(false); expect(isAskStreamingSupported( - { provider: 'openrouter', model: 'openai/gpt-5.6-luna' }, + { provider: 'openrouter', model: 'openai/gpt-6-luna' }, { citationMode: true } )).toBe(false); expect(isAskStreamingSupported( - { provider: 'openrouter', model: 'openai/gpt-5.6-luna' }, + { provider: 'openrouter', model: 'openai/gpt-6-luna' }, { webSearch: true } )).toBe(false); expect(isAskStreamingSupported( - { provider: 'openrouter', model: 'openai/gpt-5.6-luna' }, + { provider: 'openrouter', model: 'openai/gpt-6-luna' }, { prosConsMode: true } )).toBe(false); expect(isAskStreamingSupported( - { provider: 'openrouter', model: 'openai/gpt-5.6-luna' }, + { provider: 'openrouter', model: 'openai/gpt-6-luna' }, { argumentMapMode: true } )).toBe(false); expect(isAskStreamingSupported( - { provider: 'openrouter', model: 'openai/gpt-5.6-luna' }, + { provider: 'openrouter', model: 'openai/gpt-6-luna' }, { mode: 'clarify' } )).toBe(false); expect(isAskStreamingSupported( - { provider: 'openrouter', model: 'openai/gpt-5.6-luna' }, + { provider: 'openrouter', model: 'openai/gpt-6-luna' }, { mode: 'general' } )).toBe(true); for (const mode of ['clarify', 'summarize', 'proscons', 'argumentmap']) { expect(isAskStreamingSupported( - { provider: 'openrouter', model: 'openai/gpt-5.6-luna' }, + { provider: 'openrouter', model: 'openai/gpt-6-luna' }, { mode } )).toBe(false); } diff --git a/src/test/debate/debate-context.test.tsx b/src/test/debate/debate-context.test.tsx index 64ac090eb..765f78ca0 100644 --- a/src/test/debate/debate-context.test.tsx +++ b/src/test/debate/debate-context.test.tsx @@ -128,7 +128,7 @@ describe('Current Debate context in requests and the Review control', () => { }); const brainstormInput = { motion: 'This House would make public transport free', side: 'pro' as const, outputMode: 'topfive' as const, language: 'en' }; - const config = { provider: 'openrouter' as const, model: 'openai/gpt-5.6-luna' }; + const config = { provider: 'openrouter' as const, model: 'openai/gpt-6-luna' }; const lastBody = () => (supabaseMock.functions.invoke.mock.calls.at(-1) as unknown as [string, { body: Record }])[1].body; it('sends IDs only, and a Review choice lasts for exactly one successful request', async () => { diff --git a/src/test/security-hardening.test.ts b/src/test/security-hardening.test.ts index e960eda45..c6a3c59b6 100644 --- a/src/test/security-hardening.test.ts +++ b/src/test/security-hardening.test.ts @@ -75,8 +75,8 @@ describe('security hardening regressions', () => { it('uses allowlisted model/provider pairs and SSRF-safe citation verification', () => { expect(modelCatalog).toContain('id: "z-ai/glm-5.3-flash"'); - expect(modelCatalog).toContain('id: "anthropic/claude-sonnet-5"'); - expect(modelCatalog).toContain('id: "openai/gpt-5.6-luna"'); + expect(modelCatalog).toContain('id: "anthropic/claude-haiku-5.5"'); + expect(modelCatalog).toContain('id: "openai/gpt-6-luna"'); expect(modelCatalog).toContain('id: "google/gemini-3.8-flash"'); expect(ai).toContain('function modelForRequest'); expect(stream).toContain('isModelSelection(rawModel)'); diff --git a/src/test/settings/settings-migration.test.ts b/src/test/settings/settings-migration.test.ts index fb24a1033..e9bdc3bb8 100644 --- a/src/test/settings/settings-migration.test.ts +++ b/src/test/settings/settings-migration.test.ts @@ -51,7 +51,7 @@ import { useSettingsStore } from '@/lib/stores/settings-store'; const features = ['generate', 'attack', 'brainstorm', 'factoids', 'ask', 'sparring'] as const; const savedWithTemperature = Object.fromEntries(features.map((feature) => [ feature, - { provider: 'openrouter', model: 'openai/gpt-5.6-luna', temperature: 0.7, reasoningEffort: 'low' }, + { provider: 'openrouter', model: 'openai/gpt-6-luna', temperature: 0.7, reasoningEffort: 'low' }, ])); describe('settings hydration cleanup', () => { @@ -73,7 +73,7 @@ describe('settings hydration cleanup', () => { const saved = db.row.feature_configs as Record; expect(Object.keys(saved.ask)).not.toContain('temperature'); - expect(saved.ask.model).toBe('openai/gpt-5.6-luna'); + expect(saved.ask.model).toBe('openai/gpt-6-luna'); }); it('does not overwrite a settings save that lands before the cleanup', async () => { @@ -83,7 +83,7 @@ describe('settings hydration cleanup', () => { ...db.row, feature_configs: { ...savedWithTemperature, - ask: { provider: 'openrouter', model: 'google/gemini-3.8-flash', reasoningEffort: 'low' }, + ask: { provider: 'openrouter', model: 'meta/muse-spark-1.3-contributor', reasoningEffort: 'low' }, }, updated_at: '2026-10-09T17:00:00.000Z', }; @@ -92,6 +92,6 @@ describe('settings hydration cleanup', () => { await useSettingsStore.getState().hydrateFromServer(); const saved = db.row.feature_configs as Record; - expect(saved.ask.model).toBe('google/gemini-3.8-flash'); + expect(saved.ask.model).toBe('meta/muse-spark-1.3-contributor'); }); }); diff --git a/supabase/README.md b/supabase/README.md index bac85dcb0..4ad607716 100644 --- a/supabase/README.md +++ b/supabase/README.md @@ -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. GPT-5.6 Luna is the Basic default; Pro-only models require active Pro access. +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. The browser boundary is [`../src/lib/ai/client.ts`](../src/lib/ai/client.ts). Shared prompt, research, source-quality, cost, entitlement, CORS, and control logic lives in `_shared`. diff --git a/supabase/functions/_shared/auto-model-router.test.ts b/supabase/functions/_shared/auto-model-router.test.ts index b6b0f4b52..a6cb9fbf5 100644 --- a/supabase/functions/_shared/auto-model-router.test.ts +++ b/supabase/functions/_shared/auto-model-router.test.ts @@ -29,41 +29,41 @@ function jevReturning(result: JevDecisionResult, observed?: { calls: number; sta }; } -Deno.test("Auto Fast normally resolves to GLM for a Basic user", async () => { +Deno.test("Auto Fast normally resolves to Claude Haiku for a Basic user", async () => { const resolved = await resolveModelSelection({ requestedModel: AUTO_FAST, feature: "generate", input: { motion: "This house would subsidize public transit", language: "en" }, canAccessProModels: false, - callJevImpl: jevReturning(choiceResult("z-ai/glm-5.3-flash")), + callJevImpl: jevReturning(choiceResult("anthropic/claude-haiku-5.5")), }); - if (resolved.requestedModel !== AUTO_FAST || resolved.resolvedModel !== "z-ai/glm-5.3-flash" || resolved.usedFallback) { + if (resolved.requestedModel !== AUTO_FAST || resolved.resolvedModel !== "anthropic/claude-haiku-5.5" || resolved.usedFallback) { throw new Error(`unexpected Auto Fast resolution: ${JSON.stringify(resolved)}`); } }); -Deno.test("Auto Fast can route logic-heavy work to DeepSeek Flash", async () => { +Deno.test("Auto Fast can route long-context work to Muse Spark", async () => { const resolved = await resolveModelSelection({ requestedModel: AUTO_FAST, feature: "attack", - input: { caseText: "A long causal chain requiring adversarial logical analysis" }, + input: { caseText: "A very long multilingual case file requiring broad context handling" }, canAccessProModels: false, - callJevImpl: jevReturning(choiceResult("deepseek/deepseek-v4-flash-0731")), + callJevImpl: jevReturning(choiceResult("meta/muse-spark-1.3-contributor")), }); - if (resolved.resolvedModel !== "deepseek/deepseek-v4-flash-0731" || resolved.usedFallback) { - throw new Error(`logic-heavy route was not accepted: ${JSON.stringify(resolved)}`); + if (resolved.resolvedModel !== "meta/muse-spark-1.3-contributor" || resolved.usedFallback) { + throw new Error(`long-context route was not accepted: ${JSON.stringify(resolved)}`); } }); -Deno.test("Auto Fast falls back to GLM when Jev is uncertain or unavailable", async () => { +Deno.test("Auto Fast falls back to Claude Haiku when Jev is uncertain or unavailable", async () => { const uncertain = await resolveModelSelection({ requestedModel: AUTO_FAST, feature: "ask", input: { message: "Help me" }, canAccessProModels: false, - callJevImpl: jevReturning(choiceResult("openai/gpt-5.6-luna", 0.54)), + callJevImpl: jevReturning(choiceResult("openai/gpt-6-luna", 0.54)), }); - if (uncertain.resolvedModel !== "z-ai/glm-5.3-flash" || !uncertain.usedFallback) throw new Error("uncertain route did not fall back"); + if (uncertain.resolvedModel !== "anthropic/claude-haiku-5.5" || !uncertain.usedFallback) throw new Error("uncertain route did not fall back"); const unavailable = await resolveModelSelection({ requestedModel: AUTO_FAST, @@ -72,7 +72,7 @@ Deno.test("Auto Fast falls back to GLM when Jev is uncertain or unavailable", as canAccessProModels: false, callJevImpl: () => Promise.reject(Object.assign(new Error("provider unavailable"), { usage: { inputTokens: 9, outputTokens: 0 } })), }); - if (unavailable.resolvedModel !== "z-ai/glm-5.3-flash" || !unavailable.usedFallback) throw new Error("failed route did not fall back"); + if (unavailable.resolvedModel !== "anthropic/claude-haiku-5.5" || !unavailable.usedFallback) throw new Error("failed route did not fall back"); if (unavailable.jevUsage?.inputTokens !== 9) throw new Error("failed route usage was discarded"); }); @@ -82,9 +82,9 @@ Deno.test("Auto Fast ignores a Jev label outside its candidate set", async () => feature: "generate", input: { motion: "Should cities ban cars?" }, canAccessProModels: false, - callJevImpl: jevReturning(choiceResult("anthropic/claude-opus-5")), + callJevImpl: jevReturning(choiceResult("anthropic/claude-opus-5.5")), }); - if (resolved.resolvedModel !== "z-ai/glm-5.3-flash" || !resolved.usedFallback) throw new Error("unsupported label escaped candidate gate"); + if (resolved.resolvedModel !== "anthropic/claude-haiku-5.5" || !resolved.usedFallback) throw new Error("unsupported label escaped candidate gate"); }); Deno.test("Auto Pro rejects Basic before calling Jev", async () => { @@ -95,7 +95,7 @@ Deno.test("Auto Pro rejects Basic before calling Jev", async () => { feature: "generate", input: { motion: "Should cities ban cars?" }, canAccessProModels: false, - callJevImpl: jevReturning(choiceResult("openai/gpt-5.6-sol"), observed), + callJevImpl: jevReturning(choiceResult("openai/gpt-6.1-sol"), observed), }); } catch (error) { if (!(error instanceof Error) || !error.message.includes("Pro")) throw error; @@ -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 ["openai/gpt-5.6-sol", "anthropic/claude-opus-5", "anthropic/claude-sonnet-5"]) { + for (const model of ["anthropic/claude-opus-5.5", "anthropic/claude-sonnet-5.5", "openai/gpt-6.1-sol"]) { const resolved = await resolveModelSelection({ requestedModel: AUTO_PRO, feature: "generate", @@ -122,9 +122,9 @@ Deno.test("Auto Pro accepts only its approved smart-model pool", async () => { feature: "generate", input: { motion: "Complex international law motion" }, canAccessProModels: true, - callJevImpl: jevReturning(choiceResult("meta/muse-spark-1.3")), + callJevImpl: jevReturning(choiceResult("z-ai/glm-5.3-flash")), }); - if (outsidePool.resolvedModel !== "anthropic/claude-sonnet-5" || !outsidePool.usedFallback) { + if (outsidePool.resolvedModel !== "anthropic/claude-opus-5.5" || !outsidePool.usedFallback) { throw new Error("Auto Pro accepted a model outside its pool"); } }); @@ -132,13 +132,13 @@ Deno.test("Auto Pro accepts only its approved smart-model pool", async () => { Deno.test("manual model selections bypass Jev", async () => { const observed = { calls: 0 }; const resolved = await resolveModelSelection({ - requestedModel: "google/gemini-3.8-flash", + requestedModel: "openai/gpt-6-luna", feature: "ask", input: { message: "Hi" }, canAccessProModels: false, callJevImpl: jevReturning(choiceResult("z-ai/glm-5.3-flash"), observed), }); - if (resolved.resolvedModel !== "google/gemini-3.8-flash" || resolved.requestedModel !== "google/gemini-3.8-flash") { + if (resolved.resolvedModel !== "openai/gpt-6-luna" || resolved.requestedModel !== "openai/gpt-6-luna") { throw new Error("manual selection changed"); } if (observed.calls !== 0) throw new Error("manual selection called Jev"); diff --git a/supabase/functions/_shared/auto-model-router.ts b/supabase/functions/_shared/auto-model-router.ts index 1cb0358c4..a16d465fd 100644 --- a/supabase/functions/_shared/auto-model-router.ts +++ b/supabase/functions/_shared/auto-model-router.ts @@ -18,16 +18,15 @@ const ROUTING_CONFIDENCE = 0.55; const MAX_ROUTING_STATE_CHARS = 8_000; const FAST_CANDIDATES = { - "z-ai/glm-5.3-flash": "Default for ordinary structured DebateLab work and efficient long-context requests.", - "openai/gpt-5.6-luna": "Best for short latency-sensitive conversation and simple coaching.", - "google/gemini-3.8-flash": "Best for very long input, multilingual output, or broad context handling.", - "deepseek/deepseek-v4-flash-0731": "Best for logic-heavy analysis, adversarial attack, or structured reasoning at low cost.", + "anthropic/claude-haiku-5.5": "Default for ordinary DebateLab work: cases, rebuttals, coaching, and structured output.", + "openai/gpt-6-luna": "Best for short latency-sensitive conversation and simple coaching.", + "meta/muse-spark-1.3-contributor": "Best for very long input, multilingual output, or broad context handling.", } as const; const PRO_CANDIDATES = { - "openai/gpt-5.6-sol": "Best for complex structured reasoning and reliable multi-part analysis.", - "anthropic/claude-opus-5": "Best for nuanced rhetoric, adversarial critique, and demanding long-form writing.", - "anthropic/claude-sonnet-5": "Balanced choice for professional work when no specialist clearly wins.", + "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.", + "openai/gpt-6.1-sol": "Best for complex structured reasoning and reliable multi-part analysis.", } as const; type AutoCandidate = keyof typeof FAST_CANDIDATES | keyof typeof PRO_CANDIDATES; @@ -73,7 +72,7 @@ function routingState(args: ResolveModelSelectionArgs) { } function fallbackFor(selection: typeof AUTO_FAST | typeof AUTO_PRO): AutoCandidate { - return selection === AUTO_FAST ? "z-ai/glm-5.3-flash" : "anthropic/claude-sonnet-5"; + return selection === AUTO_FAST ? "anthropic/claude-haiku-5.5" : "anthropic/claude-opus-5.5"; } function isAbort(error: unknown, signal?: AbortSignal): boolean { diff --git a/supabase/functions/_shared/entitlements.test.ts b/supabase/functions/_shared/entitlements.test.ts index cc4c79baf..72b117a18 100644 --- a/supabase/functions/_shared/entitlements.test.ts +++ b/supabase/functions/_shared/entitlements.test.ts @@ -104,7 +104,7 @@ Deno.test("banned users are refused before any quota is consumed", async () => { user_roles: { role: "banned" }, get quota() { quotaConsumed = true; return [{ allowed: true, current_count: 1 }]; }, }, async () => { - const result = await checkRateLimit("user-1", "openai/gpt-5.6-luna"); + const result = await checkRateLimit("user-1", "openai/gpt-6-luna"); assertEquals(result.allowed, false, "allowed"); assertEquals(result.code, "banned", "code"); assertEquals(result.statusCode, 403, "statusCode"); diff --git a/supabase/functions/_shared/model-catalog.test.ts b/supabase/functions/_shared/model-catalog.test.ts index f18ab5ea5..141954c13 100644 --- a/supabase/functions/_shared/model-catalog.test.ts +++ b/supabase/functions/_shared/model-catalog.test.ts @@ -5,6 +5,7 @@ import { getModelCatalogEntry, isReasoningEffort, MODEL_CATALOG, + retiredModelSuccessor, } from "./model-catalog.ts"; Deno.test("every catalog model supports the forced trial effort and both defaults resolve to valid tiers", () => { @@ -19,9 +20,9 @@ Deno.test("every catalog model supports the forced trial effort and both default Deno.test("model catalog marks only the approved Pro entries as Pro-only", () => { const basic = new Set([ - "openai/gpt-5.6-luna", - "google/gemini-3.8-flash", - "deepseek/deepseek-v4-flash-0731", + "anthropic/claude-haiku-5.5", + "openai/gpt-6-luna", + "meta/muse-spark-1.3-contributor", ]); for (const entry of MODEL_CATALOG) { if (entry.isProOnly === basic.has(entry.id)) throw new Error(`incorrect tier for ${entry.id}`); @@ -38,15 +39,27 @@ Deno.test("reasoning effort validator accepts only the product values", () => { }); Deno.test("trial and unsupported Pro reasoning resolve server-side", () => { - if (effectiveReasoningEffort("openai/gpt-5.6-luna", "high", false) !== "low") { + if (effectiveReasoningEffort("openai/gpt-6-luna", "high", false) !== "low") { throw new Error("trial request did not force low"); } - if (effectiveReasoningEffort("anthropic/claude-sonnet-5", "medium", true) !== "medium") { + if (effectiveReasoningEffort("anthropic/claude-opus-5.5", "medium", true) !== "medium") { throw new Error("native Pro medium was changed"); } - for (const model of ["deepseek/deepseek-v4-flash-0731", "deepseek/deepseek-v4-pro-0813", "z-ai/glm-5.3-flash"]) { + for (const model of ["deepseek/deepseek-v4.1-flash", "deepseek/deepseek-v4-pro-0813", "z-ai/glm-5.3-flash"]) { if (effectiveReasoningEffort(model, "medium", true) !== "low") { throw new Error(`unsupported Pro medium was not lowered for ${model}`); } } }); + +Deno.test("retired model IDs resolve to catalog models without crossing into Pro", () => { + const retiredBasic = ["openai/gpt-5.6-luna", "deepseek/deepseek-v4-flash-0731"]; + const retiredPro = ["anthropic/claude-sonnet-5", "anthropic/claude-opus-5", "openai/gpt-5.6-sol", "openai/gpt-5.6-terra", "meta/muse-spark-1.3"]; + for (const id of [...retiredBasic, ...retiredPro]) { + const successor = retiredModelSuccessor(id); + const entry = successor && getModelCatalogEntry(successor); + if (!entry) throw new Error(`retired ${id} has no catalog successor`); + if (retiredBasic.includes(id) && entry.isProOnly) throw new Error(`retired Basic ${id} resolves to Pro ${entry.id}`); + } + if (retiredModelSuccessor(DEFAULT_MODEL) !== undefined) throw new Error("a current model was treated as retired"); +}); diff --git a/supabase/functions/_shared/model-catalog.ts b/supabase/functions/_shared/model-catalog.ts index 7cc8ae81e..9ac386965 100644 --- a/supabase/functions/_shared/model-catalog.ts +++ b/supabase/functions/_shared/model-catalog.ts @@ -15,16 +15,16 @@ export interface ModelCatalogEntry { } export const MODEL_CATALOG = [ - { id: "openai/gpt-5.6-luna", name: "GPT-5.6 Luna", maxTokens: 128_000, isProOnly: false, supportedEfforts: ["low", "medium", "high"] }, - { id: "google/gemini-3.8-flash", name: "Gemini 3.8 Flash", maxTokens: 65_536, isProOnly: false, supportedEfforts: ["low", "medium", "high"] }, - { id: "deepseek/deepseek-v4-flash-0731", name: "DeepSeek V4 Flash", maxTokens: 131_072, isProOnly: false, supportedEfforts: ["low", "high"] }, - { id: "anthropic/claude-sonnet-5", name: "Claude Sonnet 5", maxTokens: 128_000, isProOnly: true, supportedEfforts: ["low", "medium", "high"] }, - { id: "anthropic/claude-opus-5", name: "Claude Opus 5", maxTokens: 128_000, isProOnly: true, supportedEfforts: ["low", "medium", "high"] }, - { id: "openai/gpt-5.6-sol", name: "GPT-5.6 Sol", maxTokens: 128_000, isProOnly: true, supportedEfforts: ["low", "medium", "high"] }, - { id: "openai/gpt-5.6-terra", name: "GPT-5.6 Terra", maxTokens: 128_000, isProOnly: true, supportedEfforts: ["low", "medium", "high"] }, + { id: "anthropic/claude-haiku-5.5", name: "Claude Haiku 5.5", maxTokens: 128_000, isProOnly: false, supportedEfforts: ["low", "medium", "high"] }, + { id: "openai/gpt-6-luna", name: "GPT-6 Luna", maxTokens: 128_000, isProOnly: false, supportedEfforts: ["low", "medium", "high"] }, + { id: "meta/muse-spark-1.3-contributor", name: "Muse Spark 1.3", maxTokens: 943_718, isProOnly: false, supportedEfforts: ["low", "medium", "high"] }, + { id: "anthropic/claude-opus-5.5", name: "Claude Opus 5.5", maxTokens: 128_000, isProOnly: true, supportedEfforts: ["low", "medium", "high"] }, + { id: "anthropic/claude-sonnet-5.5", name: "Claude Sonnet 5.5", maxTokens: 128_000, isProOnly: true, supportedEfforts: ["low", "medium", "high"] }, + { id: "openai/gpt-6.1-sol", name: "GPT-6.1 Sol", maxTokens: 128_000, isProOnly: true, supportedEfforts: ["low", "medium", "high"] }, + { id: "google/gemini-3.8-flash", name: "Gemini 3.8 Flash", maxTokens: 65_536, isProOnly: true, supportedEfforts: ["low", "medium", "high"] }, + { id: "deepseek/deepseek-v4.1-flash", name: "DeepSeek V4.1 Flash", maxTokens: 943_718, isProOnly: true, supportedEfforts: ["low", "high"] }, { id: "deepseek/deepseek-v4-pro-0813", name: "DeepSeek V4 Pro", maxTokens: 384_000, isProOnly: true, supportedEfforts: ["low", "high"] }, { id: "z-ai/glm-5.3-flash", name: "GLM-5.3 Flash", maxTokens: 131_072, isProOnly: true, supportedEfforts: ["low", "high"] }, - { id: "meta/muse-spark-1.3", name: "Muse Spark 1.3", maxTokens: 943_718, isProOnly: true, supportedEfforts: ["low", "medium", "high"] }, ] as const satisfies readonly ModelCatalogEntry[]; export type ModelId = (typeof MODEL_CATALOG)[number]["id"]; @@ -37,8 +37,23 @@ export const AUTO_MODEL_SELECTIONS = [ export type AutoModelSelection = (typeof AUTO_MODEL_SELECTIONS)[number]["id"]; export type ModelSelection = ModelId | AutoModelSelection; -export const DEFAULT_MODEL = "openai/gpt-5.6-luna" as const; -export const DEFAULT_PRO_MODEL = "anthropic/claude-sonnet-5" as const; +export const DEFAULT_MODEL = "anthropic/claude-haiku-5.5" as const; +export const DEFAULT_PRO_MODEL = "anthropic/claude-opus-5.5" as const; + +// Retired catalog IDs that older clients (an installed PWA, a saved setting) +// may still send. Both the server and the settings store resolve them here. +// The old defaults (GPT-5.6 Luna, Claude Sonnet 5) move to the new defaults; +// the rest move to their family's successor. A Basic ID never maps to Pro, +// so DeepSeek V4 Flash, which was Basic, moves to the Basic default. +const RETIRED_MODEL_SUCCESSORS = new Map([ + ["openai/gpt-5.6-luna", DEFAULT_MODEL], + ["deepseek/deepseek-v4-flash-0731", DEFAULT_MODEL], + ["anthropic/claude-sonnet-5", DEFAULT_PRO_MODEL], + ["anthropic/claude-opus-5", DEFAULT_PRO_MODEL], + ["openai/gpt-5.6-sol", "openai/gpt-6.1-sol"], + ["openai/gpt-5.6-terra", "openai/gpt-6.1-sol"], + ["meta/muse-spark-1.3", "meta/muse-spark-1.3-contributor"], +]); const MODEL_BY_ID = new Map(MODEL_CATALOG.map((entry) => [entry.id, entry])); @@ -50,6 +65,10 @@ export function isAutoModelSelection(value: unknown): value is AutoModelSelectio return value === AUTO_FAST || value === AUTO_PRO; } +export function retiredModelSuccessor(model: string): ModelId | undefined { + return RETIRED_MODEL_SUCCESSORS.get(model); +} + export function isModelSelection(value: unknown): value is ModelSelection { return typeof value === "string" && (isAutoModelSelection(value) || MODEL_BY_ID.has(value)); } diff --git a/supabase/functions/ai-stream/auth_test.ts b/supabase/functions/ai-stream/auth_test.ts index f287cf7eb..759cd4435 100644 --- a/supabase/functions/ai-stream/auth_test.ts +++ b/supabase/functions/ai-stream/auth_test.ts @@ -21,7 +21,7 @@ const send = (authorization?: string) => "Content-Type": "application/json", ...(authorization === undefined ? {} : { Authorization: authorization }), }, - body: JSON.stringify({ feature: "ask", model: "openai/gpt-5.6-luna", input: { message: "Question", mode: "general" } }), + body: JSON.stringify({ feature: "ask", model: "openai/gpt-6-luna", input: { message: "Question", mode: "general" } }), }); Deno.test("ai-stream refuses missing, empty, non-Bearer and rejected tokens with the shared auth errors", async () => { diff --git a/supabase/functions/ai-stream/handler_test.ts b/supabase/functions/ai-stream/handler_test.ts index 853556c43..54e0c9ba5 100644 --- a/supabase/functions/ai-stream/handler_test.ts +++ b/supabase/functions/ai-stream/handler_test.ts @@ -103,13 +103,12 @@ Deno.test("streaming Ask forwards the shared prompt and returns provider SSE", a answers: { model: { type: "choice", - choice: "z-ai/glm-5.3-flash", + choice: "anthropic/claude-haiku-5.5", confidence: 0.96, probabilities: { - "z-ai/glm-5.3-flash": 0.96, - "openai/gpt-5.6-luna": 0.02, - "google/gemini-3.8-flash": 0.01, - "deepseek/deepseek-v4-flash-0731": 0.01, + "anthropic/claude-haiku-5.5": 0.96, + "openai/gpt-6-luna": 0.02, + "meta/muse-spark-1.3-contributor": 0.02, }, }, }, @@ -154,10 +153,10 @@ Deno.test("streaming Ask forwards the shared prompt and returns provider SSE", a assertIncludes(body, "event: final"); assertIncludes(body, '"response":"Szia Adam"'); assertIncludes(body, '"requestedModel":"auto-fast"'); - assertIncludes(body, '"resolvedModel":"z-ai/glm-5.3-flash"'); + assertIncludes(body, '"resolvedModel":"anthropic/claude-haiku-5.5"'); const messages = providerRequest?.messages as Array<{ role: string; content: string }> | undefined; - if (providerRequest?.model !== "z-ai/glm-5.3-flash") { + if (providerRequest?.model !== "anthropic/claude-haiku-5.5") { throw new Error(`unexpected OpenRouter model: ${String(providerRequest?.model)}`); } const streamOptions = providerRequest?.stream_options as { include_usage?: unknown } | undefined; @@ -264,7 +263,7 @@ Deno.test("streaming Ask rejects advanced modes and toggles before quota or prov }, body: JSON.stringify({ feature: "ask", - model: "openai/gpt-5.6-luna", + model: "openai/gpt-6-luna", input: { message: "Question", ...testCase.input }, }), }), diff --git a/supabase/functions/ai-stream/index.ts b/supabase/functions/ai-stream/index.ts index 34d940e7c..c90a502bb 100644 --- a/supabase/functions/ai-stream/index.ts +++ b/supabase/functions/ai-stream/index.ts @@ -31,6 +31,7 @@ import { isModelSelection, isReasoningEffort, MAX_REQUEST_OUTPUT_TOKENS, + retiredModelSuccessor, type ModelCatalogEntry, type ModelSelection, type ReasoningEffort, @@ -122,7 +123,8 @@ export async function handleRequest(req: Request): Promise { return jsonResponse({ error: "Streaming currently supports Ask only" }, 400, corsHeaders); } - const rawModel = sanitizeText(body.model, 80) || DEFAULT_MODEL; + const sentModel = sanitizeText(body.model, 80) || DEFAULT_MODEL; + const rawModel = retiredModelSuccessor(sentModel) ?? sentModel; if (!isModelSelection(rawModel)) return jsonResponse({ error: "Unsupported model or provider combination" }, 400, corsHeaders); const requestedModel: ModelSelection = rawModel; const requestedReasoningEffort = body.reasoningEffort === undefined diff --git a/supabase/functions/ai-stream/index_test.ts b/supabase/functions/ai-stream/index_test.ts index 5c4067417..59dbe5dad 100644 --- a/supabase/functions/ai-stream/index_test.ts +++ b/supabase/functions/ai-stream/index_test.ts @@ -30,7 +30,7 @@ function request(signal?: AbortSignal): Request { headers: { Authorization: "Bearer test-user-token", "Content-Type": "application/json" }, body: JSON.stringify({ feature: "ask", - model: "openai/gpt-5.6-luna", + model: "openai/gpt-6-luna", input: { message: "Hello", mode: "general" }, }), }); diff --git a/supabase/functions/ai/debate-context.test.ts b/supabase/functions/ai/debate-context.test.ts index db2804cf2..8d0d8c99e 100644 --- a/supabase/functions/ai/debate-context.test.ts +++ b/supabase/functions/ai/debate-context.test.ts @@ -126,7 +126,7 @@ function aiRequest(body: Record, path = "ai"): Request { return new Request(`http://127.0.0.1/functions/v1/${path}`, { method: "POST", headers: { Authorization: "Bearer test-user-token", "Content-Type": "application/json" }, - body: JSON.stringify({ model: "openai/gpt-5.6-luna", ...body }), + body: JSON.stringify({ model: "openai/gpt-6-luna", ...body }), }); } diff --git a/supabase/functions/ai/generate-honesty_test.ts b/supabase/functions/ai/generate-honesty_test.ts index 759aa374e..ddd064d5a 100644 --- a/supabase/functions/ai/generate-honesty_test.ts +++ b/supabase/functions/ai/generate-honesty_test.ts @@ -128,7 +128,7 @@ Deno.test("generate marks every contention when research has no citable sources" }, body: JSON.stringify({ feature: "generate", - model: "openai/gpt-5.6-luna", + model: "openai/gpt-6-luna", input: { motion: "Should schools teach media literacy?", side: "pro", @@ -177,7 +177,7 @@ Deno.test("generate marks every contention when research has no citable sources" }, body: JSON.stringify({ feature: "generate", - model: "openai/gpt-5.6-luna", + model: "openai/gpt-6-luna", input: { motion: "Should schools teach media literacy?", side: "pro", diff --git a/supabase/functions/ai/handler-research.test.ts b/supabase/functions/ai/handler-research.test.ts index 9e4d03478..a9c3bd2cb 100644 --- a/supabase/functions/ai/handler-research.test.ts +++ b/supabase/functions/ai/handler-research.test.ts @@ -65,7 +65,7 @@ Deno.test("AI handlers reject malformed controls before quota or provider work", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ feature: "ask", - model: "openai/gpt-5.6-luna", + model: "openai/gpt-6-luna", input: { message: "Question", mode: "\u200Bgeneral", citationMode: "false" }, }), })); @@ -94,7 +94,7 @@ Deno.test("AI handlers reject malformed controls before quota or provider work", const languageResponse = await handleRequest(new Request("http://127.0.0.1/functions/v1/ai", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ feature, model: "openai/gpt-5.6-luna", input }), + body: JSON.stringify({ feature, model: "openai/gpt-6-luna", input }), })); if (languageResponse.status !== 400) throw new Error(`${feature} invalid language returned ${languageResponse.status}`); if (!(await languageResponse.text()).includes(`${feature}.language`)) throw new Error(`${feature} invalid language was accepted`); @@ -109,7 +109,7 @@ Deno.test("AI handlers reject malformed controls before quota or provider work", }, body: JSON.stringify({ feature: "ask", - model: "openai/gpt-5.6-luna", + model: "openai/gpt-6-luna", input: { message: "Question", mode: "general", citationMode: "false" }, }), })); @@ -146,7 +146,7 @@ Deno.test("AI handler rejects missing, empty, and invalid bearer tokens with the }, body: JSON.stringify({ feature: "ask", - model: "openai/gpt-5.6-luna", + model: "openai/gpt-6-luna", input: { message: "Question", mode: "general" }, }), })); @@ -269,7 +269,7 @@ Deno.test("factoids handler carries Exa filters and consulted-source metadata ac }, body: JSON.stringify({ feature: "factoids", - model: "openai/gpt-5.6-luna", + model: "openai/gpt-6-luna", input: { claim: "solar energy costs", direction: "for", @@ -379,7 +379,7 @@ Deno.test("Ask marks only surviving URL and research context as available", asyn const response = await handleRequest(new Request("http://127.0.0.1/functions/v1/ai", { method: "POST", headers: { Authorization: "Bearer test-user-token", "Content-Type": "application/json" }, - body: JSON.stringify({ feature: "ask", model: "openai/gpt-5.6-luna", input }), + body: JSON.stringify({ feature: "ask", model: "openai/gpt-6-luna", input }), })); if (response.status !== 200) throw new Error(`Ask handler returned ${response.status}: ${await response.text()}`); const mainRequest = providerBodies.find((body) => { @@ -512,7 +512,7 @@ Deno.test("AI handler ranks stable library sources and returns bounded assessmen headers: { Authorization: "Bearer test-user-token", "Content-Type": "application/json" }, body: JSON.stringify({ feature: "ask", - model: "openai/gpt-5.6-luna", + model: "openai/gpt-6-luna", input: { message: "Compare the evidence", mode: "general", webSearch: false }, sourceIds: [sourceA, sourceB], }), @@ -575,7 +575,7 @@ Deno.test("Crossfire returns newest-exchange signals without making them a reply headers: { Authorization: "Bearer test-user-token", "Content-Type": "application/json" }, body: JSON.stringify({ feature: "crossfire", - model: "openai/gpt-5.6-luna", + model: "openai/gpt-6-luna", input: { operation: "next-turn", config: config(mode), @@ -587,7 +587,7 @@ Deno.test("Crossfire returns newest-exchange signals without making them a reply const requestSignal = (mode: "question-ai" | "ai-questions-user", transcript: unknown[], reply: { type: "question" | "answer"; text: string }, signalToken: string) => handleRequest(new Request("http://127.0.0.1/functions/v1/ai", { method: "POST", headers: { Authorization: "Bearer test-user-token", "Content-Type": "application/json" }, - body: JSON.stringify({ feature: "crossfire", model: "openai/gpt-5.6-luna", input: { operation: "signal", config: config(mode), transcript, reply, signalToken } }), + body: JSON.stringify({ feature: "crossfire", model: "openai/gpt-6-luna", input: { operation: "signal", config: config(mode), transcript, reply, signalToken } }), })); try { @@ -655,7 +655,7 @@ Deno.test("AI handlers use strict OpenRouter routing and server-side reasoning t decisionBodies.push(body); const questions = body.questions as Record }>; const labels = Object.keys(questions.model.criteria); - const choice = labels.includes("z-ai/glm-5.3-flash") ? "z-ai/glm-5.3-flash" : "openai/gpt-5.6-sol"; + const choice = labels.includes("anthropic/claude-haiku-5.5") ? "anthropic/claude-haiku-5.5" : "anthropic/claude-opus-5.5"; return jsonResponse({ model: "typesafe/jev-1.13", answers: { @@ -689,10 +689,10 @@ Deno.test("AI handlers use strict OpenRouter routing and server-side reasoning t if (response.status !== 200) throw new Error(`${entry.id} returned ${response.status}: ${await response.text()}`); } for (const effort of ["low", "medium", "high"]) { - const response = await requestForModel("anthropic/claude-sonnet-5", "pro", effort); + const response = await requestForModel("anthropic/claude-sonnet-5.5", "pro", effort); if (response.status !== 200) throw new Error(`Pro ${effort} returned ${response.status}: ${await response.text()}`); } - for (const model of ["deepseek/deepseek-v4-flash-0731", "deepseek/deepseek-v4-pro-0813", "z-ai/glm-5.3-flash"]) { + for (const model of ["deepseek/deepseek-v4.1-flash", "deepseek/deepseek-v4-pro-0813", "z-ai/glm-5.3-flash"]) { const response = await requestForModel(model, "pro", "medium"); if (response.status !== 200) throw new Error(`Pro medium ${model} returned ${response.status}: ${await response.text()}`); } @@ -702,7 +702,7 @@ Deno.test("AI handlers use strict OpenRouter routing and server-side reasoning t const autoFastBasic = await requestForModel("auto-fast", "trial"); if (autoFastBasic.status !== 200) throw new Error(`Basic Auto Fast returned ${autoFastBasic.status}: ${await autoFastBasic.text()}`); const autoFastPayload = await autoFastBasic.json(); - if (autoFastPayload.meta?.requestedModel !== "auto-fast" || autoFastPayload.meta?.resolvedModel !== "z-ai/glm-5.3-flash") { + if (autoFastPayload.meta?.requestedModel !== "auto-fast" || autoFastPayload.meta?.resolvedModel !== "anthropic/claude-haiku-5.5") { throw new Error(`Auto Fast metadata was wrong: ${JSON.stringify(autoFastPayload.meta)}`); } const autoProBasic = await requestForModel("auto-pro", "trial"); @@ -710,7 +710,7 @@ Deno.test("AI handlers use strict OpenRouter routing and server-side reasoning t const autoPro = await requestForModel("auto-pro", "pro"); if (autoPro.status !== 200) throw new Error(`Pro Auto Pro returned ${autoPro.status}: ${await autoPro.text()}`); const autoProPayload = await autoPro.json(); - if (autoProPayload.meta?.resolvedModel !== "openai/gpt-5.6-sol") { + if (autoProPayload.meta?.resolvedModel !== "anthropic/claude-opus-5.5") { throw new Error(`Auto Pro metadata was wrong: ${JSON.stringify(autoProPayload.meta)}`); } @@ -739,8 +739,13 @@ Deno.test("AI handlers use strict OpenRouter routing and server-side reasoning t const body = openRouterBodies[MODEL_CATALOG.length + offset]; if ((body.reasoning as Record).effort !== "low") throw new Error("unsupported Pro medium was not lowered"); } - if (openRouterBodies.at(-2)?.model !== "z-ai/glm-5.3-flash") throw new Error("Auto Fast did not call GLM"); - if (openRouterBodies.at(-1)?.model !== "openai/gpt-5.6-sol") throw new Error("Auto Pro did not call Sol"); + if (openRouterBodies.at(-2)?.model !== "anthropic/claude-haiku-5.5") throw new Error("Auto Fast did not call Haiku"); + if (openRouterBodies.at(-1)?.model !== "anthropic/claude-opus-5.5") throw new Error("Auto Pro did not call Opus"); + + // A stale client still sending a retired ID reaches its successor, not a 400. + const retired = await requestForModel("anthropic/claude-sonnet-5", "pro"); + if (retired.status !== 200) throw new Error(`retired Sonnet 5 returned ${retired.status}: ${await retired.text()}`); + if (openRouterBodies.at(-1)?.model !== "anthropic/claude-opus-5.5") throw new Error("retired Sonnet 5 did not reach Opus 5.5"); } finally { globalThis.fetch = originalFetch; if (originalTier === undefined) delete testState.__testTier; @@ -781,7 +786,7 @@ Deno.test("streaming OpenRouter usage records zero and missing costs distinctly" const withZero = await handleStreamRequest(new Request("http://127.0.0.1/functions/v1/ai-stream", { method: "POST", headers: { Authorization: "Bearer test-user-token", "Content-Type": "application/json" }, - body: JSON.stringify({ feature: "ask", model: "openai/gpt-5.6-luna", input: { message: "Hello", mode: "general" } }), + body: JSON.stringify({ feature: "ask", model: "openai/gpt-6-luna", input: { message: "Hello", mode: "general" } }), })); await withZero.text(); if (streamBodies[0]?.max_tokens !== MAX_REQUEST_OUTPUT_TOKENS) throw new Error("stream output cap was not enforced"); @@ -791,7 +796,7 @@ Deno.test("streaming OpenRouter usage records zero and missing costs distinctly" const withoutUsage = await handleStreamRequest(new Request("http://127.0.0.1/functions/v1/ai-stream", { method: "POST", headers: { Authorization: "Bearer test-user-token", "Content-Type": "application/json" }, - body: JSON.stringify({ feature: "ask", model: "openai/gpt-5.6-luna", input: { message: "Hello", mode: "general" } }), + body: JSON.stringify({ feature: "ask", model: "openai/gpt-6-luna", input: { message: "Hello", mode: "general" } }), })); await withoutUsage.text(); if (usageRows.at(-1)?.cost_usd !== null) throw new Error(`missing stream cost was estimated: ${JSON.stringify(usageRows.at(-1))}`); @@ -822,7 +827,7 @@ Deno.test("streaming rejects provider errors and incomplete EOFs, while acceptin const request = () => new Request("http://127.0.0.1/functions/v1/ai-stream", { method: "POST", headers: { Authorization: "Bearer test-user-token", "Content-Type": "application/json" }, - body: JSON.stringify({ feature: "ask", model: "openai/gpt-5.6-luna", input: { message: "Hello", mode: "general" } }), + body: JSON.stringify({ feature: "ask", model: "openai/gpt-6-luna", input: { message: "Hello", mode: "general" } }), }); const testState = globalThis as typeof globalThis & { __testTier?: string }; @@ -891,7 +896,7 @@ Deno.test("cancelled JSON repair keeps observed tokens but makes the total cost const response = await handleRequest(new Request("http://127.0.0.1/functions/v1/ai", { method: "POST", headers: { Authorization: "Bearer test-user-token", "Content-Type": "application/json" }, - body: JSON.stringify({ feature: "ask", model: "openai/gpt-5.6-luna", input: { message: "Hello", mode: "general" } }), + body: JSON.stringify({ feature: "ask", model: "openai/gpt-6-luna", input: { message: "Hello", mode: "general" } }), signal: caller.signal, })); if (response.status !== 500) throw new Error(`cancelled repair returned ${response.status}`); @@ -953,7 +958,7 @@ Deno.test("stream terminal events stop an open upstream promptly", async () => { const request = (signal?: AbortSignal) => new Request("http://127.0.0.1/functions/v1/ai-stream", { method: "POST", headers: { Authorization: "Bearer test-user-token", "Content-Type": "application/json" }, - body: JSON.stringify({ feature: "ask", model: "openai/gpt-5.6-luna", input: { message: "Hello", mode: "general" } }), + body: JSON.stringify({ feature: "ask", model: "openai/gpt-6-luna", input: { message: "Hello", mode: "general" } }), signal, }); const withTimeout = async (promise: Promise, label: string): Promise => await Promise.race([ diff --git a/supabase/functions/ai/index.ts b/supabase/functions/ai/index.ts index 34281ee4a..1dc4e85f2 100644 --- a/supabase/functions/ai/index.ts +++ b/supabase/functions/ai/index.ts @@ -30,6 +30,7 @@ import { effectiveReasoningEffort, isModelSelection, isReasoningEffort, + retiredModelSuccessor, type ModelCatalogEntry, type ModelSelection, } from "../_shared/model-catalog.ts"; @@ -145,7 +146,8 @@ export async function handleRequest(req: Request): Promise { } const feature = typeof body.feature === "string" ? body.feature as Feature : undefined; const rawInput = body.input; - const rawModel = (typeof body.model === "string" ? body.model : "") || DEFAULT_MODEL; + const sentModel = (typeof body.model === "string" ? body.model : "") || DEFAULT_MODEL; + const rawModel = retiredModelSuccessor(sentModel) ?? sentModel; if (!isModelSelection(rawModel)) { log(requestId, "WARN", "Rejected unsupported model/provider combination", { model: rawModel }); return badRequest("Unsupported model or provider combination", corsHeaders); diff --git a/supabase/functions/ai/voice.test.ts b/supabase/functions/ai/voice.test.ts index af631f2db..268a2f9d5 100644 --- a/supabase/functions/ai/voice.test.ts +++ b/supabase/functions/ai/voice.test.ts @@ -75,7 +75,7 @@ function aiRequest(body: Record): Request { return new Request("http://127.0.0.1/functions/v1/ai", { method: "POST", headers: { Authorization: "Bearer test-user-token", "Content-Type": "application/json" }, - body: JSON.stringify({ model: "openai/gpt-5.6-luna", ...body }), + body: JSON.stringify({ model: "openai/gpt-6-luna", ...body }), }); }