From 6c9314d0daf02cf7f88471d5e3e1b4cacb40ab29 Mon Sep 17 00:00:00 2001 From: Pavel401 Date: Fri, 29 May 2026 12:27:43 +0530 Subject: [PATCH 1/9] UI Mockups for the model preferences --- backend/src/config/models.ts | 22 ++ frontend/app/dashboard/page.tsx | 15 ++ frontend/app/dashboard/settings/layout.tsx | 105 ++++++++ .../app/dashboard/settings/models/page.tsx | 110 ++++++++ frontend/app/dashboard/settings/page.tsx | 15 ++ .../components/settings/ModelSideSheet.tsx | 236 ++++++++++++++++++ .../components/settings/SettingsHeader.tsx | 17 ++ .../settings/SettingsPageLayout.tsx | 140 +++++++++++ .../components/settings/SettingsSidebar.tsx | 65 +++++ frontend/components/settings/SettingsTile.tsx | 67 +++++ frontend/components/settings/Skeleton.tsx | 27 ++ frontend/components/settings/types.ts | 119 +++++++++ 12 files changed, 938 insertions(+) create mode 100644 backend/src/config/models.ts create mode 100644 frontend/app/dashboard/settings/layout.tsx create mode 100644 frontend/app/dashboard/settings/models/page.tsx create mode 100644 frontend/app/dashboard/settings/page.tsx create mode 100644 frontend/components/settings/ModelSideSheet.tsx create mode 100644 frontend/components/settings/SettingsHeader.tsx create mode 100644 frontend/components/settings/SettingsPageLayout.tsx create mode 100644 frontend/components/settings/SettingsSidebar.tsx create mode 100644 frontend/components/settings/SettingsTile.tsx create mode 100644 frontend/components/settings/Skeleton.tsx create mode 100644 frontend/components/settings/types.ts diff --git a/backend/src/config/models.ts b/backend/src/config/models.ts new file mode 100644 index 0000000..3449033 --- /dev/null +++ b/backend/src/config/models.ts @@ -0,0 +1,22 @@ + + + + +export interface OpenRouterModel{ + + modelName:string, + canonicalSlug:string, + contextLength:number, + pricing:{ + completionCost:number, + promptCost:number, + } +} + + +export interface OpenRouterModelList{ + lastModified:string, + models:OpenRouterModel[] +} + + 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..3a056fb --- /dev/null +++ b/frontend/app/dashboard/settings/models/page.tsx @@ -0,0 +1,110 @@ +"use client"; + +import { useState, useEffect } from "react"; +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, MOCK_MODELS, type ModelRole } from "@/components/settings/types"; +import { SkeletonList } from "@/components/settings/Skeleton"; + +export default function ModelSettingsPage() { + const [selectedModels, setSelectedModels] = useState>({ + schemaInference: "anthropic/claude-sonnet-4-6", + populateOrchestrator: "qwen/qwen3.7-max", + investigateSubagent: "qwen/qwen3.7-max", + }); + const [refreshing, setRefreshing] = useState(false); + const [isLoading, setIsLoading] = useState(true); + const [activeSheet, setActiveSheet] = useState<{ + role: ModelRole; + } | null>(null); + + useEffect(() => { + const timer = setTimeout(() => setIsLoading(false), 1000); + return () => clearTimeout(timer); + }, []); + + 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: ( + + + + + ), + }, + ]; + + async function handleRefresh() { + setRefreshing(true); + await new Promise((resolve) => setTimeout(resolve, 1000)); + setRefreshing(false); + } + + function handleModelSelect(role: ModelRole, modelSlug: string) { + setSelectedModels((prev) => ({ ...prev, [role.key]: modelSlug })); + } + + return ( + + + +
+ {isLoading ? ( + + ) : ( + MODEL_ROLES.map((role) => ( + setActiveSheet({ role })} + /> + )) + )} +
+ + {activeSheet && ( + setActiveSheet(null)} + title={`Select ${activeSheet.role.label} Model`} + selectedModel={selectedModels[activeSheet.role.key]} + models={MOCK_MODELS} + onSelect={(slug) => handleModelSelect(activeSheet.role, slug)} + onRefresh={handleRefresh} + isRefreshing={refreshing} + /> + )} +
+ ); +} \ No newline at end of file diff --git a/frontend/app/dashboard/settings/page.tsx b/frontend/app/dashboard/settings/page.tsx new file mode 100644 index 0000000..de0ed56 --- /dev/null +++ b/frontend/app/dashboard/settings/page.tsx @@ -0,0 +1,15 @@ +"use client"; + +import { SettingsSidebar } from "@/components/settings/SettingsSidebar"; +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/components/settings/ModelSideSheet.tsx b/frontend/components/settings/ModelSideSheet.tsx new file mode 100644 index 0000000..749fcf7 --- /dev/null +++ b/frontend/components/settings/ModelSideSheet.tsx @@ -0,0 +1,236 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { X, Search, RefreshCw } from "lucide-react"; + +export interface OpenRouterModel { + canonicalSlug: string; + name: string; + contextLength: number; + completionCost: number; + promptCost: number; + provider?: string; +} + +interface ModelSideSheetProps { + open: boolean; + onClose: () => void; + title: string; + selectedModel: string; + models: OpenRouterModel[]; + onSelect: (modelSlug: string) => void; + onRefresh?: () => Promise; + isRefreshing?: boolean; +} + +function groupModelsByProvider(models: OpenRouterModel[]): Record { + const groups: Record = {}; + for (const model of models) { + const provider = model.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, +}: ModelSideSheetProps) { + const [search, setSearch] = useState(""); + const panelRef = useRef(null); + const inputRef = useRef(null); + + const filteredModels = search.trim() + ? models.filter( + (m) => + m.name.toLowerCase().includes(search.toLowerCase()) || + m.canonicalSlug.toLowerCase().includes(search.toLowerCase()), + ) + : models; + + const groupedModels = groupModelsByProvider(filteredModels); + const providers = Object.keys(groupedModels).sort(); + + useEffect(() => { + if (open) { + setSearch(""); + 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

+
+ ) : ( +
+ {providers.map((provider) => ( +
+
+

+ {provider} +

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

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

+
+
+
+ ); +} \ No newline at end of file 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..7095e09 --- /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..e9bd628 --- /dev/null +++ b/frontend/components/settings/types.ts @@ -0,0 +1,119 @@ +export interface OpenRouterModel { + canonicalSlug: string; + name: string; + contextLength: number; + completionCost: number; + promptCost: number; + provider?: string; +} + +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", + }, +]; + +export const MOCK_MODELS: OpenRouterModel[] = [ + { + canonicalSlug: "anthropic/claude-sonnet-4-6", + name: "Claude Sonnet 4", + contextLength: 200000, + completionCost: 0.000003, + promptCost: 0.000004, + }, + { + canonicalSlug: "anthropic/claude-opus-4", + name: "Claude Opus 4", + contextLength: 200000, + completionCost: 0.000015, + promptCost: 0.000018, + }, + { + canonicalSlug: "qwen/qwen3.7-max", + name: "Qwen 3.7 Max", + contextLength: 32000, + completionCost: 0.0000012, + promptCost: 0.0000012, + }, + { + canonicalSlug: "qwen/qwen2.5-72b", + name: "Qwen 2.5 72B", + contextLength: 32000, + completionCost: 0.0000009, + promptCost: 0.0000009, + }, + { + canonicalSlug: "moonshotai/kimi-k2-0905", + name: "Kimi K2", + contextLength: 128000, + completionCost: 0.000001, + promptCost: 0.000001, + }, + { + canonicalSlug: "google/gemini-flash-2.0", + name: "Gemini Flash 2.0", + contextLength: 1000000, + completionCost: 0.0000001, + promptCost: 0.0000001, + }, + { + canonicalSlug: "google/gemini-pro-1.5", + name: "Gemini Pro 1.5", + contextLength: 2000000, + completionCost: 0.000000125, + promptCost: 0.000000125, + }, + { + canonicalSlug: "deepseek/deepseek-chat-v3", + name: "DeepSeek Chat V3", + contextLength: 64000, + completionCost: 0.0000007, + promptCost: 0.0000007, + }, + { + canonicalSlug: "openai/gpt-4o-mini", + name: "GPT-4o Mini", + contextLength: 128000, + completionCost: 0.00000015, + promptCost: 0.0000006, + }, + { + canonicalSlug: "openai/gpt-4o", + name: "GPT-4o", + contextLength: 128000, + completionCost: 0.0000025, + promptCost: 0.00001, + }, + { + canonicalSlug: "meta-llama/llama-3-3-70b", + name: "Llama 3.3 70B", + contextLength: 128000, + completionCost: 0.0000005, + promptCost: 0.0000005, + }, + { + canonicalSlug: "mistral/mistral-large", + name: "Mistral Large", + contextLength: 128000, + completionCost: 0.000002, + promptCost: 0.000008, + }, +]; \ No newline at end of file From c957e5aa2f204d1b2d08a392da0b14e5f08fe01b Mon Sep 17 00:00:00 2001 From: Pavel401 Date: Sat, 30 May 2026 14:29:31 +0530 Subject: [PATCH 2/9] Implement model configuration and OpenRouter model management features --- frontend/components/settings/types.ts | 110 +-------------------- frontend/convex/modelConfig.ts | 89 +++++++++++++++++ frontend/convex/openRouterModels.ts | 40 ++++++++ frontend/convex/schema.ts | 19 +++- frontend/lib/backend.ts | 135 ++++++++++++++++++++++++++ 5 files changed, 284 insertions(+), 109 deletions(-) create mode 100644 frontend/convex/modelConfig.ts create mode 100644 frontend/convex/openRouterModels.ts diff --git a/frontend/components/settings/types.ts b/frontend/components/settings/types.ts index e9bd628..e04d2fc 100644 --- a/frontend/components/settings/types.ts +++ b/frontend/components/settings/types.ts @@ -1,10 +1,9 @@ export interface OpenRouterModel { + modelName: string; canonicalSlug: string; - name: string; contextLength: number; - completionCost: number; promptCost: number; - provider?: string; + completionCost: number; } export interface ModelRole { @@ -14,106 +13,7 @@ export interface ModelRole { } 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", - }, -]; - -export const MOCK_MODELS: OpenRouterModel[] = [ - { - canonicalSlug: "anthropic/claude-sonnet-4-6", - name: "Claude Sonnet 4", - contextLength: 200000, - completionCost: 0.000003, - promptCost: 0.000004, - }, - { - canonicalSlug: "anthropic/claude-opus-4", - name: "Claude Opus 4", - contextLength: 200000, - completionCost: 0.000015, - promptCost: 0.000018, - }, - { - canonicalSlug: "qwen/qwen3.7-max", - name: "Qwen 3.7 Max", - contextLength: 32000, - completionCost: 0.0000012, - promptCost: 0.0000012, - }, - { - canonicalSlug: "qwen/qwen2.5-72b", - name: "Qwen 2.5 72B", - contextLength: 32000, - completionCost: 0.0000009, - promptCost: 0.0000009, - }, - { - canonicalSlug: "moonshotai/kimi-k2-0905", - name: "Kimi K2", - contextLength: 128000, - completionCost: 0.000001, - promptCost: 0.000001, - }, - { - canonicalSlug: "google/gemini-flash-2.0", - name: "Gemini Flash 2.0", - contextLength: 1000000, - completionCost: 0.0000001, - promptCost: 0.0000001, - }, - { - canonicalSlug: "google/gemini-pro-1.5", - name: "Gemini Pro 1.5", - contextLength: 2000000, - completionCost: 0.000000125, - promptCost: 0.000000125, - }, - { - canonicalSlug: "deepseek/deepseek-chat-v3", - name: "DeepSeek Chat V3", - contextLength: 64000, - completionCost: 0.0000007, - promptCost: 0.0000007, - }, - { - canonicalSlug: "openai/gpt-4o-mini", - name: "GPT-4o Mini", - contextLength: 128000, - completionCost: 0.00000015, - promptCost: 0.0000006, - }, - { - canonicalSlug: "openai/gpt-4o", - name: "GPT-4o", - contextLength: 128000, - completionCost: 0.0000025, - promptCost: 0.00001, - }, - { - canonicalSlug: "meta-llama/llama-3-3-70b", - name: "Llama 3.3 70B", - contextLength: 128000, - completionCost: 0.0000005, - promptCost: 0.0000005, - }, - { - canonicalSlug: "mistral/mistral-large", - name: "Mistral Large", - contextLength: 128000, - completionCost: 0.000002, - promptCost: 0.000008, - }, + { 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..398cd45 --- /dev/null +++ b/frontend/convex/modelConfig.ts @@ -0,0 +1,89 @@ +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; + }, +}); + +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) { + await ctx.db.patch(existing._id, { + schemaInference: args.schemaInference, + populateOrchestrator: args.populateOrchestrator, + investigateSubagent: args.investigateSubagent, + }); + } else { + await ctx.db.insert("modelConfig", { + userId: identity.subject, + schemaInference: args.schemaInference, + populateOrchestrator: args.populateOrchestrator, + investigateSubagent: args.investigateSubagent, + }); + } + }, +}); + +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; + }, +}); + +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..39b4e15 --- /dev/null +++ b/frontend/convex/openRouterModels.ts @@ -0,0 +1,40 @@ +import { query, mutation } 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 = mutation({ + 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 11e635a..1796cf6 100644 --- a/frontend/convex/schema.ts +++ b/frontend/convex/schema.ts @@ -93,10 +93,21 @@ 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"]), }); diff --git a/frontend/lib/backend.ts b/frontend/lib/backend.ts index 8043c5a..a091115 100644 --- a/frontend/lib/backend.ts +++ b/frontend/lib/backend.ts @@ -34,9 +34,144 @@ 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 request (via Clerk JWT cookie) + * 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. + * + * Throws if the request fails (network error, 401, 500). + */ +export async function getModelConfig(): Promise { + const res = await fetch(`${BACKEND_URL}/settings/models`, { + method: "GET", + credentials: "include", + }); + + 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 request (via Clerk JWT cookie) + * 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. + * + * Throws if the request fails (network error, 401, 500). + */ +export async function saveModelConfig( + config: Partial +): Promise { + const res = await fetch(`${BACKEND_URL}/settings/models`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + 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. + * + * Returns the newly fetched model list. + * Throws if the request fails (network error, 500). + */ +export async function refreshOpenRouterModels(): Promise { + const res = await fetch(`${BACKEND_URL}/openrouter/refresh`, { + method: "POST", + }); + + 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, From 43b67843fcf5645d55163ffa29e9dcdf8da6655d Mon Sep 17 00:00:00 2001 From: Pavel401 Date: Sat, 30 May 2026 14:29:49 +0530 Subject: [PATCH 3/9] Add OpenRouter model configuration and management features --- .env.example | 10 ++ backend/src/config/models.ts | 174 +++++++++++++++++++++-- backend/src/env.ts | 11 +- backend/src/index.ts | 102 ++++++++++++- backend/src/mastra/agents/investigate.ts | 4 +- backend/src/mastra/agents/populate.ts | 4 +- backend/src/mastra/workflows/populate.ts | 10 +- backend/src/pipeline/schema-inference.ts | 10 +- 8 files changed, 304 insertions(+), 21 deletions(-) diff --git a/.env.example b/.env.example index 7edaa64..b9c77da 100644 --- a/.env.example +++ b/.env.example @@ -19,6 +19,16 @@ 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. # 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 index 3449033..80b59c5 100644 --- a/backend/src/config/models.ts +++ b/backend/src/config/models.ts @@ -1,22 +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; -export interface OpenRouterModel{ +/** + * 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; - modelName:string, - canonicalSlug:string, - contextLength:number, - pricing:{ - completionCost:number, - promptCost:number, - } +/** + * 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(api.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, + }); +} -export interface OpenRouterModelList{ - lastModified:string, - models:OpenRouterModel[] +/** + * 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..2717080 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 @@ -47,4 +56,4 @@ export const env = { process.env.POSTHOG_HOST || process.env.NEXT_PUBLIC_POSTHOG_HOST || "https://us.i.posthog.com", -}; +}; \ No newline at end of file diff --git a/backend/src/index.ts b/backend/src/index.ts index e4e6155..0db5dff 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -136,12 +136,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; @@ -152,6 +158,7 @@ async function runPopulateWorkflowInBackground({ authContext: { authorizedUserId, workflowRunId: run.runId, + modelConfig, }, }, }); @@ -252,6 +259,31 @@ fastify.addHook("onClose", async () => { fastify.get("/health", async () => ({ status: "ok" })); + +fastify.post("/openrouter/refresh", 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 // ──────────────────────────────────────────────────────────────────────── @@ -259,14 +291,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"); @@ -307,6 +398,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(); @@ -322,6 +416,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 }); @@ -361,12 +456,15 @@ await fastify.register(async (instance) => { } const run = await updateWorkflow.createRun(); + const { getModelConfig } = await import("./config/models.js"); + const modelConfig = await getModelConfig(auth.userId); const result = await run.start({ inputData: { ...parsed.data, authContext: { authorizedUserId: auth.userId, workflowRunId: run.runId, + modelConfig, }, }, }); diff --git a/backend/src/mastra/agents/investigate.ts b/backend/src/mastra/agents/investigate.ts index ad8ce1b..3446c1c 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 5fa792b..0b7c64e 100644 --- a/backend/src/mastra/agents/populate.ts +++ b/backend/src/mastra/agents/populate.ts @@ -42,11 +42,13 @@ export function buildPopulateAgent( authContext: AuthContext, columns: PopulateColumn[], ): 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 924457d..633c3aa 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"; /** @@ -28,6 +29,11 @@ import { buildPopulateAgent } from "../agents/populate.js"; export const authContextSchema = z.object({ authorizedUserId: z.string().min(1), workflowRunId: z.string().min(1), + modelConfig: z.object({ + schemaInference: z.string(), + populateOrchestrator: z.string(), + investigateSubagent: z.string(), + }), }); export type AuthContext = z.infer; @@ -101,8 +107,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) { From 4cc5fbd27f1ba3305db3c63bfca6789feb576239 Mon Sep 17 00:00:00 2001 From: Pavel401 Date: Sat, 30 May 2026 14:29:58 +0530 Subject: [PATCH 4/9] Refactor model settings and layout components; enhance model selection and saving functionality --- frontend/app/dashboard/settings/layout.tsx | 14 +-- .../app/dashboard/settings/models/page.tsx | 108 +++++++++++++----- frontend/app/dashboard/settings/page.tsx | 1 - .../components/settings/ModelSideSheet.tsx | 72 +++++++----- .../components/settings/SettingsSidebar.tsx | 2 +- frontend/package.json | 1 + 6 files changed, 133 insertions(+), 65 deletions(-) diff --git a/frontend/app/dashboard/settings/layout.tsx b/frontend/app/dashboard/settings/layout.tsx index 16b430b..5073466 100644 --- a/frontend/app/dashboard/settings/layout.tsx +++ b/frontend/app/dashboard/settings/layout.tsx @@ -3,7 +3,6 @@ import Link from "next/link"; import { useUser, useClerk } from "@clerk/nextjs"; import { useTheme } from "@/components/ThemeToggle"; -import { useRouter } from "next/navigation"; import { useEffect, useRef, useState } from "react"; export default function SettingsLayout({ @@ -13,7 +12,6 @@ export default function SettingsLayout({ }) { const { user } = useUser(); const { signOut } = useClerk(); - const router = useRouter(); const [profileOpen, setProfileOpen] = useState(false); const profileRef = useRef(null); const { theme, toggle: toggleTheme } = useTheme(); @@ -36,8 +34,8 @@ export default function SettingsLayout({
- BigSet - BigSet + BigSet + BigSet
@@ -52,7 +50,7 @@ export default function SettingsLayout({ {profileOpen && ( -
+

{name}

{email && ( @@ -77,7 +75,7 @@ export default function SettingsLayout({
diff --git a/frontend/app/dashboard/settings/models/page.tsx b/frontend/app/dashboard/settings/models/page.tsx index 3a056fb..d33eaed 100644 --- a/frontend/app/dashboard/settings/models/page.tsx +++ b/frontend/app/dashboard/settings/models/page.tsx @@ -1,30 +1,75 @@ "use client"; import { useState, useEffect } from "react"; +import { useQuery } from "convex/react"; +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, MOCK_MODELS, type ModelRole } from "@/components/settings/types"; +import { MODEL_ROLES, type ModelRole } from "@/components/settings/types"; import { SkeletonList } from "@/components/settings/Skeleton"; export default function ModelSettingsPage() { - const [selectedModels, setSelectedModels] = useState>({ - schemaInference: "anthropic/claude-sonnet-4-6", - populateOrchestrator: "qwen/qwen3.7-max", - investigateSubagent: "qwen/qwen3.7-max", - }); + const convexModels = useQuery(api.openRouterModels.list, {}); + + const [effectiveConfig, setEffectiveConfig] = useState(null); + const [isLoadingConfig, setIsLoadingConfig] = useState(true); const [refreshing, setRefreshing] = useState(false); - const [isLoading, setIsLoading] = useState(true); - const [activeSheet, setActiveSheet] = useState<{ - role: ModelRole; - } | null>(null); + const [sheetModels, setSheetModels] = useState([]); + const [activeSheet, setActiveSheet] = useState<{ role: ModelRole } | null>(null); + const [isSavingModel, setIsSavingModel] = useState(false); + + const isLoading = convexModels === undefined || isLoadingConfig; useEffect(() => { - const timer = setTimeout(() => setIsLoading(false), 1000); - return () => clearTimeout(timer); + getModelConfig() + .then((config) => setEffectiveConfig(config)) + .catch(() => setEffectiveConfig(null)) + .finally(() => setIsLoadingConfig(false)); }, []); + 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 { + await saveModelConfig({ [role.key]: model.canonicalSlug }); + 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", @@ -60,16 +105,6 @@ export default function ModelSettingsPage() { }, ]; - async function handleRefresh() { - setRefreshing(true); - await new Promise((resolve) => setTimeout(resolve, 1000)); - setRefreshing(false); - } - - function handleModelSelect(role: ModelRole, modelSlug: string) { - setSelectedModels((prev) => ({ ...prev, [role.key]: modelSlug })); - } - return ( setActiveSheet({ role })} + value={getSelectedModel(role)} + onClick={() => openSideSheet(role)} /> )) )} @@ -96,13 +131,28 @@ export default function ModelSettingsPage() { {activeSheet && ( setActiveSheet(null)} + onClose={() => !isSavingModel && setActiveSheet(null)} title={`Select ${activeSheet.role.label} Model`} - selectedModel={selectedModels[activeSheet.role.key]} - models={MOCK_MODELS} - onSelect={(slug) => handleModelSelect(activeSheet.role, slug)} - onRefresh={handleRefresh} + 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 models = await refreshOpenRouterModels(); + 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 index de0ed56..18d5f68 100644 --- a/frontend/app/dashboard/settings/page.tsx +++ b/frontend/app/dashboard/settings/page.tsx @@ -1,6 +1,5 @@ "use client"; -import { SettingsSidebar } from "@/components/settings/SettingsSidebar"; import { useRouter } from "next/navigation"; import { useEffect } from "react"; diff --git a/frontend/components/settings/ModelSideSheet.tsx b/frontend/components/settings/ModelSideSheet.tsx index 749fcf7..5878c5f 100644 --- a/frontend/components/settings/ModelSideSheet.tsx +++ b/frontend/components/settings/ModelSideSheet.tsx @@ -1,16 +1,9 @@ "use client"; import { useEffect, useRef, useState } from "react"; +import { flushSync } from "react-dom"; import { X, Search, RefreshCw } from "lucide-react"; - -export interface OpenRouterModel { - canonicalSlug: string; - name: string; - contextLength: number; - completionCost: number; - promptCost: number; - provider?: string; -} +import type { OpenRouterModel } from "./types"; interface ModelSideSheetProps { open: boolean; @@ -21,12 +14,13 @@ interface ModelSideSheetProps { 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.provider || model.canonicalSlug.split("/")[0] || "Other"; + const provider = model.canonicalSlug.split("/")[0] || "Other"; if (!groups[provider]) groups[provider] = []; groups[provider].push(model); } @@ -74,6 +68,7 @@ export function ModelSideSheet({ onSelect, onRefresh, isRefreshing, + isSaving, }: ModelSideSheetProps) { const [search, setSearch] = useState(""); const panelRef = useRef(null); @@ -82,7 +77,7 @@ export function ModelSideSheet({ const filteredModels = search.trim() ? models.filter( (m) => - m.name.toLowerCase().includes(search.toLowerCase()) || + m.modelName.toLowerCase().includes(search.toLowerCase()) || m.canonicalSlug.toLowerCase().includes(search.toLowerCase()), ) : models; @@ -92,7 +87,7 @@ export function ModelSideSheet({ useEffect(() => { if (open) { - setSearch(""); + flushSync(() => setSearch("")); setTimeout(() => inputRef.current?.focus(), 100); } }, [open]); @@ -119,7 +114,7 @@ export function ModelSideSheet({ />

{title}

@@ -127,8 +122,8 @@ export function ModelSideSheet({ {onRefresh && (
) : (
+
+
+ Model +
+
+ Context +
+
+ Input +
+
+ Output +
+
{providers.map((provider) => (
@@ -181,14 +191,12 @@ export function ModelSideSheet({ return ( @@ -227,7 +247,7 @@ export function ModelSideSheet({

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

diff --git a/frontend/components/settings/SettingsSidebar.tsx b/frontend/components/settings/SettingsSidebar.tsx index 7095e09..bfd4e3a 100644 --- a/frontend/components/settings/SettingsSidebar.tsx +++ b/frontend/components/settings/SettingsSidebar.tsx @@ -50,7 +50,7 @@ export function SettingsSidebar({ items }: SettingsSidebarProps) { className={`flex items-center gap-2.5 px-3 py-2 rounded-lg text-sm transition-colors ${ isActive ? "bg-foreground/5 text-foreground font-medium" - : "text-muted hover:bg-foreground/[0.03] hover:text-foreground" + : "text-muted hover:bg-foreground/3 hover:text-foreground" }`} > {item.icon} 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", From 093b1b2af9d430cdf3bb560caf05e837ce641a40 Mon Sep 17 00:00:00 2001 From: Pavel401 Date: Sat, 30 May 2026 17:32:51 +0530 Subject: [PATCH 5/9] Fixed remaining issues --- backend/src/config/models.ts | 2 +- backend/src/index.ts | 2 +- backend/src/mastra/workflows/populate.ts | 6 ++--- .../components/settings/ModelSideSheet.tsx | 7 +++--- frontend/convex/modelConfig.ts | 23 +++++++++---------- frontend/convex/openRouterModels.ts | 4 ++-- 6 files changed, 21 insertions(+), 23 deletions(-) diff --git a/backend/src/config/models.ts b/backend/src/config/models.ts index 80b59c5..f63b419 100644 --- a/backend/src/config/models.ts +++ b/backend/src/config/models.ts @@ -81,7 +81,7 @@ export async function validateModelSlug( * Called after successfully fetching from OpenRouter API. */ export async function upsertModelBatch(models: OpenRouterModel[]): Promise { - await convex.mutation(api.openRouterModels.upsertBatch, { models }); + await convex.mutation(internal.openRouterModels.upsertBatch, { models }); } /** diff --git a/backend/src/index.ts b/backend/src/index.ts index 17989a4..010df15 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -378,7 +378,7 @@ fastify.addHook("onClose", async () => { fastify.get("/health", async () => ({ status: "ok" })); -fastify.post("/openrouter/refresh", async (req, reply) => { +fastify.post("/openrouter/refresh", { preHandler: requireAuth }, async (req, reply) => { const { fetchModelsFromOpenRouter, upsertModelBatch } = await import("./config/models.js"); try { const models = await fetchModelsFromOpenRouter(); diff --git a/backend/src/mastra/workflows/populate.ts b/backend/src/mastra/workflows/populate.ts index 70cbbe1..a2a8246 100644 --- a/backend/src/mastra/workflows/populate.ts +++ b/backend/src/mastra/workflows/populate.ts @@ -32,9 +32,9 @@ export const authContextSchema = z.object({ authorizedUserId: z.string().min(1), workflowRunId: z.string().min(1), modelConfig: z.object({ - schemaInference: z.string(), - populateOrchestrator: z.string(), - investigateSubagent: z.string(), + schemaInference: z.string().min(1), + populateOrchestrator: z.string().min(1), + investigateSubagent: z.string().min(1), }), isBenchmark: z.boolean().optional(), }); diff --git a/frontend/components/settings/ModelSideSheet.tsx b/frontend/components/settings/ModelSideSheet.tsx index 5878c5f..b5d1003 100644 --- a/frontend/components/settings/ModelSideSheet.tsx +++ b/frontend/components/settings/ModelSideSheet.tsx @@ -1,7 +1,6 @@ "use client"; import { useEffect, useRef, useState } from "react"; -import { flushSync } from "react-dom"; import { X, Search, RefreshCw } from "lucide-react"; import type { OpenRouterModel } from "./types"; @@ -87,7 +86,7 @@ export function ModelSideSheet({ useEffect(() => { if (open) { - flushSync(() => setSearch("")); + setSearch(""); setTimeout(() => inputRef.current?.focus(), 100); } }, [open]); @@ -227,12 +226,12 @@ export function ModelSideSheet({

- ${model.promptCost.toFixed(2)} + ${model.promptCost.toFixed(2)}/1M

- ${model.completionCost.toFixed(2)} + ${model.completionCost.toFixed(2)}/1M

diff --git a/frontend/convex/modelConfig.ts b/frontend/convex/modelConfig.ts index 398cd45..25b5a6c 100644 --- a/frontend/convex/modelConfig.ts +++ b/frontend/convex/modelConfig.ts @@ -32,18 +32,17 @@ export const upsert = mutation({ .first(); if (existing) { - await ctx.db.patch(existing._id, { - schemaInference: args.schemaInference, - populateOrchestrator: args.populateOrchestrator, - investigateSubagent: args.investigateSubagent, - }); - } else { - await ctx.db.insert("modelConfig", { - userId: identity.subject, - schemaInference: args.schemaInference, - populateOrchestrator: args.populateOrchestrator, - investigateSubagent: args.investigateSubagent, - }); + 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 { + const insert: Record = { userId: args.userId }; + 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); } }, }); diff --git a/frontend/convex/openRouterModels.ts b/frontend/convex/openRouterModels.ts index 39b4e15..9cc6916 100644 --- a/frontend/convex/openRouterModels.ts +++ b/frontend/convex/openRouterModels.ts @@ -1,4 +1,4 @@ -import { query, mutation } from "./_generated/server.js"; +import { query, internalMutation } from "./_generated/server.js"; import { v } from "convex/values"; export const list = query({ @@ -9,7 +9,7 @@ export const list = query({ }, }); -export const upsertBatch = mutation({ +export const upsertBatch = internalMutation({ args: { models: v.array( v.object({ From d09403d412d21f80ca0c1f3ad345578e99edd6bd Mon Sep 17 00:00:00 2001 From: Pavel401 Date: Sat, 30 May 2026 20:48:20 +0530 Subject: [PATCH 6/9] Enhance upsert functionality to support partial updates and improve documentation for model preferences --- frontend/convex/modelConfig.ts | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/frontend/convex/modelConfig.ts b/frontend/convex/modelConfig.ts index 25b5a6c..c498202 100644 --- a/frontend/convex/modelConfig.ts +++ b/frontend/convex/modelConfig.ts @@ -16,6 +16,15 @@ export const get = query({ }, }); +/** + * 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()), @@ -32,13 +41,17 @@ export const upsert = mutation({ .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 { - const insert: Record = { userId: args.userId }; + } 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; @@ -58,6 +71,12 @@ export const getInternal = internalQuery({ }, }); +/** + * 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(), From 838f1d4a92fefdc31beea364f6f439806dc8458f Mon Sep 17 00:00:00 2001 From: Pavel401 Date: Sat, 30 May 2026 23:44:13 +0530 Subject: [PATCH 7/9] explicitly pass the auth token --- .../app/dashboard/settings/models/page.tsx | 20 +++++++++---- frontend/lib/backend.ts | 29 ++++++++++++++----- 2 files changed, 36 insertions(+), 13 deletions(-) diff --git a/frontend/app/dashboard/settings/models/page.tsx b/frontend/app/dashboard/settings/models/page.tsx index d33eaed..5531f9e 100644 --- a/frontend/app/dashboard/settings/models/page.tsx +++ b/frontend/app/dashboard/settings/models/page.tsx @@ -2,6 +2,7 @@ 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"; @@ -12,6 +13,7 @@ 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); @@ -24,11 +26,15 @@ export default function ModelSettingsPage() { const isLoading = convexModels === undefined || isLoadingConfig; useEffect(() => { - getModelConfig() + 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) => ({ @@ -47,7 +53,9 @@ export default function ModelSettingsPage() { async function handleModelSelect(role: ModelRole, model: OpenRouterModel) { setIsSavingModel(true); try { - await saveModelConfig({ [role.key]: model.canonicalSlug }); + 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 ); @@ -143,7 +151,9 @@ export default function ModelSettingsPage() { onRefresh={async () => { setRefreshing(true); try { - const models = await refreshOpenRouterModels(); + const token = await getToken(); + if (!token) throw new Error("Not authenticated"); + const models = await refreshOpenRouterModels(token); setSheetModels(models); } catch { // we will add toast later @@ -157,4 +167,4 @@ export default function ModelSettingsPage() { )} ); -} \ No newline at end of file +} diff --git a/frontend/lib/backend.ts b/frontend/lib/backend.ts index 2d0d655..e8905d8 100644 --- a/frontend/lib/backend.ts +++ b/frontend/lib/backend.ts @@ -69,18 +69,22 @@ const BACKEND_URL = /** * Fetch the current user's effective (resolved) model config from the backend. * - * The backend resolves the authenticated user from the request (via Clerk JWT cookie) + * 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(): Promise { +export async function getModelConfig(token: string): Promise { const res = await fetch(`${BACKEND_URL}/settings/models`, { method: "GET", - credentials: "include", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, }); if (!res.ok) { @@ -96,22 +100,26 @@ export async function getModelConfig(): Promise { /** * Save (upsert) one or more of the current user's model preferences. * - * The backend resolves the authenticated user from the request (via Clerk JWT cookie) + * 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 + config: Partial, + token: string, ): Promise { const res = await fetch(`${BACKEND_URL}/settings/models`, { method: "POST", - headers: { "Content-Type": "application/json" }, - credentials: "include", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, body: JSON.stringify(config), }); @@ -154,12 +162,17 @@ export async function getOpenRouterModels(): Promise { * 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(): Promise { +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) { From f4593561bcce02dbf15ed31fd5fddf5ad170691f Mon Sep 17 00:00:00 2001 From: Pavel401 Date: Sat, 30 May 2026 23:44:23 +0530 Subject: [PATCH 8/9] Add lucide-react dependency to project --- frontend/bun.lock | 3 +++ 1 file changed, 3 insertions(+) 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=="], From 20052d24ab8b13a655f8c11e4256a100d8384c52 Mon Sep 17 00:00:00 2001 From: Edward Tran Date: Sun, 31 May 2026 14:03:09 +0700 Subject: [PATCH 9/9] Fix OpenRouter default model slug --- .env.example | 4 ++-- backend/src/env.ts | 4 ++-- backend/src/mastra/agents/populate.ts | 2 +- frontend/components/settings/ModelSideSheet.tsx | 3 +-- frontend/lib/backend.ts | 2 +- 5 files changed, 7 insertions(+), 8 deletions(-) diff --git a/.env.example b/.env.example index 50e0487..33eb8d4 100644 --- a/.env.example +++ b/.env.example @@ -14,11 +14,11 @@ 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) +# 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 +SCHEMA_INFERENCE_MODEL=anthropic/claude-sonnet-4.6 POPULATE_ORCHESTRATOR_MODEL=qwen/qwen3.7-max INVESTIGATE_SUBAGENT_MODEL=qwen/qwen3.7-max diff --git a/backend/src/env.ts b/backend/src/env.ts index 2717080..084ffe0 100644 --- a/backend/src/env.ts +++ b/backend/src/env.ts @@ -33,7 +33,7 @@ export const env = { // 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", + 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: @@ -56,4 +56,4 @@ export const env = { process.env.POSTHOG_HOST || process.env.NEXT_PUBLIC_POSTHOG_HOST || "https://us.i.posthog.com", -}; \ No newline at end of file +}; diff --git a/backend/src/mastra/agents/populate.ts b/backend/src/mastra/agents/populate.ts index 765d836..fa0837f 100644 --- a/backend/src/mastra/agents/populate.ts +++ b/backend/src/mastra/agents/populate.ts @@ -45,7 +45,7 @@ export function buildPopulateAgent( metrics?: RunMetrics, ): Agent { const modelSlug = authContext.modelConfig!.populateOrchestrator; - + return new Agent({ id: "populate-agent", name: "Dataset Populate Orchestrator", diff --git a/frontend/components/settings/ModelSideSheet.tsx b/frontend/components/settings/ModelSideSheet.tsx index b5d1003..581103e 100644 --- a/frontend/components/settings/ModelSideSheet.tsx +++ b/frontend/components/settings/ModelSideSheet.tsx @@ -86,7 +86,6 @@ export function ModelSideSheet({ useEffect(() => { if (open) { - setSearch(""); setTimeout(() => inputRef.current?.focus(), 100); } }, [open]); @@ -252,4 +251,4 @@ export function ModelSideSheet({
); -} \ No newline at end of file +} diff --git a/frontend/lib/backend.ts b/frontend/lib/backend.ts index e8905d8..9406a18 100644 --- a/frontend/lib/backend.ts +++ b/frontend/lib/backend.ts @@ -46,7 +46,7 @@ export interface EffectiveModelConfig { } /** - * User's saved model preferences — stores the canonical slug (e.g. "anthropic/claude-sonnet-4-6") + * 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 {