diff --git a/docs/architecture.md b/docs/architecture.md
index 7bcc14b9d..7381e903f 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 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.
## 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/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..f77ad983d 100644
--- a/src/lib/stores/settings-store.ts
+++ b/src/lib/stores/settings-store.ts
@@ -1,7 +1,12 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import i18n from '@/i18n';
+import { DEFAULT_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 +32,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_MODEL,
+ 'claude-sonnet-4-6': DEFAULT_MODEL,
+ 'claude-haiku-4-5-20251001': DEFAULT_MODEL,
+ '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 = {
- provider: 'google',
- model: 'gemini-3-flash-preview',
+ provider: DEFAULT_PROVIDER,
+ model: DEFAULT_MODEL,
temperature: 0.7,
+ reasoningEffort: 'low',
};
const getDefaultFeatureConfigs = (): Record => ({
@@ -42,28 +65,65 @@ const getDefaultFeatureConfigs = (): Record => ({
sparring: { ...defaultConfig },
});
-function normalizeFeatureConfigs(configs: unknown): Record {
+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, 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;
+ const reasoningEffort = isReasoningEffort(candidate?.reasoningEffort)
+ ? candidate.reasoningEffort
+ : fallback.reasoningEffort ?? 'low';
+
+ return {
+ provider: isLocalMock ? 'mock' : DEFAULT_PROVIDER,
+ model: isLocalMock ? 'local' : normalizeModelId(candidate?.model, fallback.model),
+ temperature,
+ reasoningEffort,
+ };
+}
+
+function normalizeFeatureConfigs(configs: unknown, preserveLocalMock = false): 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], preserveLocalMock)];
})) 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;
@@ -147,7 +207,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({
@@ -174,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({
@@ -199,9 +275,18 @@ 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);
+ 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/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/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..e556cfc46 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: '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: 'openai', model: 'gpt-5.4-mini', temperature: 0.7 },
+ { provider: 'openrouter', model: 'z-ai/glm-5.3-flash', temperature: 0.7 },
{ citationMode: true }
)).toBe(false);
expect(isAskStreamingSupported(
- { provider: 'openai', model: 'gpt-5.4-mini', temperature: 0.7 },
+ { provider: 'openrouter', model: 'z-ai/glm-5.3-flash', temperature: 0.7 },
{ webSearch: true }
)).toBe(false);
expect(isAskStreamingSupported(
- { provider: 'openai', model: 'gpt-5.4-mini', temperature: 0.7 },
+ { provider: 'openrouter', model: 'z-ai/glm-5.3-flash', temperature: 0.7 },
{ prosConsMode: true }
)).toBe(false);
expect(isAskStreamingSupported(
- { provider: 'openai', model: 'gpt-5.4-mini', temperature: 0.7 },
+ { provider: 'openrouter', model: 'z-ai/glm-5.3-flash', temperature: 0.7 },
{ argumentMapMode: true }
)).toBe(false);
expect(isAskStreamingSupported(
- { provider: 'openai', model: 'gpt-5.4-mini', temperature: 0.7 },
+ { provider: 'openrouter', model: 'z-ai/glm-5.3-flash', temperature: 0.7 },
{ mode: 'clarify' }
)).toBe(false);
expect(isAskStreamingSupported(
- { provider: 'openai', model: 'gpt-5.4-mini', 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: 'openai', model: 'gpt-5.4-mini', temperature: 0.7 },
+ { provider: 'openrouter', model: 'z-ai/glm-5.3-flash', 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..795358e13 100644
--- a/src/test/components/ModelSelector.test.tsx
+++ b/src/test/components/ModelSelector.test.tsx
@@ -2,46 +2,33 @@
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: '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: 'z-ai/glm-5.3-flash',
+ 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: 'z-ai/glm-5.3-flash', name: 'GLM 5.3 Flash', maxTokens: 131072, isProOnly: false, supportsTemperature: true },
],
isAvailable: () => true,
},
@@ -54,37 +41,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 +84,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 +93,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 +102,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 +145,55 @@ 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: 'z-ai/glm-5.3-flash', 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('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' },
+ } as any,
+ setFeatureConfig: vi.fn(),
+ });
+
+ render();
+
+ expect(screen.getAllByRole('combobox')[0]).toHaveTextContent('GLM 5.3 Flash');
+ });
+
+ it('enables model and reasoning controls for Pro users', () => {
+ subscriptionState.isPro = true;
+ subscriptionState.tier = 'pro';
+ vi.mocked(useSettingsStore).mockReturnValue({
+ featureConfigs: {
+ generate: { provider: 'openrouter', model: 'z-ai/glm-5.3-flash', 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/observability.test.ts b/src/test/observability.test.ts
index 9f779e46e..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: "gpt-5.4-mini",
+ model: "z-ai/glm-5.3-flash",
});
const attributes = {
event_name: "ai_request_succeeded",
durationMs: 234,
feature: "ask",
- model: "gpt-5.4-mini",
+ model: "z-ai/glm-5.3-flash",
};
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/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/security-hardening.test.ts b/src/test/security-hardening.test.ts
index 56ffaefae..6f71f1972 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,16 @@ describe('security hardening regressions', () => {
});
it('uses allowlisted model/provider pairs and SSRF-safe citation verification', () => {
- expect(ai).toContain('function providerForModel');
+ 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');
+ 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/settings-store.test.ts b/src/test/settings-store.test.ts
index 454abb663..c3e5bb835 100644
--- a/src/test/settings-store.test.ts
+++ b/src/test/settings-store.test.ts
@@ -17,10 +17,11 @@ 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.generate.reasoningEffort).toBe("low");
expect(state.featureConfigs.brainstorm.temperature).toBe(0.9);
expect(state.featureConfigs.factoids.temperature).toBe(0.3);
});
@@ -41,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: {
@@ -60,9 +68,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 +83,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 +121,79 @@ 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("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");
+
+ 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 cf7dbe1ef..a28df763e 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('z-ai/glm-5.3-flash');
+ 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('z-ai/glm-5.3-flash');
});
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('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 4b134a48d..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('gemini-3-flash-preview');
+ expect(state.featureConfigs.generate.model).toBe('z-ai/glm-5.3-flash');
});
it('normalizes persisted state in onRehydrateStorage callback', () => {
@@ -62,4 +62,84 @@ 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: 'z-ai/glm-5.3-flash',
+ temperature: 0.3,
+ reasoningEffort: 'medium',
+ });
+ 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',
+ });
+ });
+
+ 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 b9b215baf..3650298d0 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: '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', () => {
@@ -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');
});
});
diff --git a/supabase/README.md b/supabase/README.md
index 7ee5b164a..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
@@ -129,7 +131,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..2538f927b 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: "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 })}`);
+ }
+ 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: "z-ai/glm-5.3-flash", 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: "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([
+ 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: "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([
+ 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: "z-ai/glm-5.3-flash", 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 }),
@@ -190,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 2b521d823..2db7b61aa 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;
@@ -209,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/_shared/model-catalog.test.ts b/supabase/functions/_shared/model-catalog.test.ts
new file mode 100644
index 000000000..116ca2280
--- /dev/null
+++ b/supabase/functions/_shared/model-catalog.test.ts
@@ -0,0 +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 (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");
+});
+
+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..f08e39b90
--- /dev/null
+++ b/supabase/functions/_shared/model-catalog.ts
@@ -0,0 +1,44 @@
+/**
+ * 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: "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 = "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]));
+
+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/handler_test.ts b/supabase/functions/ai-stream/handler_test.ts
index 5de1a44d7..c8e0dce10 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.");
@@ -216,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-stream/index.ts b/supabase/functions/ai-stream/index.ts
index 1dc2f1e20..1cf204aa0 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) => {
@@ -419,144 +406,95 @@ 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 {
+ 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}`);
+
+ 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);
+ if (streamSawDone) break;
+ }
+ if (streamSawDone) break;
+ }
+ if (!streamSawDone) {
+ buffer += decoder.decode();
+ for (const line of buffer.split("\n")) {
+ processLine(line);
+ if (streamSawDone) break;
+ }
+ }
+
+ 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 +504,10 @@ 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;
+ cancelUpstream();
const errorMessage = err instanceof Error ? err.message : "Streaming failed";
const isAbort = req.signal.aborted || (err instanceof DOMException && err.name === "AbortError");
if (!isAbort) {
@@ -575,7 +515,7 @@ export async function handleRequest(req: Request): Promise {
}
await logUsage(
user.id,
- resolvedProvider,
+ OPENROUTER_PROVIDER,
model,
collectUsage(),
Date.now() - startedAt,
@@ -584,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/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 b5109fc55..df9db35ea 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 } = 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: "z-ai/glm-5.3-flash",
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: "z-ai/glm-5.3-flash",
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: "z-ai/glm-5.3-flash",
input: {
claim: "solar energy costs",
direction: "for",
@@ -247,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;
@@ -278,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) => {
@@ -324,3 +325,389 @@ Deno.test("Ask marks only surviving URL and research context as available", asyn
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");
+
+ 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.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;
+ 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: "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");
+ 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: "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))}`);
+ } 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: "z-ai/glm-5.3-flash", 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: "z-ai/glm-5.3-flash", 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;
+ }
+});
+
+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: "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}`);
+ 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;
+ }
+});
+
+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: "z-ai/glm-5.3-flash", 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;
+ }
+});
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;