diff --git a/.env.example b/.env.example index 0ba4f0a..33eb8d4 100644 --- a/.env.example +++ b/.env.example @@ -12,6 +12,17 @@ CLERK_JWT_ISSUER_DOMAIN=https://your-app.clerk.accounts.dev # Generate at https://openrouter.ai/settings/keys OPENROUTER_API_KEY=sk-or-... +# OpenRouter model slugs for each AI task. +# Defaults (used when no user preference is saved): +# SCHEMA_INFERENCE_MODEL: anthropic/claude-sonnet-4.6 (powerful for schema inference) +# POPULATE_ORCHESTRATOR_MODEL: qwen/qwen3.7-max (cost-effective orchestrator) +# INVESTIGATE_SUBAGENT_MODEL: qwen/qwen3.7-max (cost-effective subagent) +# Find model IDs at https://openrouter.ai/models — any OpenRouter model slug is valid. +SCHEMA_INFERENCE_MODEL=anthropic/claude-sonnet-4.6 +POPULATE_ORCHESTRATOR_MODEL=qwen/qwen3.7-max +INVESTIGATE_SUBAGENT_MODEL=qwen/qwen3.7-max + +# TinyFish — used by the backend's populate agent for web search and fetch. # TinyFish (required) — web search + dataset population. # Generate at https://agent.tinyfish.ai/api-keys TINYFISH_API_KEY= diff --git a/backend/src/config/models.ts b/backend/src/config/models.ts new file mode 100644 index 0000000..f63b419 --- /dev/null +++ b/backend/src/config/models.ts @@ -0,0 +1,174 @@ +/** + * Backend configuration for AI models. + * + * Defines the typed interfaces and constants for OpenRouter model management. + */ + +import { api, internal, convex } from "../convex.js"; +import { env } from "../env.js"; + +export interface OpenRouterModel { + modelName: string; + canonicalSlug: string; + contextLength: number; + completionCost: number; + promptCost: number; +} + +/** + * Default model slugs for each agent role. + * Read from environment variables so operators can change defaults + * without touching code. Falls back to typed literals when env vars + * are unset (useful for local dev without a .env file). + */ +export const DEFAULT_MODEL_IDS = { + SCHEMA_INFERENCE: env.SCHEMA_INFERENCE_MODEL, + POPULATE_ORCHESTRATOR: env.POPULATE_ORCHESTRATOR_MODEL, + INVESTIGATE_SUBAGENT: env.INVESTIGATE_SUBAGENT_MODEL, +} as const; + +/** + * Model roles for the settings UI. + */ +export const MODEL_ROLES = [ + { key: "schemaInference", label: "Schema Inference" }, + { key: "populateOrchestrator", label: "Populate Orchestrator" }, + { key: "investigateSubagent", label: "Investigate Subagent" }, +] as const; + +/** + * Models explicitly excluded from the list. + * These are models that we exclude from the OpenRouter fetch results + * based on known incompatibilities or undesirability for our use case. + */ +export const EXCLUDED_MODEL_SLUGS: string[] = []; + +/** + * Fetch all cached models from Convex. + * If the cache is empty, fetches from OpenRouter, stores in Convex, and returns. + */ +export async function getCachedModels(): Promise { + const models = await convex.query(api.openRouterModels.list, {}); + const cached = models as unknown as OpenRouterModel[]; + if (cached.length > 0) return cached; + + const fetched = await fetchModelsFromOpenRouter(); + await upsertModelBatch(fetched); + return fetched; +} + +/** + * Validate that a model slug exists in the cached model list. + * Throws with a clear message if the slug is not found. + * Should be called before using any model from user config. + */ +export async function validateModelSlug( + slug: string, + role: "schemaInference" | "populateOrchestrator" | "investigateSubagent" +): Promise { + const models = await getCachedModels(); + const found = models.some((m) => m.canonicalSlug === slug); + if (!found) { + throw new Error( + `Invalid model slug "${slug}" for ${role}. ` + + `Available models: ${models.map((m) => m.canonicalSlug).join(", ") || "none (run /openrouter/refresh first)"}` + ); + } +} + +/** + * Upsert a batch of models to Convex. + * Called after successfully fetching from OpenRouter API. + */ +export async function upsertModelBatch(models: OpenRouterModel[]): Promise { + await convex.mutation(internal.openRouterModels.upsertBatch, { models }); +} + +/** + * Upsert the model configuration for a specific user in Convex. + * Only fields that are explicitly provided (not undefined) are updated. + * Unset fields retain their existing values. + */ +export async function upsertModelConfig( + userId: string, + config: { + schemaInference?: string; + populateOrchestrator?: string; + investigateSubagent?: string; + } +): Promise { + await convex.mutation(internal.modelConfig.upsertInternal, { + userId, + schemaInference: config.schemaInference ?? undefined, + populateOrchestrator: config.populateOrchestrator ?? undefined, + investigateSubagent: config.investigateSubagent ?? undefined, + }); +} + +/** + * Fetch the model configuration for a specific user from Convex. + * If the user has no saved config, returns the system defaults from env. + * Callers always get a complete config — never null. + */ +export async function getModelConfig( + userId: string +): Promise<{ + schemaInference: string; + populateOrchestrator: string; + investigateSubagent: string; +}> { + const config = await convex.query(internal.modelConfig.getInternal, { userId }); + return { + schemaInference: config?.schemaInference ?? DEFAULT_MODEL_IDS.SCHEMA_INFERENCE, + populateOrchestrator: config?.populateOrchestrator ?? DEFAULT_MODEL_IDS.POPULATE_ORCHESTRATOR, + investigateSubagent: config?.investigateSubagent ?? DEFAULT_MODEL_IDS.INVESTIGATE_SUBAGENT, + }; +} + +/** + * Fetch models from OpenRouter REST API and return parsed models ready + * for Convex storage. + */ +export async function fetchModelsFromOpenRouter(): Promise { + const apiKey = env.OPENROUTER_API_KEY; + if (!apiKey) { + throw new Error("OPENROUTER_API_KEY is not set"); + } + + // Only text-based models that support tools + const response = await fetch( + "https://openrouter.ai/api/v1/models?output_modalities=text&supported_parameters=tools", + { + headers: { + Authorization: `Bearer ${apiKey}`, + }, + } + ); + + if (!response.ok) { + throw new Error(`OpenRouter API failed: ${response.status} ${response.statusText}`); + } + + const json = (await response.json()) as { + data: Array<{ + id: string; + name: string; + context_length: number; + pricing?: { completion?: string; prompt?: string }; + }>; + }; + + // Filter excluded and map to OpenRouterModel + // Prices from OpenRouter are per-token; multiply by 1M for per-million + const models = json.data + .filter((m) => !EXCLUDED_MODEL_SLUGS.includes(m.id)) + .map((model) => ({ + modelName: model.name, + canonicalSlug: model.id, + contextLength: model.context_length ?? 0, + promptCost: parseFloat(model.pricing?.prompt ?? "0") * 1_000_000, + completionCost: parseFloat(model.pricing?.completion ?? "0") * 1_000_000, + })); + + return models; +} \ No newline at end of file diff --git a/backend/src/env.ts b/backend/src/env.ts index 9ae3c09..084ffe0 100644 --- a/backend/src/env.ts +++ b/backend/src/env.ts @@ -30,6 +30,15 @@ export const env = { OPENROUTER_API_KEY: process.env.OPENROUTER_API_KEY, + // Default models — used when a user has not saved a preference. + // Each must be a valid OpenRouter model slug. + SCHEMA_INFERENCE_MODEL: + process.env.SCHEMA_INFERENCE_MODEL ?? "anthropic/claude-sonnet-4.6", + POPULATE_ORCHESTRATOR_MODEL: + process.env.POPULATE_ORCHESTRATOR_MODEL ?? "qwen/qwen3.7-max", + INVESTIGATE_SUBAGENT_MODEL: + process.env.INVESTIGATE_SUBAGENT_MODEL ?? "qwen/qwen3.7-max", + // Resend (transactional email). Optional — when RESEND_API_KEY is unset // the email module no-ops with a log line, so local dev works without // a Resend account. EMAIL_FROM must be a domain that's verified in the diff --git a/backend/src/index.ts b/backend/src/index.ts index f3eb7e0..010df15 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -158,12 +158,18 @@ async function runUpdateWorkflowInBackground({ authorizedUserId, logger, clerk, + modelConfig, }: { input: DatasetContext; run: UpdateWorkflowRun; authorizedUserId: string; logger: FastifyBaseLogger; clerk: ClerkClient; + modelConfig: { + schemaInference: string; + populateOrchestrator: string; + investigateSubagent: string; + }; }): Promise { const datasetId = input.datasetId; @@ -174,6 +180,7 @@ async function runUpdateWorkflowInBackground({ authContext: { authorizedUserId, workflowRunId: run.runId, + modelConfig, }, }, }); @@ -247,12 +254,18 @@ async function runPopulateWorkflowInBackground({ authorizedUserId, logger, clerk, + modelConfig, }: { input: DatasetContext; run: PopulateWorkflowRun; authorizedUserId: string; logger: FastifyBaseLogger; clerk: ClerkClient; + modelConfig: { + schemaInference: string; + populateOrchestrator: string; + investigateSubagent: string; + }; }): Promise { const datasetId = input.datasetId; @@ -263,6 +276,7 @@ async function runPopulateWorkflowInBackground({ authContext: { authorizedUserId, workflowRunId: run.runId, + modelConfig, }, }, }); @@ -363,6 +377,31 @@ fastify.addHook("onClose", async () => { fastify.get("/health", async () => ({ status: "ok" })); + +fastify.post("/openrouter/refresh", { preHandler: requireAuth }, async (req, reply) => { + const { fetchModelsFromOpenRouter, upsertModelBatch } = await import("./config/models.js"); + try { + const models = await fetchModelsFromOpenRouter(); + await upsertModelBatch(models); + return { success: true, models }; + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to refresh models"; + req.log.error(err, "OpenRouter refresh failed"); + return reply.code(500).send({ error: message }); + } +}); + +fastify.get("/openrouter/models", async (req, reply) => { + const { getCachedModels } = await import("./config/models.js"); + try { + const models = await getCachedModels(); + return { models }; + } catch (err) { + req.log.error(err, "Failed to load cached models"); + return reply.code(500).send({ error: "Failed to load models" }); + } +}); + // ──────────────────────────────────────────────────────────────────────── // Protected routes — gated by Clerk JWT verification // ──────────────────────────────────────────────────────────────────────── @@ -370,14 +409,73 @@ fastify.get("/health", async () => ({ status: "ok" })); await fastify.register(async (instance) => { instance.addHook("preHandler", requireAuth); + instance.get("/settings/models", async (req) => { + const { getModelConfig } = await import("./config/models.js"); + const config = await getModelConfig(req.auth!.userId); + return { config }; + }); + + instance.post("/settings/models", async (req, reply) => { + const { upsertModelConfig, validateModelSlug, getCachedModels } = await import("./config/models.js"); + const body = req.body as { + schemaInference?: string | null; + populateOrchestrator?: string | null; + investigateSubagent?: string | null; + }; + + const toValidate: Array<{ role: "schemaInference" | "populateOrchestrator" | "investigateSubagent"; slug: string }> = []; + if (body.schemaInference) toValidate.push({ role: "schemaInference", slug: body.schemaInference }); + if (body.populateOrchestrator) toValidate.push({ role: "populateOrchestrator", slug: body.populateOrchestrator }); + if (body.investigateSubagent) toValidate.push({ role: "investigateSubagent", slug: body.investigateSubagent }); + + if (toValidate.length > 0) { + try { + const models = await getCachedModels(); + for (const { role, slug } of toValidate) { + const found = models.some((m) => m.canonicalSlug === slug); + if (!found) { + return reply.code(400).send({ + error: `Invalid model slug "${slug}" for ${role}. Refresh the model list first or choose a different model.`, + }); + } + } + } catch (err) { + req.log.error(err, "Failed to validate model slugs — allowing save"); + } + } + + try { + await upsertModelConfig(req.auth!.userId, { + schemaInference: body.schemaInference ?? undefined, + populateOrchestrator: body.populateOrchestrator ?? undefined, + investigateSubagent: body.investigateSubagent ?? undefined, + }); + return { success: true }; + } catch (err) { + req.log.error(err, "Failed to save model config"); + return reply.code(500).send({ error: "Failed to save model preferences" }); + } + }); + instance.post("/infer-schema", async (req, reply) => { - const body = req.body as { prompt?: string }; + const body = req.body as { prompt?: string; modelSlug?: string }; if (!body?.prompt || typeof body.prompt !== "string" || !body.prompt.trim()) { return reply.code(400).send({ error: "prompt is required" }); } try { - const schema = await inferSchema(body.prompt.trim()); + const auth = req.auth; + let modelSlug = body.modelSlug; + + if (!modelSlug && auth) { + const { getModelConfig } = await import("./config/models.js"); + const config = await getModelConfig(auth.userId); + if (config?.schemaInference) { + modelSlug = config.schemaInference; + } + } + + const schema = await inferSchema(body.prompt.trim(), modelSlug); return schema; } catch (err) { req.log.error(err, "Schema inference failed"); @@ -418,6 +516,9 @@ await fastify.register(async (instance) => { throw new Error(`Unexpected populate claim outcome: ${populateOutcome}`); } + const { getModelConfig } = await import("./config/models.js"); + const modelConfig = await getModelConfig(auth.userId); + let run: Awaited>; try { run = await populateWorkflow.createRun(); @@ -433,6 +534,7 @@ await fastify.register(async (instance) => { authorizedUserId: auth.userId, logger: req.log, clerk: req.server.clerk, + modelConfig, }); return reply.code(202).send({ success: true, runId: run.runId }); @@ -491,12 +593,16 @@ await fastify.register(async (instance) => { return reply.code(502).send({ error: "Failed to update dataset. Please try again." }); } + const { getModelConfig } = await import("./config/models.js"); + const modelConfig = await getModelConfig(auth.userId); + void runUpdateWorkflowInBackground({ input: parsed.data, run, authorizedUserId: auth.userId, logger: req.log, clerk: req.server.clerk, + modelConfig, }); return reply.code(202).send({ success: true, runId: run.runId }); diff --git a/backend/src/mastra/agents/investigate.ts b/backend/src/mastra/agents/investigate.ts index 10faaae..4cdc32e 100644 --- a/backend/src/mastra/agents/investigate.ts +++ b/backend/src/mastra/agents/investigate.ts @@ -60,6 +60,8 @@ export function buildInvestigateAgent( authContext: AuthContext, columns: PopulateColumn[], ): Agent { + const modelSlug = authContext.modelConfig!.investigateSubagent; + const { insert_row } = buildPopulateTools( authorizedDatasetId, authContext, @@ -68,7 +70,7 @@ export function buildInvestigateAgent( id: "investigate-agent", name: "Dataset Investigate Agent", instructions: buildInvestigateInstructions(columns), - model: openrouter("qwen/qwen3.7-max"), + model: openrouter(modelSlug), tools: { insert_row, diff --git a/backend/src/mastra/agents/populate.ts b/backend/src/mastra/agents/populate.ts index 36a659c..fa0837f 100644 --- a/backend/src/mastra/agents/populate.ts +++ b/backend/src/mastra/agents/populate.ts @@ -44,11 +44,13 @@ export function buildPopulateAgent( columns: PopulateColumn[], metrics?: RunMetrics, ): Agent { + const modelSlug = authContext.modelConfig!.populateOrchestrator; + return new Agent({ id: "populate-agent", name: "Dataset Populate Orchestrator", instructions: INSTRUCTIONS, - model: openrouter("qwen/qwen3.7-max"), + model: openrouter(modelSlug), tools: { search_web: searchWebTool, fetch_page: fetchPageTool, diff --git a/backend/src/mastra/workflows/populate.ts b/backend/src/mastra/workflows/populate.ts index 65afa1a..a2a8246 100644 --- a/backend/src/mastra/workflows/populate.ts +++ b/backend/src/mastra/workflows/populate.ts @@ -4,6 +4,7 @@ import { generateText } from "ai"; import { createOpenRouter } from "@openrouter/ai-sdk-provider"; import { datasetContextSchema, populateColumnSchema } from "../../pipeline/populate.js"; import { convex, internal } from "../../convex.js"; +import { DEFAULT_MODEL_IDS } from "../../config/models.js"; import { buildPopulateAgent } from "../agents/populate.js"; import { RunMetrics } from "../run-metrics.js"; import { saveRunMetrics } from "../save-run-metrics.js"; @@ -30,6 +31,11 @@ import { saveRunMetrics } from "../save-run-metrics.js"; export const authContextSchema = z.object({ authorizedUserId: z.string().min(1), workflowRunId: z.string().min(1), + modelConfig: z.object({ + schemaInference: z.string().min(1), + populateOrchestrator: z.string().min(1), + investigateSubagent: z.string().min(1), + }), isBenchmark: z.boolean().optional(), }); export type AuthContext = z.infer; @@ -104,8 +110,10 @@ Respond with EXACTLY one word: scraper or search`; const openrouter = createOpenRouter({ apiKey: process.env.OPENROUTER_API_KEY!, }); + const modelSlug = + inputData.authContext?.modelConfig?.schemaInference ?? DEFAULT_MODEL_IDS.SCHEMA_INFERENCE; const result = await generateText({ - model: openrouter("anthropic/claude-sonnet-4-6"), + model: openrouter(modelSlug), prompt: classificationPrompt, maxOutputTokens: 10, }); diff --git a/backend/src/pipeline/schema-inference.ts b/backend/src/pipeline/schema-inference.ts index 7ad50f4..13adf71 100644 --- a/backend/src/pipeline/schema-inference.ts +++ b/backend/src/pipeline/schema-inference.ts @@ -1,6 +1,7 @@ import { generateText, Output, NoObjectGeneratedError } from "ai"; import { createOpenRouter } from "@openrouter/ai-sdk-provider"; +import { DEFAULT_MODEL_IDS } from "../config/models.js"; import { datasetSchemaSchema, type DatasetSchema } from "./types.js"; const SYSTEM_PROMPT = `You are a data engineering assistant that converts natural-language prompts into structured dataset schemas. Given a user prompt describing a dataset they want to build, you produce a precise schema definition. @@ -24,17 +25,18 @@ Rules: - All column \`name\` values must be snake_case and unique. - Prefer concrete column choices over speculative ones — better to omit a column than guess wildly.`; -function getModel() { +function getModel(modelSlug?: string) { const apiKey = process.env.OPENROUTER_API_KEY; if (!apiKey) { throw new Error("Missing required environment variable: OPENROUTER_API_KEY"); } const openrouter = createOpenRouter({ apiKey }); - return openrouter("anthropic/claude-sonnet-4-6"); + const resolvedSlug = modelSlug ?? DEFAULT_MODEL_IDS.SCHEMA_INFERENCE; + return openrouter(resolvedSlug); } -export async function inferSchema(prompt: string): Promise { - const model = getModel(); +export async function inferSchema(prompt: string, modelSlug?: string): Promise { + const model = getModel(modelSlug); try { return await callOnce(model, prompt); } catch (error) { diff --git a/frontend/app/dashboard/page.tsx b/frontend/app/dashboard/page.tsx index 83cc2b7..6432de2 100644 --- a/frontend/app/dashboard/page.tsx +++ b/frontend/app/dashboard/page.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from "react"; import Link from "next/link"; +import { useRouter } from "next/navigation"; import { useQuery, useConvexAuth } from "convex/react"; import { useUser, useClerk } from "@clerk/nextjs"; import { api } from "@/convex/_generated/api"; @@ -256,6 +257,7 @@ function ProfileMenu({ }) { const [open, setOpen] = useState(false); const menuRef = useRef(null); + const router = useRouter(); useEffect(() => { if (!open) return; @@ -320,6 +322,19 @@ function ProfileMenu({ /> + + + {profileOpen && ( +
+
+

{name}

+ {email && ( +

+ {email} +

+ )} +
+
+ + +
+
+ )} + + + + +
+ {children} +
+ + ); +} \ No newline at end of file diff --git a/frontend/app/dashboard/settings/models/page.tsx b/frontend/app/dashboard/settings/models/page.tsx new file mode 100644 index 0000000..5531f9e --- /dev/null +++ b/frontend/app/dashboard/settings/models/page.tsx @@ -0,0 +1,170 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useQuery } from "convex/react"; +import { useAuth } from "@clerk/nextjs"; +import { api } from "@/convex/_generated/api"; +import { getModelConfig, saveModelConfig, getOpenRouterModels, refreshOpenRouterModels, type EffectiveModelConfig, type OpenRouterModel } from "@/lib/backend"; +import { SettingsPageLayout } from "@/components/settings/SettingsPageLayout"; +import { SettingsHeader } from "@/components/settings/SettingsHeader"; +import { SettingsTile } from "@/components/settings/SettingsTile"; +import { ModelSideSheet } from "@/components/settings/ModelSideSheet"; +import { MODEL_ROLES, type ModelRole } from "@/components/settings/types"; +import { SkeletonList } from "@/components/settings/Skeleton"; + +export default function ModelSettingsPage() { + const { getToken } = useAuth(); + const convexModels = useQuery(api.openRouterModels.list, {}); + + const [effectiveConfig, setEffectiveConfig] = useState(null); + const [isLoadingConfig, setIsLoadingConfig] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [sheetModels, setSheetModels] = useState([]); + const [activeSheet, setActiveSheet] = useState<{ role: ModelRole } | null>(null); + const [isSavingModel, setIsSavingModel] = useState(false); + + const isLoading = convexModels === undefined || isLoadingConfig; + + useEffect(() => { + getToken() + .then((token) => { + if (!token) throw new Error("Not authenticated"); + return getModelConfig(token); + }) + .then((config) => setEffectiveConfig(config)) + .catch(() => setEffectiveConfig(null)) + .finally(() => setIsLoadingConfig(false)); + }, [getToken]); + + const models: OpenRouterModel[] = convexModels + ? convexModels.map((m) => ({ + modelName: m.modelName, + canonicalSlug: m.canonicalSlug, + contextLength: m.contextLength, + completionCost: m.completionCost, + promptCost: m.promptCost, + })) + : []; + + function getSelectedModel(role: ModelRole): string { + return effectiveConfig?.[role.key as keyof typeof effectiveConfig] ?? ""; + } + + async function handleModelSelect(role: ModelRole, model: OpenRouterModel) { + setIsSavingModel(true); + try { + const token = await getToken(); + if (!token) throw new Error("Not authenticated"); + await saveModelConfig({ [role.key]: model.canonicalSlug }, token); + setEffectiveConfig((prev: EffectiveModelConfig | null) => + prev ? { ...prev, [role.key]: model.canonicalSlug } : null + ); + setActiveSheet(null); + } catch { + // we will add toast later + } finally { + setIsSavingModel(false); + } + } + + function openSideSheet(role: ModelRole) { + if (sheetModels.length === 0) { + getOpenRouterModels() + .then((models) => setSheetModels(models)) + .catch(() => { + // we will add toast later + }); + } + setActiveSheet({ role }); + } + + const navItems = [ + { + label: "Models", + href: "/dashboard/settings/models", + icon: ( + + + + + ), + }, + { + label: "Account", + href: "/dashboard/settings/account", + disabled: true, + icon: ( + + + + + ), + }, + { + label: "Billing", + href: "/dashboard/settings/billing", + disabled: true, + icon: ( + + + + + ), + }, + ]; + + return ( + + + +
+ {isLoading ? ( + + ) : ( + MODEL_ROLES.map((role) => ( + openSideSheet(role)} + /> + )) + )} +
+ + {activeSheet && ( + !isSavingModel && setActiveSheet(null)} + title={`Select ${activeSheet.role.label} Model`} + selectedModel={getSelectedModel(activeSheet.role)} + models={sheetModels.length > 0 ? sheetModels : models} + onSelect={(slug) => { + const sourceModels = sheetModels.length > 0 ? sheetModels : models; + const model = sourceModels.find((m) => m.canonicalSlug === slug); + if (model) handleModelSelect(activeSheet.role, model); + }} + onRefresh={async () => { + setRefreshing(true); + try { + const token = await getToken(); + if (!token) throw new Error("Not authenticated"); + const models = await refreshOpenRouterModels(token); + setSheetModels(models); + } catch { + // we will add toast later + } finally { + setRefreshing(false); + } + }} + isRefreshing={refreshing} + isSaving={isSavingModel} + /> + )} +
+ ); +} diff --git a/frontend/app/dashboard/settings/page.tsx b/frontend/app/dashboard/settings/page.tsx new file mode 100644 index 0000000..18d5f68 --- /dev/null +++ b/frontend/app/dashboard/settings/page.tsx @@ -0,0 +1,14 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useEffect } from "react"; + +export default function SettingsPage() { + const router = useRouter(); + + useEffect(() => { + router.replace("/dashboard/settings/models"); + }, [router]); + + return null; +} \ No newline at end of file diff --git a/frontend/bun.lock b/frontend/bun.lock index 131d93c..d96b019 100644 --- a/frontend/bun.lock +++ b/frontend/bun.lock @@ -8,6 +8,7 @@ "@clerk/nextjs": "^7.3.7", "@tanstack/react-table": "^8.21.3", "convex": "^1.39.1", + "lucide-react": "^1.17.0", "next": "16.2.6", "posthog-js": "^1.374.2", "react": "19.2.4", @@ -795,6 +796,8 @@ "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + "lucide-react": ["lucide-react@1.17.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-9FA9evdox/JQL5PT57fdA1x/yg8T7knJ98+zjTL3UfKza6pflQUUh3XtaQIHKvnsJw1lmsEyHVlt5jchYxOQ5w=="], + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], diff --git a/frontend/components/settings/ModelSideSheet.tsx b/frontend/components/settings/ModelSideSheet.tsx new file mode 100644 index 0000000..581103e --- /dev/null +++ b/frontend/components/settings/ModelSideSheet.tsx @@ -0,0 +1,254 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { X, Search, RefreshCw } from "lucide-react"; +import type { OpenRouterModel } from "./types"; + +interface ModelSideSheetProps { + open: boolean; + onClose: () => void; + title: string; + selectedModel: string; + models: OpenRouterModel[]; + onSelect: (modelSlug: string) => void; + onRefresh?: () => Promise; + isRefreshing?: boolean; + isSaving?: boolean; +} + +function groupModelsByProvider(models: OpenRouterModel[]): Record { + const groups: Record = {}; + for (const model of models) { + const provider = model.canonicalSlug.split("/")[0] || "Other"; + if (!groups[provider]) groups[provider] = []; + groups[provider].push(model); + } + return groups; +} + +function SkeletonItem() { + return ( +
+
+
+
+
+
+
+
+ ); +} + +function SkeletonList({ count = 8 }: { count?: number }) { + return ( +
+ {["Loading", "Models", "Please Wait"].map((_, i) => ( +
+
+
+
+
+ {Array.from({ length: count }).map((_, j) => ( + + ))} +
+
+ ))} +
+ ); +} + +export function ModelSideSheet({ + open, + onClose, + title, + selectedModel, + models, + onSelect, + onRefresh, + isRefreshing, + isSaving, +}: ModelSideSheetProps) { + const [search, setSearch] = useState(""); + const panelRef = useRef(null); + const inputRef = useRef(null); + + const filteredModels = search.trim() + ? models.filter( + (m) => + m.modelName.toLowerCase().includes(search.toLowerCase()) || + m.canonicalSlug.toLowerCase().includes(search.toLowerCase()), + ) + : models; + + const groupedModels = groupModelsByProvider(filteredModels); + const providers = Object.keys(groupedModels).sort(); + + useEffect(() => { + if (open) { + setTimeout(() => inputRef.current?.focus(), 100); + } + }, [open]); + + useEffect(() => { + if (!open || !panelRef.current) return; + + function handleKey(e: KeyboardEvent) { + if (e.key === "Escape") onClose(); + } + + const panel = panelRef.current; + panel.addEventListener("keydown", handleKey); + return () => panel.removeEventListener("keydown", handleKey); + }, [open, onClose]); + + if (!open) return null; + + return ( +
+
+
+
+

{title}

+
+ {onRefresh && ( + + )} + +
+
+ +
+
+ + setSearch(e.target.value)} + placeholder="Search models..." + className="w-full rounded-lg border border-border bg-background pl-9 pr-3 py-2 text-sm text-foreground placeholder:text-muted outline-none focus:border-foreground/30 transition-colors" + /> +
+
+ +
+ {isRefreshing ? ( + + ) : providers.length === 0 ? ( +
+

No models found

+

Try a different search term

+
+ ) : ( +
+
+
+ Model +
+
+ Context +
+
+ Input +
+
+ Output +
+
+ {providers.map((provider) => ( +
+
+

+ {provider} +

+
+
+ {groupedModels[provider].map((model) => { + const isSelected = model.canonicalSlug === selectedModel; + return ( + + ); + })} +
+
+ ))} +
+ )} +
+ +
+

+ {isSaving ? "Saving..." : isRefreshing ? "Refreshing..." : `${filteredModels.length} models available`} +

+
+
+
+ ); +} diff --git a/frontend/components/settings/SettingsHeader.tsx b/frontend/components/settings/SettingsHeader.tsx new file mode 100644 index 0000000..5a3ad05 --- /dev/null +++ b/frontend/components/settings/SettingsHeader.tsx @@ -0,0 +1,17 @@ +"use client"; + +interface SettingsHeaderProps { + title: string; + subtitle?: string; +} + +export function SettingsHeader({ title, subtitle }: SettingsHeaderProps) { + return ( +
+

{title}

+ {subtitle && ( +

{subtitle}

+ )} +
+ ); +} \ No newline at end of file diff --git a/frontend/components/settings/SettingsPageLayout.tsx b/frontend/components/settings/SettingsPageLayout.tsx new file mode 100644 index 0000000..ed34933 --- /dev/null +++ b/frontend/components/settings/SettingsPageLayout.tsx @@ -0,0 +1,140 @@ +"use client"; + +import { useState, useEffect } from "react"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { X, Menu } from "lucide-react"; + +interface NavItem { + label: string; + href: string; + icon: React.ReactNode; + disabled?: boolean; +} + +interface SettingsSidebarProps { + items: NavItem[]; + open: boolean; + onClose: () => void; +} + +export function SettingsSidebar({ items, open, onClose }: SettingsSidebarProps) { + const pathname = usePathname(); + + useEffect(() => { + if (open) { + document.body.style.overflow = "hidden"; + } else { + document.body.style.overflow = ""; + } + return () => { + document.body.style.overflow = ""; + }; + }, [open]); + + return ( + <> + {open && ( +
+ )} + + + ); +} + +interface SettingsPageLayoutProps { + children: React.ReactNode; + navItems: NavItem[]; +} + +export function SettingsPageLayout({ children, navItems }: SettingsPageLayoutProps) { + const [sidebarOpen, setSidebarOpen] = useState(false); + + return ( +
+ setSidebarOpen(false)} + /> + +
+
+ + Settings +
+ +
+ {children} +
+
+
+ ); +} \ No newline at end of file diff --git a/frontend/components/settings/SettingsSidebar.tsx b/frontend/components/settings/SettingsSidebar.tsx new file mode 100644 index 0000000..bfd4e3a --- /dev/null +++ b/frontend/components/settings/SettingsSidebar.tsx @@ -0,0 +1,65 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; + +interface NavItem { + label: string; + href: string; + icon: React.ReactNode; + disabled?: boolean; +} + +interface SettingsSidebarProps { + items: NavItem[]; +} + +export function SettingsSidebar({ items }: SettingsSidebarProps) { + const pathname = usePathname(); + + return ( + + ); +} \ No newline at end of file diff --git a/frontend/components/settings/SettingsTile.tsx b/frontend/components/settings/SettingsTile.tsx new file mode 100644 index 0000000..51b16c9 --- /dev/null +++ b/frontend/components/settings/SettingsTile.tsx @@ -0,0 +1,67 @@ +"use client"; + +import { ChevronRight } from "lucide-react"; + +interface SettingsTileProps { + label: string; + description?: string; + value?: string; + onClick: () => void; + showTrailingButton?: boolean; + trailingIcon?: React.ReactNode; +} + +export function SettingsTile({ + label, + description, + value, + onClick, + showTrailingButton = true, + trailingIcon, +}: SettingsTileProps) { + return ( + + ); +} \ No newline at end of file diff --git a/frontend/components/settings/Skeleton.tsx b/frontend/components/settings/Skeleton.tsx new file mode 100644 index 0000000..9bad80f --- /dev/null +++ b/frontend/components/settings/Skeleton.tsx @@ -0,0 +1,27 @@ +"use client"; + +export function SkeletonTile() { + return ( +
+
+
+
+
+
+
+
+
+
+
+ ); +} + +export function SkeletonList({ count = 3 }: { count?: number }) { + return ( +
+ {Array.from({ length: count }).map((_, i) => ( + + ))} +
+ ); +} \ No newline at end of file diff --git a/frontend/components/settings/types.ts b/frontend/components/settings/types.ts new file mode 100644 index 0000000..e04d2fc --- /dev/null +++ b/frontend/components/settings/types.ts @@ -0,0 +1,19 @@ +export interface OpenRouterModel { + modelName: string; + canonicalSlug: string; + contextLength: number; + promptCost: number; + completionCost: number; +} + +export interface ModelRole { + key: string; + label: string; + description: string; +} + +export const MODEL_ROLES: ModelRole[] = [ + { key: "schemaInference", label: "Schema Inference", description: "Used to generate dataset schema from natural language" }, + { key: "populateOrchestrator", label: "Populate Orchestrator", description: "Coordinates row population workflow" }, + { key: "investigateSubagent", label: "Investigate Subagent", description: "Researches individual entities" }, +]; \ No newline at end of file diff --git a/frontend/convex/modelConfig.ts b/frontend/convex/modelConfig.ts new file mode 100644 index 0000000..c498202 --- /dev/null +++ b/frontend/convex/modelConfig.ts @@ -0,0 +1,107 @@ +import { query, mutation, internalQuery, internalMutation } from "./_generated/server.js"; +import { v } from "convex/values"; +import { getIdentity } from "./lib/authz.js"; + +export const get = query({ + args: {}, + handler: async (ctx) => { + const identity = await getIdentity(ctx); + if (!identity) return null; + + const existing = await ctx.db + .query("modelConfig") + .withIndex("by_user", (q) => q.eq("userId", identity.subject)) + .first(); + return existing ?? null; + }, +}); + +/** + * Upsert one or more model preferences for the authenticated user. + * + * Only fields that are explicitly provided (not undefined) are updated. + * Unset fields retain their existing database values. + * + * Example: sending only { schemaInference: "x" } will update schemaInference + * while leaving populateOrchestrator and investigateSubagent untouched. + */ +export const upsert = mutation({ + args: { + schemaInference: v.optional(v.string()), + populateOrchestrator: v.optional(v.string()), + investigateSubagent: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const identity = await getIdentity(ctx); + if (!identity) throw new Error("Not authenticated"); + + const existing = await ctx.db + .query("modelConfig") + .withIndex("by_user", (q) => q.eq("userId", identity.subject)) + .first(); + + if (existing) { + // Partial update — only touch fields that were explicitly provided. + // Omitting a field preserves its current database value. + const patch: Record = {}; + if (args.schemaInference !== undefined) patch.schemaInference = args.schemaInference; + if (args.populateOrchestrator !== undefined) patch.populateOrchestrator = args.populateOrchestrator; + if (args.investigateSubagent !== undefined) patch.investigateSubagent = args.investigateSubagent; + await ctx.db.patch(existing._id, patch); + } else { + // First-time save — build insert object from provided fields only. + // userId is always required and comes from the authenticated identity. + const insert: Record = { userId: identity.subject }; + if (args.schemaInference !== undefined) insert.schemaInference = args.schemaInference; + if (args.populateOrchestrator !== undefined) insert.populateOrchestrator = args.populateOrchestrator; + if (args.investigateSubagent !== undefined) insert.investigateSubagent = args.investigateSubagent; + await ctx.db.insert("modelConfig", insert); + } + }, +}); + +export const getInternal = internalQuery({ + args: { userId: v.string() }, + handler: async (ctx, args) => { + const existing = await ctx.db + .query("modelConfig") + .withIndex("by_user", (q) => q.eq("userId", args.userId)) + .first(); + return existing ?? null; + }, +}); + +/** + * Upsert model preferences for a specific user (internal, backend-only). + * + * Only fields that are explicitly provided (not undefined) are updated. + * Unset fields are omitted from the insert, leaving the database unchanged. + */ +export const upsertInternal = internalMutation({ + args: { + userId: v.string(), + schemaInference: v.optional(v.string()), + populateOrchestrator: v.optional(v.string()), + investigateSubagent: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const existing = await ctx.db + .query("modelConfig") + .withIndex("by_user", (q) => q.eq("userId", args.userId)) + .first(); + + const patch: Record = {}; + if (args.schemaInference !== undefined) patch.schemaInference = args.schemaInference; + if (args.populateOrchestrator !== undefined) patch.populateOrchestrator = args.populateOrchestrator; + if (args.investigateSubagent !== undefined) patch.investigateSubagent = args.investigateSubagent; + + if (existing) { + await ctx.db.patch(existing._id, patch); + } else { + await ctx.db.insert("modelConfig", { + userId: args.userId, + ...patch, + }); + } + }, +}); \ No newline at end of file diff --git a/frontend/convex/openRouterModels.ts b/frontend/convex/openRouterModels.ts new file mode 100644 index 0000000..9cc6916 --- /dev/null +++ b/frontend/convex/openRouterModels.ts @@ -0,0 +1,40 @@ +import { query, internalMutation } from "./_generated/server.js"; +import { v } from "convex/values"; + +export const list = query({ + args: {}, + handler: async (ctx) => { + const models = await ctx.db.query("openRouterModels").collect(); + return models.sort((a, b) => a.modelName.localeCompare(b.modelName)); + }, +}); + +export const upsertBatch = internalMutation({ + args: { + models: v.array( + v.object({ + modelName: v.string(), + canonicalSlug: v.string(), + contextLength: v.number(), + completionCost: v.number(), + promptCost: v.number(), + }) + ), + }, + handler: async (ctx, args) => { + const existing = await ctx.db.query("openRouterModels").collect(); + for (const model of existing) { + await ctx.db.delete(model._id); + } + + for (const model of args.models) { + await ctx.db.insert("openRouterModels", { + modelName: model.modelName, + canonicalSlug: model.canonicalSlug, + contextLength: model.contextLength, + completionCost: model.completionCost, + promptCost: model.promptCost, + }); + } + }, +}); \ No newline at end of file diff --git a/frontend/convex/schema.ts b/frontend/convex/schema.ts index d3cc0bd..14fab32 100644 --- a/frontend/convex/schema.ts +++ b/frontend/convex/schema.ts @@ -97,38 +97,41 @@ export default defineSchema({ usage: defineTable({ userId: v.string(), rowsConsumed: v.number(), - // ms epoch of the start of the period this counter belongs to (first - // ms of the current UTC calendar month). Optional for forward-compat - // with rows written before this field existed — missing = treated as - // "before current period", which forces a reset on next write. periodStart: v.optional(v.number()), }).index("by_user", ["userId"]), + openRouterModels: defineTable({ + modelName: v.string(), + canonicalSlug: v.string(), + contextLength: v.number(), + completionCost: v.number(), + promptCost: v.number(), + }).index("by_slug", ["canonicalSlug"]), + + modelConfig: defineTable({ + userId: v.string(), + schemaInference: v.optional(v.string()), + populateOrchestrator: v.optional(v.string()), + investigateSubagent: v.optional(v.string()), + }).index("by_user", ["userId"]), + // One row per populate workflow run. Written once at the end of each run // (success or error) by the backend agent runner — never by the frontend. // Tracks tool-call counts, token usage, and timing so runs can be // compared across datasets, users, and benchmark sessions. runStats: defineTable({ workflowRunId: v.string(), - // v.string() (not v.id) so benchmark runs can use synthetic dataset ids - // without needing a real Convex dataset document. datasetId: v.string(), userId: v.string(), startedAt: v.number(), finishedAt: v.number(), durationMs: v.number(), - - // Tool-call counts searchCalls: v.number(), fetchCalls: v.number(), investigateCalls: v.number(), rowsInserted: v.number(), - - // Token usage — totals across all agent invocations in this run tokensInput: v.number(), tokensOutput: v.number(), - - // Breakdown by agent tier orchestratorTokensInput: v.number(), orchestratorTokensOutput: v.number(), orchestratorSteps: v.number(), @@ -136,20 +139,12 @@ export default defineSchema({ investigateTokensOutput: v.number(), investigateSteps: v.number(), investigateRuns: v.number(), - status: v.union(v.literal("success"), v.literal("error")), error: v.optional(v.string()), - - // True when written by the benchmark runner rather than a real user session. isBenchmark: v.optional(v.boolean()), - - // "populate" = initial fill workflow; "update" = refresh/update workflow. - // Optional for backward compat with rows written before this field existed - // (treat missing as "populate"). workflowType: v.optional( v.union(v.literal("populate"), v.literal("update")) ), - // Rows successfully updated by the refresh agent (update workflow only). rowsUpdated: v.optional(v.number()), }) .index("by_dataset", ["datasetId"]) diff --git a/frontend/lib/backend.ts b/frontend/lib/backend.ts index 1bb78f1..9406a18 100644 --- a/frontend/lib/backend.ts +++ b/frontend/lib/backend.ts @@ -34,9 +34,157 @@ export interface WorkflowResult { result: unknown; } +/** + * The effective model config — always complete, never null. + * schemaInference / populateOrchestrator / investigateSubagent are always strings + * (user preference or system default from env). + */ +export interface EffectiveModelConfig { + schemaInference: string; + populateOrchestrator: string; + investigateSubagent: string; +} + +/** + * User's saved model preferences — stores the canonical slug (e.g. "anthropic/claude-sonnet-4.6") + * for each agent role. Null means no preference saved — backend will use the env default. + */ +export interface SavedModelConfig { + schemaInference: string | null; + populateOrchestrator: string | null; + investigateSubagent: string | null; +} + +export interface OpenRouterModel { + modelName: string; + canonicalSlug: string; + contextLength: number; + completionCost: number; + promptCost: number; +} + const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL || "http://localhost:3501"; +/** + * Fetch the current user's effective (resolved) model config from the backend. + * + * The backend resolves the authenticated user from the Clerk JWT in the Authorization header + * and looks up their row in the modelConfig Convex table. + * If the user has no saved preference, returns the system defaults from env. + * + * Always returns a complete config — no nulls, no partials. + * + * @param token - Clerk JWT obtained via getToken() + * Throws if the request fails (network error, 401, 500). + */ +export async function getModelConfig(token: string): Promise { + const res = await fetch(`${BACKEND_URL}/settings/models`, { + method: "GET", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + }); + + if (!res.ok) { + const body = await res.json().catch(() => null); + const message = body?.error || `Backend error (${res.status})`; + throw new Error(message); + } + + const data = await res.json(); + return data.config; +} + +/** + * Save (upsert) one or more of the current user's model preferences. + * + * The backend resolves the authenticated user from the Clerk JWT in the Authorization header + * and does a partial upsert — only the fields provided in the body are updated. + * Unset fields retain their existing values. + * + * @param config - A partial model config. e.g. { schemaInference: "google/gemini-2.0-flash-001" } + * Only the roles the user wants to change need to be included. + * @param token - Clerk JWT obtained via getToken() + * + * Throws if the request fails (network error, 401, 500). + */ +export async function saveModelConfig( + config: Partial, + token: string, +): Promise { + const res = await fetch(`${BACKEND_URL}/settings/models`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(config), + }); + + if (!res.ok) { + const body = await res.json().catch(() => null); + const message = body?.error || `Backend error (${res.status})`; + throw new Error(message); + } +} + +/** + * Fetch the cached list of OpenRouter models from the backend. + * + * The backend serves models from the openRouterModels Convex table, which is + * populated by a prior call to refreshOpenRouterModels(). If the cache is empty, + * the backend auto-fetches from the OpenRouter API on first call. + * + * Returns an array of OpenRouterModel objects sorted by modelName. + * Throws if the request fails (network error, 500). + */ +export async function getOpenRouterModels(): Promise { + const res = await fetch(`${BACKEND_URL}/openrouter/models`, { + method: "GET", + }); + + if (!res.ok) { + const body = await res.json().catch(() => null); + const message = body?.error || `Backend error (${res.status})`; + throw new Error(message); + } + + const data = await res.json(); + return data.models ?? []; +} + +/** + * Refresh the OpenRouter model cache by fetching the latest list from the + * OpenRouter API and storing it in Convex. + * + * This is called when the user clicks "Refresh" in the settings UI to ensure + * they see the most up-to-date model list and pricing. + * + * @param token - Clerk JWT obtained via getToken() + * Returns the newly fetched model list. + * Throws if the request fails (network error, 500). + */ +export async function refreshOpenRouterModels(token: string): Promise { + const res = await fetch(`${BACKEND_URL}/openrouter/refresh`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + }); + + if (!res.ok) { + const body = await res.json().catch(() => null); + const message = body?.error || `Backend error (${res.status})`; + throw new Error(message); + } + + const data = await res.json(); + return data.models ?? []; +} + export async function inferSchema( prompt: string, token: string, diff --git a/frontend/package.json b/frontend/package.json index 5dc165c..60e8d47 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,6 +12,7 @@ "@clerk/nextjs": "^7.3.7", "@tanstack/react-table": "^8.21.3", "convex": "^1.39.1", + "lucide-react": "^1.17.0", "next": "16.2.6", "posthog-js": "^1.374.2", "react": "19.2.4",