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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
47 changes: 42 additions & 5 deletions src/components/ModelSelector.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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' },
Comment on lines +21 to +24
];

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 (
<div className={cn("space-y-2", className)}>
{showLabel && (
<Label className="flex items-center gap-2 text-sm font-medium">
<Label className="flex h-5 items-center gap-2 text-sm font-medium">
<Cpu className="h-4 w-4 text-muted-foreground" />
{t('modelSelector.label', 'AI model')}
</Label>
Expand Down Expand Up @@ -62,6 +72,33 @@ export function ModelSelector({ feature, className, showLabel = false }: ModelSe
))}
</SelectContent>
</Select>
<div className="space-y-2">
<Label className="text-sm font-medium">
{t('modelSelector.reasoning', 'Reasoning')}
</Label>
<Select
value={currentReasoning}
disabled={!isPro}
onValueChange={(value) => {
if (!isPro || !isReasoningEffort(value)) return;
setFeatureConfig(feature, { reasoningEffort: value });
}}
>
<SelectTrigger
className="h-10 w-full text-sm"
aria-label={t('modelSelector.reasoning', 'Reasoning')}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{reasoningOptions.map((option) => (
<SelectItem key={option.value} value={option.value} className="text-xs">
{t(option.labelKey, option.fallback)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
);
}
5 changes: 3 additions & 2 deletions src/lib/ai/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -369,7 +369,7 @@ async function callSupabaseAI<T = unknown>(
? boundCrossfireRequestSources(selectedSources)
: selectedSources;

const provider = config.provider === 'lovable' ? 'openai' : config.provider;
const provider = config.provider === 'mock' ? 'mock' : 'openrouter';
captureProductEvent('ai_request_started', {
feature,
provider,
Expand Down Expand Up @@ -420,6 +420,7 @@ async function callSupabaseAI<T = unknown>(
provider,
model: config.model,
temperature: config.temperature,
reasoningEffort: config.reasoningEffort ?? 'low',
customSystemPrompt: options.includeCustomSystemPrompt === false ? null : settings.customSystemPrompt || null,
sources: activeSources,
};
Expand Down Expand Up @@ -1236,7 +1237,7 @@ async function sparringRequestKey(
): Promise<string> {
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. */
Expand Down
51 changes: 14 additions & 37 deletions src/lib/ai/providers/types.ts
Original file line number Diff line number Diff line change
@@ -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,
},
];
6 changes: 3 additions & 3 deletions src/lib/ai/streaming.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ||
Expand All @@ -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 {
Expand Down Expand Up @@ -115,7 +114,7 @@ export async function streamAskChat(
options: AskStreamOptions,
handlers: AskStreamHandlers
): Promise<AskOutput> {
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', {
Expand Down Expand Up @@ -203,6 +202,7 @@ export async function streamAskChat(
provider,
model: config.model,
temperature: config.temperature,
reasoningEffort: config.reasoningEffort ?? 'low',
}),
signal: controller.signal,
});
Expand Down
Loading
Loading