From 97ac68c25cc0a8f9ea9a7f10d95ee79e7089c018 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 18 Sep 2026 15:02:44 +0000 Subject: [PATCH 1/7] test: require OpenRouter GLM routing --- src/test/providers.test.ts | 8 ++++++++ src/test/settings-store.test.ts | 18 ++++++++--------- .../functions/_shared/model-catalog.test.ts | 20 +++++++++++++++++++ supabase/functions/ai-stream/handler_test.ts | 12 ++++++++--- 4 files changed, 46 insertions(+), 12 deletions(-) create mode 100644 supabase/functions/_shared/model-catalog.test.ts diff --git a/src/test/providers.test.ts b/src/test/providers.test.ts index 80e2cfabd..7d718d02d 100644 --- a/src/test/providers.test.ts +++ b/src/test/providers.test.ts @@ -8,4 +8,12 @@ describe('DEFAULT_PROVIDERS', () => { const allIds = DEFAULT_PROVIDERS.flatMap(p => p.models.map(m => m.id)); expect(new Set(allIds).size).toBe(allIds.length); }); + + it('exposes only GLM 5.3 Flash through OpenRouter', () => { + expect(DEFAULT_PROVIDERS).toHaveLength(1); + expect(DEFAULT_PROVIDERS[0]?.id).toBe('openrouter'); + expect(DEFAULT_PROVIDERS[0]?.models.map((model) => model.id)).toEqual([ + 'z-ai/glm-5.3-flash', + ]); + }); }); diff --git a/src/test/settings-store.test.ts b/src/test/settings-store.test.ts index 454abb663..99ab8ba30 100644 --- a/src/test/settings-store.test.ts +++ b/src/test/settings-store.test.ts @@ -17,10 +17,10 @@ describe("settings-store", () => { useSettingsStore.getState().resetLocalData(); }); - it("uses Gemini 3 Flash defaults for all features", () => { + it("uses GLM 5.3 Flash through OpenRouter for all features", () => { const state = useSettingsStore.getState(); - expect(state.featureConfigs.generate.provider).toBe("google"); - expect(state.featureConfigs.generate.model).toBe("gemini-3-flash-preview"); + expect(state.featureConfigs.generate.provider).toBe("openrouter"); + expect(state.featureConfigs.generate.model).toBe("z-ai/glm-5.3-flash"); expect(state.featureConfigs.brainstorm.temperature).toBe(0.9); expect(state.featureConfigs.factoids.temperature).toBe(0.3); }); @@ -60,9 +60,9 @@ describe("settings-store", () => { expect(state.theme).toBe("dark"); expect(state.customSystemPrompt).toBe("Server prompt"); expect(state.featureConfigs.generate.temperature).toBe(0.4); - expect(state.featureConfigs.generate.provider).toBe("google"); - expect(state.featureConfigs.generate.model).toBe("gemini-3-flash-preview"); - expect(state.featureConfigs.attack.model).toBe("gemini-3-flash-preview"); + expect(state.featureConfigs.generate.provider).toBe("openrouter"); + expect(state.featureConfigs.generate.model).toBe("z-ai/glm-5.3-flash"); + expect(state.featureConfigs.attack.model).toBe("z-ai/glm-5.3-flash"); }); it("resetLocalData restores defaults", () => { @@ -75,7 +75,7 @@ describe("settings-store", () => { const state = useSettingsStore.getState(); expect(state.theme).toBe("system"); expect(state.customSystemPrompt).toBeUndefined(); - expect(state.featureConfigs.generate.model).toBe("gemini-3-flash-preview"); + expect(state.featureConfigs.generate.model).toBe("z-ai/glm-5.3-flash"); expect(state.wikiResearchEnabled).toBe(false); }); @@ -113,8 +113,8 @@ describe("settings-store", () => { it("getFeatureConfig returns defaults when missing", () => { useSettingsStore.setState({ featureConfigs: {} } as Partial>); const config = useSettingsStore.getState().getFeatureConfig("ask"); - expect(config.provider).toBe("google"); - expect(config.model).toBe("gemini-3-flash-preview"); + expect(config.provider).toBe("openrouter"); + expect(config.model).toBe("z-ai/glm-5.3-flash"); }); it("does not upsert when user is not signed in", async () => { diff --git a/supabase/functions/_shared/model-catalog.test.ts b/supabase/functions/_shared/model-catalog.test.ts new file mode 100644 index 000000000..ad96ff2ec --- /dev/null +++ b/supabase/functions/_shared/model-catalog.test.ts @@ -0,0 +1,20 @@ +import { + DEFAULT_MODEL, + getModelCatalogEntry, + MODEL_CATALOG, +} from "./model-catalog.ts"; + +const GLM_FLASH = "z-ai/glm-5.3-flash"; + +Deno.test("GLM 5.3 Flash is the only default OpenRouter model", () => { + if (DEFAULT_MODEL !== GLM_FLASH) { + throw new Error(`unexpected default model: ${DEFAULT_MODEL}`); + } + if (MODEL_CATALOG.length !== 1 || MODEL_CATALOG[0]?.id !== GLM_FLASH) { + throw new Error(`unexpected model catalog: ${MODEL_CATALOG.map((entry) => entry.id).join(", ")}`); + } + const entry = getModelCatalogEntry(GLM_FLASH); + if (!entry || entry.isProOnly) { + throw new Error("GLM 5.3 Flash must be available to trial and Pro users"); + } +}); diff --git a/supabase/functions/ai-stream/handler_test.ts b/supabase/functions/ai-stream/handler_test.ts index 5de1a44d7..dd57ce914 100644 --- a/supabase/functions/ai-stream/handler_test.ts +++ b/supabase/functions/ai-stream/handler_test.ts @@ -1,7 +1,7 @@ const testEnvironment = { SUPABASE_URL: "https://supabase.test", SUPABASE_SERVICE_ROLE_KEY: "test-service-role-key", - OPENAI_API_KEY: "test-openai-key", + OPENROUTER_API_KEY: "test-openrouter-key", }; for (const [key, value] of Object.entries(testEnvironment)) { @@ -85,7 +85,10 @@ Deno.test("streaming Ask forwards the shared prompt and returns provider SSE", a throw new Error(`Unexpected Supabase request: ${url}`); } - if (url.href === "https://api.openai.com/v1/chat/completions") { + if (url.href === "https://openrouter.ai/api/v1/chat/completions") { + if (new Headers(init?.headers).get("Authorization") !== "Bearer test-openrouter-key") { + throw new Error("streaming handler did not use OPENROUTER_API_KEY"); + } providerRequest = JSON.parse(String(init?.body)) as Record; return new Response(upstream, { headers: { "Content-Type": "text/event-stream" }, @@ -105,7 +108,7 @@ Deno.test("streaming Ask forwards the shared prompt and returns provider SSE", a }, body: JSON.stringify({ feature: "ask", - model: "gpt-4o-mini", + model: "z-ai/glm-5.3-flash", input: { message: "¿Cómo respondo a este argumento?", history: [], @@ -129,6 +132,9 @@ Deno.test("streaming Ask forwards the shared prompt and returns provider SSE", a assertIncludes(body, '"response":"Szia Adam"'); const messages = providerRequest?.messages as Array<{ role: string; content: string }> | undefined; + if (providerRequest?.model !== "z-ai/glm-5.3-flash") { + throw new Error(`unexpected OpenRouter model: ${String(providerRequest?.model)}`); + } const system = messages?.find((message) => message.role === "system")?.content ?? ""; assertIncludes(system, "Answer in clear Markdown that can stream smoothly token by token."); assertIncludes(system, "Follow any explicit output-language instruction."); From b32d401a23659dd9b5111a121139533d293273b1 Mon Sep 17 00:00:00 2001 From: Adam Date: Mon, 7 Sep 2026 20:05:49 +0000 Subject: [PATCH 2/7] Route AI requests through OpenRouter catalog --- docs/architecture.md | 6 +- package.json | 2 +- supabase/README.md | 2 +- supabase/functions/_shared/cost.test.ts | 50 +++ supabase/functions/_shared/cost.ts | 35 ++- .../functions/_shared/model-catalog.test.ts | 23 +- supabase/functions/_shared/model-catalog.ts | 53 ++++ supabase/functions/_shared/research.test.ts | 6 +- supabase/functions/_shared/research.ts | 8 +- supabase/functions/ai-stream/index.ts | 289 +++++++----------- .../functions/ai/handler-research.test.ts | 276 ++++++++++++++++- supabase/functions/ai/index.ts | 210 ++++++------- 12 files changed, 649 insertions(+), 311 deletions(-) create mode 100644 supabase/functions/_shared/model-catalog.ts diff --git a/docs/architecture.md b/docs/architecture.md index 7bcc14b9d..d9a2eddf8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -47,7 +47,7 @@ Client-side environment selection is a developer convenience, not authorization. 2. The client reads the current Supabase session. 3. It invokes `ai` or `ai-stream` with selected controls and active source context. 4. The Edge Function validates authentication, input bounds, supported model and control combinations, access limits, and feature-specific rules. -5. The backend optionally gathers research, builds prompts, calls a provider, validates output, and records usage. +5. The backend optionally gathers research, builds prompts, calls an allowlisted model through OpenRouter, validates output, and records the provider-reported usage and charge. 6. The client validates or transforms the response and updates the relevant store. [`supabase/functions/ai/index.ts`](../supabase/functions/ai/index.ts) serves non-streaming structured tools and practice modes. [`supabase/functions/ai-stream/index.ts`](../supabase/functions/ai-stream/index.ts) serves Ask over server-sent events. @@ -83,7 +83,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 provider usage and calculated cost using shared pricing logic. -Provider pricing evidence is retained under [`docs/pricing-snapshots/`](pricing-snapshots/). The snapshots are evidence, not runtime configuration. +All supported model requests use the single OpenRouter backend route and `OPENROUTER_API_KEY`; the shared allowlist 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. + +Historical provider pricing evidence is retained under [`docs/pricing-snapshots/`](pricing-snapshots/). Those snapshots support legacy calculator records and are evidence, not runtime configuration for the OpenRouter route. ## Observability diff --git a/package.json b/package.json index d0f739477..251e0da33 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "preview": "vite preview", "test": "vitest run", "test:watch": "vitest", - "test:edge": "deno test --allow-read --allow-env=DEMO_CODE_PEPPER,EXA_API_KEY,SUPABASE_URL,SUPABASE_SERVICE_ROLE_KEY,SPARRING_INTEGRITY_SECRET,OPENAI_API_KEY,GOOGLE_API_KEY,ANTHROPIC_API_KEY --allow-net=127.0.0.1 supabase/functions/", + "test:edge": "deno test --allow-read --allow-env=DEMO_CODE_PEPPER,EXA_API_KEY,SUPABASE_URL,SUPABASE_SERVICE_ROLE_KEY,SPARRING_INTEGRITY_SECRET,OPENROUTER_API_KEY --allow-net=127.0.0.1 supabase/functions/", "test:db": "supabase test db", "test:e2e": "playwright test --project=desktop-chromium --project=mobile-chromium", "test:e2e:webkit": "playwright test --project=webkit", diff --git a/supabase/README.md b/supabase/README.md index 7ee5b164a..5a1d1d2b1 100644 --- a/supabase/README.md +++ b/supabase/README.md @@ -129,7 +129,7 @@ Run focused tests while changing one function, then run the complete Edge Functi Document names, never values. Depending on deployed features, server-side configuration includes categories such as: - Supabase URL, anon key, and service-role key; -- supported AI provider keys; +- `OPENROUTER_API_KEY` for all model requests; - research provider keys; - Stripe secret, webhook secret, and price configuration; - demo-code hashing material; diff --git a/supabase/functions/_shared/cost.test.ts b/supabase/functions/_shared/cost.test.ts index 847525630..092c33b3f 100644 --- a/supabase/functions/_shared/cost.test.ts +++ b/supabase/functions/_shared/cost.test.ts @@ -3,6 +3,7 @@ import { mergeNormalizedUsage, normalizeAnthropicUsage, normalizeGoogleUsage, + normalizeOpenRouterUsage, normalizeOpenAIUsage, selectObservedUsage, } from "./cost.ts"; @@ -118,6 +119,29 @@ Deno.test("raw provider usage is preserved for audit", () => { if (!result.rawUsage || result.rawUsage.system_fingerprint !== "fp_x") throw new Error("raw usage dropped"); }); +Deno.test("OpenRouter usage uses its reported account cost, including zero", () => { + const raw = { + prompt_tokens: 10, + completion_tokens: 5, + total_tokens: 15, + cost: 0, + }; + const usage = normalizeOpenRouterUsage(raw); + const result = calculateRequestCost({ provider: "openrouter", model: "openai/gpt-5.6-luna", usage }); + if (usage.reportedCostUsd !== 0 || result.costUsd !== 0) { + throw new Error(`zero OpenRouter cost was not preserved: ${JSON.stringify({ usage, result })}`); + } + if (result.rawUsage?.cost !== 0) throw new Error("raw OpenRouter usage was dropped"); +}); + +Deno.test("OpenRouter usage without a reported cost fails closed", () => { + const usage = normalizeOpenRouterUsage({ prompt_tokens: 10, completion_tokens: 5 }); + const result = calculateRequestCost({ provider: "openrouter", model: "openai/gpt-5.6-luna", usage }); + if (result.costUsd !== null) { + throw new Error(`missing OpenRouter cost was estimated: ${JSON.stringify(result)}`); + } +}); + Deno.test("anthropic ttl-split cache writes bill at 5m and 1h rates", () => { const result = calculateRequestCost({ provider: "anthropic", @@ -146,6 +170,32 @@ Deno.test("research sub-calls merge into one auditable usage record", async () = if (!Array.isArray(rawCalls) || rawCalls.length !== 2) throw new Error("raw calls not preserved"); }); +Deno.test("merged OpenRouter research usage sums only complete reported costs", () => { + const complete = mergeNormalizedUsage([ + normalizeOpenRouterUsage({ prompt_tokens: 10, completion_tokens: 5, cost: 0.25 }), + normalizeOpenRouterUsage({ prompt_tokens: 20, completion_tokens: 8, cost: 0.75 }), + ]); + if (complete?.reportedCostUsd !== 1) throw new Error(`complete costs were not summed: ${complete?.reportedCostUsd}`); + const completeCost = calculateRequestCost({ provider: "openrouter", model: "openai/gpt-5.6-luna", usage: complete }); + if (completeCost.costUsd !== 1) throw new Error(`complete merged cost was not retained: ${completeCost.costUsd}`); + + const missing = mergeNormalizedUsage([ + normalizeOpenRouterUsage({ prompt_tokens: 10, completion_tokens: 5, cost: 0.25 }), + normalizeOpenRouterUsage({ prompt_tokens: 20, completion_tokens: 8 }), + ]); + if (missing?.reportedCostUsd !== undefined) throw new Error("missing sub-call cost was summed"); + const missingCost = calculateRequestCost({ provider: "openrouter", model: "openai/gpt-5.6-luna", usage: missing }); + if (missingCost.costUsd !== null) throw new Error(`missing sub-call cost was estimated: ${missingCost.costUsd}`); + + const incomplete = mergeNormalizedUsage([ + normalizeOpenRouterUsage({ prompt_tokens: 10, completion_tokens: 5, cost: 0.25 }), + { incomplete: true, raw: { aborted: true } }, + ]); + if (!incomplete?.incomplete || incomplete.reportedCostUsd !== undefined) throw new Error("incomplete sub-call was billed"); + const incompleteCost = calculateRequestCost({ provider: "openrouter", model: "openai/gpt-5.6-luna", usage: incomplete }); + if (incompleteCost.costUsd !== null) throw new Error(`incomplete merged cost was estimated: ${incompleteCost.costUsd}`); +}); + Deno.test("incomplete merged usage keeps observed tokens but fails closed on cost", () => { const merged = mergeNormalizedUsage([ normalizeOpenAIUsage({ prompt_tokens: 1000, completion_tokens: 500 }), diff --git a/supabase/functions/_shared/cost.ts b/supabase/functions/_shared/cost.ts index 2b521d823..b60fdde2a 100644 --- a/supabase/functions/_shared/cost.ts +++ b/supabase/functions/_shared/cost.ts @@ -10,6 +10,7 @@ import { } from "./model-pricing.ts"; export const COST_CALCULATION_VERSION = "cost-calc-v1"; +export const OPENROUTER_COST_CALCULATION_VERSION = "openrouter-usage-v1"; export interface NormalizedUsage { /** True when part of the request's provider work was not observed to settle. */ @@ -26,6 +27,8 @@ export interface NormalizedUsage { outputTokens?: number; /** Reported reasoning/thinking subset of outputTokens. Never billed twice. */ reasoningOutputTokens?: number; + /** OpenRouter's authoritative account charge, in USD. */ + reportedCostUsd?: number; /** Untouched provider usage object, kept verbatim for audit. */ raw?: Record; } @@ -53,6 +56,10 @@ function asRecord(value: unknown): Record | undefined { return value && typeof value === "object" && !Array.isArray(value) ? value as Record : undefined; } +function reportedCostOrUndefined(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined; +} + export function normalizeOpenAIUsage(usage: Record): NormalizedUsage { const promptDetails = asRecord(usage.prompt_tokens_details); const completionDetails = asRecord(usage.completion_tokens_details); @@ -100,6 +107,20 @@ export function normalizeGoogleUsage(usageMetadata: Record): No }; } +export function normalizeOpenRouterUsage(usage: Record): NormalizedUsage { + const promptDetails = asRecord(usage.prompt_tokens_details); + const completionDetails = asRecord(usage.completion_tokens_details); + return { + inputTokens: intOrUndefined(usage.prompt_tokens), + cachedInputTokens: intOrUndefined(promptDetails?.cached_tokens), + cacheWriteTokens: intOrUndefined(promptDetails?.cache_write_tokens), + outputTokens: intOrUndefined(usage.completion_tokens), + reasoningOutputTokens: intOrUndefined(completionDetails?.reasoning_tokens), + reportedCostUsd: reportedCostOrUndefined(usage.cost), + raw: usage, + }; +} + function nonNegative(value: number | undefined): number { return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0; } @@ -112,7 +133,8 @@ export function calculateRequestCost(args: { provider: PricingProvider | string; model: string; usage?: NormalizedUsage; -}): CostResult { const { provider, model, usage } = args; +}): CostResult { + const { provider, model, usage } = args; // Cache writes report the combined 5m + 1h total for storage; raw_usage // keeps the exact TTL split for audit. const combinedCacheWrite = usage?.cacheWriteTokens !== undefined || usage?.cacheWrite1hTokens !== undefined @@ -132,6 +154,14 @@ export function calculateRequestCost(args: { }; if (usage?.raw) base.rawUsage = usage.raw; + if (provider === "openrouter") { + base.pricingSnapshotId = "openrouter-response-usage"; + base.pricingSourceUrl = "https://openrouter.ai/docs/cookbook/administration/usage-accounting"; + base.costCalculationVersion = OPENROUTER_COST_CALCULATION_VERSION; + base.costUsd = usage?.incomplete ? null : usage?.reportedCostUsd ?? null; + return base; + } + // Preserve observed token fields and raw audit data, but never publish a // lower-bound cost when cancelled work may have consumed unobserved tokens. if (usage?.incomplete) return base; @@ -202,6 +232,9 @@ export function mergeNormalizedUsage(usages: Array) cacheWrite1hTokens: sum(defined.map((u) => u.cacheWrite1hTokens)), outputTokens: sum(defined.map((u) => u.outputTokens)), reasoningOutputTokens: sum(defined.map((u) => u.reasoningOutputTokens)), + reportedCostUsd: defined.every((u) => !u.incomplete && typeof u.reportedCostUsd === "number") + ? defined.reduce((total, usage) => total + (usage.reportedCostUsd ?? 0), 0) + : undefined, raw: { calls: defined.map((u) => u.raw ?? null) }, }; return merged; diff --git a/supabase/functions/_shared/model-catalog.test.ts b/supabase/functions/_shared/model-catalog.test.ts index ad96ff2ec..116ca2280 100644 --- a/supabase/functions/_shared/model-catalog.test.ts +++ b/supabase/functions/_shared/model-catalog.test.ts @@ -1,20 +1,33 @@ import { DEFAULT_MODEL, + effectiveReasoningEffort, getModelCatalogEntry, + isReasoningEffort, MODEL_CATALOG, } from "./model-catalog.ts"; const GLM_FLASH = "z-ai/glm-5.3-flash"; Deno.test("GLM 5.3 Flash is the only default OpenRouter model", () => { - if (DEFAULT_MODEL !== GLM_FLASH) { - throw new Error(`unexpected default model: ${DEFAULT_MODEL}`); - } + if (DEFAULT_MODEL !== GLM_FLASH) throw new Error(`unexpected default model: ${DEFAULT_MODEL}`); if (MODEL_CATALOG.length !== 1 || MODEL_CATALOG[0]?.id !== GLM_FLASH) { throw new Error(`unexpected model catalog: ${MODEL_CATALOG.map((entry) => entry.id).join(", ")}`); } const entry = getModelCatalogEntry(GLM_FLASH); - if (!entry || entry.isProOnly) { - throw new Error("GLM 5.3 Flash must be available to trial and Pro users"); + if (!entry || entry.isProOnly) throw new Error("GLM 5.3 Flash must be available to trial and Pro users"); +}); + +Deno.test("reasoning effort is validated and trial requests stay low", () => { + for (const effort of ["low", "medium", "high"]) { + if (!isReasoningEffort(effort)) throw new Error(`accepted effort rejected: ${effort}`); + } + for (const effort of ["none", "max", "xhigh", "", null, 1, undefined]) { + if (isReasoningEffort(effort)) throw new Error(`unsupported effort accepted: ${String(effort)}`); + } + if (effectiveReasoningEffort(GLM_FLASH, "high", false) !== "low") { + throw new Error("trial request did not force low reasoning effort"); + } + if (effectiveReasoningEffort(GLM_FLASH, "medium", true) !== "low") { + throw new Error("unsupported GLM medium effort was not lowered"); } }); diff --git a/supabase/functions/_shared/model-catalog.ts b/supabase/functions/_shared/model-catalog.ts new file mode 100644 index 000000000..4d35079e2 --- /dev/null +++ b/supabase/functions/_shared/model-catalog.ts @@ -0,0 +1,53 @@ +/** + * The server and client share this catalog. Keep provider routing out of + * feature handlers so an accepted model can only use the OpenRouter route. + */ + +export type ReasoningEffort = "low" | "medium" | "high"; +export const MAX_REQUEST_OUTPUT_TOKENS = 32_768; + +export interface ModelCatalogEntry { + readonly id: string; + readonly name: string; + readonly maxTokens: number; + readonly isProOnly: boolean; + readonly supportsTemperature: boolean; + readonly supportedEfforts: readonly ReasoningEffort[]; +} + +export const MODEL_CATALOG = [ + { id: "openai/gpt-5.6-luna", name: "GPT-5.6 Luna", maxTokens: 128_000, isProOnly: false, supportsTemperature: false, supportedEfforts: ["low", "medium", "high"] }, + { id: "google/gemini-3.8-flash", name: "Gemini 3.8 Flash", maxTokens: 65_536, isProOnly: false, supportsTemperature: true, supportedEfforts: ["low", "medium", "high"] }, + { id: "deepseek/deepseek-v4-flash-0731", name: "DeepSeek V4 Flash", maxTokens: 131_072, isProOnly: false, supportsTemperature: true, supportedEfforts: ["low", "high"] }, + { id: "anthropic/claude-sonnet-5", name: "Claude Sonnet 5", maxTokens: 128_000, isProOnly: true, supportsTemperature: false, supportedEfforts: ["low", "medium", "high"] }, + { id: "anthropic/claude-opus-5", name: "Claude Opus 5", maxTokens: 128_000, isProOnly: true, supportsTemperature: true, supportedEfforts: ["low", "medium", "high"] }, + { id: "openai/gpt-5.6-sol", name: "GPT-5.6 Sol", maxTokens: 128_000, isProOnly: true, supportsTemperature: false, supportedEfforts: ["low", "medium", "high"] }, + { id: "openai/gpt-5.6-terra", name: "GPT-5.6 Terra", maxTokens: 128_000, isProOnly: true, supportsTemperature: false, supportedEfforts: ["low", "medium", "high"] }, + { id: "deepseek/deepseek-v4-pro-0813", name: "DeepSeek V4 Pro", maxTokens: 384_000, isProOnly: true, supportsTemperature: true, supportedEfforts: ["low", "high"] }, + { id: "z-ai/glm-5.3-flash", name: "GLM-5.3 Flash", maxTokens: 131_072, isProOnly: true, supportsTemperature: true, supportedEfforts: ["low", "high"] }, + { id: "meta/muse-spark-1.3", name: "Muse Spark 1.3", maxTokens: 943_718, isProOnly: true, supportsTemperature: true, supportedEfforts: ["low", "medium", "high"] }, +] as const satisfies readonly ModelCatalogEntry[]; + +export const DEFAULT_MODEL = "openai/gpt-5.6-luna" as const; +export const DEFAULT_PRO_MODEL = "anthropic/claude-sonnet-5" as const; + +const MODEL_BY_ID = new Map(MODEL_CATALOG.map((entry) => [entry.id, entry])); + +export function getModelCatalogEntry(model: string): ModelCatalogEntry | undefined { + return MODEL_BY_ID.get(model); +} + +export function isReasoningEffort(value: unknown): value is ReasoningEffort { + return value === "low" || value === "medium" || value === "high"; +} + +export function effectiveReasoningEffort( + model: string, + requested: ReasoningEffort | undefined, + canAccessProModels: boolean, +): ReasoningEffort { + if (!canAccessProModels) return "low"; + const effort = requested ?? "medium"; + const entry = getModelCatalogEntry(model); + return effort === "medium" && entry && !entry.supportedEfforts.includes("medium") ? "low" : effort; +} diff --git a/supabase/functions/_shared/research.test.ts b/supabase/functions/_shared/research.test.ts index aea2f07ad..64ac254f4 100644 --- a/supabase/functions/_shared/research.test.ts +++ b/supabase/functions/_shared/research.test.ts @@ -271,8 +271,9 @@ Deno.test("filtered research retries without domain filters when Exa returns no try { const result = await deepResearch("federal procurement reform", "United States", { - provider: "openai", + provider: "openrouter", model: "test-model", + reasoningEffort: "low", callModel: async () => ({ output: { queries: [] } }), }, { maxFollowUpQueries: 0, @@ -484,8 +485,9 @@ Deno.test("research timeout stays bounded and marks accounting incomplete", asyn let providerCompletedAt: number | undefined; let timeoutMarked = false; const deps = { - provider: "openai" as const, + provider: "openrouter" as const, model: "test-model", + reasoningEffort: "low" as const, onTimeout: () => { timeoutMarked = true; }, diff --git a/supabase/functions/_shared/research.ts b/supabase/functions/_shared/research.ts index 09a03e126..3caea4bd8 100644 --- a/supabase/functions/_shared/research.ts +++ b/supabase/functions/_shared/research.ts @@ -17,7 +17,9 @@ import { export type { ExaResult, RankedSearchResult, ResearchSource }; -export type Provider = "openai" | "anthropic" | "google"; +import type { ReasoningEffort } from "./model-catalog.ts"; + +export type Provider = "openrouter"; export const MAX_PROVIDER_RESPONSE_BYTES = 1_048_576; export const MAX_PROVIDER_ERROR_BODY_BYTES = 65_536; @@ -63,6 +65,7 @@ export const ASK_RESEARCH: ResearchOptions = { export interface ResearchDeps { provider: Provider; model: string; + reasoningEffort: ReasoningEffort; /** Request-scoped cancellation signal, if the caller has one. */ signal?: AbortSignal; /** Called synchronously when the overall research timeout fires. */ @@ -74,6 +77,7 @@ export interface ResearchDeps { systemPrompt: string; messages: Array<{ role: string; content: string }>; temperature: number; + reasoningEffort: ReasoningEffort; signal?: AbortSignal; }) => Promise<{ output: unknown }>; } @@ -371,6 +375,7 @@ export async function decideAskSearch( systemPrompt: ASK_SEARCH_DECISION_PROMPT, messages: [{ role: "user", content: prompt }], temperature: 0, + reasoningEffort: deps.reasoningEffort, signal: deps.signal, }); const decision = decisionResult.output; @@ -409,6 +414,7 @@ async function generateFollowUpQueries( systemPrompt: QUERY_GENERATION_PROMPT, messages: [{ role: "user", content: prompt }], temperature: 0.3, + reasoningEffort: deps.reasoningEffort, signal: deps.signal, }); const parsed = typeof result.output === "object" && result.output !== null ? result.output : {}; diff --git a/supabase/functions/ai-stream/index.ts b/supabase/functions/ai-stream/index.ts index 1dc2f1e20..4d072e413 100644 --- a/supabase/functions/ai-stream/index.ts +++ b/supabase/functions/ai-stream/index.ts @@ -28,11 +28,18 @@ import { buildStreamingAskSystemPrompt } from "../_shared/prompts.ts"; import { calculateRequestCost, type CostResult, - normalizeAnthropicUsage, - normalizeGoogleUsage, - normalizeOpenAIUsage, + normalizeOpenRouterUsage, type NormalizedUsage, } from "../_shared/cost.ts"; +import { + DEFAULT_MODEL, + effectiveReasoningEffort, + getModelCatalogEntry, + isReasoningEffort, + MAX_REQUEST_OUTPUT_TOKENS, + type ModelCatalogEntry, + type ReasoningEffort, +} from "../_shared/model-catalog.ts"; type ChatRole = "user" | "assistant"; @@ -43,6 +50,7 @@ type ChatMessage = { type RateLimitResult = { allowed: boolean; + canAccessProModels?: boolean; reason?: string; limit?: number; current?: number; @@ -60,25 +68,12 @@ const supabaseUrl = Deno.env.get("SUPABASE_URL")!; const supabaseServiceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!; const supabase = createClient(supabaseUrl, supabaseServiceKey); -const PRO_MODELS = new Set([ - "gpt-5.5", - "gpt-5.4", - "claude-opus-4-7", - "claude-sonnet-4-6", - "gemini-3.1-pro-preview", -]); - -const OPENAI_MODELS = new Set(["gpt-5.5", "gpt-5.4", "gpt-5.4-mini", "gpt-4o", "gpt-4o-mini"]); -const GOOGLE_MODELS = new Set(["gemini-3.5-flash", "gemini-3-flash-preview", "gemini-3.1-pro-preview", "gemini-3.1-flash-lite-preview"]); -const ANTHROPIC_MODELS = new Set(["claude-opus-4-7", "claude-sonnet-4-6", "claude-haiku-4-5-20251001"]); const MAX_INPUT_LENGTH = 50000; const MAX_HISTORY_ITEMS = 8; +const OPENROUTER_PROVIDER = "openrouter" as const; -function getProviderForModel(model: string): "openai" | "google" | "anthropic" | null { - if (OPENAI_MODELS.has(model)) return "openai"; - if (GOOGLE_MODELS.has(model)) return "google"; - if (ANTHROPIC_MODELS.has(model)) return "anthropic"; - return null; +function modelForRequest(model: string): ModelCatalogEntry | undefined { + return getModelCatalogEntry(model); } function jsonResponse(body: Record, status: number, corsHeaders: Record) { @@ -217,7 +212,7 @@ async function checkRateLimit(userId: string, model: string): Promise { return jsonResponse({ error: "Streaming currently supports Ask only" }, 400, corsHeaders); } - const model = sanitizeText(body.model, 80) || "gpt-5.4-mini"; + const model = sanitizeText(body.model, 80) || DEFAULT_MODEL; + const modelEntry = modelForRequest(model); + if (!modelEntry) return jsonResponse({ error: "Unsupported model or provider combination" }, 400, corsHeaders); + const requestedReasoningEffort = body.reasoningEffort === undefined + ? undefined + : isReasoningEffort(body.reasoningEffort) ? body.reasoningEffort : null; + if (requestedReasoningEffort === null) return jsonResponse({ error: "Invalid reasoning effort" }, 400, corsHeaders); const rawInput = body.input && typeof body.input === "object" && !Array.isArray(body.input) ? body.input as Record : {}; @@ -353,11 +354,7 @@ export async function handleRequest(req: Request): Promise { } const advancedMode = Boolean(input.citationMode || input.webSearch || input.prosConsMode || input.argumentMapMode); - // Provider is derived strictly from the allowlisted model, never from the - // client-supplied provider field, so an unlisted model can't reach any provider API. - const resolvedProvider = getProviderForModel(model); - - if (!resolvedProvider || advancedMode) { + if (advancedMode) { return jsonResponse({ error: "Streaming is not available for this model/mode combination" }, 400, corsHeaders); } @@ -374,18 +371,16 @@ export async function handleRequest(req: Request): Promise { }, rateLimit.statusCode || 402, corsHeaders); } - const openaiKey = Deno.env.get("OPENAI_API_KEY"); - const googleKey = Deno.env.get("GOOGLE_API_KEY"); - const anthropicKey = Deno.env.get("ANTHROPIC_API_KEY"); - - const providerKey = resolvedProvider === "openai" ? openaiKey - : resolvedProvider === "google" ? googleKey - : resolvedProvider === "anthropic" ? anthropicKey - : null; - - if (!providerKey) { - return jsonResponse({ error: `${resolvedProvider} is not configured` }, 500, corsHeaders); - } + const reasoningEffort = effectiveReasoningEffort(model, requestedReasoningEffort ?? undefined, Boolean(rateLimit.canAccessProModels)); + console.info(JSON.stringify({ + function: "ai-stream", + event: "model_controls", + model, + requestedReasoningEffort: requestedReasoningEffort ?? "medium", + effectiveReasoningEffort: reasoningEffort, + })); + const providerKey = Deno.env.get("OPENROUTER_API_KEY"); + if (!providerKey) return jsonResponse({ error: "OpenRouter is not configured" }, 500, corsHeaders); const history = normalizeHistory(input.history); const temperature = typeof body.temperature === "number" ? body.temperature : 0.7; @@ -395,23 +390,15 @@ export async function handleRequest(req: Request): Promise { async start(controller) { const encoder = new TextEncoder(); let accumulated = ""; - const streamUsageParts: Record> = { openai: {}, anthropic: {}, google: {} }; - - // Merge streamed usage fragments per provider, then normalize once at the end. - const mergeUsage = (part: "openai" | "anthropic" | "google", patch: Record) => { - streamUsageParts[part] = { ...streamUsageParts[part], ...patch }; - }; - const collectUsage = (): NormalizedUsage | undefined => { - if (resolvedProvider === "openai") { - return Object.keys(streamUsageParts.openai).length ? normalizeOpenAIUsage(streamUsageParts.openai) : undefined; - } - if (resolvedProvider === "anthropic") { - return Object.keys(streamUsageParts.anthropic).length ? normalizeAnthropicUsage(streamUsageParts.anthropic) : undefined; - } - if (resolvedProvider === "google") { - return Object.keys(streamUsageParts.google).length ? normalizeGoogleUsage(streamUsageParts.google) : undefined; - } - return undefined; + let streamUsageRaw: Record | undefined; + let streamIncomplete = false; + let streamSawDone = false; + let streamSawFinishReason = false; + const collectUsage = (): NormalizedUsage => { + const usage = streamUsageRaw + ? normalizeOpenRouterUsage(streamUsageRaw) + : { raw: { stream_usage_missing: true } }; + return streamIncomplete ? { ...usage, incomplete: true } : usage; }; const emit = (event: string, payload: Record) => { @@ -423,140 +410,77 @@ export async function handleRequest(req: Request): Promise { req.signal.addEventListener("abort", clientAbortHandler); try { + if (req.signal.aborted) throw new DOMException("Client aborted", "AbortError"); const messages = buildMessages(userMessage, history, mode); - if (resolvedProvider === "openai") { - const response = await fetch("https://api.openai.com/v1/chat/completions", { - method: "POST", - headers: { Authorization: `Bearer ${providerKey}`, "Content-Type": "application/json" }, - body: JSON.stringify({ model, messages, temperature, stream: true, stream_options: { include_usage: true } }), - signal: upstreamAbort.signal, - }); - if (!response.ok || !response.body) throw new Error(await response.text() || `OpenAI ${response.status}`); - - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ""; - while (true) { - if (req.signal.aborted) break; - const { done, value } = await reader.read(); - if (done) break; - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split("\n"); - buffer = lines.pop() || ""; - for (const line of lines) { - const trimmed = line.trim(); - if (!trimmed.startsWith("data:")) continue; - const data = trimmed.slice("data:".length).trim(); - if (data === "[DONE]") continue; - let parsed: Record; - try { parsed = JSON.parse(data); } catch { continue; } - const delta = (parsed.choices as Array<{ delta?: { content?: string } }>)?.[0]?.delta?.content; - if (typeof delta === "string" && delta) { accumulated += delta; emit("delta", { text: delta }); } - const usage = parsed.usage as Record | undefined; - if (usage) mergeUsage("openai", usage); - } + const payload: Record = { + model, + messages, + max_tokens: Math.min(modelEntry.maxTokens, MAX_REQUEST_OUTPUT_TOKENS), + reasoning: { effort: reasoningEffort, exclude: true }, + provider: { require_parameters: true }, + stream: true, + }; + if (modelEntry.supportsTemperature) payload.temperature = temperature; + + const response = await fetch("https://openrouter.ai/api/v1/chat/completions", { + method: "POST", + headers: { Authorization: `Bearer ${providerKey}`, "Content-Type": "application/json" }, + body: JSON.stringify(payload), + signal: upstreamAbort.signal, + }); + if (!response.ok || !response.body) throw new Error(await response.text() || `OpenRouter ${response.status}`); + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + const processLine = (line: string) => { + const trimmed = line.trim(); + if (!trimmed.startsWith("data:")) return; + const data = trimmed.slice("data:".length).trim(); + if (data === "[DONE]") { + streamSawDone = true; + return; } - - } else if (resolvedProvider === "google") { - const geminiMessages = messages.map((m) => ({ - role: m.role === "assistant" ? "model" : m.role === "system" ? "user" : "user", - parts: [{ text: m.content }], - })); - // Gemini API: system instruction is separate - const systemInstruction = messages.find((m) => m.role === "system"); - const contents = geminiMessages.filter((_, i) => messages[i].role !== "system"); - - const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:streamGenerateContent?alt=sse&key=${providerKey}`; - const response = await fetch(url, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - contents, - ...(systemInstruction ? { systemInstruction: { parts: [{ text: systemInstruction.content }] } } : {}), - generationConfig: { temperature }, - }), - signal: upstreamAbort.signal, - }); - if (!response.ok || !response.body) throw new Error(await response.text() || `Gemini ${response.status}`); - - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ""; - while (true) { - if (req.signal.aborted) break; - const { done, value } = await reader.read(); - if (done) break; - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split("\n"); - buffer = lines.pop() || ""; - for (const line of lines) { - const trimmed = line.trim(); - if (!trimmed.startsWith("data:")) continue; - const data = trimmed.slice("data:".length).trim(); - let parsed: Record; - try { parsed = JSON.parse(data); } catch { continue; } - const candidates = parsed.candidates as Array<{ content?: { parts?: Array<{ text?: string }> } }> | undefined; - const text = candidates?.[0]?.content?.parts?.[0]?.text; - if (typeof text === "string" && text) { accumulated += text; emit("delta", { text }); } - const usageMeta = parsed.usageMetadata as Record | undefined; - if (usageMeta) mergeUsage("google", usageMeta); - } + let parsed: Record; + try { parsed = JSON.parse(data); } catch { return; } + if (parsed.error) { + const providerError = parsed.error; + const message = providerError && typeof providerError === "object" && "message" in providerError + ? (providerError as { message?: unknown }).message + : providerError; + throw new Error(typeof message === "string" ? message : "OpenRouter stream error"); } - - } else if (resolvedProvider === "anthropic") { - const systemMsg = messages.find((m) => m.role === "system")?.content || ""; - const anthropicMessages = messages.filter((m) => m.role !== "system").map((m) => ({ role: m.role, content: m.content })); - - const response = await fetch("https://api.anthropic.com/v1/messages", { - method: "POST", - headers: { - "x-api-key": providerKey, - "anthropic-version": "2023-06-01", - "Content-Type": "application/json", - }, - body: JSON.stringify({ model, max_tokens: 4096, system: systemMsg, messages: anthropicMessages, temperature, stream: true }), - signal: upstreamAbort.signal, - }); - if (!response.ok || !response.body) throw new Error(await response.text() || `Anthropic ${response.status}`); - - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ""; - while (true) { - if (req.signal.aborted) break; - const { done, value } = await reader.read(); - if (done) break; - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split("\n"); - buffer = lines.pop() || ""; - for (const line of lines) { - const trimmed = line.trim(); - if (trimmed.startsWith("event:")) continue; - if (!trimmed.startsWith("data:")) continue; - const data = trimmed.slice("data:".length).trim(); - let parsed: Record; - try { parsed = JSON.parse(data); } catch { continue; } - if (parsed.type === "content_block_delta") { - const delta = (parsed.delta as { text?: string })?.text; - if (typeof delta === "string" && delta) { accumulated += delta; emit("delta", { text: delta }); } - } - if (parsed.type === "message_start") { - const usage = (parsed.message as { usage?: Record } | undefined)?.usage; - if (usage) mergeUsage("anthropic", usage); - } - if (parsed.type === "message_delta") { - const usage = parsed.usage as Record | undefined; - if (usage) mergeUsage("anthropic", usage); - } - } + const delta = (Array.isArray(parsed.choices) ? parsed.choices[0] : undefined) as { delta?: { content?: string }; finish_reason?: unknown } | undefined; + if (typeof delta?.finish_reason === "string" && delta.finish_reason) streamSawFinishReason = true; + if (typeof delta?.delta?.content === "string" && delta.delta.content) { + accumulated += delta.delta.content; + emit("delta", { text: delta.delta.content }); } + if (parsed.usage && typeof parsed.usage === "object" && !Array.isArray(parsed.usage)) { + streamUsageRaw = { ...streamUsageRaw, ...(parsed.usage as Record) }; + } + }; + while (true) { + if (req.signal.aborted) throw new DOMException("Client aborted", "AbortError"); + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + for (const line of lines) processLine(line); + } + buffer += decoder.decode(); + for (const line of buffer.split("\n")) processLine(line); + + if (!streamSawDone && !streamSawFinishReason) { + throw new Error("OpenRouter stream ended before completion"); } const output = { response: accumulated.trim(), model, - provider: resolvedProvider, + provider: OPENROUTER_PROVIDER, durationMs: Date.now() - startedAt, generatedAt: new Date().toISOString(), followUpQuestions: [], @@ -566,8 +490,9 @@ export async function handleRequest(req: Request): Promise { if (!req.signal.aborted) { emit("final", { output }); } - await logUsage(user.id, resolvedProvider, model, collectUsage(), Date.now() - startedAt); + await logUsage(user.id, OPENROUTER_PROVIDER, model, collectUsage(), Date.now() - startedAt); } catch (err) { + streamIncomplete = true; const errorMessage = err instanceof Error ? err.message : "Streaming failed"; const isAbort = req.signal.aborted || (err instanceof DOMException && err.name === "AbortError"); if (!isAbort) { @@ -575,7 +500,7 @@ export async function handleRequest(req: Request): Promise { } await logUsage( user.id, - resolvedProvider, + OPENROUTER_PROVIDER, model, collectUsage(), Date.now() - startedAt, diff --git a/supabase/functions/ai/handler-research.test.ts b/supabase/functions/ai/handler-research.test.ts index b5109fc55..422c67a53 100644 --- a/supabase/functions/ai/handler-research.test.ts +++ b/supabase/functions/ai/handler-research.test.ts @@ -10,13 +10,14 @@ const environment = { SUPABASE_SERVICE_ROLE_KEY: "test-service-role-key", SPARRING_INTEGRITY_SECRET: "test-sparring-secret", EXA_API_KEY: "test-exa-key", - OPENAI_API_KEY: "test-openai-key", + OPENROUTER_API_KEY: "test-openrouter-key", }; for (const [key, value] of Object.entries(environment)) Deno.env.set(key, value); const { handleRequest } = await import("./index.ts"); const { handleRequest: handleStreamRequest } = await import("../ai-stream/index.ts"); +const { MAX_REQUEST_OUTPUT_TOKENS, MODEL_CATALOG } = await import("../_shared/model-catalog.ts"); Deno.test("AI handlers reject malformed controls before quota or provider work", async () => { const originalFetch = globalThis.fetch; @@ -37,7 +38,7 @@ Deno.test("AI handlers reject malformed controls before quota or provider work", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ feature: "ask", - model: "gpt-5.4-mini", + model: "openai/gpt-5.6-luna", input: { message: "Question", mode: "\u200Bgeneral", citationMode: "false" }, }), })); @@ -53,7 +54,7 @@ Deno.test("AI handlers reject malformed controls before quota or provider work", }, body: JSON.stringify({ feature: "ask", - model: "gpt-5.4-mini", + model: "openai/gpt-5.6-luna", input: { message: "Question", mode: "general", citationMode: "false" }, }), })); @@ -124,7 +125,7 @@ Deno.test("factoids handler carries Exa filters and consulted-source metadata ac }); } - if (url.href === "https://api.openai.com/v1/chat/completions") { + if (url.href === "https://openrouter.ai/api/v1/chat/completions") { providerCalls += 1; const content = providerCalls === 1 ? { queries: [] } @@ -151,7 +152,7 @@ Deno.test("factoids handler carries Exa filters and consulted-source metadata ac }; return jsonResponse({ choices: [{ message: { content: JSON.stringify(content) } }], - usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, + usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15, cost: 0.000001 }, }); } @@ -168,7 +169,7 @@ Deno.test("factoids handler carries Exa filters and consulted-source metadata ac }, body: JSON.stringify({ feature: "factoids", - model: "gpt-5.4-mini", + model: "openai/gpt-5.6-luna", input: { claim: "solar energy costs", direction: "for", @@ -322,5 +323,268 @@ Deno.test("Ask marks only surviving URL and research context as available", asyn } } finally { globalThis.fetch = originalFetch; +function supabaseTestResponse(url: URL): Response | undefined { + if (url.origin !== "https://supabase.test") return undefined; + if (url.pathname === "/auth/v1/user") return jsonResponse({ id: "user-1", aud: "authenticated" }); + if (url.pathname.includes("/rest/v1/user_roles")) return jsonResponse({ role: "user" }); + if (url.pathname.includes("/rest/v1/user_settings")) return jsonResponse({ + subscription_tier: (globalThis as typeof globalThis & { __testTier?: string }).__testTier || "trial", + daily_limit_override: 50, + trial_ends_at: "2099-01-01T00:00:00.000Z", + grace_period_ends_at: null, + }); + if (url.pathname.includes("/rest/v1/subscriptions")) return jsonResponse([{ status: "active", current_period_end: "2099-01-01T00:00:00.000Z" }]); + if (url.pathname.includes("/rest/v1/rate_limits")) return jsonResponse({ limit_value: 50 }); + if (url.pathname.includes("/rest/v1/rpc/consume_daily_quota")) return jsonResponse([{ allowed: true, current_count: 1 }]); + if (url.pathname.includes("/rest/v1/global_prompts")) return jsonResponse({ prompt_text: null }); + if (url.pathname.includes("/rest/v1/ai_requests")) return new Response(null, { status: 201 }); + throw new Error(`Unhandled Supabase request: ${url}`); +} + +function askRequest(model: string, reasoningEffort?: string): 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({ + feature: "ask", + model, + ...(reasoningEffort === undefined ? {} : { reasoningEffort }), + input: { message: "How can I compare two policies?", mode: "general" }, + }), + }); +} + +Deno.test("AI handlers use strict OpenRouter routing and server-side reasoning tiers", async () => { + const originalFetch = globalThis.fetch; + const originalTier = (globalThis as typeof globalThis & { __testTier?: string }).__testTier; + const openRouterBodies: Array> = []; + const testState = globalThis as typeof globalThis & { __testTier?: string }; + const requestForModel = (model: string, tier: "trial" | "pro", effort = "high") => { + testState.__testTier = tier; + return handleRequest(askRequest(model, effort)); + }; + + globalThis.fetch = (async (input: Request | URL | string, init?: RequestInit) => { + const url = new URL(input instanceof Request ? input.url : String(input)); + const supabaseResponse = supabaseTestResponse(url); + if (supabaseResponse) return supabaseResponse; + if (url.href === "https://openrouter.ai/api/v1/chat/completions") { + const body = JSON.parse(String(init?.body)) as Record; + openRouterBodies.push(body); + return jsonResponse({ + choices: [{ message: { content: "{}" } }], + usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15, cost: 0.001 }, + }); + } + throw new Error(`Unexpected request: ${url}`); + }) as typeof fetch; + + try { + const unknown = await handleRequest(askRequest("provider/unknown-model", "low")); + if (unknown.status !== 400 || openRouterBodies.length !== 0) throw new Error("unknown model reached routing"); + + for (const entry of MODEL_CATALOG) { + const response = await requestForModel(entry.id, entry.isProOnly ? "pro" : "trial"); + 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); + 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"]) { + const response = await requestForModel(model, "pro", "medium"); + if (response.status !== 200) throw new Error(`Pro medium ${model} returned ${response.status}: ${await response.text()}`); + } + + if (openRouterBodies.length !== MODEL_CATALOG.length + 6) throw new Error(`unexpected OpenRouter call count: ${openRouterBodies.length}`); + for (let index = 0; index < MODEL_CATALOG.length; index += 1) { + const entry = MODEL_CATALOG[index]; + const body = openRouterBodies[index]; + if (body.model !== entry.id) throw new Error(`wrong routed model: ${String(body.model)}`); + if (body.max_tokens !== MAX_REQUEST_OUTPUT_TOKENS) throw new Error(`output cap was not enforced for ${entry.id}`); + const reasoning = body.reasoning as Record; + const provider = body.provider as Record; + if (reasoning.effort !== (entry.isProOnly ? "high" : "low")) throw new Error(`tier effort was not enforced for ${entry.id}`); + if (reasoning.exclude !== true || provider.require_parameters !== true) throw new Error(`strict reasoning route missing for ${entry.id}`); + if (body.response_format && (body.response_format as Record).type !== "json_object") throw new Error(`JSON response format missing for ${entry.id}`); + if (entry.supportsTemperature !== ("temperature" in body)) throw new Error(`temperature support mismatch for ${entry.id}`); + } + for (const [offset, effort] of ["low", "medium", "high"].entries()) { + const body = openRouterBodies[MODEL_CATALOG.length + offset]; + if ((body.reasoning as Record).effort !== effort) throw new Error(`Pro effort ${effort} was not forwarded`); + } + for (const offset of [3, 4, 5]) { + const body = openRouterBodies[MODEL_CATALOG.length + offset]; + if ((body.reasoning as Record).effort !== "low") throw new Error("unsupported Pro medium was not lowered"); + } + } finally { + globalThis.fetch = originalFetch; + if (originalTier === undefined) delete testState.__testTier; + else testState.__testTier = originalTier; + } +}); + +Deno.test("streaming OpenRouter usage records zero and missing costs distinctly", async () => { + const originalFetch = globalThis.fetch; + const usageRows: Array> = []; + const streamBodies: Array> = []; + let includeUsage = true; + globalThis.fetch = (async (input: Request | URL | string, init?: RequestInit) => { + const url = new URL(input instanceof Request ? input.url : String(input)); + const supabaseResponse = supabaseTestResponse(url); + if (supabaseResponse) { + if (url.pathname.includes("/rest/v1/ai_requests")) { + usageRows.push(JSON.parse(String(init?.body)) as Record); + } + return supabaseResponse; + } + if (url.href === "https://openrouter.ai/api/v1/chat/completions") { + streamBodies.push(JSON.parse(String(init?.body)) as Record); + const frames = [ + `data: ${JSON.stringify({ choices: [{ delta: { content: "Hello" } }] })}\n\n`, + ...(includeUsage ? [`data: ${JSON.stringify({ choices: [], usage: { prompt_tokens: 2, completion_tokens: 1, total_tokens: 3, cost: 0 } })}\n\n`] : []), + "data: [DONE]\n\n", + ].join(""); + return new Response(frames, { headers: { "Content-Type": "text/event-stream" } }); + } + throw new Error(`Unexpected request: ${url}`); + }) as typeof fetch; + + const testState = globalThis as typeof globalThis & { __testTier?: string }; + const originalTier = testState.__testTier; + testState.__testTier = "trial"; + try { + 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" } }), + })); + await withZero.text(); + if (streamBodies[0]?.max_tokens !== MAX_REQUEST_OUTPUT_TOKENS) throw new Error("stream output cap was not enforced"); + if (usageRows.at(-1)?.cost_usd !== 0) throw new Error(`zero stream cost was not logged: ${JSON.stringify(usageRows.at(-1))}`); + + includeUsage = false; + 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" } }), + })); + await withoutUsage.text(); + if (usageRows.at(-1)?.cost_usd !== null) throw new Error(`missing stream cost was estimated: ${JSON.stringify(usageRows.at(-1))}`); + } finally { + globalThis.fetch = originalFetch; + if (originalTier === undefined) delete testState.__testTier; + else testState.__testTier = originalTier; + } +}); + +Deno.test("streaming rejects provider errors and incomplete EOFs, while accepting a tail finish reason", async () => { + const originalFetch = globalThis.fetch; + const usageRows: Array> = []; + let frames = ""; + globalThis.fetch = (async (input: Request | URL | string, init?: RequestInit) => { + const url = new URL(input instanceof Request ? input.url : String(input)); + const supabaseResponse = supabaseTestResponse(url); + if (supabaseResponse) { + if (url.pathname.includes("/rest/v1/ai_requests")) usageRows.push(JSON.parse(String(init?.body)) as Record); + return supabaseResponse; + } + if (url.href === "https://openrouter.ai/api/v1/chat/completions") { + return new Response(frames, { headers: { "Content-Type": "text/event-stream" } }); + } + throw new Error(`Unexpected request: ${url}`); + }) as typeof fetch; + + 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" } }), + }); + + const testState = globalThis as typeof globalThis & { __testTier?: string }; + const originalTier = testState.__testTier; + testState.__testTier = "trial"; + try { + frames = `data: ${JSON.stringify({ error: { message: "provider exploded" } })}`; + const providerError = await (await handleStreamRequest(request())).text(); + if (providerError.includes("event: final") || !providerError.includes("event: error") || !providerError.includes("provider exploded")) { + throw new Error(`provider error was not surfaced as an SSE error: ${providerError}`); + } + if (usageRows.at(-1)?.cost_usd !== null) throw new Error("provider error published a cost"); + + frames = `data: ${JSON.stringify({ choices: [{ delta: { content: "partial" } }] })}\n\n`; + const incomplete = await (await handleStreamRequest(request())).text(); + if (incomplete.includes("event: final") || !incomplete.includes("event: error") || !incomplete.includes("ended before completion")) { + throw new Error(`incomplete EOF was treated as success: ${incomplete}`); + } + if (usageRows.at(-1)?.cost_usd !== null) throw new Error("incomplete EOF published a cost"); + + frames = [ + `data: ${JSON.stringify({ choices: [{ delta: { content: "complete" } }] })}`, + `data: ${JSON.stringify({ choices: [{ delta: {}, finish_reason: "stop" }] })}`, + `data: ${JSON.stringify({ choices: [], usage: { prompt_tokens: 2, completion_tokens: 1, cost: 0.25 } })}`, + ].join("\n"); + const finished = await (await handleStreamRequest(request())).text(); + if (!finished.includes("event: final") || finished.includes("event: error")) throw new Error(`tail finish reason was rejected: ${finished}`); + if (usageRows.at(-1)?.cost_usd !== 0.25) throw new Error("tail usage was not logged"); + } finally { + globalThis.fetch = originalFetch; + if (originalTier === undefined) delete testState.__testTier; + else testState.__testTier = originalTier; + } +}); + +Deno.test("cancelled streams keep observed cost unknown", async () => { + const originalFetch = globalThis.fetch; + const usageRows: Array> = []; + const encoder = new TextEncoder(); + let providerStartedResolve!: () => void; + const providerStarted = new Promise((resolve) => providerStartedResolve = resolve); + globalThis.fetch = (async (input: Request | URL | string, init?: RequestInit) => { + const url = new URL(input instanceof Request ? input.url : String(input)); + const supabaseResponse = supabaseTestResponse(url); + if (supabaseResponse) { + if (url.pathname.includes("/rest/v1/ai_requests")) usageRows.push(JSON.parse(String(init?.body)) as Record); + return supabaseResponse; + } + if (url.href === "https://openrouter.ai/api/v1/chat/completions") { + const body = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(`data: ${JSON.stringify({ choices: [{ delta: { content: "partial" } }] })}\n\n`)); + controller.enqueue(encoder.encode(`data: ${JSON.stringify({ choices: [], usage: { prompt_tokens: 2, completion_tokens: 1, cost: 0.25 } })}\n\n`)); + providerStartedResolve(); + init?.signal?.addEventListener("abort", () => controller.error(new DOMException("Upstream aborted", "AbortError")), { once: true }); + }, + pull() { + return new Promise(() => {}); + }, + }); + return new Response(body, { headers: { "Content-Type": "text/event-stream" } }); + } + throw new Error(`Unexpected request: ${url}`); + }) as typeof fetch; + + const testState = globalThis as typeof globalThis & { __testTier?: string }; + const originalTier = testState.__testTier; + testState.__testTier = "trial"; + const caller = new AbortController(); + try { + const response = 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" } }), + signal: caller.signal, + })); + const read = response.text(); + await providerStarted; + await new Promise((resolve) => setTimeout(resolve, 5)); + caller.abort(); + await read; + const row = usageRows.at(-1); + if (row?.status !== "cancelled" || row.cost_usd !== null) throw new Error(`cancelled stream was billed: ${JSON.stringify(row)}`); + } finally { + globalThis.fetch = originalFetch; + if (originalTier === undefined) delete testState.__testTier; + else testState.__testTier = originalTier; } }); diff --git a/supabase/functions/ai/index.ts b/supabase/functions/ai/index.ts index 9456b623c..a64c1aac6 100644 --- a/supabase/functions/ai/index.ts +++ b/supabase/functions/ai/index.ts @@ -90,12 +90,19 @@ import { calculateRequestCost, type CostResult, mergeNormalizedUsage, - normalizeAnthropicUsage, - normalizeGoogleUsage, - normalizeOpenAIUsage, + normalizeOpenRouterUsage, selectObservedUsage, type NormalizedUsage, } from "../_shared/cost.ts"; +import { + DEFAULT_MODEL, + effectiveReasoningEffort, + getModelCatalogEntry, + isReasoningEffort, + MAX_REQUEST_OUTPUT_TOKENS, + type ModelCatalogEntry, + type ReasoningEffort, +} from "../_shared/model-catalog.ts"; import { ASK_RESEARCH, applyFactoidsVerification, @@ -132,43 +139,17 @@ function narrowSparringDatabase(database: object): SparringDatabase { // ============== RATE LIMITING & SUBSCRIPTION GATING ============== -// Pro-only models: require active subscription -const PRO_MODELS = new Set([ - "gpt-5.5", - "gpt-5.4", - "claude-opus-4-7", - "claude-sonnet-4-6", - "gemini-3.1-pro-preview", -]); - -// Every model this endpoint will call, mapped to its provider. The client -// picks a model; the server derives (and trusts) the provider from this -// allowlist rather than the client-supplied provider field, so an arbitrary -// or unlisted model/provider pair can never reach a provider API or bypass -// PRO_MODELS gating. -const MODEL_PROVIDERS: Record = { - "gpt-5.5": "openai", - "gpt-5.4": "openai", - "gpt-5.4-mini": "openai", - "claude-opus-4-7": "anthropic", - "claude-sonnet-4-6": "anthropic", - "claude-haiku-4-5-20251001": "anthropic", - "gemini-3.1-pro-preview": "google", - "gemini-3-flash-preview": "google", - "gemini-3.1-flash-lite-preview": "google", - // Also served by ai-stream's allowlist; without this entry the model works - // while streaming but fails on every non-streaming (advanced) Ask request. - "gemini-3.5-flash": "google", -}; +const OPENROUTER_PROVIDER: Provider = "openrouter"; -function providerForModel(model: string): Provider { - const provider = MODEL_PROVIDERS[model]; - if (!provider) throw new Error("Unsupported model or provider combination"); - return provider; +function modelForRequest(model: string): ModelCatalogEntry { + const entry = getModelCatalogEntry(model); + if (!entry) throw new Error("Unsupported model or provider combination"); + return entry; } interface RateLimitResult { allowed: boolean; + canAccessProModels?: boolean; reason?: string; limit?: number; current?: number; @@ -358,7 +339,7 @@ async function checkRateLimit(userId: string | null, model?: string): Promise; usage?: unknown; }; -type AnthropicResponse = { - content?: Array<{ text?: string }>; - usage?: unknown; -}; +function normalizeProviderUsage(usage: unknown): NormalizedUsage | undefined { + if (!usage || typeof usage !== "object") return undefined; + return normalizeOpenRouterUsage(usage as Record); +} -type GoogleResponse = { - candidates?: Array<{ content?: { parts?: Array<{ text?: string }> } }>; - usageMetadata?: unknown; -}; +function missingOpenRouterUsage(reason: string): NormalizedUsage { + return { incomplete: true, raw: { openrouter_usage_missing: reason } }; +} -function normalizeProviderUsage(provider: Provider, usage: unknown): NormalizedUsage | undefined { - if (!usage || typeof usage !== "object") return undefined; - const raw = usage as Record; - if (provider === "openai") return normalizeOpenAIUsage(raw); - if (provider === "anthropic") return normalizeAnthropicUsage(raw); - return normalizeGoogleUsage(raw); +function attachMissingUsage(error: unknown, reason: string): unknown { + if (!error || typeof error !== "object") return error; + const errorWithUsage = error as { usage?: NormalizedUsage }; + if (!errorWithUsage.usage) errorWithUsage.usage = missingOpenRouterUsage(reason); + return error; } async function callAI( @@ -844,12 +823,13 @@ async function callAI( systemPrompt: string, messages: Array<{ role: string; content: string }>, temperature: number, + reasoningEffort: ReasoningEffort, signal?: AbortSignal ): Promise { try { - return await callAIOnce(provider, model, systemPrompt, messages, temperature, signal); + return await callAIOnce(provider, model, systemPrompt, messages, temperature, reasoningEffort, signal); } catch (err) { - if (!(err instanceof InvalidJsonResponseError)) throw err; + if (!(err instanceof InvalidJsonResponseError)) throw attachMissingUsage(err, "call"); console.warn(`[AI] ${err.providerName} returned invalid JSON; retrying with repair prompt`); let retry: AIResult; @@ -860,14 +840,15 @@ async function callAI( `${systemPrompt}\n\nJSON REPAIR INSTRUCTION: Return only valid JSON matching this schema, no prose.`, withJsonRepairPrompt(messages), 0, + reasoningEffort, signal, ); } catch (retryError) { // Preserve the completed first attempt if cancellation or another // failure prevents the repair attempt from returning a result. const retryUsage = retryError && typeof retryError === "object" && "usage" in retryError - ? (retryError as { usage?: NormalizedUsage }).usage - : undefined; + ? (retryError as { usage?: NormalizedUsage }).usage ?? missingOpenRouterUsage("repair_retry") + : missingOpenRouterUsage("repair_retry"); const usage = mergeNormalizedUsage([err.usage, retryUsage]); if (usage && retryError && typeof retryError === "object") { const errorWithUsage = retryError as { @@ -879,7 +860,7 @@ async function callAI( // even if the retry is the operation cancelled by the caller. errorWithUsage.completedUsage = err.usage; } - throw retryError; + throw attachMissingUsage(retryError, "repair_retry"); } // The failed attempt still consumed tokens; bill both. return { @@ -910,55 +891,49 @@ async function callAIOnce( systemPrompt: string, messages: Array<{ role: string; content: string }>, temperature: number, + reasoningEffort: ReasoningEffort, signal?: AbortSignal ): Promise { - const effectiveSystemPrompt = systemPrompt; - if (provider === "openai") { - return await callOpenAI([{ role: "system", content: effectiveSystemPrompt }, ...messages], model, temperature, signal); - } else if (provider === "anthropic") { - return await callAnthropic(messages, model, temperature, effectiveSystemPrompt, signal); - } else if (provider === "google") { - return await callGoogle(messages, model, temperature, effectiveSystemPrompt, signal); - } - throw new Error(`Unsupported provider: ${provider}`); + if (provider !== OPENROUTER_PROVIDER) throw new Error(`Unsupported provider: ${provider}`); + return await callOpenRouter( + [{ role: "system", content: systemPrompt }, ...messages], + model, + temperature, + reasoningEffort, + signal, + ); } -async function callOpenAI(messages: Array<{ role: string; content: string }>, model: string, temperature: number, signal?: AbortSignal): Promise { - const apiKey = Deno.env.get("OPENAI_API_KEY"); - if (!apiKey) throw new Error("OPENAI_API_KEY missing"); - const { response, data, errorBody } = await fetchJsonWithTimeout("https://api.openai.com/v1/chat/completions", { - method: "POST", - headers: { "Content-Type": "application/json", "Authorization": `Bearer ${apiKey}` }, - body: JSON.stringify({ model, messages, temperature, response_format: { type: "json_object" } }), - }, 60000, signal); - if (!response.ok) { const text = errorBody ?? ""; throw new Error(`OpenAI error: ${text}`); } - if (!data?.choices || !Array.isArray(data.choices) || data.choices.length === 0) throw new Error("OpenAI returned no choices"); - return parseProviderContent(data.choices[0]?.message?.content ?? "", "OpenAI", normalizeProviderUsage("openai", data.usage)); -} +async function callOpenRouter( + messages: Array<{ role: string; content: string }>, + model: string, + temperature: number, + reasoningEffort: ReasoningEffort, + signal?: AbortSignal, +): Promise { + const apiKey = Deno.env.get("OPENROUTER_API_KEY"); + if (!apiKey) throw new Error("OPENROUTER_API_KEY missing"); -async function callAnthropic(messages: Array<{ role: string; content: string }>, model: string, temperature: number, system: string, signal?: AbortSignal): Promise { - const apiKey = Deno.env.get("ANTHROPIC_API_KEY"); - if (!apiKey) throw new Error("ANTHROPIC_API_KEY missing"); - const { response, data, errorBody } = await fetchJsonWithTimeout("https://api.anthropic.com/v1/messages", { + const modelEntry = modelForRequest(model); + const payload: Record = { + model, + messages, + max_tokens: Math.min(modelEntry.maxTokens, MAX_REQUEST_OUTPUT_TOKENS), + response_format: { type: "json_object" }, + reasoning: { effort: reasoningEffort, exclude: true }, + provider: { require_parameters: true }, + }; + if (modelEntry.supportsTemperature) payload.temperature = temperature; + + const { response, data, errorBody } = await fetchJsonWithTimeout("https://openrouter.ai/api/v1/chat/completions", { method: "POST", - headers: { "x-api-key": apiKey, "anthropic-version": "2023-06-01", "content-type": "application/json" }, - body: JSON.stringify({ model, system, messages, max_tokens: 4096, temperature }), + headers: { "Content-Type": "application/json", "Authorization": `Bearer ${apiKey}` }, + body: JSON.stringify(payload), }, 60000, signal); - if (!response.ok) { const text = errorBody ?? ""; throw new Error(`Anthropic error: ${text}`); } - if (!data?.content || !Array.isArray(data.content) || data.content.length === 0) throw new Error("Anthropic returned no content"); - return parseProviderContent(data.content[0]?.text || "", "Anthropic", normalizeProviderUsage("anthropic", data.usage)); -} - -async function callGoogle(messages: Array<{ role: string; content: string }>, model: string, temperature: number, system: string, signal?: AbortSignal): Promise { - const apiKey = Deno.env.get("GOOGLE_API_KEY"); - if (!apiKey) throw new Error("GOOGLE_API_KEY missing"); - const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${apiKey}`; - const contents = messages.map((m) => ({ role: m.role === "user" ? "user" : "model", parts: [{ text: m.content }] })); - const payload = { contents, systemInstruction: system ? { parts: [{ text: system }] } : undefined, generationConfig: { temperature, responseMimeType: "application/json" } }; - const { response, data, errorBody } = await fetchJsonWithTimeout(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) }, 60000, signal); - if (!response.ok) { const text = errorBody ?? ""; throw new Error(`Google error: ${text}`); } - if (!data?.candidates || !Array.isArray(data.candidates) || data.candidates.length === 0) throw new Error("Google returned no candidates"); - return parseProviderContent(data.candidates[0]?.content?.parts?.[0]?.text || "", "Google", normalizeProviderUsage("google", data.usageMetadata)); + if (!response.ok) throw new Error(`OpenRouter error: ${errorBody ?? ""}`); + if (!data?.choices || !Array.isArray(data.choices) || data.choices.length === 0) throw new Error("OpenRouter returned no choices"); + const usage = normalizeProviderUsage(data.usage) ?? missingOpenRouterUsage("response"); + return parseProviderContent(data.choices[0]?.message?.content ?? "", "OpenRouter", usage); } function extractUrls(text: string) { @@ -1049,14 +1024,19 @@ export async function handleRequest(req: Request): Promise { } const feature = typeof body.feature === "string" ? body.feature as Feature : undefined; const rawInput = body.input; - const model = (body.model as string | undefined) || Deno.env.get("AI_DEFAULT_MODEL") || "gemini-3-flash-preview"; - let provider: Provider; + const model = (typeof body.model === "string" ? body.model : "") || DEFAULT_MODEL; + let modelEntry: ModelCatalogEntry; try { - provider = providerForModel(model); + modelEntry = modelForRequest(model); } catch (err) { log(requestId, "WARN", "Rejected unsupported model/provider combination", { model }); return badRequest(err instanceof Error ? err.message : "Unsupported model or provider combination", corsHeaders); } + const provider: Provider = OPENROUTER_PROVIDER; + const requestedReasoningEffort = body.reasoningEffort === undefined + ? undefined + : isReasoningEffort(body.reasoningEffort) ? body.reasoningEffort : null; + if (requestedReasoningEffort === null) return badRequest("Invalid reasoning effort", corsHeaders); const temperature = typeof body.temperature === "number" ? body.temperature : 0.7; // Crossfire has non-overridable transcript-grounding and typed-session // safety rules. Ignore both user and admin custom prompts for this feature. @@ -1238,6 +1218,14 @@ export async function handleRequest(req: Request): Promise { }); } + const reasoningEffort = effectiveReasoningEffort(model, requestedReasoningEffort ?? undefined, Boolean(rateLimit.canAccessProModels)); + log(requestId, "INFO", "Resolved model controls", { + model: modelEntry.id, + requestedReasoningEffort: requestedReasoningEffort ?? "medium", + reasoningEffort, + supportsTemperature: modelEntry.supportsTemperature, + }); + const startTime = Date.now(); // Usage from research sub-calls (search decision, follow-up queries) is // collected here so the parent request's row accounts for every token. @@ -1246,13 +1234,14 @@ export async function handleRequest(req: Request): Promise { const researchDeps: ResearchDeps = { provider, model, + reasoningEffort, signal: req.signal, onTimeout: () => { researchAccountingIncomplete = true; }, callModel: async (args) => { try { - const result = await callAI(args.provider, args.model, args.systemPrompt, args.messages, args.temperature, args.signal); + const result = await callAI(args.provider, args.model, args.systemPrompt, args.messages, args.temperature, args.reasoningEffort, args.signal); // A provider that ignores cancellation must not append usage after the // research timeout has already been finalized by the parent request. if (result.usage && !args.signal?.aborted) researchUsage.push(result.usage); @@ -1308,7 +1297,7 @@ export async function handleRequest(req: Request): Promise { const systemPrompt = await buildSystemPromptFor(feature); const userPrompt = buildUserPrompt(feature, sparringRequest as unknown as Record, ""); const messages = [{ role: "user", content: userPrompt }]; - const aiResult = recordMainResult(await callAI(provider, model, systemPrompt, messages, temperature, req.signal)); + const aiResult = recordMainResult(await callAI(provider, model, systemPrompt, messages, temperature, reasoningEffort, req.signal)); const output = cleanGeneratedText(aiResult.output); const normalizedOutput = normalizeSparringOutput(output, sparringRequest.action); const parsedOutput = parseSparringProviderOutput(normalizedOutput, sparringRequest.action); @@ -1374,6 +1363,7 @@ export async function handleRequest(req: Request): Promise { systemPrompt, [{ role: "user", content: userPrompt }], temperature, + reasoningEffort, req.signal, )); const cleanedOutput = cleanGeneratedText(aiResult.output); @@ -1406,7 +1396,7 @@ export async function handleRequest(req: Request): Promise { const userPrompt = buildUserPrompt(feature, sanitizedInput, searchContext); const messages = [{ role: "user", content: userPrompt }]; - const aiResult = recordMainResult(await callAI(provider, model, systemPrompt, messages, temperature, req.signal)); + const aiResult = recordMainResult(await callAI(provider, model, systemPrompt, messages, temperature, reasoningEffort, req.signal)); const output = cleanGeneratedText(aiResult.output); // Post-process: quality floor. Only citations whose URL matches fetched, @@ -1519,7 +1509,7 @@ export async function handleRequest(req: Request): Promise { const systemPrompt = await buildSystemPromptFor(feature, effectiveResearch); const prompt = `${basePrompt}${urlContext}${webSearchContext}`; const messages = [...history, { role: "user", content: prompt }]; - const aiResult = recordMainResult(await callAI(provider, model, systemPrompt, messages, temperature, req.signal)); + const aiResult = recordMainResult(await callAI(provider, model, systemPrompt, messages, temperature, reasoningEffort, req.signal)); const output = cleanGeneratedText(aiResult.output); if (output && typeof output === "object") { @@ -1599,7 +1589,7 @@ export async function handleRequest(req: Request): Promise { const systemPrompt = await buildSystemPromptFor(feature, research); const userPrompt = buildUserPrompt(feature, sanitizedInput, searchContext); const messages = [{ role: "user", content: userPrompt }]; - const aiResult = recordMainResult(await callAI(provider, model, systemPrompt, messages, temperature, req.signal)); + const aiResult = recordMainResult(await callAI(provider, model, systemPrompt, messages, temperature, reasoningEffort, req.signal)); const output = cleanGeneratedText(aiResult.output); // Preserve model evidence warnings when research exists. If research @@ -1659,7 +1649,7 @@ export async function handleRequest(req: Request): Promise { const systemPrompt = await buildSystemPromptFor(feature, research); const userPrompt = buildUserPrompt(feature, sanitizedInput, searchContext); const messages = [{ role: "user", content: userPrompt }]; - const aiResult = recordMainResult(await callAI(provider, model, systemPrompt, messages, temperature, req.signal)); + const aiResult = recordMainResult(await callAI(provider, model, systemPrompt, messages, temperature, reasoningEffort, req.signal)); const output = cleanGeneratedText(aiResult.output); enforceBrainstormControls(output, side, outputMode); @@ -1687,7 +1677,7 @@ export async function handleRequest(req: Request): Promise { const systemPrompt = await buildSystemPromptFor(feature, research); const userPrompt = buildUserPrompt(feature, sanitizedInput, searchContext); const messages = [{ role: "user", content: userPrompt }]; - const aiResult = recordMainResult(await callAI(provider, model, systemPrompt, messages, temperature, req.signal)); + const aiResult = recordMainResult(await callAI(provider, model, systemPrompt, messages, temperature, reasoningEffort, req.signal)); const output = cleanGeneratedText(aiResult.output); enforceAttackControls(output, detectFallacies, analyzeWeighing); @@ -1703,7 +1693,7 @@ export async function handleRequest(req: Request): Promise { const systemPrompt = await buildSystemPromptFor(feature); const userPrompt = buildUserPrompt(feature, sanitizedInput, ""); const messages = [{ role: "user", content: userPrompt }]; - const fallbackResult = recordMainResult(await callAI(provider, model, systemPrompt, messages, temperature, req.signal)); + const fallbackResult = recordMainResult(await callAI(provider, model, systemPrompt, messages, temperature, reasoningEffort, req.signal)); const output = cleanGeneratedText(fallbackResult.output); const latencyMs = Date.now() - startTime; From 0e85435aa8b7f8edb074fa549356c472d0aae532 Mon Sep 17 00:00:00 2001 From: Adam Date: Mon, 7 Sep 2026 20:10:24 +0000 Subject: [PATCH 3/7] Preserve incomplete usage on cancelled repair --- supabase/functions/_shared/cost.test.ts | 10 ++++ supabase/functions/_shared/cost.ts | 7 +-- .../functions/ai/handler-research.test.ts | 49 +++++++++++++++++++ 3 files changed, 63 insertions(+), 3 deletions(-) diff --git a/supabase/functions/_shared/cost.test.ts b/supabase/functions/_shared/cost.test.ts index 092c33b3f..0473c6768 100644 --- a/supabase/functions/_shared/cost.test.ts +++ b/supabase/functions/_shared/cost.test.ts @@ -240,6 +240,16 @@ Deno.test("observed usage selection prefers completed usage after abort and supp throw new Error("completed usage selection changed token counts"); } + const incompleteUsage = mergeNormalizedUsage([usage, { incomplete: true, raw: { repair_cancelled: true } }]); + if (!incompleteUsage) throw new Error("incomplete usage merge failed"); + const incompleteSelection = selectObservedUsage({ usage: incompleteUsage, completedUsage }, true); + if (incompleteSelection !== incompleteUsage || !incompleteSelection.incomplete) { + throw new Error("incomplete merged usage must take precedence after abort"); + } + if (incompleteSelection.inputTokens !== 300 || incompleteSelection.outputTokens !== 100) { + throw new Error("incomplete usage did not retain observed tokens"); + } + if (selectObservedUsage({ completedUsage }, false) !== completedUsage) { throw new Error("non-aborted selection did not fall back to completed usage"); } diff --git a/supabase/functions/_shared/cost.ts b/supabase/functions/_shared/cost.ts index b60fdde2a..2db7b61aa 100644 --- a/supabase/functions/_shared/cost.ts +++ b/supabase/functions/_shared/cost.ts @@ -242,14 +242,15 @@ export function mergeNormalizedUsage(usages: Array) /** * Selects the one usage record that best represents observed provider work. - * A non-aborted error prefers the merged usage on `usage`; an aborted error - * prefers the completed work on `completedUsage` so a cancelled retry is not - * counted twice. + * A non-aborted error prefers the merged usage on `usage`. An aborted error + * prefers completed work on `completedUsage` unless the merged usage is + * incomplete, because that marker must keep the total cost unknown. */ export function selectObservedUsage( usageError: { usage?: NormalizedUsage; completedUsage?: NormalizedUsage } | undefined, aborted: boolean, ): NormalizedUsage | undefined { + if (aborted && usageError?.usage?.incomplete) return usageError.usage; return aborted ? usageError?.completedUsage ?? usageError?.usage : usageError?.usage ?? usageError?.completedUsage; diff --git a/supabase/functions/ai/handler-research.test.ts b/supabase/functions/ai/handler-research.test.ts index 422c67a53..fe9f726f4 100644 --- a/supabase/functions/ai/handler-research.test.ts +++ b/supabase/functions/ai/handler-research.test.ts @@ -588,3 +588,52 @@ Deno.test("cancelled streams keep observed cost unknown", async () => { else testState.__testTier = originalTier; } }); + +Deno.test("cancelled JSON repair keeps observed tokens but makes the total cost unknown", async () => { + const originalFetch = globalThis.fetch; + const usageRows: Array> = []; + const caller = new AbortController(); + let providerCalls = 0; + globalThis.fetch = (async (input: Request | URL | string, init?: RequestInit) => { + const url = new URL(input instanceof Request ? input.url : String(input)); + const supabaseResponse = supabaseTestResponse(url); + if (supabaseResponse) { + if (url.pathname.includes("/rest/v1/ai_requests")) usageRows.push(JSON.parse(String(init?.body)) as Record); + return supabaseResponse; + } + if (url.href === "https://openrouter.ai/api/v1/chat/completions") { + providerCalls += 1; + if (providerCalls === 1) { + return jsonResponse({ + choices: [{ message: { content: "not valid JSON" } }], + usage: { prompt_tokens: 11, completion_tokens: 7, cost: 0.25 }, + }); + } + caller.abort(new DOMException("Repair cancelled", "AbortError")); + throw new DOMException("Repair cancelled", "AbortError"); + } + throw new Error(`Unexpected request: ${url}`); + }) as typeof fetch; + + const testState = globalThis as typeof globalThis & { __testTier?: string }; + const originalTier = testState.__testTier; + testState.__testTier = "trial"; + try { + 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" } }), + signal: caller.signal, + })); + if (response.status !== 500) throw new Error(`cancelled repair returned ${response.status}`); + if (providerCalls !== 2) throw new Error(`expected initial and repair calls, got ${providerCalls}`); + const row = usageRows.at(-1); + if (row?.cost_usd !== null || row.input_tokens !== 11 || row.output_tokens !== 7) { + throw new Error(`cancelled repair accounting was not incomplete: ${JSON.stringify(row)}`); + } + } finally { + globalThis.fetch = originalFetch; + if (originalTier === undefined) delete testState.__testTier; + else testState.__testTier = originalTier; + } +}); From 250c9145d94c5e7b97a2b958e2d8477ec69d2ef6 Mon Sep 17 00:00:00 2001 From: Adam Date: Mon, 7 Sep 2026 20:16:51 +0000 Subject: [PATCH 4/7] Stop OpenRouter streams at terminal events --- supabase/functions/ai-stream/index.ts | 28 +++++-- .../functions/ai/handler-research.test.ts | 84 +++++++++++++++++++ 2 files changed, 107 insertions(+), 5 deletions(-) diff --git a/supabase/functions/ai-stream/index.ts b/supabase/functions/ai-stream/index.ts index 4d072e413..1cf204aa0 100644 --- a/supabase/functions/ai-stream/index.ts +++ b/supabase/functions/ai-stream/index.ts @@ -406,7 +406,12 @@ export async function handleRequest(req: Request): Promise { }; const upstreamAbort = new AbortController(); - const clientAbortHandler = () => upstreamAbort.abort(); + let reader: ReadableStreamDefaultReader | undefined; + const cancelUpstream = () => { + upstreamAbort.abort(); + void reader?.cancel().catch(() => undefined); + }; + const clientAbortHandler = cancelUpstream; req.signal.addEventListener("abort", clientAbortHandler); try { @@ -431,7 +436,7 @@ export async function handleRequest(req: Request): Promise { }); if (!response.ok || !response.body) throw new Error(await response.text() || `OpenRouter ${response.status}`); - const reader = response.body.getReader(); + reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; const processLine = (line: string) => { @@ -468,10 +473,19 @@ export async function handleRequest(req: Request): Promise { buffer += decoder.decode(value, { stream: true }); const lines = buffer.split("\n"); buffer = lines.pop() || ""; - for (const line of lines) processLine(line); + for (const line of lines) { + processLine(line); + if (streamSawDone) break; + } + if (streamSawDone) break; + } + if (!streamSawDone) { + buffer += decoder.decode(); + for (const line of buffer.split("\n")) { + processLine(line); + if (streamSawDone) break; + } } - buffer += decoder.decode(); - for (const line of buffer.split("\n")) processLine(line); if (!streamSawDone && !streamSawFinishReason) { throw new Error("OpenRouter stream ended before completion"); @@ -493,6 +507,7 @@ export async function handleRequest(req: Request): Promise { await logUsage(user.id, OPENROUTER_PROVIDER, model, collectUsage(), Date.now() - startedAt); } catch (err) { streamIncomplete = true; + cancelUpstream(); const errorMessage = err instanceof Error ? err.message : "Streaming failed"; const isAbort = req.signal.aborted || (err instanceof DOMException && err.name === "AbortError"); if (!isAbort) { @@ -509,6 +524,9 @@ export async function handleRequest(req: Request): Promise { ); } finally { req.signal.removeEventListener("abort", clientAbortHandler); + cancelUpstream(); + try { await reader?.cancel(); } catch { /* already closed */ } + reader?.releaseLock(); try { controller.close(); } catch { /* already closed */ } } }, diff --git a/supabase/functions/ai/handler-research.test.ts b/supabase/functions/ai/handler-research.test.ts index fe9f726f4..8af1a01a9 100644 --- a/supabase/functions/ai/handler-research.test.ts +++ b/supabase/functions/ai/handler-research.test.ts @@ -637,3 +637,87 @@ Deno.test("cancelled JSON repair keeps observed tokens but makes the total cost else testState.__testTier = originalTier; } }); + +Deno.test("stream terminal events stop an open upstream promptly", async () => { + const originalFetch = globalThis.fetch; + const usageRows: Array> = []; + const encoder = new TextEncoder(); + let scenario: "done" | "error" = "done"; + let upstreamCancelled: Promise = Promise.resolve(); + let resolveUpstreamCancelled = () => {}; + globalThis.fetch = (async (input: Request | URL | string, init?: RequestInit) => { + const url = new URL(input instanceof Request ? input.url : String(input)); + const supabaseResponse = supabaseTestResponse(url); + if (supabaseResponse) { + if (url.pathname.includes("/rest/v1/ai_requests")) usageRows.push(JSON.parse(String(init?.body)) as Record); + return supabaseResponse; + } + if (url.href === "https://openrouter.ai/api/v1/chat/completions") { + upstreamCancelled = new Promise((resolve) => resolveUpstreamCancelled = resolve); + const payload = scenario === "done" + ? [ + `data: ${JSON.stringify({ choices: [{ delta: { content: "complete" } }] })}\n\n`, + `data: ${JSON.stringify({ choices: [], usage: { prompt_tokens: 2, completion_tokens: 1, cost: 0.25 } })}\n\n`, + "data: [DONE]\n\n", + ].join("") + : `data: ${JSON.stringify({ error: { message: "provider exploded" } })}\n\n`; + const body = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(payload)); + init?.signal?.addEventListener("abort", () => { + resolveUpstreamCancelled(); + controller.error(new DOMException("Upstream aborted", "AbortError")); + }, { once: true }); + }, + pull() { + return new Promise(() => {}); + }, + cancel() { + resolveUpstreamCancelled(); + }, + }); + return new Response(body, { headers: { "Content-Type": "text/event-stream" } }); + } + throw new Error(`Unexpected request: ${url}`); + }) as typeof fetch; + + 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" } }), + signal, + }); + const withTimeout = async (promise: Promise, label: string): Promise => await Promise.race([ + promise, + new Promise((_, reject) => setTimeout(() => reject(new Error(`${label} timed out`)), 100)), + ]); + + const testState = globalThis as typeof globalThis & { __testTier?: string }; + const originalTier = testState.__testTier; + testState.__testTier = "trial"; + try { + const doneCaller = new AbortController(); + const doneRead = (await handleStreamRequest(request(doneCaller.signal))).text(); + let doneText: string; + try { + doneText = await withTimeout(doneRead, "DONE stream"); + } catch (error) { + doneCaller.abort(); + await doneRead; + throw error; + } + if (!doneText.includes("event: final") || doneText.includes("event: error")) throw new Error(`DONE stream did not succeed: ${doneText}`); + await withTimeout(upstreamCancelled, "DONE upstream cancellation"); + if (usageRows.at(-1)?.cost_usd !== 0.25) throw new Error("complete DONE stream lost its reported cost"); + + scenario = "error"; + const errorText = await withTimeout((await handleStreamRequest(request())).text(), "error stream"); + if (errorText.includes("event: final") || !errorText.includes("provider exploded")) throw new Error(`provider error was not emitted: ${errorText}`); + await withTimeout(upstreamCancelled, "error upstream cancellation"); + if (usageRows.at(-1)?.cost_usd !== null) throw new Error("provider error published a cost"); + } finally { + globalThis.fetch = originalFetch; + if (originalTier === undefined) delete testState.__testTier; + else testState.__testTier = originalTier; + } +}); From 6a5770b8e190f912b241aaee4b949f50a32a63b4 Mon Sep 17 00:00:00 2001 From: Adam Date: Mon, 7 Sep 2026 20:17:40 +0000 Subject: [PATCH 5/7] cleanup: route client AI through OpenRouter --- src/components/ModelSelector.tsx | 47 +++++- src/lib/ai/client.ts | 5 +- src/lib/ai/providers/types.ts | 51 ++----- src/lib/ai/streaming.ts | 6 +- src/lib/stores/settings-store.ts | 83 ++++++++--- src/pages/AskPage.tsx | 2 +- src/test/ai/client-analytics.test.ts | 8 +- src/test/ai/client-branches.test.ts | 4 +- src/test/ai/client.test.ts | 44 +++++- src/test/ai/sparring.test.ts | 2 +- src/test/ai/streaming-integration.test.ts | 24 ++- src/test/ai/streaming.test.ts | 38 ++--- src/test/components/ModelSelector.test.tsx | 138 +++++++++++++----- src/test/providers.test.ts | 17 +++ src/test/settings-store.test.ts | 41 ++++++ .../stores/settings-store-additional.test.ts | 16 +- .../stores/settings-store-branches.test.ts | 32 +++- .../stores/settings-store-extended.test.ts | 17 ++- 18 files changed, 422 insertions(+), 153 deletions(-) diff --git a/src/components/ModelSelector.tsx b/src/components/ModelSelector.tsx index e26b713a6..a7c666d62 100644 --- a/src/components/ModelSelector.tsx +++ b/src/components/ModelSelector.tsx @@ -1,7 +1,8 @@ import { Cpu } from 'lucide-react'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Badge } from '@/components/ui/badge'; -import { DEFAULT_PROVIDERS } from '@/lib/ai/providers/types'; +import { DEFAULT_MODEL, DEFAULT_PROVIDERS, isReasoningEffort } from '@/lib/ai/providers/types'; +import type { ReasoningEffort } from '@/lib/ai/providers/types'; import { useSettingsStore, type FeatureTab } from '@/lib/stores/settings-store'; import { useSubscription } from '@/hooks/use-subscription'; import { Label } from '@/components/ui/label'; @@ -15,20 +16,29 @@ interface ModelSelectorProps { } const allModels = DEFAULT_PROVIDERS - .filter(p => p.id === 'google' || p.id === 'openai' || p.id === 'anthropic') .flatMap(p => p.models.map(m => ({ ...m, provider: p.id, providerName: p.name }))); +const reasoningOptions: { value: ReasoningEffort; labelKey: string; fallback: string }[] = [ + { value: 'low', labelKey: 'modelSelector.reasoningLow', fallback: 'Low' }, + { value: 'medium', labelKey: 'modelSelector.reasoningMedium', fallback: 'Medium' }, + { value: 'high', labelKey: 'modelSelector.reasoningHigh', fallback: 'High' }, +]; + export function ModelSelector({ feature, className, showLabel = false }: ModelSelectorProps) { const { t } = useTranslation(); const { featureConfigs, setFeatureConfig } = useSettingsStore(); - const { isPro } = useSubscription(); + const { isPro, loading: subscriptionLoading } = useSubscription(); const config = featureConfigs?.[feature]; - const currentModel = config?.model || 'gemini-3-flash-preview'; + const selectedModel = allModels.find((model) => model.id === config?.model); + const currentModel = selectedModel?.id ?? DEFAULT_MODEL; + const currentReasoning = (isPro || subscriptionLoading) && config?.reasoningEffort && isReasoningEffort(config.reasoningEffort) + ? config.reasoningEffort + : 'low'; return (
{showLabel && ( -
); } diff --git a/src/lib/ai/client.ts b/src/lib/ai/client.ts index d01c31454..0db421b16 100644 --- a/src/lib/ai/client.ts +++ b/src/lib/ai/client.ts @@ -369,7 +369,7 @@ async function callSupabaseAI( ? boundCrossfireRequestSources(selectedSources) : selectedSources; - const provider = config.provider === 'lovable' ? 'openai' : config.provider; + const provider = config.provider === 'mock' ? 'mock' : 'openrouter'; captureProductEvent('ai_request_started', { feature, provider, @@ -420,6 +420,7 @@ async function callSupabaseAI( provider, model: config.model, temperature: config.temperature, + reasoningEffort: config.reasoningEffort ?? 'low', customSystemPrompt: options.includeCustomSystemPrompt === false ? null : settings.customSystemPrompt || null, sources: activeSources, }; @@ -1236,7 +1237,7 @@ async function sparringRequestKey( ): Promise { const { data } = await supabase.auth.getSession(); const authScope = data.session?.user?.id ?? data.session?.access_token ?? 'signed-out'; - return JSON.stringify([authScope, input, config.provider, config.model, config.temperature]); + return JSON.stringify([authScope, input, config.provider, config.model, config.temperature, config.reasoningEffort]); } /** Generate the opponent's next speech for a validated sparring turn. */ diff --git a/src/lib/ai/providers/types.ts b/src/lib/ai/providers/types.ts index 4364ddb2d..f4de72653 100644 --- a/src/lib/ai/providers/types.ts +++ b/src/lib/ai/providers/types.ts @@ -1,62 +1,39 @@ // src/lib/ai/providers/types.ts +import { MODEL_CATALOG } from '../../../../supabase/functions/_shared/model-catalog'; +import type { ReasoningEffort } from '../../../../supabase/functions/_shared/model-catalog'; + +export { DEFAULT_MODEL, DEFAULT_PRO_MODEL, isReasoningEffort } from '../../../../supabase/functions/_shared/model-catalog'; +export type { ReasoningEffort } from '../../../../supabase/functions/_shared/model-catalog'; + export interface AIProvider { id: string; name: string; - models: AIModel[]; + models: readonly AIModel[]; isAvailable: () => boolean; - requiresKey?: boolean; } export interface AIModel { id: string; name: string; - description: string; maxTokens: number; - isProOnly?: boolean; + isProOnly: boolean; + supportsTemperature: boolean; + supportedEfforts: readonly ReasoningEffort[]; } export interface ProviderConfig { provider: string; model: string; temperature: number; - apiKey?: string; - baseUrl?: string; + reasoningEffort?: ReasoningEffort; } export const DEFAULT_PROVIDERS: AIProvider[] = [ { - id: 'openai', - name: 'OpenAI', - models: [ - { id: 'gpt-5.5', name: 'GPT-5.5', description: '', maxTokens: 128000, isProOnly: true }, - { id: 'gpt-5.4', name: 'GPT-5.4', description: '', maxTokens: 128000, isProOnly: true }, - { id: 'gpt-5.4-mini', name: 'GPT-5.4 Mini', description: '', maxTokens: 128000 }, - ], - isAvailable: () => true, - requiresKey: true, - }, - { - id: 'anthropic', - name: 'Anthropic', - models: [ - { id: 'claude-opus-4-7', name: 'Claude Opus 4.7', description: '', maxTokens: 200000, isProOnly: true }, - { id: 'claude-sonnet-4-6', name: 'Claude Sonnet 4.6', description: '', maxTokens: 200000, isProOnly: true }, - { id: 'claude-haiku-4-5-20251001', name: 'Claude Haiku 4.5', description: '', maxTokens: 200000 }, - ], - isAvailable: () => true, - requiresKey: true, - }, - { - id: 'google', - name: 'Google Gemini', - models: [ - { id: 'gemini-3.1-pro-preview', name: 'Gemini 3.1 Pro', description: '', maxTokens: 2000000, isProOnly: true }, - { id: 'gemini-3.5-flash', name: 'Gemini 3.5 Flash', description: '', maxTokens: 1000000 }, - { id: 'gemini-3-flash-preview', name: 'Gemini 3 Flash', description: '', maxTokens: 1000000 }, - { id: 'gemini-3.1-flash-lite-preview', name: 'Gemini 3.1 Flash Lite', description: '', maxTokens: 1000000 }, - ], + id: 'openrouter', + name: 'OpenRouter', + models: MODEL_CATALOG, isAvailable: () => true, - requiresKey: true, }, ]; diff --git a/src/lib/ai/streaming.ts b/src/lib/ai/streaming.ts index 984d07574..b6945db8d 100644 --- a/src/lib/ai/streaming.ts +++ b/src/lib/ai/streaming.ts @@ -24,7 +24,6 @@ export type ParsedSSEEvent = { }; export function isAskStreamingSupported(config: ProviderConfig, options: AskStreamOptions = {}) { - const provider = config.provider === 'lovable' ? 'openai' : config.provider; const advancedStructuredMode = Boolean( (options.mode ?? 'general') !== 'general' || options.citationMode || @@ -33,7 +32,7 @@ export function isAskStreamingSupported(config: ProviderConfig, options: AskStre options.argumentMapMode ); - return !advancedStructuredMode && ['openai', 'google', 'anthropic'].includes(provider); + return !advancedStructuredMode && config.provider === 'openrouter'; } function parseEventBlock(block: string): ParsedSSEEvent | null { @@ -115,7 +114,7 @@ export async function streamAskChat( options: AskStreamOptions, handlers: AskStreamHandlers ): Promise { - const provider = config.provider === 'lovable' ? 'openai' : config.provider; + const provider = config.provider === 'mock' ? 'mock' : 'openrouter'; const requestId = `stream_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; const startTime = performance.now(); captureProductEvent('ai_request_started', { @@ -203,6 +202,7 @@ export async function streamAskChat( provider, model: config.model, temperature: config.temperature, + reasoningEffort: config.reasoningEffort ?? 'low', }), signal: controller.signal, }); diff --git a/src/lib/stores/settings-store.ts b/src/lib/stores/settings-store.ts index 099e9d994..e558a4f91 100644 --- a/src/lib/stores/settings-store.ts +++ b/src/lib/stores/settings-store.ts @@ -1,7 +1,15 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; import i18n from '@/i18n'; +import { + DEFAULT_MODEL, + DEFAULT_PRO_MODEL, +} from '../ai/providers/types'; import type { ProviderConfig } from '../ai/providers/types'; +import { + getModelCatalogEntry, + isReasoningEffort, +} from '../../../supabase/functions/_shared/model-catalog'; import { supabase } from '@/lib/supabase/client'; export type FeatureTab = 'generate' | 'attack' | 'brainstorm' | 'factoids' | 'ask' | 'sparring'; @@ -27,10 +35,28 @@ interface SettingsState { resetLocalData: () => void; } +const DEFAULT_PROVIDER = 'openrouter'; + +const LEGACY_MODEL_SUCCESSORS: Record = { + 'gpt-5.5': DEFAULT_MODEL, + 'gpt-5.4': DEFAULT_MODEL, + 'gpt-5.4-mini': DEFAULT_MODEL, + 'gpt-4o': DEFAULT_MODEL, + 'gpt-4o-mini': DEFAULT_MODEL, + 'claude-opus-4-7': DEFAULT_PRO_MODEL, + 'claude-sonnet-4-6': DEFAULT_PRO_MODEL, + 'claude-haiku-4-5-20251001': DEFAULT_MODEL, + 'gemini-3.1-pro-preview': 'google/gemini-3.8-flash', + 'gemini-3.5-flash': 'google/gemini-3.8-flash', + 'gemini-3-flash-preview': 'google/gemini-3.8-flash', + 'gemini-3.1-flash-lite-preview': 'google/gemini-3.8-flash', +}; + const defaultConfig: ProviderConfig = { - provider: 'google', - model: 'gemini-3-flash-preview', + provider: DEFAULT_PROVIDER, + model: DEFAULT_MODEL, temperature: 0.7, + reasoningEffort: 'low', }; const getDefaultFeatureConfigs = (): Record => ({ @@ -42,25 +68,43 @@ const getDefaultFeatureConfigs = (): Record => ({ sparring: { ...defaultConfig }, }); +function asRecord(value: unknown): Record | null { + return value && typeof value === 'object' && !Array.isArray(value) + ? value as Record + : null; +} + +function normalizeModelId(value: unknown, fallback: string = DEFAULT_MODEL): string { + if (typeof value !== 'string' || !value.trim()) return fallback; + const model = value.trim(); + if (getModelCatalogEntry(model)) return model; + return LEGACY_MODEL_SUCCESSORS[model] ?? DEFAULT_MODEL; +} + +function normalizeFeatureConfig(value: unknown, fallback: ProviderConfig): ProviderConfig { + const candidate = asRecord(value); + const temperature = typeof candidate?.temperature === 'number' && Number.isFinite(candidate.temperature) + ? candidate.temperature + : fallback.temperature; + const reasoningEffort = isReasoningEffort(candidate?.reasoningEffort) + ? candidate.reasoningEffort + : fallback.reasoningEffort ?? 'low'; + + return { + provider: DEFAULT_PROVIDER, + model: normalizeModelId(candidate?.model, fallback.model), + temperature, + reasoningEffort, + }; +} + function normalizeFeatureConfigs(configs: unknown): Record { const defaults = getDefaultFeatureConfigs(); - if (!configs || typeof configs !== 'object' || Array.isArray(configs)) return defaults; + const saved = asRecord(configs); + if (!saved) return defaults; - const saved = configs as Record; return Object.fromEntries((Object.keys(defaults) as FeatureTab[]).map((feature) => { - const value = saved[feature]; - const candidate = value && typeof value === 'object' && !Array.isArray(value) - ? value as Partial - : {}; - const fallback = defaults[feature]; - return [feature, { - ...fallback, - ...(typeof candidate.provider === 'string' ? { provider: candidate.provider } : {}), - ...(typeof candidate.model === 'string' ? { model: candidate.model } : {}), - ...(typeof candidate.temperature === 'number' ? { temperature: candidate.temperature } : {}), - ...(typeof candidate.apiKey === 'string' ? { apiKey: candidate.apiKey } : {}), - ...(typeof candidate.baseUrl === 'string' ? { baseUrl: candidate.baseUrl } : {}), - }]; + return [feature, normalizeFeatureConfig(saved[feature], defaults[feature])]; })) as Record; } @@ -147,7 +191,10 @@ export const useSettingsStore = create()( set((state) => ({ featureConfigs: { ...state.featureConfigs, - [feature]: { ...state.featureConfigs[feature], ...config }, + [feature]: normalizeFeatureConfig( + { ...state.featureConfigs[feature], ...config }, + getDefaultFeatureConfigs()[feature], + ), }, })); void upsertSettings({ diff --git a/src/pages/AskPage.tsx b/src/pages/AskPage.tsx index 8421ffad6..766f77c82 100644 --- a/src/pages/AskPage.tsx +++ b/src/pages/AskPage.tsx @@ -215,7 +215,7 @@ export default function AskPage() { output: { ...result, model: result.model || config.model, - provider: result.provider || (config.provider === 'lovable' ? 'openai' : config.provider), + provider: result.provider || (config.provider === 'mock' ? 'mock' : 'openrouter'), durationMs: result.durationMs || Date.now() - askStartTime, generatedAt: result.generatedAt || new Date().toISOString(), } diff --git a/src/test/ai/client-analytics.test.ts b/src/test/ai/client-analytics.test.ts index 3eb23328b..f51dc627e 100644 --- a/src/test/ai/client-analytics.test.ts +++ b/src/test/ai/client-analytics.test.ts @@ -116,12 +116,12 @@ describe('non-streaming AI request analytics', () => { expect(mocks.captureProductEvent).toHaveBeenNthCalledWith(1, 'ai_request_started', { feature: 'generate', - provider: 'openai', + provider: 'openrouter', model: 'gpt-4o', }); expect(mocks.captureProductEvent).toHaveBeenNthCalledWith(2, 'ai_request_succeeded', { feature: 'generate', - provider: 'openai', + provider: 'openrouter', model: 'gpt-4o', durationMs: expect.any(Number), }); @@ -138,7 +138,7 @@ describe('non-streaming AI request analytics', () => { expect(mocks.captureProductEvent).toHaveBeenNthCalledWith(2, 'ai_request_failed', { feature: 'generate', - provider: 'openai', + provider: 'openrouter', model: 'gpt-4o', durationMs: expect.any(Number), errorName: 'AIProviderError', @@ -146,7 +146,7 @@ describe('non-streaming AI request analytics', () => { }); expect(mocks.captureException).toHaveBeenCalledWith( expect.objectContaining({ name: 'AIProviderError', message: 'AIProviderError' }), - expect.objectContaining({ feature: 'generate', provider: 'openai', model: 'gpt-4o' }), + expect.objectContaining({ feature: 'generate', provider: 'openrouter', model: 'gpt-4o' }), ); const [capturedError, capturedContext] = mocks.captureException.mock.calls[0]; expect(capturedError).toMatchObject({ message: 'AIProviderError' }); diff --git a/src/test/ai/client-branches.test.ts b/src/test/ai/client-branches.test.ts index a85990f0f..8cdd22637 100644 --- a/src/test/ai/client-branches.test.ts +++ b/src/test/ai/client-branches.test.ts @@ -787,14 +787,14 @@ describe('AI client branch coverage', () => { expect(isTimeoutError({ name: 'TimeoutError' })).toBe(false); }); - it('passes only active sources and mapped provider in invoke payload', async () => { + it('passes only active sources and the OpenRouter provider in invoke payload', async () => { invokeMock.mockResolvedValueOnce({ data: { output: validGenerateOutput }, error: null }); await generateCase(validGenerateInput, { ...config, provider: 'lovable' }); expect(invokeMock).toHaveBeenCalledWith('ai', expect.objectContaining({ body: expect.objectContaining({ - provider: 'openai', + provider: 'openrouter', customSystemPrompt: 'custom-system-prompt', sources: [ { diff --git a/src/test/ai/client.test.ts b/src/test/ai/client.test.ts index ad5c8ab73..8a1512f96 100644 --- a/src/test/ai/client.test.ts +++ b/src/test/ai/client.test.ts @@ -41,7 +41,7 @@ vi.mock('@/lib/stores/session-store', () => ({ describe('AI Client Integration', () => { const mockConfig: ProviderConfig = { - provider: 'openai', + provider: 'openrouter', model: 'gpt-4o', temperature: 0.8, }; @@ -99,7 +99,7 @@ describe('AI Client Integration', () => { headers: { Authorization: expect.stringContaining('Bearer') }, body: expect.objectContaining({ feature: 'generate', - provider: 'openai', + provider: 'openrouter', model: 'gpt-4o', temperature: 0.8, input: expect.objectContaining(input), @@ -115,7 +115,41 @@ describe('AI Client Integration', () => { expect(callArgs.body.sources).toHaveLength(1); }); - it('should use "openai" provider when "lovable" is selected (legacy mapping)', async () => { + it('sends the configured reasoning effort in the non-streaming request', async () => { + const mockResponse: { output: GenerateOutput } = { + output: { + caseStructure: { + thesis: 'Test Thesis', + framework: 'Framework', + keyDefinitions: [], + burdenOfProof: 'Burden', + }, + contentions: [ + { id: '1', claim: 'Claim 1', reasoning: 'Reasoning 1', evidence: 'Evidence 1', needsSource: false, impact: 'Impact 1' }, + { id: '2', claim: 'Claim 2', reasoning: 'Reasoning 2', evidence: 'Evidence 2', needsSource: false, impact: 'Impact 2' }, + ], + predictedOpponentCase: { + likelyArguments: [], + strategicWeaknesses: [], + keyClashes: [], + }, + counters: [], + metadata: { side: 'pro', format: 'bp', generatedAt: '2026-01-01T00:00:00.000Z' }, + }, + }; + setMockInvokeResponse(mockResponse); + + await generateCase( + { motion: 'Test Motion', side: 'pro', format: 'bp', role: 'intro', length: 'brief', audience: 'advanced', tone: 'formal' }, + { ...mockConfig, reasoningEffort: 'high' }, + ); + + expect(supabaseMock.functions.invoke).toHaveBeenCalledWith('ai', expect.objectContaining({ + body: expect.objectContaining({ reasoningEffort: 'high' }), + })); + }); + + it('canonicalizes legacy providers to OpenRouter', async () => { const lovableConfig: ProviderConfig = { ...mockConfig, provider: 'lovable' }; // Mock response const mockResponse: { output: GenerateOutput } = { @@ -145,7 +179,7 @@ describe('AI Client Integration', () => { expect(supabaseMock.functions.invoke).toHaveBeenCalledWith('ai', expect.objectContaining({ body: expect.objectContaining({ - provider: 'openai' // Should be mapped + provider: 'openrouter' }) })); }); @@ -251,7 +285,7 @@ describe('AI Client Integration', () => { expect(supabaseMock.functions.invoke).toHaveBeenCalledWith('ai', expect.objectContaining({ body: expect.objectContaining({ feature: 'attack', - provider: 'openai', + provider: 'openrouter', model: 'gpt-4o', input: expect.objectContaining(input), }) diff --git a/src/test/ai/sparring.test.ts b/src/test/ai/sparring.test.ts index 983783b20..76941ae55 100644 --- a/src/test/ai/sparring.test.ts +++ b/src/test/ai/sparring.test.ts @@ -96,7 +96,7 @@ describe('sparring AI client', () => { headers: { Authorization: expect.stringContaining('Bearer') }, body: expect.objectContaining({ feature: 'sparring', - provider: 'openai', + provider: 'openrouter', model: 'gpt-4o', input: expect.objectContaining({ action: 'respond', diff --git a/src/test/ai/streaming-integration.test.ts b/src/test/ai/streaming-integration.test.ts index baef31c3f..3e1d14d86 100644 --- a/src/test/ai/streaming-integration.test.ts +++ b/src/test/ai/streaming-integration.test.ts @@ -31,7 +31,7 @@ function mockFetchOnce(body: BodyInit | null, init: ResponseInit = { status: 200 return fetchMock; } -const config: ProviderConfig = { provider: 'openai', model: 'gpt-5.4-mini', temperature: 0.7 }; +const config: ProviderConfig = { provider: 'openrouter', model: 'gpt-5.4-mini', temperature: 0.7 }; const options = {}; describe('streamAskChat (integration with mocked fetch)', () => { @@ -66,6 +66,28 @@ describe('streamAskChat (integration with mocked fetch)', () => { expect(result.response).toBe('Hello world'); }); + it('sends the configured reasoning effort in the SSE request', async () => { + const fetchMock = mockFetchOnce(streamFromChunks([ + 'event: final\ndata: {"output":{"response":"answer","followUpQuestions":[],"uncertainties":[]}}\n\n', + ])); + + await streamAskChat( + 'hi', + [], + 'general', + { ...config, reasoningEffort: 'high' }, + options, + { onDelta: () => undefined }, + ); + + const requestInit = (fetchMock.mock.calls[0] as unknown[] | undefined)?.[1] as RequestInit | undefined; + expect(JSON.parse(String(requestInit.body))).toMatchObject({ + provider: 'openrouter', + model: 'gpt-5.4-mini', + reasoningEffort: 'high', + }); + }); + it('skips malformed JSON chunks and still delivers the final output', async () => { const chunks = [ 'event: delta\ndata: {"text":"Hi"}\n\n', diff --git a/src/test/ai/streaming.test.ts b/src/test/ai/streaming.test.ts index 6fac53950..cd5b72e8f 100644 --- a/src/test/ai/streaming.test.ts +++ b/src/test/ai/streaming.test.ts @@ -39,7 +39,7 @@ function mockFetchOnce(body: BodyInit | null, init: ResponseInit = { status: 200 return fetchMock; } -const streamConfig = { provider: 'openai', model: 'gpt-5.4-mini', temperature: 0.7 } as const; +const streamConfig = { provider: 'openrouter', model: 'gpt-5.4-mini', temperature: 0.7 } as const; const streamOptions = {}; beforeEach(() => { @@ -84,39 +84,39 @@ describe('AI streaming helpers', () => { ]); }); - it('enables streaming for OpenAI, Google, and Anthropic basic ask chats', () => { - expect(isAskStreamingSupported({ provider: 'openai', model: 'gpt-5.4-mini', temperature: 0.7 })).toBe(true); - expect(isAskStreamingSupported({ provider: 'lovable', model: 'gpt-5.4-mini', temperature: 0.7 })).toBe(true); - expect(isAskStreamingSupported({ provider: 'google', model: 'gemini-3-flash-preview', temperature: 0.7 })).toBe(true); - expect(isAskStreamingSupported({ provider: 'anthropic', model: 'claude-haiku-4-5-20251001', temperature: 0.7 })).toBe(true); + it('enables streaming for basic OpenRouter ask chats', () => { + expect(isAskStreamingSupported({ provider: 'openrouter', model: 'openai/gpt-5.6-luna', temperature: 0.7 })).toBe(true); + expect(isAskStreamingSupported({ provider: 'openai', model: 'gpt-5.4-mini', temperature: 0.7 })).toBe(false); + expect(isAskStreamingSupported({ provider: 'google', model: 'gemini-3-flash-preview', temperature: 0.7 })).toBe(false); + expect(isAskStreamingSupported({ provider: 'anthropic', model: 'claude-haiku-4-5-20251001', temperature: 0.7 })).toBe(false); expect(isAskStreamingSupported({ provider: 'unknown' as string, model: 'x', temperature: 0.7 })).toBe(false); expect(isAskStreamingSupported( - { provider: 'openai', model: 'gpt-5.4-mini', temperature: 0.7 }, + { provider: 'openrouter', model: 'openai/gpt-5.6-luna', temperature: 0.7 }, { citationMode: true } )).toBe(false); expect(isAskStreamingSupported( - { provider: 'openai', model: 'gpt-5.4-mini', temperature: 0.7 }, + { provider: 'openrouter', model: 'openai/gpt-5.6-luna', temperature: 0.7 }, { webSearch: true } )).toBe(false); expect(isAskStreamingSupported( - { provider: 'openai', model: 'gpt-5.4-mini', temperature: 0.7 }, + { provider: 'openrouter', model: 'openai/gpt-5.6-luna', temperature: 0.7 }, { prosConsMode: true } )).toBe(false); expect(isAskStreamingSupported( - { provider: 'openai', model: 'gpt-5.4-mini', temperature: 0.7 }, + { provider: 'openrouter', model: 'openai/gpt-5.6-luna', temperature: 0.7 }, { argumentMapMode: true } )).toBe(false); expect(isAskStreamingSupported( - { provider: 'openai', model: 'gpt-5.4-mini', temperature: 0.7 }, + { provider: 'openrouter', model: 'openai/gpt-5.6-luna', temperature: 0.7 }, { mode: 'clarify' } )).toBe(false); expect(isAskStreamingSupported( - { provider: 'openai', model: 'gpt-5.4-mini', temperature: 0.7 }, + { provider: 'openrouter', model: 'openai/gpt-5.6-luna', temperature: 0.7 }, { mode: 'general' } )).toBe(true); for (const mode of ['clarify', 'summarize', 'proscons', 'argumentmap']) { expect(isAskStreamingSupported( - { provider: 'openai', model: 'gpt-5.4-mini', temperature: 0.7 }, + { provider: 'openrouter', model: 'openai/gpt-5.6-luna', temperature: 0.7 }, { mode } )).toBe(false); } @@ -141,13 +141,13 @@ describe('AI streaming helpers', () => { expect(captureProductEventMock).toHaveBeenNthCalledWith(1, 'ai_request_started', { feature: 'ask', streaming: true, - provider: 'openai', + provider: 'openrouter', model: 'gpt-5.4-mini', }); expect(captureProductEventMock).toHaveBeenNthCalledWith(2, 'ai_request_succeeded', expect.objectContaining({ feature: 'ask', streaming: true, - provider: 'openai', + provider: 'openrouter', model: 'gpt-5.4-mini', durationMs: expect.any(Number), })); @@ -173,7 +173,7 @@ describe('AI streaming helpers', () => { expect(captureProductEventMock).toHaveBeenNthCalledWith(2, 'ai_request_failed', expect.objectContaining({ feature: 'ask', streaming: true, - provider: 'openai', + provider: 'openrouter', model: 'gpt-5.4-mini', durationMs: expect.any(Number), errorName: 'AIProviderError', @@ -209,7 +209,7 @@ describe('AI streaming helpers', () => { expect(captureProductEventMock).toHaveBeenNthCalledWith(2, 'ai_request_failed', expect.objectContaining({ feature: 'ask', streaming: true, - provider: 'openai', + provider: 'openrouter', model: 'gpt-5.4-mini', errorName, statusCode: status, @@ -232,7 +232,7 @@ describe('AI streaming helpers', () => { expect(captureProductEventMock).toHaveBeenNthCalledWith(2, 'ai_request_failed', expect.objectContaining({ feature: 'ask', streaming: true, - provider: 'openai', + provider: 'openrouter', model: 'gpt-5.4-mini', errorName: 'AIAuthError', })); @@ -256,7 +256,7 @@ describe('AI streaming helpers', () => { expect(captureProductEventMock).toHaveBeenNthCalledWith(2, 'ai_request_failed', expect.objectContaining({ feature: 'ask', streaming: true, - provider: 'openai', + provider: 'openrouter', model: 'gpt-5.4-mini', errorName: 'AbortError', })); diff --git a/src/test/components/ModelSelector.test.tsx b/src/test/components/ModelSelector.test.tsx index a4136c0ea..33a193829 100644 --- a/src/test/components/ModelSelector.test.tsx +++ b/src/test/components/ModelSelector.test.tsx @@ -2,46 +2,42 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen } from '@testing-library/react'; +const subscriptionState = vi.hoisted(() => ({ isPro: false, tier: 'trial', loading: false })); + vi.mock('@/hooks/use-subscription', () => ({ - useSubscription: () => ({ isPro: false, tier: 'trial', loading: false }), + useSubscription: () => subscriptionState, })); vi.mock('@/lib/stores/settings-store', () => ({ useSettingsStore: vi.fn(() => ({ featureConfigs: { - generate: { provider: 'google', model: 'gemini-3-flash-preview', temperature: 0.7 }, - attack: { provider: 'google', model: 'gemini-3-flash-preview', temperature: 0.7 }, - brainstorm: { provider: 'google', model: 'gemini-3-flash-preview', temperature: 0.9 }, - factoids: { provider: 'google', model: 'gemini-3-flash-preview', temperature: 0.3 }, - ask: { provider: 'google', model: 'gemini-3-flash-preview', temperature: 0.7 }, + generate: { provider: 'openrouter', model: 'openai/gpt-5.6-luna', temperature: 0.7, reasoningEffort: 'low' }, + attack: { provider: 'openrouter', model: 'openai/gpt-5.6-luna', temperature: 0.7, reasoningEffort: 'low' }, + brainstorm: { provider: 'openrouter', model: 'openai/gpt-5.6-luna', temperature: 0.9, reasoningEffort: 'low' }, + factoids: { provider: 'openrouter', model: 'openai/gpt-5.6-luna', temperature: 0.3, reasoningEffort: 'low' }, + ask: { provider: 'openrouter', model: 'openai/gpt-5.6-luna', temperature: 0.7, reasoningEffort: 'low' }, }, setFeatureConfig: vi.fn(), })), })); vi.mock('@/lib/ai/providers/types', () => ({ + DEFAULT_MODEL: 'openai/gpt-5.6-luna', + isReasoningEffort: (value: unknown) => value === 'low' || value === 'medium' || value === 'high', DEFAULT_PROVIDERS: [ { - id: 'openai', name: 'OpenAI', - models: [ - { id: 'gpt-5.5', name: 'GPT-5.5', description: '', maxTokens: 128000, isProOnly: true }, - { id: 'gpt-5.4-mini', name: 'GPT-5.4 Mini', description: '', maxTokens: 128000 }, - ], - isAvailable: () => true, - }, - { - id: 'anthropic', name: 'Anthropic', - models: [ - { id: 'claude-opus-4-7', name: 'Claude Opus 4.7', description: '', maxTokens: 200000, isProOnly: true }, - { id: 'claude-haiku-4-5-20251001', name: 'Claude Haiku 4.5', description: '', maxTokens: 200000 }, - ], - isAvailable: () => true, - }, - { - id: 'google', name: 'Google Gemini', + id: 'openrouter', name: 'OpenRouter', models: [ - { id: 'gemini-3.1-pro-preview', name: 'Gemini 3.1 Pro', description: '', maxTokens: 2000000, isProOnly: true }, - { id: 'gemini-3-flash-preview', name: 'Gemini 3 Flash', description: '', maxTokens: 1000000 }, + { id: 'openai/gpt-5.6-luna', name: 'GPT-5.6 Luna', maxTokens: 128000, isProOnly: false, supportsTemperature: false }, + { id: 'google/gemini-3.8-flash', name: 'Gemini 3.8 Flash', maxTokens: 65536, isProOnly: false, supportsTemperature: true }, + { id: 'deepseek/deepseek-v4-flash-0731', name: 'DeepSeek V4 Flash', maxTokens: 131072, isProOnly: false, supportsTemperature: true }, + { id: 'anthropic/claude-sonnet-5', name: 'Claude Sonnet 5', maxTokens: 128000, isProOnly: true, supportsTemperature: false }, + { id: 'anthropic/claude-opus-5', name: 'Claude Opus 5', maxTokens: 128000, isProOnly: true, supportsTemperature: true }, + { id: 'openai/gpt-5.6-sol', name: 'GPT-5.6 Sol', maxTokens: 128000, isProOnly: true, supportsTemperature: false }, + { id: 'openai/gpt-5.6-terra', name: 'GPT-5.6 Terra', maxTokens: 128000, isProOnly: true, supportsTemperature: false }, + { id: 'deepseek/deepseek-v4-pro-0813', name: 'DeepSeek V4 Pro', maxTokens: 384000, isProOnly: true, supportsTemperature: true }, + { id: 'z-ai/glm-5.3-flash', name: 'GLM-5.3 Flash', maxTokens: 131072, isProOnly: true, supportsTemperature: true }, + { id: 'meta/muse-spark-1.3', name: 'Muse Spark 1.3', maxTokens: 943718, isProOnly: true, supportsTemperature: true }, ], isAvailable: () => true, }, @@ -54,37 +50,41 @@ import { useSettingsStore } from '@/lib/stores/settings-store'; describe('ModelSelector', () => { beforeEach(() => { vi.clearAllMocks(); + subscriptionState.isPro = false; + subscriptionState.tier = 'trial'; + subscriptionState.loading = false; + Element.prototype.scrollIntoView = vi.fn(); }); it('renders without crashing', () => { render(); - expect(screen.getByRole('combobox')).toBeDefined(); + expect(screen.getAllByRole('combobox')[0]).toBeDefined(); }); it('renders with default model value', () => { render(); - const trigger = screen.getByRole('combobox'); + const trigger = screen.getAllByRole('combobox')[0]; expect(trigger).toBeDefined(); }); it('renders for attack feature', () => { render(); - expect(screen.getByRole('combobox')).toBeDefined(); + expect(screen.getAllByRole('combobox')[0]).toBeDefined(); }); it('renders for brainstorm feature', () => { render(); - expect(screen.getByRole('combobox')).toBeDefined(); + expect(screen.getAllByRole('combobox')[0]).toBeDefined(); }); it('renders for factoids feature', () => { render(); - expect(screen.getByRole('combobox')).toBeDefined(); + expect(screen.getAllByRole('combobox')[0]).toBeDefined(); }); it('renders for ask feature', () => { render(); - expect(screen.getByRole('combobox')).toBeDefined(); + expect(screen.getAllByRole('combobox')[0]).toBeDefined(); }); it('uses default model when featureConfigs is undefined', () => { @@ -93,7 +93,7 @@ describe('ModelSelector', () => { setFeatureConfig: vi.fn(), }); render(); - expect(screen.getByRole('combobox')).toBeDefined(); + expect(screen.getAllByRole('combobox')[0]).toBeDefined(); }); it('uses default model when feature config is missing', () => { @@ -102,7 +102,7 @@ describe('ModelSelector', () => { setFeatureConfig: vi.fn(), }); render(); - expect(screen.getByRole('combobox')).toBeDefined(); + expect(screen.getAllByRole('combobox')[0]).toBeDefined(); }); it('uses default model when config.model is empty', () => { @@ -111,24 +111,24 @@ describe('ModelSelector', () => { setFeatureConfig: vi.fn(), }); render(); - expect(screen.getByRole('combobox')).toBeDefined(); + expect(screen.getAllByRole('combobox')[0]).toBeDefined(); }); it('has a select trigger element', () => { render(); - const trigger = screen.getByRole('combobox'); + const trigger = screen.getAllByRole('combobox')[0]; expect(trigger.tagName).toBeDefined(); }); it('trigger uses form-sized text', () => { render(); - const trigger = screen.getByRole('combobox'); + const trigger = screen.getAllByRole('combobox')[0]; expect(trigger.className).toContain('text-sm'); }); it('renders full width inside forms', () => { render(); - const trigger = screen.getByRole('combobox'); + const trigger = screen.getAllByRole('combobox')[0]; expect(trigger.className).toContain('w-full'); }); @@ -154,8 +154,70 @@ describe('ModelSelector', () => { const features = ['generate', 'attack', 'brainstorm', 'factoids', 'ask'] as const; features.forEach(feature => { const { unmount } = render(); - expect(screen.getByRole('combobox')).toBeDefined(); + expect(screen.getAllByRole('combobox')[0]).toBeDefined(); unmount(); }); }); + + it('shows low reasoning and disables the choice for trial users with saved high reasoning', () => { + const mockSetFeatureConfig = vi.fn(); + vi.mocked(useSettingsStore).mockReturnValue({ + featureConfigs: { + generate: { provider: 'openrouter', model: 'openai/gpt-5.6-luna', temperature: 0.7, reasoningEffort: 'high' }, + } as any, + setFeatureConfig: mockSetFeatureConfig, + }); + + render(); + + const reasoning = screen.getAllByRole('combobox')[1]; + expect(reasoning).toBeDisabled(); + expect(reasoning).toHaveTextContent('Low'); + expect(mockSetFeatureConfig).not.toHaveBeenCalled(); + }); + + it('keeps a saved Pro model visible after a trial entitlement resolves', () => { + vi.mocked(useSettingsStore).mockReturnValue({ + featureConfigs: { + generate: { provider: 'openrouter', model: 'anthropic/claude-sonnet-5', temperature: 0.7, reasoningEffort: 'low' }, + } as any, + setFeatureConfig: vi.fn(), + }); + + render(); + + expect(screen.getAllByRole('combobox')[0]).toHaveTextContent('Claude Sonnet 5'); + }); + + it('keeps saved Pro controls visible while entitlement is loading', () => { + subscriptionState.loading = true; + vi.mocked(useSettingsStore).mockReturnValue({ + featureConfigs: { + generate: { provider: 'openrouter', model: 'anthropic/claude-sonnet-5', temperature: 0.7, reasoningEffort: 'high' }, + } as any, + setFeatureConfig: vi.fn(), + }); + + render(); + + expect(screen.getAllByRole('combobox')[0]).toHaveTextContent('Claude Sonnet 5'); + expect(screen.getAllByRole('combobox')[1]).toHaveTextContent('High'); + }); + + it('enables model and reasoning controls for Pro users', () => { + subscriptionState.isPro = true; + subscriptionState.tier = 'pro'; + vi.mocked(useSettingsStore).mockReturnValue({ + featureConfigs: { + generate: { provider: 'openrouter', model: 'openai/gpt-5.6-luna', temperature: 0.7, reasoningEffort: 'low' }, + } as any, + setFeatureConfig: vi.fn(), + }); + + render(); + const [model, reasoning] = screen.getAllByRole('combobox'); + expect(model).not.toBeDisabled(); + expect(reasoning).not.toBeDisabled(); + }); + }); diff --git a/src/test/providers.test.ts b/src/test/providers.test.ts index 7d718d02d..c8627d6e6 100644 --- a/src/test/providers.test.ts +++ b/src/test/providers.test.ts @@ -2,6 +2,23 @@ import { describe, it, expect } from 'vitest'; import { DEFAULT_PROVIDERS } from '@/lib/ai/providers/types'; describe('DEFAULT_PROVIDERS', () => { + it('exposes the approved OpenRouter catalog as one provider', () => { + expect(DEFAULT_PROVIDERS).toHaveLength(1); + expect(DEFAULT_PROVIDERS[0].id).toBe('openrouter'); + expect(DEFAULT_PROVIDERS[0].models.map((model) => model.id)).toEqual([ + 'openai/gpt-5.6-luna', + 'google/gemini-3.8-flash', + 'deepseek/deepseek-v4-flash-0731', + 'anthropic/claude-sonnet-5', + 'anthropic/claude-opus-5', + 'openai/gpt-5.6-sol', + 'openai/gpt-5.6-terra', + 'deepseek/deepseek-v4-pro-0813', + 'z-ai/glm-5.3-flash', + 'meta/muse-spark-1.3', + ]); + }); + // Model IDs key request routing and the stored per-session model choice, so a // collision across providers would silently route to the wrong provider. it('no duplicate model IDs across all providers', () => { diff --git a/src/test/settings-store.test.ts b/src/test/settings-store.test.ts index 99ab8ba30..30970ce56 100644 --- a/src/test/settings-store.test.ts +++ b/src/test/settings-store.test.ts @@ -21,6 +21,7 @@ describe("settings-store", () => { const state = useSettingsStore.getState(); expect(state.featureConfigs.generate.provider).toBe("openrouter"); expect(state.featureConfigs.generate.model).toBe("z-ai/glm-5.3-flash"); + expect(state.featureConfigs.generate.reasoningEffort).toBe("low"); expect(state.featureConfigs.brainstorm.temperature).toBe(0.9); expect(state.featureConfigs.factoids.temperature).toBe(0.3); }); @@ -117,6 +118,46 @@ describe("settings-store", () => { expect(config.model).toBe("z-ai/glm-5.3-flash"); }); + it("canonicalizes legacy and unknown server configs without client credentials", async () => { + setMockTableResponse("user_settings", { + single: { + feature_configs: { + generate: { + provider: "openai", + model: "gpt-5.5", + temperature: 0.4, + reasoningEffort: "high", + apiKey: "legacy-secret", + baseUrl: "https://legacy.example.test", + }, + attack: { + provider: "unknown-provider", + model: "unknown-model", + temperature: 0.8, + reasoningEffort: "invalid", + apiKey: "another-secret", + }, + }, + }, + }); + + await useSettingsStore.getState().hydrateFromServer(); + + const state = useSettingsStore.getState(); + const generate = state.featureConfigs.generate; + const attack = state.featureConfigs.attack; + expect(generate).toMatchObject({ + provider: "openrouter", + model: "z-ai/glm-5.3-flash", + temperature: 0.4, + reasoningEffort: "high", + }); + expect(generate).not.toHaveProperty("apiKey"); + expect(generate).not.toHaveProperty("baseUrl"); + expect(attack).toMatchObject({ provider: "openrouter", model: "z-ai/glm-5.3-flash", reasoningEffort: "low" }); + expect(attack).not.toHaveProperty("apiKey"); + }); + it("does not upsert when user is not signed in", async () => { setMockAuthUser(null); const store = useSettingsStore.getState(); diff --git a/src/test/stores/settings-store-additional.test.ts b/src/test/stores/settings-store-additional.test.ts index cf7dbe1ef..c6acb5dea 100644 --- a/src/test/stores/settings-store-additional.test.ts +++ b/src/test/stores/settings-store-additional.test.ts @@ -42,10 +42,10 @@ describe('settings-store additional branches', () => { }); it('setFeatureConfig merges correctly', () => { - useSettingsStore.getState().setFeatureConfig('generate', { model: 'gpt-5.5', provider: 'openai' }); + useSettingsStore.getState().setFeatureConfig('generate', { model: 'anthropic/claude-sonnet-5', provider: 'openrouter' }); const config = useSettingsStore.getState().featureConfigs.generate; - expect(config.model).toBe('gpt-5.5'); - expect(config.provider).toBe('openai'); + expect(config.model).toBe('anthropic/claude-sonnet-5'); + expect(config.provider).toBe('openrouter'); expect(config.temperature).toBe(0.7); }); @@ -57,15 +57,15 @@ describe('settings-store additional branches', () => { it('getFeatureConfig returns config for known feature', () => { const config = useSettingsStore.getState().getFeatureConfig('generate'); - expect(config.provider).toBe('google'); - expect(config.model).toBe('gemini-3-flash-preview'); + expect(config.provider).toBe('openrouter'); + expect(config.model).toBe('openai/gpt-5.6-luna'); }); it('getFeatureConfig returns defaults for unknown feature', () => { useSettingsStore.setState({ featureConfigs: {} as any }); const config = useSettingsStore.getState().getFeatureConfig('generate'); expect(config).toBeDefined(); - expect(config.provider).toBe('google'); + expect(config.provider).toBe('openrouter'); }); it('resetLocalData resets language', () => { @@ -81,9 +81,9 @@ describe('settings-store additional branches', () => { }); it('resetLocalData resets featureConfigs', () => { - useSettingsStore.getState().setFeatureConfig('generate', { model: 'gpt-5.5' }); + useSettingsStore.getState().setFeatureConfig('generate', { model: 'openai/gpt-5.6-terra' }); useSettingsStore.getState().resetLocalData(); - expect(useSettingsStore.getState().featureConfigs.generate.model).toBe('gemini-3-flash-preview'); + expect(useSettingsStore.getState().featureConfigs.generate.model).toBe('openai/gpt-5.6-luna'); }); it('resetLocalData resets customSystemPrompt', () => { diff --git a/src/test/stores/settings-store-branches.test.ts b/src/test/stores/settings-store-branches.test.ts index 4b134a48d..506539547 100644 --- a/src/test/stores/settings-store-branches.test.ts +++ b/src/test/stores/settings-store-branches.test.ts @@ -38,7 +38,7 @@ describe('settings-store branch coverage', () => { expect(state.language).toBe('en'); expect(state.theme).toBe('system'); expect(state.customSystemPrompt).toBeUndefined(); - expect(state.featureConfigs.generate.model).toBe('gemini-3-flash-preview'); + expect(state.featureConfigs.generate.model).toBe('openai/gpt-5.6-luna'); }); it('normalizes persisted state in onRehydrateStorage callback', () => { @@ -62,4 +62,34 @@ describe('settings-store branch coverage', () => { expect(partialState.theme).toBe('system'); expect(partialState.wikiResearchEnabled).toBe(false); }); + + it('canonicalizes legacy local configs and removes client credentials', () => { + const onRehydrateStorage = useSettingsStore.persist.getOptions().onRehydrateStorage; + const afterRehydrate = onRehydrateStorage?.(useSettingsStore.getState()); + if (!afterRehydrate) throw new Error('Expected a rehydration callback'); + + const partialState = { + featureConfigs: { + generate: { + provider: 'google', + model: 'gemini-3-flash-preview', + temperature: 0.3, + reasoningEffort: 'medium', + apiKey: 'legacy-secret', + baseUrl: 'https://legacy.example.test', + }, + }, + } as any; + + afterRehydrate(partialState); + + expect(partialState.featureConfigs.generate).toMatchObject({ + provider: 'openrouter', + model: 'google/gemini-3.8-flash', + temperature: 0.3, + reasoningEffort: 'medium', + }); + expect(partialState.featureConfigs.generate).not.toHaveProperty('apiKey'); + expect(partialState.featureConfigs.generate).not.toHaveProperty('baseUrl'); + }); }); diff --git a/src/test/stores/settings-store-extended.test.ts b/src/test/stores/settings-store-extended.test.ts index b9b215baf..75919ec8b 100644 --- a/src/test/stores/settings-store-extended.test.ts +++ b/src/test/stores/settings-store-extended.test.ts @@ -28,14 +28,15 @@ vi.mock('@/lib/supabase/client', () => ({ // Import after mocking import { useSettingsStore } from '@/lib/stores/settings-store'; +import type { ProviderConfig } from '@/lib/ai/providers/types'; // Helper to create default configs since it's not exported -const createDefaultConfigs = () => ({ - generate: { provider: 'google', model: 'gemini-3-flash-preview', temperature: 0.7 }, - attack: { provider: 'google', model: 'gemini-3-flash-preview', temperature: 0.7 }, - brainstorm: { provider: 'google', model: 'gemini-3-flash-preview', temperature: 0.9 }, - factoids: { provider: 'google', model: 'gemini-3-flash-preview', temperature: 0.3 }, - ask: { provider: 'google', model: 'gemini-3-flash-preview', temperature: 0.7 }, +const createDefaultConfigs = (): Record<'generate' | 'attack' | 'brainstorm' | 'factoids' | 'ask', ProviderConfig> => ({ + generate: { provider: 'openrouter', model: 'openai/gpt-5.6-luna', temperature: 0.7, reasoningEffort: 'low' }, + attack: { provider: 'openrouter', model: 'openai/gpt-5.6-luna', temperature: 0.7, reasoningEffort: 'low' }, + brainstorm: { provider: 'openrouter', model: 'openai/gpt-5.6-luna', temperature: 0.9, reasoningEffort: 'low' }, + factoids: { provider: 'openrouter', model: 'openai/gpt-5.6-luna', temperature: 0.3, reasoningEffort: 'low' }, + ask: { provider: 'openrouter', model: 'openai/gpt-5.6-luna', temperature: 0.7, reasoningEffort: 'low' }, }); describe('Settings Store Extended', () => { @@ -45,7 +46,7 @@ describe('Settings Store Extended', () => { useSettingsStore.setState({ language: 'en', theme: 'system', - featureConfigs: createDefaultConfigs() as ReturnType, + featureConfigs: createDefaultConfigs(), customSystemPrompt: undefined, wikiResearchEnabled: false, }); @@ -148,7 +149,7 @@ describe('Settings Store Extended', () => { const store = useSettingsStore.getState(); const config = store.getFeatureConfig('generate'); expect(config).toBeDefined(); - expect(config.provider).toBe('google'); + expect(config.provider).toBe('openrouter'); }); }); From 75d221d7c51ac7928c724cf316b51c6ced947854 Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 9 Sep 2026 15:40:17 +0000 Subject: [PATCH 6/7] fix: preserve local mock fixture in settings --- src/lib/stores/settings-store.ts | 13 ++++++------ src/test/observability.test.ts | 12 +++++------ src/test/security-hardening.test.ts | 9 ++++++++- .../stores/settings-store-branches.test.ts | 20 +++++++++++++++++++ 4 files changed, 41 insertions(+), 13 deletions(-) diff --git a/src/lib/stores/settings-store.ts b/src/lib/stores/settings-store.ts index e558a4f91..971ff19d7 100644 --- a/src/lib/stores/settings-store.ts +++ b/src/lib/stores/settings-store.ts @@ -81,8 +81,9 @@ function normalizeModelId(value: unknown, fallback: string = DEFAULT_MODEL): str return LEGACY_MODEL_SUCCESSORS[model] ?? DEFAULT_MODEL; } -function normalizeFeatureConfig(value: unknown, fallback: ProviderConfig): ProviderConfig { +function normalizeFeatureConfig(value: unknown, fallback: ProviderConfig, preserveLocalMock = false): ProviderConfig { const candidate = asRecord(value); + const isLocalMock = preserveLocalMock && candidate?.provider === 'mock' && candidate?.model === 'local'; const temperature = typeof candidate?.temperature === 'number' && Number.isFinite(candidate.temperature) ? candidate.temperature : fallback.temperature; @@ -91,20 +92,20 @@ function normalizeFeatureConfig(value: unknown, fallback: ProviderConfig): Provi : fallback.reasoningEffort ?? 'low'; return { - provider: DEFAULT_PROVIDER, - model: normalizeModelId(candidate?.model, fallback.model), + provider: isLocalMock ? 'mock' : DEFAULT_PROVIDER, + model: isLocalMock ? 'local' : normalizeModelId(candidate?.model, fallback.model), temperature, reasoningEffort, }; } -function normalizeFeatureConfigs(configs: unknown): Record { +function normalizeFeatureConfigs(configs: unknown, preserveLocalMock = false): Record { const defaults = getDefaultFeatureConfigs(); const saved = asRecord(configs); if (!saved) return defaults; return Object.fromEntries((Object.keys(defaults) as FeatureTab[]).map((feature) => { - return [feature, normalizeFeatureConfig(saved[feature], defaults[feature])]; + return [feature, normalizeFeatureConfig(saved[feature], defaults[feature], preserveLocalMock)]; })) as Record; } @@ -248,7 +249,7 @@ export const useSettingsStore = create()( name: 'debate-trainer-settings', onRehydrateStorage: () => (state) => { if (!state) return; - state.featureConfigs = normalizeFeatureConfigs(state.featureConfigs); + state.featureConfigs = normalizeFeatureConfigs(state.featureConfigs, true); if (!state.language) state.language = 'en'; if (!state.outputLanguage) state.outputLanguage = 'en'; if (!state.theme) state.theme = 'system'; diff --git a/src/test/observability.test.ts b/src/test/observability.test.ts index 9f779e46e..abe918578 100644 --- a/src/test/observability.test.ts +++ b/src/test/observability.test.ts @@ -102,14 +102,14 @@ describe("observability primitives", () => { durationMs: 234, feature: "ask", message: "private debate content", - model: "gpt-5.4-mini", + model: "openai/gpt-5.6-luna", }); const attributes = { event_name: "ai_request_succeeded", durationMs: 234, feature: "ask", - model: "gpt-5.4-mini", + model: "openai/gpt-5.6-luna", }; expect(sentryLoggerInfoMock).toHaveBeenCalledWith("Product event", attributes); expect(sentryMetricsCountMock).toHaveBeenCalledWith("debatelab.product_event", 1, { attributes }); @@ -132,16 +132,16 @@ describe("observability primitives", () => { captureProductEvent("ai_request_succeeded", { feature: "private debate content", model: "student@example.com", - provider: "openai", + provider: "openrouter", }); captureProductEvent("student@example.com", { feature: "ask" }); expect(captureMock).toHaveBeenCalledOnce(); - expect(captureMock).toHaveBeenCalledWith("ai_request_succeeded", { provider: "openai" }); + expect(captureMock).toHaveBeenCalledWith("ai_request_succeeded", { provider: "openrouter" }); expect(sentryLoggerInfoMock).toHaveBeenCalledOnce(); expect(sentryLoggerInfoMock).toHaveBeenCalledWith("Product event", { event_name: "ai_request_succeeded", - provider: "openai", + provider: "openrouter", }); expect(JSON.stringify([ ...captureMock.mock.calls, @@ -159,7 +159,7 @@ describe("observability primitives", () => { it("passes exceptions and structured context through when Sentry is configured", () => { vi.stubEnv("VITE_SENTRY_DSN", "https://public-test@sentry.example/1"); const error = new Error("synthetic test error"); - const context = { provider: "openai", category: "provider_error" }; + const context = { provider: "openrouter", category: "provider_error" }; captureException(error, context); diff --git a/src/test/security-hardening.test.ts b/src/test/security-hardening.test.ts index 56ffaefae..4da414773 100644 --- a/src/test/security-hardening.test.ts +++ b/src/test/security-hardening.test.ts @@ -8,6 +8,7 @@ const migration = read('supabase/migrations/20260819000000_security_hardening.sq const advisorMigration = read('supabase/migrations/20260820090000_advisor_security_cleanup.sql'); const ai = read('supabase/functions/ai/index.ts'); const stream = read('supabase/functions/ai-stream/index.ts'); +const modelCatalog = read('supabase/functions/_shared/model-catalog.ts'); const importer = read('supabase/functions/import-source/index.ts'); const share = read('supabase/functions/share/index.ts'); const promo = read('supabase/functions/redeem-promo/index.ts'); @@ -63,8 +64,14 @@ describe('security hardening regressions', () => { }); it('uses allowlisted model/provider pairs and SSRF-safe citation verification', () => { - expect(ai).toContain('function providerForModel'); + expect(modelCatalog).toContain('id: "openai/gpt-5.6-luna"'); + expect(modelCatalog).toContain('id: "anthropic/claude-sonnet-5"'); + expect(ai).toContain('function modelForRequest'); + expect(stream).toContain('function modelForRequest'); + expect(ai).toContain('const provider: Provider = OPENROUTER_PROVIDER'); + expect(stream).toContain('const OPENROUTER_PROVIDER = "openrouter"'); expect(ai).toContain('Unsupported model or provider combination'); + expect(stream).toContain('Unsupported model or provider combination'); // SSRF-safe verification now lives in the shared research module. expect(read('supabase/functions/_shared/research.ts')).toContain('Do not fetch model-provided citation URLs'); expect(ai).not.toContain('fetchWithTimeout(currentUrl.toString(), { method: "HEAD"'); diff --git a/src/test/stores/settings-store-branches.test.ts b/src/test/stores/settings-store-branches.test.ts index 506539547..0b1f8fa8f 100644 --- a/src/test/stores/settings-store-branches.test.ts +++ b/src/test/stores/settings-store-branches.test.ts @@ -92,4 +92,24 @@ describe('settings-store branch coverage', () => { expect(partialState.featureConfigs.generate).not.toHaveProperty('apiKey'); expect(partialState.featureConfigs.generate).not.toHaveProperty('baseUrl'); }); + + it('preserves the local mock fixture during local rehydration', () => { + const onRehydrateStorage = useSettingsStore.persist.getOptions().onRehydrateStorage; + const afterRehydrate = onRehydrateStorage?.(useSettingsStore.getState()); + if (!afterRehydrate) throw new Error('Expected a rehydration callback'); + + const partialState = { + featureConfigs: { + generate: { provider: 'mock', model: 'local', temperature: 0.7 }, + }, + } as any; + + afterRehydrate(partialState); + + expect(partialState.featureConfigs.generate).toMatchObject({ + provider: 'mock', + model: 'local', + reasoningEffort: 'low', + }); + }); }); From d87759fc9f460bb86df7e6899e261599eb950ef5 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 18 Sep 2026 15:52:21 +0000 Subject: [PATCH 7/7] feat: make GLM 5.3 Flash the only AI model --- docs/architecture.md | 2 +- src/lib/stores/settings-store.ts | 63 +++++++++++---- src/locales/en.json | 40 +++++----- src/locales/es.json | 40 +++++----- src/locales/hu.json | 40 +++++----- src/test/ai/streaming.test.ts | 16 ++-- src/test/components/ModelSelector.test.tsx | 46 +++-------- src/test/observability.test.ts | 4 +- src/test/providers.test.ts | 17 ----- src/test/security-hardening.test.ts | 6 +- src/test/settings-store.test.ts | 38 ++++++++++ .../stores/settings-store-additional.test.ts | 6 +- .../stores/settings-store-branches.test.ts | 34 ++++++++- .../stores/settings-store-extended.test.ts | 10 +-- supabase/README.md | 2 + supabase/functions/_shared/cost.test.ts | 10 +-- supabase/functions/_shared/model-catalog.ts | 15 +--- supabase/functions/ai-stream/handler_test.ts | 2 +- .../functions/ai/generate-honesty_test.ts | 10 +-- .../functions/ai/handler-research.test.ts | 76 ++++++++----------- 20 files changed, 263 insertions(+), 214 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index d9a2eddf8..7381e903f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -83,7 +83,7 @@ Supabase Auth owns user sessions. Protected routes provide a client navigation b Checkout and portal functions create Stripe sessions after authenticated ownership and redirect validation. The webhook updates subscription state and records event processing for idempotency. AI handlers record provider usage and calculated cost using shared pricing logic. -All supported model requests use the single OpenRouter backend route and `OPENROUTER_API_KEY`; the shared allowlist 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. +All model requests use the single OpenRouter backend route and `OPENROUTER_API_KEY`. The only selectable and default model is `z-ai/glm-5.3-flash`; the shared allowlist 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. Historical provider pricing evidence is retained under [`docs/pricing-snapshots/`](pricing-snapshots/). Those snapshots support legacy calculator records and are evidence, not runtime configuration for the OpenRouter route. diff --git a/src/lib/stores/settings-store.ts b/src/lib/stores/settings-store.ts index 971ff19d7..f77ad983d 100644 --- a/src/lib/stores/settings-store.ts +++ b/src/lib/stores/settings-store.ts @@ -1,10 +1,7 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; import i18n from '@/i18n'; -import { - DEFAULT_MODEL, - DEFAULT_PRO_MODEL, -} from '../ai/providers/types'; +import { DEFAULT_MODEL } from '../ai/providers/types'; import type { ProviderConfig } from '../ai/providers/types'; import { getModelCatalogEntry, @@ -43,13 +40,13 @@ const LEGACY_MODEL_SUCCESSORS: Record = { 'gpt-5.4-mini': DEFAULT_MODEL, 'gpt-4o': DEFAULT_MODEL, 'gpt-4o-mini': DEFAULT_MODEL, - 'claude-opus-4-7': DEFAULT_PRO_MODEL, - 'claude-sonnet-4-6': DEFAULT_PRO_MODEL, + 'claude-opus-4-7': DEFAULT_MODEL, + 'claude-sonnet-4-6': DEFAULT_MODEL, 'claude-haiku-4-5-20251001': DEFAULT_MODEL, - 'gemini-3.1-pro-preview': 'google/gemini-3.8-flash', - 'gemini-3.5-flash': 'google/gemini-3.8-flash', - 'gemini-3-flash-preview': 'google/gemini-3.8-flash', - 'gemini-3.1-flash-lite-preview': 'google/gemini-3.8-flash', + 'gemini-3.1-pro-preview': DEFAULT_MODEL, + 'gemini-3.5-flash': DEFAULT_MODEL, + 'gemini-3-flash-preview': DEFAULT_MODEL, + 'gemini-3.1-flash-lite-preview': DEFAULT_MODEL, }; const defaultConfig: ProviderConfig = { @@ -109,6 +106,24 @@ function normalizeFeatureConfigs(configs: unknown, preserveLocalMock = false): R })) as Record; } +function featureConfigsAreCanonical(value: unknown, expected: Record): boolean { + const saved = asRecord(value); + const features = Object.keys(expected) as FeatureTab[]; + if (!saved || Object.keys(saved).length !== features.length) return false; + + const allowedKeys = new Set(['provider', 'model', 'temperature', 'reasoningEffort']); + return features.every((feature) => { + const config = asRecord(saved[feature]); + if (!config) return false; + const target = expected[feature]; + return Object.keys(config).every((key) => allowedKeys.has(key)) + && config.provider === target.provider + && config.model === target.model + && config.temperature === target.temperature + && config.reasoningEffort === target.reasoningEffort; + }); +} + async function getUserId() { const { data } = await supabase.auth.getUser(); return data.user?.id || null; @@ -222,17 +237,30 @@ export const useSettingsStore = create()( if (error || !data) return; const mergedConfigs = normalizeFeatureConfigs(data.feature_configs); + const configsNeedMigration = !featureConfigsAreCanonical(data.feature_configs, mergedConfigs); const language = (data.language as 'en' | 'es' | 'hu') || 'en'; + const outputLanguage = (data.output_language as 'en' | 'es' | 'hu') || get().outputLanguage || 'en'; + const theme = (data.theme as 'light' | 'dark' | 'system') || 'system'; + const customSystemPrompt = data.custom_system_prompt || undefined; await i18n.changeLanguage(language); set({ language, - outputLanguage: (data.output_language as 'en' | 'es' | 'hu') || get().outputLanguage || 'en', - theme: (data.theme as 'light' | 'dark' | 'system') || 'system', + outputLanguage, + theme, featureConfigs: mergedConfigs, - customSystemPrompt: data.custom_system_prompt || undefined, + customSystemPrompt, wikiResearchEnabled: get().wikiResearchEnabled, }); + if (configsNeedMigration) { + await upsertSettings({ + language, + output_language: outputLanguage, + theme, + feature_configs: mergedConfigs, + custom_system_prompt: customSystemPrompt ?? null, + }); + } }, resetLocalData: () => { set({ @@ -247,6 +275,15 @@ export const useSettingsStore = create()( }), { name: 'debate-trainer-settings', + version: 1, + migrate: (persistedState: unknown) => { + const saved = asRecord(persistedState); + if (!saved) return persistedState; + return { + ...saved, + featureConfigs: normalizeFeatureConfigs(saved.featureConfigs, true), + }; + }, onRehydrateStorage: () => (state) => { if (!state) return; state.featureConfigs = normalizeFeatureConfigs(state.featureConfigs, true); diff --git a/src/locales/en.json b/src/locales/en.json index 24ab26da0..763d01fee 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -408,11 +408,11 @@ "recommended": "Recommended", "trialFeature1": "Starter AI prep access", "trialFeature2": "All 9 prep modes", - "trialFeature3": "Basic models only", + "trialFeature3": "GLM 5.3 Flash with low reasoning", "trialFeature4": "3-day access", "proFeature1": "Expanded AI prep access", "proFeature2": "All 9 prep modes", - "proFeature3": "Premium models (GPT-5.5, Claude Opus, Gemini Pro)", + "proFeature3": "GLM 5.3 Flash with high reasoning", "proFeature4": "Priority support", "proFeature5": "Active Grounding™ research", "startTrial": "Start Free Trial", @@ -421,14 +421,14 @@ "trialBadge": "New Users", "trialDesc": "Use the full workflow before deciding on Pro.", "proBadge": "Power Users", - "proDesc": "For heavy prep weeks, tougher topics, and advanced model access." + "proDesc": "For heavy prep weeks, tougher topics, and higher daily limits." }, "faq": { "title": "Frequently Asked Questions", "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 premium models.", + "a1": "DebateLab offers a 3-day free trial. After that, Pro is $5/month + tax for expanded access and high-reasoning responses.", "q2": "What do I get with Pro?", - "a2": "Expanded AI prep access, premium models (GPT-5.5, Claude Opus 4.7, Gemini 3.1 Pro), Active Grounding™ research, and priority support.", + "a2": "Expanded AI prep access, high reasoning on GLM 5.3 Flash, 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?", @@ -454,7 +454,7 @@ "title": "What's New", "subtitle": "Latest updates and improvements.", "sub1Title": "Subscription Tiers", - "sub1Desc": "Free trial and Pro plan with premium models.", + "sub1Desc": "Free trial and Pro plan with higher usage limits.", "sub2Title": "Argument Flowcharts", "sub2Desc": "Visual argument mapping with drag-and-drop nodes.", "sub3Title": "Upgraded Timer", @@ -817,20 +817,20 @@ "trialEndedDesc": "Your 3-day free trial has ended. Upgrade to Pro to keep using DebateLab.", "limitReached": "Daily Access Paused", "limitReachedDesc": "You've used {{current}} of {{limit}} included AI uses today. Access refreshes at midnight UTC.", - "upgrade": "Upgrade to Pro — $5/mo", - "upgradeNote": "More AI prep access + advanced models" + "upgrade": "Upgrade to Pro - $5/mo", + "upgradeNote": "More AI prep access + high reasoning" }, "modelModal": { - "title": "Pro Model", - "description": "This model is reserved for Pro members.", - "descriptionWithModel": "{{model}} is reserved for Pro members.", - "upgradeNote": "Upgrade to unlock advanced AI models.", - "upgrade": "Upgrade to Pro — $5/mo", - "useFree": "Use a free model instead" + "title": "Pro Reasoning", + "description": "High reasoning is reserved for Pro members.", + "descriptionWithModel": "High reasoning with {{model}} is reserved for Pro members.", + "upgradeNote": "Upgrade to unlock high reasoning.", + "upgrade": "Upgrade to Pro - $5/mo", + "useFree": "Use low reasoning instead" }, "pricingModal": { "title": "Upgrade to Pro", - "subtitle": "Unlock the full power of DebateLab with advanced AI models and expanded access.", + "subtitle": "Unlock higher usage limits and high reasoning with GLM 5.3 Flash.", "free": "Free", "pro": "Pro", "current": "Current", @@ -840,11 +840,11 @@ "freePeriod": "/mo", "proPeriod": "/mo + tax", "freeFeature1": "Starter AI prep access", - "freeFeature2": "Standard AI models", + "freeFeature2": "GLM 5.3 Flash with low reasoning", "freeFeature3": "All debate features", "freeFeature4": "3-day free trial", "proFeature1": "Expanded AI prep access", - "proFeature2": "Advanced models (GPT-5.5, Claude Opus 4.7, Gemini 3.1 Pro)", + "proFeature2": "GLM 5.3 Flash with high reasoning", "proFeature3": "Deep research with Exa", "proFeature4": "Priority processing", "proFeature5": "All debate features", @@ -1163,11 +1163,11 @@ "s2": "How We Use Your Information", "p2": "Your information is used to provide and improve our services, generate AI responses, maintain your saved content, and communicate service updates.", "s3": "AI Processing", - "p3": "Your inputs are sent to third-party AI providers (OpenAI, Anthropic, Google) for processing. We do not use your content to train AI models.", + "p3": "Your inputs are sent to OpenRouter for AI processing using GLM 5.3 Flash. We do not use your content to train AI models.", "s4": "Data Storage", - "p4": "Your data is stored securely using Firebase/Google Cloud infrastructure with encryption at rest and in transit.", + "p4": "Your data is stored securely using Supabase infrastructure with encryption at rest and in transit.", "s5": "Data Sharing", - "p5": "We do not sell your personal data. We share data only with AI providers for processing and payment processors for billing.", + "p5": "We do not sell your personal data. We share data only with OpenRouter for AI processing and payment processors for billing.", "s6": "Your Rights", "p6": "You may access, export, or delete your data at any time from your account settings. Account deletion removes all associated data.", "s7": "Cookies and Tracking", diff --git a/src/locales/es.json b/src/locales/es.json index 4b36061e7..9a32000f4 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -408,11 +408,11 @@ "recommended": "Recomendado", "trialFeature1": "Acceso inicial a preparación con IA", "trialFeature2": "Los 9 modos de preparación", - "trialFeature3": "Solo modelos básicos", + "trialFeature3": "GLM 5.3 Flash con razonamiento bajo", "trialFeature4": "Acceso por 3 días", "proFeature1": "Acceso ampliado a preparación con IA", "proFeature2": "Los 9 modos de preparación", - "proFeature3": "Modelos premium (GPT-5.5, Claude Opus, Gemini Pro)", + "proFeature3": "GLM 5.3 Flash con razonamiento alto", "proFeature4": "Soporte prioritario", "proFeature5": "Investigación Active Grounding™", "startTrial": "Comenzar Prueba Gratis", @@ -421,14 +421,14 @@ "trialBadge": "Usuarios nuevos", "trialDesc": "Usa el flujo de trabajo completo antes de decidirte por Pro.", "proBadge": "Usuarios avanzados", - "proDesc": "Para semanas de preparación intensiva, temas difíciles y acceso a modelos avanzados." + "proDesc": "Para semanas de preparación intensiva, temas difíciles y límites diarios más altos." }, "faq": { "title": "Preguntas Frecuentes", "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 modelos premium.", + "a1": "DebateLab ofrece una prueba gratis de 3 días. Después, Pro cuesta $5/mes + impuestos para acceso ampliado y respuestas con razonamiento alto.", "q2": "¿Qué obtengo con Pro?", - "a2": "Acceso ampliado a preparación con IA, modelos premium (GPT-5.5, Claude Opus 4.7, Gemini 3.1 Pro), investigación Active Grounding™ y soporte prioritario.", + "a2": "Acceso ampliado a preparación con IA, razonamiento alto con GLM 5.3 Flash, 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?", @@ -454,7 +454,7 @@ "title": "Novedades", "subtitle": "Últimas actualizaciones y mejoras.", "sub1Title": "Niveles de Suscripción", - "sub1Desc": "Prueba gratis y plan Pro con modelos premium.", + "sub1Desc": "Prueba gratis y plan Pro con límites de uso más altos.", "sub2Title": "Diagramas de Argumentos", "sub2Desc": "Mapeo visual de argumentos con nodos arrastrables.", "sub3Title": "Cronómetro Mejorado", @@ -817,20 +817,20 @@ "trialEndedDesc": "Tu prueba gratuita de 3 días ha terminado. Actualiza a Pro para seguir usando DebateLab.", "limitReached": "Acceso diario pausado", "limitReachedDesc": "Has usado {{current}} de {{limit}} usos de IA incluidos hoy. El acceso se renueva a medianoche UTC.", - "upgrade": "Actualizar a Pro — $5/mes", - "upgradeNote": "Más acceso a preparación con IA + modelos avanzados" + "upgrade": "Actualizar a Pro - $5/mes", + "upgradeNote": "Más acceso a preparación con IA + razonamiento alto" }, "modelModal": { - "title": "Modelo Pro", - "description": "Este modelo está reservado para miembros Pro.", - "descriptionWithModel": "{{model}} está reservado para miembros Pro.", - "upgradeNote": "Actualiza para desbloquear modelos avanzados de IA.", - "upgrade": "Actualizar a Pro — $5/mes", - "useFree": "Usar un modelo gratuito" + "title": "Razonamiento Pro", + "description": "El razonamiento alto está reservado para miembros Pro.", + "descriptionWithModel": "El razonamiento alto con {{model}} está reservado para miembros Pro.", + "upgradeNote": "Actualiza para desbloquear el razonamiento alto.", + "upgrade": "Actualizar a Pro - $5/mes", + "useFree": "Usar razonamiento bajo" }, "pricingModal": { "title": "Actualizar a Pro", - "subtitle": "Desbloquea todo el poder de DebateLab con modelos avanzados de IA y acceso ampliado.", + "subtitle": "Desbloquea límites de uso más altos y razonamiento alto con GLM 5.3 Flash.", "free": "Gratis", "pro": "Pro", "current": "Actual", @@ -840,11 +840,11 @@ "freePeriod": "/mes", "proPeriod": "/mes + impuestos", "freeFeature1": "Acceso inicial a preparación con IA", - "freeFeature2": "Modelos estándar de IA", + "freeFeature2": "GLM 5.3 Flash con razonamiento bajo", "freeFeature3": "Todas las funciones de debate", "freeFeature4": "Prueba gratis de 3 días", "proFeature1": "Acceso ampliado a preparación con IA", - "proFeature2": "Modelos avanzados (GPT-5.5, Claude Opus 4.7, Gemini 3.1 Pro)", + "proFeature2": "GLM 5.3 Flash con razonamiento alto", "proFeature3": "Investigación profunda con Exa", "proFeature4": "Procesamiento prioritario", "proFeature5": "Todas las funciones de debate", @@ -1163,11 +1163,11 @@ "s2": "Cómo Usamos Tu Información", "p2": "Tu información se usa para proporcionar y mejorar nuestros servicios, generar respuestas de IA, mantener tu contenido guardado y comunicar actualizaciones.", "s3": "Procesamiento de IA", - "p3": "Tus entradas se envían a proveedores de IA terceros (OpenAI, Anthropic, Google) para procesamiento. No usamos tu contenido para entrenar modelos de IA.", + "p3": "Tus entradas se envían a OpenRouter para el procesamiento de IA con GLM 5.3 Flash. No usamos tu contenido para entrenar modelos de IA.", "s4": "Almacenamiento de Datos", - "p4": "Tus datos se almacenan de forma segura usando infraestructura Firebase/Google Cloud con cifrado en reposo y en tránsito.", + "p4": "Tus datos se almacenan de forma segura usando infraestructura de Supabase con cifrado en reposo y en tránsito.", "s5": "Compartir Datos", - "p5": "No vendemos tus datos personales. Solo compartimos datos con proveedores de IA para procesamiento y procesadores de pago para facturación.", + "p5": "No vendemos tus datos personales. Solo compartimos datos con OpenRouter para el procesamiento de IA y con procesadores de pago para la facturación.", "s6": "Tus Derechos", "p6": "Puedes acceder, exportar o eliminar tus datos en cualquier momento desde la configuración de tu cuenta.", "s7": "Cookies y Seguimiento", diff --git a/src/locales/hu.json b/src/locales/hu.json index 3a66d717c..ea5aab57a 100644 --- a/src/locales/hu.json +++ b/src/locales/hu.json @@ -408,11 +408,11 @@ "recommended": "Ajánlott", "trialFeature1": "Kezdő MI felkészülési hozzáférés", "trialFeature2": "Mind a 9 felkészülési mód", - "trialFeature3": "Csak alap modellek", + "trialFeature3": "GLM 5.3 Flash alacsony szintű érveléssel", "trialFeature4": "3 napos hozzáférés", "proFeature1": "Bővített MI felkészülési hozzáférés", "proFeature2": "Mind a 9 felkészülési mód", - "proFeature3": "Prémium modellek (GPT-5.5, Claude Opus, Gemini Pro)", + "proFeature3": "GLM 5.3 Flash magas szintű érveléssel", "proFeature4": "Elsőbbségi támogatás", "proFeature5": "Active Grounding™ kutatás", "startTrial": "Ingyenes Próba Indítása", @@ -421,14 +421,14 @@ "trialBadge": "Új felhasználóknak", "trialDesc": "Próbáld ki a teljes munkafolyamatot, mielőtt a Pro mellett döntesz.", "proBadge": "Haladó felhasználóknak", - "proDesc": "Intenzív felkészülési hetekhez, nehezebb témákhoz és fejlett modellekhez való hozzáféréshez." + "proDesc": "Intenzív felkészülési hetekhez, nehezebb témákhoz és magasabb napi kerethez." }, "faq": { "title": "Gyakran Ismételt Kérdések", "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ésért és prémium modellekért.", + "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 magas szintű érvelést biztosít.", "q2": "Mit kapok a Pro-val?", - "a2": "Bővített MI felkészülési hozzáférés, prémium modellek (GPT-5.5, Claude Opus 4.7, Gemini 3.1 Pro), Active Grounding™ kutatás és elsőbbségi támogatás.", + "a2": "Bővített MI felkészülési hozzáférés, magas szintű érvelés a GLM 5.3 Flash modellel, Active Grounding™ kutatás és elsőbbségi támogatás.", "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?", @@ -454,7 +454,7 @@ "title": "Újdonságok", "subtitle": "Legújabb frissítések és fejlesztések.", "sub1Title": "Előfizetési Szintek", - "sub1Desc": "Ingyenes próba és Pro csomag prémium modellekkel.", + "sub1Desc": "Ingyenes próba és Pro csomag magasabb használati kerettel.", "sub2Title": "Érv Folyamatábrák", "sub2Desc": "Vizuális érvtérképezés húzd-és-ejtsd csomópontokkal.", "sub3Title": "Fejlesztett Időmérő", @@ -817,20 +817,20 @@ "trialEndedDesc": "A 3 napos ingyenes próbaidőszakod lejárt. Frissíts Pro-ra a DebateLab további használatához.", "limitReached": "Napi hozzáférés szünetel", "limitReachedDesc": "Ma {{current}} / {{limit}} benne foglalt MI használatot használtál fel. A hozzáférés éjfélkor (UTC) frissül.", - "upgrade": "Frissítés Pro-ra — $5/hó", - "upgradeNote": "Több MI felkészülési hozzáférés + fejlett modellek" + "upgrade": "Frissítés Pro-ra - $5/hó", + "upgradeNote": "Több MI felkészülési hozzáférés + magas szintű érvelés" }, "modelModal": { - "title": "Pro Modell", - "description": "Ez a modell Pro tagoknak van fenntartva.", - "descriptionWithModel": "A {{model}} Pro tagoknak van fenntartva.", - "upgradeNote": "Frissíts a fejlett MI modellek feloldásához.", - "upgrade": "Frissítés Pro-ra — $5/hó", - "useFree": "Ingyenes modell használata" + "title": "Pro érvelés", + "description": "A magas szintű érvelés csak Pro tagoknak érhető el.", + "descriptionWithModel": "A magas szintű érvelés a {{model}} modellel csak Pro tagoknak érhető el.", + "upgradeNote": "Frissíts a magas szintű érvelés feloldásához.", + "upgrade": "Frissítés Pro-ra - $5/hó", + "useFree": "Alacsony szintű érvelés használata" }, "pricingModal": { "title": "Frissítés Pro-ra", - "subtitle": "Oldd fel a DebateLab teljes erejét fejlett MI modellekkel és bővített hozzáféréssel.", + "subtitle": "Oldd fel a magasabb használati keretet és a magas szintű érvelést a GLM 5.3 Flash modellel.", "free": "Ingyenes", "pro": "Pro", "current": "Jelenlegi", @@ -840,11 +840,11 @@ "freePeriod": "/hó", "proPeriod": "/hó + adó", "freeFeature1": "Kezdő MI felkészülési hozzáférés", - "freeFeature2": "Standard MI modellek", + "freeFeature2": "GLM 5.3 Flash alacsony szintű érveléssel", "freeFeature3": "Minden vitafunkció", "freeFeature4": "3 napos ingyenes próba", "proFeature1": "Bővített MI felkészülési hozzáférés", - "proFeature2": "Fejlett modellek (GPT-5.5, Claude Opus 4.7, Gemini 3.1 Pro)", + "proFeature2": "GLM 5.3 Flash magas szintű érveléssel", "proFeature3": "Mélyreható kutatás Exa-val", "proFeature4": "Elsőbbségi feldolgozás", "proFeature5": "Minden vitafunkció", @@ -1163,11 +1163,11 @@ "s2": "Hogyan Használjuk az Információidat", "p2": "Az információidat szolgáltatásaink nyújtására és fejlesztésére, MI válaszok generálására, mentett tartalmad karbantartására és szolgáltatási frissítések kommunikálására használjuk.", "s3": "MI Feldolgozás", - "p3": "A bemeneteidet harmadik fél MI szolgáltatóknak (OpenAI, Anthropic, Google) küldjük feldolgozásra. Nem használjuk a tartalmadat MI modellek betanítására.", + "p3": "A bemeneteidet az OpenRouternek küldjük MI-feldolgozásra a GLM 5.3 Flash modellel. A tartalmadat nem használjuk MI-modellek betanítására.", "s4": "Adattárolás", - "p4": "Adataidat biztonságosan tároljuk Firebase/Google Cloud infrastruktúrán, titkosítással nyugalmi állapotban és átvitel közben.", + "p4": "Adataidat biztonságosan, a Supabase infrastruktúráján tároljuk, nyugalmi és átvitel közbeni titkosítással.", "s5": "Adatmegosztás", - "p5": "Nem adjuk el személyes adataidat. Csak MI szolgáltatókkal osztunk meg adatot feldolgozásra és fizetési szolgáltatókkal számlázásra.", + "p5": "Nem adjuk el személyes adataidat. Csak az OpenRouterrel osztunk meg adatot MI-feldolgozás céljából, valamint fizetési szolgáltatókkal a számlázáshoz.", "s6": "Jogaid", "p6": "Bármikor hozzáférhetsz, exportálhatod vagy törölheted adataidat a fiókbeállításokból. A fiók törlése eltávolítja az összes kapcsolódó adatot.", "s7": "Sütik és Nyomkövetés", diff --git a/src/test/ai/streaming.test.ts b/src/test/ai/streaming.test.ts index cd5b72e8f..e556cfc46 100644 --- a/src/test/ai/streaming.test.ts +++ b/src/test/ai/streaming.test.ts @@ -85,38 +85,38 @@ describe('AI streaming helpers', () => { }); it('enables streaming for basic OpenRouter ask chats', () => { - expect(isAskStreamingSupported({ provider: 'openrouter', model: 'openai/gpt-5.6-luna', temperature: 0.7 })).toBe(true); + expect(isAskStreamingSupported({ provider: 'openrouter', model: 'z-ai/glm-5.3-flash', temperature: 0.7 })).toBe(true); expect(isAskStreamingSupported({ provider: 'openai', model: 'gpt-5.4-mini', temperature: 0.7 })).toBe(false); expect(isAskStreamingSupported({ provider: 'google', model: 'gemini-3-flash-preview', temperature: 0.7 })).toBe(false); expect(isAskStreamingSupported({ provider: 'anthropic', model: 'claude-haiku-4-5-20251001', temperature: 0.7 })).toBe(false); expect(isAskStreamingSupported({ provider: 'unknown' as string, model: 'x', temperature: 0.7 })).toBe(false); expect(isAskStreamingSupported( - { provider: 'openrouter', model: 'openai/gpt-5.6-luna', temperature: 0.7 }, + { provider: 'openrouter', model: 'z-ai/glm-5.3-flash', temperature: 0.7 }, { citationMode: true } )).toBe(false); expect(isAskStreamingSupported( - { provider: 'openrouter', model: 'openai/gpt-5.6-luna', temperature: 0.7 }, + { provider: 'openrouter', model: 'z-ai/glm-5.3-flash', temperature: 0.7 }, { webSearch: true } )).toBe(false); expect(isAskStreamingSupported( - { provider: 'openrouter', model: 'openai/gpt-5.6-luna', temperature: 0.7 }, + { provider: 'openrouter', model: 'z-ai/glm-5.3-flash', temperature: 0.7 }, { prosConsMode: true } )).toBe(false); expect(isAskStreamingSupported( - { provider: 'openrouter', model: 'openai/gpt-5.6-luna', temperature: 0.7 }, + { provider: 'openrouter', model: 'z-ai/glm-5.3-flash', temperature: 0.7 }, { argumentMapMode: true } )).toBe(false); expect(isAskStreamingSupported( - { provider: 'openrouter', model: 'openai/gpt-5.6-luna', temperature: 0.7 }, + { provider: 'openrouter', model: 'z-ai/glm-5.3-flash', temperature: 0.7 }, { mode: 'clarify' } )).toBe(false); expect(isAskStreamingSupported( - { provider: 'openrouter', model: 'openai/gpt-5.6-luna', temperature: 0.7 }, + { provider: 'openrouter', model: 'z-ai/glm-5.3-flash', temperature: 0.7 }, { mode: 'general' } )).toBe(true); for (const mode of ['clarify', 'summarize', 'proscons', 'argumentmap']) { expect(isAskStreamingSupported( - { provider: 'openrouter', model: 'openai/gpt-5.6-luna', temperature: 0.7 }, + { provider: 'openrouter', model: 'z-ai/glm-5.3-flash', temperature: 0.7 }, { mode } )).toBe(false); } diff --git a/src/test/components/ModelSelector.test.tsx b/src/test/components/ModelSelector.test.tsx index 33a193829..795358e13 100644 --- a/src/test/components/ModelSelector.test.tsx +++ b/src/test/components/ModelSelector.test.tsx @@ -11,33 +11,24 @@ vi.mock('@/hooks/use-subscription', () => ({ vi.mock('@/lib/stores/settings-store', () => ({ useSettingsStore: vi.fn(() => ({ featureConfigs: { - generate: { provider: 'openrouter', model: 'openai/gpt-5.6-luna', temperature: 0.7, reasoningEffort: 'low' }, - attack: { provider: 'openrouter', model: 'openai/gpt-5.6-luna', temperature: 0.7, reasoningEffort: 'low' }, - brainstorm: { provider: 'openrouter', model: 'openai/gpt-5.6-luna', temperature: 0.9, reasoningEffort: 'low' }, - factoids: { provider: 'openrouter', model: 'openai/gpt-5.6-luna', temperature: 0.3, reasoningEffort: 'low' }, - ask: { provider: 'openrouter', model: 'openai/gpt-5.6-luna', temperature: 0.7, reasoningEffort: 'low' }, + generate: { provider: 'openrouter', model: 'z-ai/glm-5.3-flash', temperature: 0.7, reasoningEffort: 'low' }, + attack: { provider: 'openrouter', model: 'z-ai/glm-5.3-flash', temperature: 0.7, reasoningEffort: 'low' }, + brainstorm: { provider: 'openrouter', model: 'z-ai/glm-5.3-flash', temperature: 0.9, reasoningEffort: 'low' }, + factoids: { provider: 'openrouter', model: 'z-ai/glm-5.3-flash', temperature: 0.3, reasoningEffort: 'low' }, + ask: { provider: 'openrouter', model: 'z-ai/glm-5.3-flash', temperature: 0.7, reasoningEffort: 'low' }, }, setFeatureConfig: vi.fn(), })), })); vi.mock('@/lib/ai/providers/types', () => ({ - DEFAULT_MODEL: 'openai/gpt-5.6-luna', + DEFAULT_MODEL: 'z-ai/glm-5.3-flash', isReasoningEffort: (value: unknown) => value === 'low' || value === 'medium' || value === 'high', DEFAULT_PROVIDERS: [ { id: 'openrouter', name: 'OpenRouter', models: [ - { id: 'openai/gpt-5.6-luna', name: 'GPT-5.6 Luna', maxTokens: 128000, isProOnly: false, supportsTemperature: false }, - { id: 'google/gemini-3.8-flash', name: 'Gemini 3.8 Flash', maxTokens: 65536, isProOnly: false, supportsTemperature: true }, - { id: 'deepseek/deepseek-v4-flash-0731', name: 'DeepSeek V4 Flash', maxTokens: 131072, isProOnly: false, supportsTemperature: true }, - { id: 'anthropic/claude-sonnet-5', name: 'Claude Sonnet 5', maxTokens: 128000, isProOnly: true, supportsTemperature: false }, - { id: 'anthropic/claude-opus-5', name: 'Claude Opus 5', maxTokens: 128000, isProOnly: true, supportsTemperature: true }, - { id: 'openai/gpt-5.6-sol', name: 'GPT-5.6 Sol', maxTokens: 128000, isProOnly: true, supportsTemperature: false }, - { id: 'openai/gpt-5.6-terra', name: 'GPT-5.6 Terra', maxTokens: 128000, isProOnly: true, supportsTemperature: false }, - { id: 'deepseek/deepseek-v4-pro-0813', name: 'DeepSeek V4 Pro', maxTokens: 384000, isProOnly: true, supportsTemperature: true }, - { id: 'z-ai/glm-5.3-flash', name: 'GLM-5.3 Flash', maxTokens: 131072, isProOnly: true, supportsTemperature: true }, - { id: 'meta/muse-spark-1.3', name: 'Muse Spark 1.3', maxTokens: 943718, isProOnly: true, supportsTemperature: true }, + { id: 'z-ai/glm-5.3-flash', name: 'GLM 5.3 Flash', maxTokens: 131072, isProOnly: false, supportsTemperature: true }, ], isAvailable: () => true, }, @@ -163,7 +154,7 @@ describe('ModelSelector', () => { const mockSetFeatureConfig = vi.fn(); vi.mocked(useSettingsStore).mockReturnValue({ featureConfigs: { - generate: { provider: 'openrouter', model: 'openai/gpt-5.6-luna', temperature: 0.7, reasoningEffort: 'high' }, + generate: { provider: 'openrouter', model: 'z-ai/glm-5.3-flash', temperature: 0.7, reasoningEffort: 'high' }, } as any, setFeatureConfig: mockSetFeatureConfig, }); @@ -176,7 +167,7 @@ describe('ModelSelector', () => { expect(mockSetFeatureConfig).not.toHaveBeenCalled(); }); - it('keeps a saved Pro model visible after a trial entitlement resolves', () => { + it('falls back to GLM when a removed model is still saved', () => { vi.mocked(useSettingsStore).mockReturnValue({ featureConfigs: { generate: { provider: 'openrouter', model: 'anthropic/claude-sonnet-5', temperature: 0.7, reasoningEffort: 'low' }, @@ -186,22 +177,7 @@ describe('ModelSelector', () => { render(); - expect(screen.getAllByRole('combobox')[0]).toHaveTextContent('Claude Sonnet 5'); - }); - - it('keeps saved Pro controls visible while entitlement is loading', () => { - subscriptionState.loading = true; - vi.mocked(useSettingsStore).mockReturnValue({ - featureConfigs: { - generate: { provider: 'openrouter', model: 'anthropic/claude-sonnet-5', temperature: 0.7, reasoningEffort: 'high' }, - } as any, - setFeatureConfig: vi.fn(), - }); - - render(); - - expect(screen.getAllByRole('combobox')[0]).toHaveTextContent('Claude Sonnet 5'); - expect(screen.getAllByRole('combobox')[1]).toHaveTextContent('High'); + expect(screen.getAllByRole('combobox')[0]).toHaveTextContent('GLM 5.3 Flash'); }); it('enables model and reasoning controls for Pro users', () => { @@ -209,7 +185,7 @@ describe('ModelSelector', () => { subscriptionState.tier = 'pro'; vi.mocked(useSettingsStore).mockReturnValue({ featureConfigs: { - generate: { provider: 'openrouter', model: 'openai/gpt-5.6-luna', temperature: 0.7, reasoningEffort: 'low' }, + generate: { provider: 'openrouter', model: 'z-ai/glm-5.3-flash', temperature: 0.7, reasoningEffort: 'low' }, } as any, setFeatureConfig: vi.fn(), }); diff --git a/src/test/observability.test.ts b/src/test/observability.test.ts index abe918578..a8f083152 100644 --- a/src/test/observability.test.ts +++ b/src/test/observability.test.ts @@ -102,14 +102,14 @@ describe("observability primitives", () => { durationMs: 234, feature: "ask", message: "private debate content", - model: "openai/gpt-5.6-luna", + model: "z-ai/glm-5.3-flash", }); const attributes = { event_name: "ai_request_succeeded", durationMs: 234, feature: "ask", - model: "openai/gpt-5.6-luna", + model: "z-ai/glm-5.3-flash", }; expect(sentryLoggerInfoMock).toHaveBeenCalledWith("Product event", attributes); expect(sentryMetricsCountMock).toHaveBeenCalledWith("debatelab.product_event", 1, { attributes }); diff --git a/src/test/providers.test.ts b/src/test/providers.test.ts index c8627d6e6..7d718d02d 100644 --- a/src/test/providers.test.ts +++ b/src/test/providers.test.ts @@ -2,23 +2,6 @@ import { describe, it, expect } from 'vitest'; import { DEFAULT_PROVIDERS } from '@/lib/ai/providers/types'; describe('DEFAULT_PROVIDERS', () => { - it('exposes the approved OpenRouter catalog as one provider', () => { - expect(DEFAULT_PROVIDERS).toHaveLength(1); - expect(DEFAULT_PROVIDERS[0].id).toBe('openrouter'); - expect(DEFAULT_PROVIDERS[0].models.map((model) => model.id)).toEqual([ - 'openai/gpt-5.6-luna', - 'google/gemini-3.8-flash', - 'deepseek/deepseek-v4-flash-0731', - 'anthropic/claude-sonnet-5', - 'anthropic/claude-opus-5', - 'openai/gpt-5.6-sol', - 'openai/gpt-5.6-terra', - 'deepseek/deepseek-v4-pro-0813', - 'z-ai/glm-5.3-flash', - 'meta/muse-spark-1.3', - ]); - }); - // Model IDs key request routing and the stored per-session model choice, so a // collision across providers would silently route to the wrong provider. it('no duplicate model IDs across all providers', () => { diff --git a/src/test/security-hardening.test.ts b/src/test/security-hardening.test.ts index 4da414773..6f71f1972 100644 --- a/src/test/security-hardening.test.ts +++ b/src/test/security-hardening.test.ts @@ -64,8 +64,10 @@ describe('security hardening regressions', () => { }); it('uses allowlisted model/provider pairs and SSRF-safe citation verification', () => { - expect(modelCatalog).toContain('id: "openai/gpt-5.6-luna"'); - expect(modelCatalog).toContain('id: "anthropic/claude-sonnet-5"'); + expect(modelCatalog).toContain('id: "z-ai/glm-5.3-flash"'); + expect(modelCatalog).not.toContain('anthropic/'); + expect(modelCatalog).not.toContain('openai/'); + expect(modelCatalog).not.toContain('google/'); expect(ai).toContain('function modelForRequest'); expect(stream).toContain('function modelForRequest'); expect(ai).toContain('const provider: Provider = OPENROUTER_PROVIDER'); diff --git a/src/test/settings-store.test.ts b/src/test/settings-store.test.ts index 30970ce56..c3e5bb835 100644 --- a/src/test/settings-store.test.ts +++ b/src/test/settings-store.test.ts @@ -42,6 +42,13 @@ describe("settings-store", () => { expect(supabaseMock.from).toHaveBeenCalledWith("user_settings"); }); + it("setOutputLanguage updates state and persists", async () => { + useSettingsStore.getState().setOutputLanguage("hu"); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(useSettingsStore.getState().outputLanguage).toBe("hu"); + expect(supabaseMock.from).toHaveBeenCalledWith("user_settings"); + }); + it("hydrateFromServer merges defaults with server configs", async () => { setMockTableResponse("user_settings", { single: { @@ -156,6 +163,37 @@ describe("settings-store", () => { expect(generate).not.toHaveProperty("baseUrl"); expect(attack).toMatchObject({ provider: "openrouter", model: "z-ai/glm-5.3-flash", reasoningEffort: "low" }); expect(attack).not.toHaveProperty("apiKey"); + + const settingsWrite = supabaseMock.from.mock.results.find(({ value }) => value.upsert.mock.calls.length)?.value; + expect(settingsWrite.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + feature_configs: expect.objectContaining({ + generate: expect.objectContaining({ provider: "openrouter", model: "z-ai/glm-5.3-flash" }), + attack: expect.objectContaining({ provider: "openrouter", model: "z-ai/glm-5.3-flash" }), + }), + }), + { onConflict: "user_id" }, + ); + expect(settingsWrite.upsert.mock.calls[0][0].feature_configs.generate).not.toHaveProperty("apiKey"); + expect(settingsWrite.upsert.mock.calls[0][0].feature_configs.generate).not.toHaveProperty("baseUrl"); + }); + + it("does not rewrite server feature configs that are already canonical", async () => { + const featureConfigs = structuredClone(useSettingsStore.getState().featureConfigs); + setMockTableResponse("user_settings", { + single: { + language: "en", + output_language: "en", + theme: "system", + custom_system_prompt: null, + feature_configs: featureConfigs, + }, + }); + + await useSettingsStore.getState().hydrateFromServer(); + + const settingsWrites = supabaseMock.from.mock.results.filter(({ value }) => value.upsert.mock.calls.length); + expect(settingsWrites).toHaveLength(0); }); it("does not upsert when user is not signed in", async () => { diff --git a/src/test/stores/settings-store-additional.test.ts b/src/test/stores/settings-store-additional.test.ts index c6acb5dea..a28df763e 100644 --- a/src/test/stores/settings-store-additional.test.ts +++ b/src/test/stores/settings-store-additional.test.ts @@ -44,7 +44,7 @@ describe('settings-store additional branches', () => { it('setFeatureConfig merges correctly', () => { useSettingsStore.getState().setFeatureConfig('generate', { model: 'anthropic/claude-sonnet-5', provider: 'openrouter' }); const config = useSettingsStore.getState().featureConfigs.generate; - expect(config.model).toBe('anthropic/claude-sonnet-5'); + expect(config.model).toBe('z-ai/glm-5.3-flash'); expect(config.provider).toBe('openrouter'); expect(config.temperature).toBe(0.7); }); @@ -58,7 +58,7 @@ describe('settings-store additional branches', () => { it('getFeatureConfig returns config for known feature', () => { const config = useSettingsStore.getState().getFeatureConfig('generate'); expect(config.provider).toBe('openrouter'); - expect(config.model).toBe('openai/gpt-5.6-luna'); + expect(config.model).toBe('z-ai/glm-5.3-flash'); }); it('getFeatureConfig returns defaults for unknown feature', () => { @@ -83,7 +83,7 @@ describe('settings-store additional branches', () => { it('resetLocalData resets featureConfigs', () => { useSettingsStore.getState().setFeatureConfig('generate', { model: 'openai/gpt-5.6-terra' }); useSettingsStore.getState().resetLocalData(); - expect(useSettingsStore.getState().featureConfigs.generate.model).toBe('openai/gpt-5.6-luna'); + expect(useSettingsStore.getState().featureConfigs.generate.model).toBe('z-ai/glm-5.3-flash'); }); it('resetLocalData resets customSystemPrompt', () => { diff --git a/src/test/stores/settings-store-branches.test.ts b/src/test/stores/settings-store-branches.test.ts index 0b1f8fa8f..264ab9e91 100644 --- a/src/test/stores/settings-store-branches.test.ts +++ b/src/test/stores/settings-store-branches.test.ts @@ -38,7 +38,7 @@ describe('settings-store branch coverage', () => { expect(state.language).toBe('en'); expect(state.theme).toBe('system'); expect(state.customSystemPrompt).toBeUndefined(); - expect(state.featureConfigs.generate.model).toBe('openai/gpt-5.6-luna'); + expect(state.featureConfigs.generate.model).toBe('z-ai/glm-5.3-flash'); }); it('normalizes persisted state in onRehydrateStorage callback', () => { @@ -85,7 +85,7 @@ describe('settings-store branch coverage', () => { expect(partialState.featureConfigs.generate).toMatchObject({ provider: 'openrouter', - model: 'google/gemini-3.8-flash', + model: 'z-ai/glm-5.3-flash', temperature: 0.3, reasoningEffort: 'medium', }); @@ -112,4 +112,34 @@ describe('settings-store branch coverage', () => { reasoningEffort: 'low', }); }); + + it('durably migrates legacy local credentials to the OpenRouter GLM config', async () => { + localStorage.setItem('debate-trainer-settings', JSON.stringify({ + version: 0, + state: { + featureConfigs: { + generate: { + provider: 'openai', + model: 'gpt-5.5', + temperature: 0.4, + apiKey: 'legacy-secret', + baseUrl: 'https://legacy.example.test', + }, + }, + }, + })); + + await useSettingsStore.persist.rehydrate(); + + const persisted = JSON.parse(localStorage.getItem('debate-trainer-settings') || '{}'); + expect(persisted.version).toBe(1); + expect(persisted.state.featureConfigs.generate).toMatchObject({ + provider: 'openrouter', + model: 'z-ai/glm-5.3-flash', + temperature: 0.4, + reasoningEffort: 'low', + }); + expect(persisted.state.featureConfigs.generate).not.toHaveProperty('apiKey'); + expect(persisted.state.featureConfigs.generate).not.toHaveProperty('baseUrl'); + }); }); diff --git a/src/test/stores/settings-store-extended.test.ts b/src/test/stores/settings-store-extended.test.ts index 75919ec8b..3650298d0 100644 --- a/src/test/stores/settings-store-extended.test.ts +++ b/src/test/stores/settings-store-extended.test.ts @@ -32,11 +32,11 @@ import type { ProviderConfig } from '@/lib/ai/providers/types'; // Helper to create default configs since it's not exported const createDefaultConfigs = (): Record<'generate' | 'attack' | 'brainstorm' | 'factoids' | 'ask', ProviderConfig> => ({ - generate: { provider: 'openrouter', model: 'openai/gpt-5.6-luna', temperature: 0.7, reasoningEffort: 'low' }, - attack: { provider: 'openrouter', model: 'openai/gpt-5.6-luna', temperature: 0.7, reasoningEffort: 'low' }, - brainstorm: { provider: 'openrouter', model: 'openai/gpt-5.6-luna', temperature: 0.9, reasoningEffort: 'low' }, - factoids: { provider: 'openrouter', model: 'openai/gpt-5.6-luna', temperature: 0.3, reasoningEffort: 'low' }, - ask: { provider: 'openrouter', model: 'openai/gpt-5.6-luna', temperature: 0.7, reasoningEffort: 'low' }, + generate: { provider: 'openrouter', model: 'z-ai/glm-5.3-flash', temperature: 0.7, reasoningEffort: 'low' }, + attack: { provider: 'openrouter', model: 'z-ai/glm-5.3-flash', temperature: 0.7, reasoningEffort: 'low' }, + brainstorm: { provider: 'openrouter', model: 'z-ai/glm-5.3-flash', temperature: 0.9, reasoningEffort: 'low' }, + factoids: { provider: 'openrouter', model: 'z-ai/glm-5.3-flash', temperature: 0.3, reasoningEffort: 'low' }, + ask: { provider: 'openrouter', model: 'z-ai/glm-5.3-flash', temperature: 0.7, reasoningEffort: 'low' }, }); describe('Settings Store Extended', () => { diff --git a/supabase/README.md b/supabase/README.md index 5a1d1d2b1..a97c8dfa1 100644 --- a/supabase/README.md +++ b/supabase/README.md @@ -31,6 +31,8 @@ supabase/tests/ pgTAP database and policy tests - `ai`: authenticated non-streaming endpoint for generate, attack, brainstorm, factoids, Ask, Crossfire, and sparring. It enforces supported providers and controls, access limits, research bounds, output validation, usage accounting, and sparring integrity. - `ai-stream`: authenticated server-sent events endpoint for Ask. It emits deltas, a final event, or an error and records usage. +Both endpoints route every model inference through OpenRouter and allow only `z-ai/glm-5.3-flash`. + The browser boundary is [`../src/lib/ai/client.ts`](../src/lib/ai/client.ts). Shared prompt, research, source-quality, model-pricing, cost, and control logic lives in `_shared`. ### Content and sharing diff --git a/supabase/functions/_shared/cost.test.ts b/supabase/functions/_shared/cost.test.ts index 0473c6768..2538f927b 100644 --- a/supabase/functions/_shared/cost.test.ts +++ b/supabase/functions/_shared/cost.test.ts @@ -127,7 +127,7 @@ Deno.test("OpenRouter usage uses its reported account cost, including zero", () cost: 0, }; const usage = normalizeOpenRouterUsage(raw); - const result = calculateRequestCost({ provider: "openrouter", model: "openai/gpt-5.6-luna", usage }); + const result = calculateRequestCost({ provider: "openrouter", model: "z-ai/glm-5.3-flash", usage }); if (usage.reportedCostUsd !== 0 || result.costUsd !== 0) { throw new Error(`zero OpenRouter cost was not preserved: ${JSON.stringify({ usage, result })}`); } @@ -136,7 +136,7 @@ Deno.test("OpenRouter usage uses its reported account cost, including zero", () Deno.test("OpenRouter usage without a reported cost fails closed", () => { const usage = normalizeOpenRouterUsage({ prompt_tokens: 10, completion_tokens: 5 }); - const result = calculateRequestCost({ provider: "openrouter", model: "openai/gpt-5.6-luna", usage }); + const result = calculateRequestCost({ provider: "openrouter", model: "z-ai/glm-5.3-flash", usage }); if (result.costUsd !== null) { throw new Error(`missing OpenRouter cost was estimated: ${JSON.stringify(result)}`); } @@ -176,7 +176,7 @@ Deno.test("merged OpenRouter research usage sums only complete reported costs", normalizeOpenRouterUsage({ prompt_tokens: 20, completion_tokens: 8, cost: 0.75 }), ]); if (complete?.reportedCostUsd !== 1) throw new Error(`complete costs were not summed: ${complete?.reportedCostUsd}`); - const completeCost = calculateRequestCost({ provider: "openrouter", model: "openai/gpt-5.6-luna", usage: complete }); + const completeCost = calculateRequestCost({ provider: "openrouter", model: "z-ai/glm-5.3-flash", usage: complete }); if (completeCost.costUsd !== 1) throw new Error(`complete merged cost was not retained: ${completeCost.costUsd}`); const missing = mergeNormalizedUsage([ @@ -184,7 +184,7 @@ Deno.test("merged OpenRouter research usage sums only complete reported costs", normalizeOpenRouterUsage({ prompt_tokens: 20, completion_tokens: 8 }), ]); if (missing?.reportedCostUsd !== undefined) throw new Error("missing sub-call cost was summed"); - const missingCost = calculateRequestCost({ provider: "openrouter", model: "openai/gpt-5.6-luna", usage: missing }); + const missingCost = calculateRequestCost({ provider: "openrouter", model: "z-ai/glm-5.3-flash", usage: missing }); if (missingCost.costUsd !== null) throw new Error(`missing sub-call cost was estimated: ${missingCost.costUsd}`); const incomplete = mergeNormalizedUsage([ @@ -192,7 +192,7 @@ Deno.test("merged OpenRouter research usage sums only complete reported costs", { incomplete: true, raw: { aborted: true } }, ]); if (!incomplete?.incomplete || incomplete.reportedCostUsd !== undefined) throw new Error("incomplete sub-call was billed"); - const incompleteCost = calculateRequestCost({ provider: "openrouter", model: "openai/gpt-5.6-luna", usage: incomplete }); + const incompleteCost = calculateRequestCost({ provider: "openrouter", model: "z-ai/glm-5.3-flash", usage: incomplete }); if (incompleteCost.costUsd !== null) throw new Error(`incomplete merged cost was estimated: ${incompleteCost.costUsd}`); }); diff --git a/supabase/functions/_shared/model-catalog.ts b/supabase/functions/_shared/model-catalog.ts index 4d35079e2..f08e39b90 100644 --- a/supabase/functions/_shared/model-catalog.ts +++ b/supabase/functions/_shared/model-catalog.ts @@ -16,20 +16,11 @@ export interface ModelCatalogEntry { } export const MODEL_CATALOG = [ - { id: "openai/gpt-5.6-luna", name: "GPT-5.6 Luna", maxTokens: 128_000, isProOnly: false, supportsTemperature: false, supportedEfforts: ["low", "medium", "high"] }, - { id: "google/gemini-3.8-flash", name: "Gemini 3.8 Flash", maxTokens: 65_536, isProOnly: false, supportsTemperature: true, supportedEfforts: ["low", "medium", "high"] }, - { id: "deepseek/deepseek-v4-flash-0731", name: "DeepSeek V4 Flash", maxTokens: 131_072, isProOnly: false, supportsTemperature: true, supportedEfforts: ["low", "high"] }, - { id: "anthropic/claude-sonnet-5", name: "Claude Sonnet 5", maxTokens: 128_000, isProOnly: true, supportsTemperature: false, supportedEfforts: ["low", "medium", "high"] }, - { id: "anthropic/claude-opus-5", name: "Claude Opus 5", maxTokens: 128_000, isProOnly: true, supportsTemperature: true, supportedEfforts: ["low", "medium", "high"] }, - { id: "openai/gpt-5.6-sol", name: "GPT-5.6 Sol", maxTokens: 128_000, isProOnly: true, supportsTemperature: false, supportedEfforts: ["low", "medium", "high"] }, - { id: "openai/gpt-5.6-terra", name: "GPT-5.6 Terra", maxTokens: 128_000, isProOnly: true, supportsTemperature: false, supportedEfforts: ["low", "medium", "high"] }, - { id: "deepseek/deepseek-v4-pro-0813", name: "DeepSeek V4 Pro", maxTokens: 384_000, isProOnly: true, supportsTemperature: true, supportedEfforts: ["low", "high"] }, - { id: "z-ai/glm-5.3-flash", name: "GLM-5.3 Flash", maxTokens: 131_072, isProOnly: true, supportsTemperature: true, supportedEfforts: ["low", "high"] }, - { id: "meta/muse-spark-1.3", name: "Muse Spark 1.3", maxTokens: 943_718, isProOnly: true, supportsTemperature: true, supportedEfforts: ["low", "medium", "high"] }, + { id: "z-ai/glm-5.3-flash", name: "GLM 5.3 Flash", maxTokens: 131_072, isProOnly: false, supportsTemperature: true, supportedEfforts: ["low", "high"] }, ] as const satisfies readonly ModelCatalogEntry[]; -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 = "z-ai/glm-5.3-flash" as const; +export const DEFAULT_PRO_MODEL = DEFAULT_MODEL; const MODEL_BY_ID = new Map(MODEL_CATALOG.map((entry) => [entry.id, entry])); diff --git a/supabase/functions/ai-stream/handler_test.ts b/supabase/functions/ai-stream/handler_test.ts index dd57ce914..c8e0dce10 100644 --- a/supabase/functions/ai-stream/handler_test.ts +++ b/supabase/functions/ai-stream/handler_test.ts @@ -222,7 +222,7 @@ Deno.test("streaming Ask rejects advanced modes and toggles before quota or prov }, body: JSON.stringify({ feature: "ask", - model: "gpt-5.4-mini", + model: "z-ai/glm-5.3-flash", input: { message: "Question", ...testCase.input }, }), }), diff --git a/supabase/functions/ai/generate-honesty_test.ts b/supabase/functions/ai/generate-honesty_test.ts index 79abdf38e..23036a065 100644 --- a/supabase/functions/ai/generate-honesty_test.ts +++ b/supabase/functions/ai/generate-honesty_test.ts @@ -2,7 +2,7 @@ const environment = { SUPABASE_URL: "https://supabase.test", SUPABASE_SERVICE_ROLE_KEY: "test-service-role-key", EXA_API_KEY: "test-exa-key", - OPENAI_API_KEY: "test-openai-key", + OPENROUTER_API_KEY: "test-openrouter-key", }; for (const [key, value] of Object.entries(environment)) { @@ -95,7 +95,7 @@ Deno.test("generate marks every contention when research has no citable sources" return jsonResponse({ results: [] }); } - if (url.href === "https://api.openai.com/v1/chat/completions") { + if (url.href === "https://openrouter.ai/api/v1/chat/completions") { providerCalls += 1; const content = providerCalls % 2 === 1 ? { queries: [] } : generatedOutput; const requestBody = init?.body @@ -110,7 +110,7 @@ Deno.test("generate marks every contention when research has no citable sources" } return jsonResponse({ choices: [{ message: { content: JSON.stringify(content) } }], - usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, + usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15, cost: 0.001 }, }); } @@ -127,7 +127,7 @@ Deno.test("generate marks every contention when research has no citable sources" }, body: JSON.stringify({ feature: "generate", - model: "gpt-5.4-mini", + model: "z-ai/glm-5.3-flash", input: { motion: "Should schools teach media literacy?", side: "pro", @@ -176,7 +176,7 @@ Deno.test("generate marks every contention when research has no citable sources" }, body: JSON.stringify({ feature: "generate", - model: "gpt-5.4-mini", + model: "z-ai/glm-5.3-flash", 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 8af1a01a9..df9db35ea 100644 --- a/supabase/functions/ai/handler-research.test.ts +++ b/supabase/functions/ai/handler-research.test.ts @@ -17,7 +17,7 @@ for (const [key, value] of Object.entries(environment)) Deno.env.set(key, value) const { handleRequest } = await import("./index.ts"); const { handleRequest: handleStreamRequest } = await import("../ai-stream/index.ts"); -const { MAX_REQUEST_OUTPUT_TOKENS, MODEL_CATALOG } = await import("../_shared/model-catalog.ts"); +const { MAX_REQUEST_OUTPUT_TOKENS } = await import("../_shared/model-catalog.ts"); Deno.test("AI handlers reject malformed controls before quota or provider work", async () => { const originalFetch = globalThis.fetch; @@ -38,7 +38,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: "z-ai/glm-5.3-flash", input: { message: "Question", mode: "\u200Bgeneral", citationMode: "false" }, }), })); @@ -54,7 +54,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: "z-ai/glm-5.3-flash", input: { message: "Question", mode: "general", citationMode: "false" }, }), })); @@ -169,7 +169,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: "z-ai/glm-5.3-flash", input: { claim: "solar energy costs", direction: "for", @@ -248,7 +248,7 @@ Deno.test("Ask marks only surviving URL and research context as available", asyn } if (url.href === "https://api.exa.ai/contents") return jsonResponse({ results: [] }); - if (url.href === "https://api.openai.com/v1/chat/completions") { + if (url.href === "https://openrouter.ai/api/v1/chat/completions") { const body = JSON.parse(String(init?.body)) as Record; providerBodies.push(body); const messages = body.messages as Array<{ content?: unknown }> | undefined; @@ -279,7 +279,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: "gpt-5.4-mini", input }), + body: JSON.stringify({ feature: "ask", model: "z-ai/glm-5.3-flash", input }), })); if (response.status !== 200) throw new Error(`Ask handler returned ${response.status}: ${await response.text()}`); const mainRequest = providerBodies.find((body) => { @@ -323,6 +323,9 @@ Deno.test("Ask marks only surviving URL and research context as available", asyn } } finally { globalThis.fetch = originalFetch; + } +}); + function supabaseTestResponse(url: URL): Response | undefined { if (url.origin !== "https://supabase.test") return undefined; if (url.pathname === "/auth/v1/user") return jsonResponse({ id: "user-1", aud: "authenticated" }); @@ -383,40 +386,27 @@ Deno.test("AI handlers use strict OpenRouter routing and server-side reasoning t const unknown = await handleRequest(askRequest("provider/unknown-model", "low")); if (unknown.status !== 400 || openRouterBodies.length !== 0) throw new Error("unknown model reached routing"); - for (const entry of MODEL_CATALOG) { - const response = await requestForModel(entry.id, entry.isProOnly ? "pro" : "trial"); - 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); - 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"]) { - const response = await requestForModel(model, "pro", "medium"); - if (response.status !== 200) throw new Error(`Pro medium ${model} returned ${response.status}: ${await response.text()}`); - } - - if (openRouterBodies.length !== MODEL_CATALOG.length + 6) throw new Error(`unexpected OpenRouter call count: ${openRouterBodies.length}`); - for (let index = 0; index < MODEL_CATALOG.length; index += 1) { - const entry = MODEL_CATALOG[index]; - const body = openRouterBodies[index]; - if (body.model !== entry.id) throw new Error(`wrong routed model: ${String(body.model)}`); - if (body.max_tokens !== MAX_REQUEST_OUTPUT_TOKENS) throw new Error(`output cap was not enforced for ${entry.id}`); + const trial = await requestForModel("z-ai/glm-5.3-flash", "trial", "high"); + if (trial.status !== 200) throw new Error(`trial GLM returned ${trial.status}: ${await trial.text()}`); + const pro = await requestForModel("z-ai/glm-5.3-flash", "pro", "high"); + if (pro.status !== 200) throw new Error(`Pro GLM returned ${pro.status}: ${await pro.text()}`); + const unsupportedMedium = await requestForModel("z-ai/glm-5.3-flash", "pro", "medium"); + if (unsupportedMedium.status !== 200) throw new Error(`Pro medium GLM returned ${unsupportedMedium.status}: ${await unsupportedMedium.text()}`); + + const routedBodies: Array> = Array.from(openRouterBodies); + if (routedBodies.length !== 3) throw new Error(`unexpected OpenRouter call count: ${routedBodies.length}`); + for (const body of routedBodies) { + if (body.model !== "z-ai/glm-5.3-flash") throw new Error(`wrong routed model: ${String(body.model)}`); + if (body.max_tokens !== MAX_REQUEST_OUTPUT_TOKENS) throw new Error("output cap was not enforced"); const reasoning = body.reasoning as Record; const provider = body.provider as Record; - if (reasoning.effort !== (entry.isProOnly ? "high" : "low")) throw new Error(`tier effort was not enforced for ${entry.id}`); - if (reasoning.exclude !== true || provider.require_parameters !== true) throw new Error(`strict reasoning route missing for ${entry.id}`); - if (body.response_format && (body.response_format as Record).type !== "json_object") throw new Error(`JSON response format missing for ${entry.id}`); - if (entry.supportsTemperature !== ("temperature" in body)) throw new Error(`temperature support mismatch for ${entry.id}`); - } - for (const [offset, effort] of ["low", "medium", "high"].entries()) { - const body = openRouterBodies[MODEL_CATALOG.length + offset]; - if ((body.reasoning as Record).effort !== effort) throw new Error(`Pro effort ${effort} was not forwarded`); - } - for (const offset of [3, 4, 5]) { - const body = openRouterBodies[MODEL_CATALOG.length + offset]; - if ((body.reasoning as Record).effort !== "low") throw new Error("unsupported Pro medium was not lowered"); + if (reasoning.exclude !== true || provider.require_parameters !== true) throw new Error("strict OpenRouter route missing"); + if ((body.response_format as Record)?.type !== "json_object") throw new Error("JSON response format missing"); + if (!("temperature" in body)) throw new Error("GLM temperature was not forwarded"); } + if ((routedBodies[0].reasoning as Record).effort !== "low") throw new Error("trial effort was not forced low"); + if ((routedBodies[1].reasoning as Record).effort !== "high") throw new Error("Pro high effort was not forwarded"); + if ((routedBodies[2].reasoning as Record).effort !== "low") throw new Error("unsupported GLM medium was not lowered"); } finally { globalThis.fetch = originalFetch; if (originalTier === undefined) delete testState.__testTier; @@ -457,7 +447,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: "z-ai/glm-5.3-flash", 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"); @@ -467,7 +457,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: "z-ai/glm-5.3-flash", 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))}`); @@ -498,7 +488,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: "z-ai/glm-5.3-flash", input: { message: "Hello", mode: "general" } }), }); const testState = globalThis as typeof globalThis & { __testTier?: string }; @@ -572,7 +562,7 @@ Deno.test("cancelled streams keep observed cost unknown", async () => { const response = 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: "z-ai/glm-5.3-flash", input: { message: "Hello", mode: "general" } }), signal: caller.signal, })); const read = response.text(); @@ -622,7 +612,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: "z-ai/glm-5.3-flash", input: { message: "Hello", mode: "general" } }), signal: caller.signal, })); if (response.status !== 500) throw new Error(`cancelled repair returned ${response.status}`); @@ -684,7 +674,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: "z-ai/glm-5.3-flash", input: { message: "Hello", mode: "general" } }), signal, }); const withTimeout = async (promise: Promise, label: string): Promise => await Promise.race([