diff --git a/src/components/agent/model-dropdown.tsx b/src/components/agent/model-dropdown.tsx
new file mode 100755
index 0000000..9c67c88
--- /dev/null
+++ b/src/components/agent/model-dropdown.tsx
@@ -0,0 +1,569 @@
+"use client"
+
+import * as React from "react"
+import {
+ ChevronDown,
+ Check,
+ Search,
+ Loader2,
+} from "lucide-react"
+import { toast } from "sonner"
+import { Badge } from "@/components/ui/badge"
+import { Input } from "@/components/ui/input"
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover"
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip"
+import { cn } from "@/lib/utils"
+import { ProviderIcon, hasLogo } from "./provider-icon"
+import {
+ getActiveModel,
+ getModelList,
+ getUserModelPreference,
+ setUserModelPreference,
+} from "@/app/actions/ai-config"
+
+const DEFAULT_MODEL_ID = "qwen/qwen3-coder-next"
+const DEFAULT_MODEL_NAME = "Qwen3 Coder"
+const DEFAULT_PROVIDER = "Alibaba (Qwen)"
+
+// --- shared state so all instances stay in sync ---
+
+interface SharedState {
+ readonly display: {
+ readonly id: string
+ readonly name: string
+ readonly provider: string
+ }
+ readonly global: {
+ readonly id: string
+ readonly name: string
+ readonly provider: string
+ }
+ readonly allowUserSelection: boolean
+ readonly isAdmin: boolean
+ readonly maxCostPerMillion: string | null
+ readonly configLoaded: boolean
+}
+
+let shared: SharedState = {
+ display: {
+ id: DEFAULT_MODEL_ID,
+ name: DEFAULT_MODEL_NAME,
+ provider: DEFAULT_PROVIDER,
+ },
+ global: {
+ id: DEFAULT_MODEL_ID,
+ name: DEFAULT_MODEL_NAME,
+ provider: DEFAULT_PROVIDER,
+ },
+ allowUserSelection: true,
+ isAdmin: false,
+ maxCostPerMillion: null,
+ configLoaded: false,
+}
+
+const listeners = new Set<() => void>()
+
+function getSnapshot(): SharedState {
+ return shared
+}
+
+function setShared(
+ next: Partial
+): void {
+ shared = { ...shared, ...next }
+ for (const l of listeners) l()
+}
+
+function subscribe(
+ listener: () => void
+): () => void {
+ listeners.add(listener)
+ return () => {
+ listeners.delete(listener)
+ }
+}
+
+interface ModelInfo {
+ readonly id: string
+ readonly name: string
+ readonly provider: string
+ readonly contextLength: number
+ readonly promptCost: string
+ readonly completionCost: string
+}
+
+interface ProviderGroup {
+ readonly provider: string
+ readonly models: ReadonlyArray
+}
+
+function outputCostPerMillion(
+ completionCost: string
+): number {
+ return parseFloat(completionCost) * 1_000_000
+}
+
+function formatOutputCost(
+ completionCost: string
+): string {
+ const cost = outputCostPerMillion(completionCost)
+ if (cost === 0) return "free"
+ if (cost < 0.01) return "<$0.01/M"
+ return `$${cost.toFixed(2)}/M`
+}
+
+export function ModelDropdown(): React.JSX.Element {
+ const [open, setOpen] = React.useState(false)
+ const state = React.useSyncExternalStore(
+ subscribe,
+ getSnapshot,
+ getSnapshot
+ )
+ const [groups, setGroups] = React.useState<
+ ReadonlyArray
+ >([])
+ const [loading, setLoading] = React.useState(false)
+ const [search, setSearch] = React.useState("")
+ const [saving, setSaving] = React.useState<
+ string | null
+ >(null)
+ const [listLoaded, setListLoaded] =
+ React.useState(false)
+ const [activeProvider, setActiveProvider] =
+ React.useState(null)
+
+ React.useEffect(() => {
+ if (state.configLoaded) return
+ setShared({ configLoaded: true })
+
+ Promise.all([
+ getActiveModel(),
+ getUserModelPreference(),
+ ]).then(([configResult, prefResult]) => {
+ let gModelId = DEFAULT_MODEL_ID
+ let gModelName = DEFAULT_MODEL_NAME
+ let gProvider = DEFAULT_PROVIDER
+ let canSelect = true
+ let ceiling: string | null = null
+
+ let admin = false
+
+ if (configResult.success && configResult.data) {
+ gModelId = configResult.data.modelId
+ gModelName = configResult.data.modelName
+ gProvider = configResult.data.provider
+ canSelect =
+ configResult.data.allowUserSelection
+ ceiling =
+ configResult.data.maxCostPerMillion
+ admin = configResult.data.isAdmin
+ }
+
+ const base: Partial = {
+ global: {
+ id: gModelId,
+ name: gModelName,
+ provider: gProvider,
+ },
+ allowUserSelection: canSelect,
+ isAdmin: admin,
+ maxCostPerMillion: ceiling,
+ }
+
+ if (
+ canSelect &&
+ prefResult.success &&
+ prefResult.data
+ ) {
+ const prefValid =
+ ceiling === null ||
+ outputCostPerMillion(
+ prefResult.data.completionCost
+ ) <= parseFloat(ceiling)
+
+ if (prefValid) {
+ const slashIdx =
+ prefResult.data.modelId.indexOf("/")
+ setShared({
+ ...base,
+ display: {
+ id: prefResult.data.modelId,
+ name:
+ slashIdx > 0
+ ? prefResult.data.modelId.slice(
+ slashIdx + 1
+ )
+ : prefResult.data.modelId,
+ provider: "",
+ },
+ })
+ return
+ }
+ }
+
+ setShared({
+ ...base,
+ display: {
+ id: gModelId,
+ name: gModelName,
+ provider: gProvider,
+ },
+ })
+ })
+ }, [state.configLoaded])
+
+ React.useEffect(() => {
+ if (!open || listLoaded) return
+ setLoading(true)
+ getModelList().then((result) => {
+ if (result.success) {
+ const sorted = [...result.data]
+ .sort((a, b) => {
+ const aHas = hasLogo(a.provider) ? 0 : 1
+ const bHas = hasLogo(b.provider) ? 0 : 1
+ if (aHas !== bHas) return aHas - bHas
+ return a.provider.localeCompare(
+ b.provider
+ )
+ })
+ .map((g) => ({
+ ...g,
+ models: [...g.models].sort(
+ (a, b) =>
+ outputCostPerMillion(
+ a.completionCost
+ ) -
+ outputCostPerMillion(
+ b.completionCost
+ )
+ ),
+ }))
+ setGroups(sorted)
+ }
+ setListLoaded(true)
+ setLoading(false)
+ })
+ }, [open, listLoaded])
+
+ // reset provider filter when popover closes
+ React.useEffect(() => {
+ if (!open) {
+ setActiveProvider(null)
+ setSearch("")
+ }
+ }, [open])
+
+ const query = search.toLowerCase()
+ const ceiling = state.maxCostPerMillion
+ ? parseFloat(state.maxCostPerMillion)
+ : null
+
+ const filtered = React.useMemo(() => {
+ return groups
+ .map((g) => ({
+ ...g,
+ models: g.models.filter((m) => {
+ if (ceiling !== null) {
+ if (
+ outputCostPerMillion(
+ m.completionCost
+ ) > ceiling
+ )
+ return false
+ }
+ if (
+ activeProvider &&
+ g.provider !== activeProvider
+ ) {
+ return false
+ }
+ if (!query) return true
+ return (
+ m.name.toLowerCase().includes(query) ||
+ m.id.toLowerCase().includes(query)
+ )
+ }),
+ }))
+ .filter((g) => g.models.length > 0)
+ }, [groups, query, ceiling, activeProvider])
+
+ const totalFiltered = React.useMemo(() => {
+ let count = 0
+ for (const g of groups) {
+ for (const m of g.models) {
+ if (
+ ceiling === null ||
+ outputCostPerMillion(m.completionCost) <=
+ ceiling
+ ) {
+ count++
+ }
+ }
+ }
+ return count
+ }, [groups, ceiling])
+
+ // sorted groups for provider sidebar (cost-filtered)
+ const sortedGroups = React.useMemo(() => {
+ return groups
+ .map((g) => ({
+ ...g,
+ models: g.models.filter((m) => {
+ if (ceiling === null) return true
+ return (
+ outputCostPerMillion(
+ m.completionCost
+ ) <= ceiling
+ )
+ }),
+ }))
+ .filter((g) => g.models.length > 0)
+ }, [groups, ceiling])
+
+ const handleSelect = async (
+ model: ModelInfo
+ ): Promise => {
+ if (model.id === state.display.id) {
+ setOpen(false)
+ return
+ }
+ setSaving(model.id)
+ const result = await setUserModelPreference(
+ model.id,
+ model.promptCost,
+ model.completionCost
+ )
+ setSaving(null)
+ if (result.success) {
+ setShared({
+ display: {
+ id: model.id,
+ name: model.name,
+ provider: model.provider,
+ },
+ })
+ toast.success(`Switched to ${model.name}`)
+ setOpen(false)
+ } else {
+ toast.error(result.error ?? "Failed to switch")
+ }
+ }
+
+ if (!state.allowUserSelection && !state.isAdmin) {
+ return (
+
+
+
+ {state.global.name}
+
+
+ )
+ }
+
+ return (
+
+
+
+
+ e.preventDefault()}
+ >
+
+ {/* search */}
+
+
+
+
+ setSearch(e.target.value)
+ }
+ placeholder="Search models..."
+ className="h-8 pl-8 text-xs"
+ />
+
+
+
+ {/* two-panel layout */}
+
+ {/* provider sidebar */}
+
+
+
+
+
+
+
+ All providers
+
+
+
+ {sortedGroups.map((group) => (
+
+
+
+
+
+
+ {group.provider} (
+ {group.models.length})
+
+
+
+ ))}
+
+
+ {/* model list */}
+
+ {loading ? (
+
+
+
+ ) : filtered.length === 0 ? (
+
+ No models found.
+
+ ) : (
+
+ {filtered.map((group) =>
+ group.models.map((model) => {
+ const isActive =
+ model.id === state.display.id
+ const isSaving =
+ saving === model.id
+
+ return (
+
+ )
+ })
+ )}
+
+ )}
+
+
+
+ {/* budget footer */}
+ {ceiling !== null && listLoaded && (
+
+
+ {totalFiltered} models within $
+ {state.maxCostPerMillion}/M budget
+
+
+ )}
+
+
+
+ )
+}
diff --git a/src/components/agent/provider-icon.tsx b/src/components/agent/provider-icon.tsx
new file mode 100755
index 0000000..4ef686f
--- /dev/null
+++ b/src/components/agent/provider-icon.tsx
@@ -0,0 +1,78 @@
+"use client"
+
+import { cn } from "@/lib/utils"
+
+// provider logo files in /public/providers/
+export const PROVIDER_LOGO: Record = {
+ Anthropic: "anthropic",
+ OpenAI: "openai",
+ Google: "google",
+ Meta: "meta",
+ Mistral: "mistral",
+ DeepSeek: "deepseek",
+ xAI: "xai",
+ NVIDIA: "nvidia",
+ Microsoft: "microsoft",
+ Amazon: "amazon",
+ Perplexity: "perplexity",
+}
+
+const PROVIDER_ABBR: Record = {
+ "Alibaba (Qwen)": "Qw",
+ Cohere: "Co",
+ Moonshot: "Ms",
+}
+
+function getProviderAbbr(name: string): string {
+ return (
+ PROVIDER_ABBR[name] ??
+ name.slice(0, 2).toUpperCase()
+ )
+}
+
+export function hasLogo(provider: string): boolean {
+ return provider in PROVIDER_LOGO
+}
+
+export function ProviderIcon({
+ provider,
+ size = 24,
+ className,
+}: {
+ readonly provider: string
+ readonly size?: number
+ readonly className?: string
+}): React.JSX.Element {
+ const logo = PROVIDER_LOGO[provider]
+
+ if (logo) {
+ return (
+
+ )
+ }
+
+ return (
+
+ {getProviderAbbr(provider)}
+
+ )
+}
diff --git a/src/components/settings-modal.tsx b/src/components/settings-modal.tsx
index d428272..7a3e61b 100755
--- a/src/components/settings-modal.tsx
+++ b/src/components/settings-modal.tsx
@@ -27,6 +27,7 @@ import { NetSuiteConnectionStatus } from "@/components/netsuite/connection-statu
import { SyncControls } from "@/components/netsuite/sync-controls"
import { MemoriesTable } from "@/components/agent/memories-table"
import { SkillsTab } from "@/components/settings/skills-tab"
+import { AIModelTab } from "@/components/settings/ai-model-tab"
export function SettingsModal({
open,
@@ -156,6 +157,8 @@ export function SettingsModal({
const slabMemoryPage =
+ const aiModelPage =
+
const skillsPage =
return (
@@ -164,10 +167,10 @@ export function SettingsModal({
onOpenChange={onOpenChange}
title="Settings"
description="Manage your app preferences."
- className="sm:max-w-xl"
+ className="sm:max-w-2xl"
>
@@ -183,6 +186,9 @@ export function SettingsModal({
Integrations
+
+ AI Model
+
Slab Memory
@@ -307,6 +313,13 @@ export function SettingsModal({
+
+
+
+
+}
+
+interface ActiveConfig {
+ readonly modelId: string
+ readonly modelName: string
+ readonly provider: string
+ readonly promptCost: string
+ readonly completionCost: string
+ readonly contextLength: number
+ readonly maxCostPerMillion: string | null
+ readonly allowUserSelection: boolean
+ readonly isAdmin: boolean
+}
+
+interface UsageMetrics {
+ readonly totalRequests: number
+ readonly totalTokens: number
+ readonly totalCost: string
+ readonly dailyBreakdown: ReadonlyArray<{
+ date: string
+ tokens: number
+ cost: string
+ requests: number
+ }>
+ readonly modelBreakdown: ReadonlyArray<{
+ modelId: string
+ tokens: number
+ cost: string
+ requests: number
+ }>
+}
+
+function formatCost(costPerToken: string): string {
+ const perMillion =
+ parseFloat(costPerToken) * 1_000_000
+ if (perMillion === 0) return "free"
+ if (perMillion < 0.01) return "<$0.01/M"
+ return `$${perMillion.toFixed(2)}/M`
+}
+
+function formatOutputCost(
+ completionCost: string
+): string {
+ const cost =
+ parseFloat(completionCost) * 1_000_000
+ if (cost === 0) return "free"
+ if (cost < 0.01) return "<$0.01/M"
+ return `$${cost.toFixed(2)}/M`
+}
+
+function formatContext(length: number): string {
+ if (length >= 1_000_000) {
+ return `${(length / 1_000_000).toFixed(0)}M`
+ }
+ return `${(length / 1000).toFixed(0)}k`
+}
+
+function formatTokenCount(tokens: number): string {
+ if (tokens >= 1_000_000) {
+ return `${(tokens / 1_000_000).toFixed(1)}M`
+ }
+ if (tokens >= 1000) {
+ return `${(tokens / 1000).toFixed(1)}k`
+ }
+ return String(tokens)
+}
+
+function outputCostPerMillion(
+ completionCost: string
+): number {
+ return parseFloat(completionCost) * 1_000_000
+}
+
+// --- two-panel model picker ---
+
+function ModelPicker({
+ groups,
+ activeConfig,
+ onSaved,
+ maxCostPerMillion,
+}: {
+ readonly groups: ReadonlyArray
+ readonly activeConfig: ActiveConfig | null
+ readonly onSaved: () => void
+ readonly maxCostPerMillion: number | null
+}) {
+ const [search, setSearch] = React.useState("")
+ const [activeProvider, setActiveProvider] =
+ React.useState(null)
+ const [selected, setSelected] =
+ React.useState(null)
+ const [saving, setSaving] = React.useState(false)
+
+ const currentId =
+ activeConfig?.modelId ?? DEFAULT_MODEL_ID
+
+ const query = search.toLowerCase()
+
+ // sort: providers with logos first, then alphabetical
+ const sortedGroups = React.useMemo(() => {
+ return [...groups].sort((a, b) => {
+ const aHas = hasLogo(a.provider) ? 0 : 1
+ const bHas = hasLogo(b.provider) ? 0 : 1
+ if (aHas !== bHas) return aHas - bHas
+ return a.provider.localeCompare(b.provider)
+ })
+ }, [groups])
+
+ // filter models by search + active provider + cost ceiling
+ const filteredGroups = React.useMemo(() => {
+ return sortedGroups
+ .map((group) => {
+ if (
+ activeProvider &&
+ group.provider !== activeProvider
+ ) {
+ return { ...group, models: [] }
+ }
+ return {
+ ...group,
+ models: [...group.models]
+ .filter((m) => {
+ if (maxCostPerMillion !== null) {
+ if (
+ outputCostPerMillion(
+ m.completionCost
+ ) > maxCostPerMillion
+ )
+ return false
+ }
+ if (!query) return true
+ return (
+ m.name
+ .toLowerCase()
+ .includes(query) ||
+ m.id.toLowerCase().includes(query)
+ )
+ })
+ .sort(
+ (a, b) =>
+ outputCostPerMillion(
+ a.completionCost
+ ) -
+ outputCostPerMillion(
+ b.completionCost
+ )
+ ),
+ }
+ })
+ .filter((g) => g.models.length > 0)
+ }, [sortedGroups, activeProvider, query, maxCostPerMillion])
+
+ const isDirty =
+ selected !== null && selected.id !== currentId
+
+ const handleSave = async () => {
+ if (!selected) return
+ setSaving(true)
+ const result = await setActiveModel(
+ selected.id,
+ selected.name,
+ selected.provider,
+ selected.promptCost,
+ selected.completionCost,
+ selected.contextLength
+ )
+ setSaving(false)
+ if (result.success) {
+ toast.success("Model updated")
+ setSelected(null)
+ onSaved()
+ } else {
+ toast.error(result.error ?? "Failed to save")
+ }
+ }
+
+ return (
+
+
+ {/* search bar */}
+
+
+ setSearch(e.target.value)}
+ placeholder="Search models..."
+ className="h-10 pl-9 text-sm"
+ />
+
+
+ {/* two-panel layout - no outer border */}
+
+ {/* provider sidebar */}
+
+
+
+
+
+
+
+ All providers
+
+
+
+ {sortedGroups.map((group) => (
+
+
+
+
+
+
+ {group.provider} (
+ {group.models.length})
+
+
+
+ ))}
+
+
+ {/* model list */}
+
+ {filteredGroups.length === 0 ? (
+
+ No models found.
+
+ ) : (
+
+ {filteredGroups.map((group) =>
+ group.models.map((model) => {
+ const isActive =
+ model.id === currentId
+ const isSelected =
+ selected?.id === model.id
+
+ return (
+
+ )
+ })
+ )}
+
+ )}
+
+
+
+ {/* save bar */}
+ {isDirty && (
+
+
+ Switch to{" "}
+
+ {selected?.name}
+
+
+
+
+ )}
+
+
+ )
+}
+
+// --- usage metrics ---
+
+const chartConfig = {
+ tokens: {
+ label: "Tokens",
+ color: "var(--chart-1)",
+ },
+} satisfies ChartConfig
+
+function UsageSection({
+ metrics,
+}: {
+ readonly metrics: UsageMetrics
+}) {
+ return (
+
+
+
+
+
+
+ Requests
+
+
+ {metrics.totalRequests.toLocaleString()}
+
+
+
+
+ Tokens
+
+
+ {formatTokenCount(metrics.totalTokens)}
+
+
+
+
+ Est. Cost
+
+
+ ${metrics.totalCost}
+
+
+
+
+ {metrics.dailyBreakdown.length > 0 && (
+
+
+ Daily token usage
+
+
+
+
+
+ v.slice(5)
+ }
+ className="text-[10px]"
+ />
+
+ }
+ />
+
+
+
+
+ )}
+
+ {metrics.modelBreakdown.length > 0 && (
+
+
+ By model
+
+
+ {metrics.modelBreakdown.map((m) => (
+
+
+ {m.modelId}
+
+
+ {m.requests} req ·{" "}
+ {formatTokenCount(m.tokens)} tok
+ · ${m.cost}
+
+
+ ))}
+
+
+ )}
+
+ )
+}
+
+// --- main tab ---
+
+export function AIModelTab() {
+ const [loading, setLoading] = React.useState(true)
+ const [activeConfig, setActiveConfig] =
+ React.useState(null)
+ const [groups, setGroups] = React.useState<
+ ReadonlyArray
+ >([])
+ const [metrics, setMetrics] =
+ React.useState(null)
+ const [isAdmin, setIsAdmin] = React.useState(false)
+ const [modelsError, setModelsError] =
+ React.useState(null)
+ const [allowUserSelection, setAllowUserSelection] =
+ React.useState(true)
+ const [costCeiling, setCostCeiling] =
+ React.useState(null)
+ const [policySaving, setPolicySaving] =
+ React.useState(false)
+
+ const loadData = React.useCallback(async () => {
+ setLoading(true)
+
+ const configResult = await getActiveModel()
+ if (configResult.success) {
+ setActiveConfig(configResult.data)
+ if (configResult.data) {
+ setAllowUserSelection(
+ configResult.data.allowUserSelection
+ )
+ setCostCeiling(
+ configResult.data.maxCostPerMillion
+ ? parseFloat(
+ configResult.data.maxCostPerMillion
+ )
+ : null
+ )
+ }
+ }
+
+ const [modelsResult, metricsResult] =
+ await Promise.all([
+ getModelList(),
+ getUsageMetrics(),
+ ])
+
+ if (modelsResult.success) {
+ setGroups(modelsResult.data)
+ setModelsError(null)
+ } else {
+ setModelsError(modelsResult.error)
+ }
+
+ if (metricsResult.success) {
+ setMetrics(metricsResult.data)
+ setIsAdmin(true)
+ } else if (
+ metricsResult.error !== "Permission denied"
+ ) {
+ setIsAdmin(false)
+ }
+
+ setLoading(false)
+ }, [])
+
+ React.useEffect(() => {
+ loadData()
+ }, [loadData])
+
+ const SLIDER_MAX = 50
+
+ const filteredModelCount = React.useMemo(() => {
+ if (costCeiling === null) return null
+ let total = 0
+ for (const g of groups) {
+ for (const m of g.models) {
+ if (
+ outputCostPerMillion(
+ m.completionCost
+ ) <= costCeiling
+ ) {
+ total++
+ }
+ }
+ }
+ return total
+ }, [groups, costCeiling])
+
+ if (loading) {
+ return (
+
+ )
+ }
+
+ return (
+
+
+
+ {activeConfig ? (
+
+
+ {activeConfig.modelName}
+
+
+ {activeConfig.provider}
+
+
+ ) : (
+
+ Using default: {DEFAULT_MODEL_ID}
+
+ )}
+
+
+ {isAdmin && (
+ <>
+
+
+
+
+
+
+ Allow user model selection
+
+
+ When off, all users use the model
+ set above.
+
+
+
+
+
+
+
+ Maximum cost ($/M tokens)
+
+
+ {costCeiling === null
+ ? "No limit"
+ : `$${costCeiling.toFixed(2)}/M`}
+
+
+
+ setCostCeiling(
+ v >= SLIDER_MAX ? null : v
+ )
+ }
+ />
+
+ $0
+
+ {costCeiling !== null &&
+ filteredModelCount !== null
+ ? `${filteredModelCount} models available`
+ : "All models available"}
+
+ $50/M
+
+
+
+
+
+
+
+
+
+ Select a model from OpenRouter. Applies
+ to all users.
+
+ {modelsError ? (
+
+ {modelsError}
+
+ ) : (
+
+ )}
+
+
+
+
+ {metrics &&
+ metrics.totalRequests > 0 ? (
+
+ ) : (
+
+
+
+ No usage data yet.
+
+
+ )}
+ >
+ )}
+
+ )
+}
diff --git a/src/db/index.ts b/src/db/index.ts
index 4b50ec5..6056edd 100755
--- a/src/db/index.ts
+++ b/src/db/index.ts
@@ -3,12 +3,14 @@ import * as schema from "./schema"
import * as netsuiteSchema from "./schema-netsuite"
import * as pluginSchema from "./schema-plugins"
import * as agentSchema from "./schema-agent"
+import * as aiConfigSchema from "./schema-ai-config"
const allSchemas = {
...schema,
...netsuiteSchema,
...pluginSchema,
...agentSchema,
+ ...aiConfigSchema,
}
export function getDb(d1: D1Database) {
diff --git a/src/db/schema-ai-config.ts b/src/db/schema-ai-config.ts
new file mode 100755
index 0000000..aee0a21
--- /dev/null
+++ b/src/db/schema-ai-config.ts
@@ -0,0 +1,73 @@
+import {
+ sqliteTable,
+ text,
+ integer,
+} from "drizzle-orm/sqlite-core"
+import { users, agentConversations } from "./schema"
+
+// singleton config row (id = "global")
+export const agentConfig = sqliteTable("agent_config", {
+ id: text("id").primaryKey(),
+ modelId: text("model_id").notNull(),
+ modelName: text("model_name").notNull(),
+ provider: text("provider").notNull(),
+ promptCost: text("prompt_cost").notNull(),
+ completionCost: text("completion_cost").notNull(),
+ contextLength: integer("context_length").notNull(),
+ maxCostPerMillion: text("max_cost_per_million"),
+ allowUserSelection: integer("allow_user_selection")
+ .notNull()
+ .default(1),
+ updatedBy: text("updated_by")
+ .notNull()
+ .references(() => users.id),
+ updatedAt: text("updated_at").notNull(),
+})
+
+// per-user model preference
+export const userModelPreference = sqliteTable(
+ "user_model_preference",
+ {
+ userId: text("user_id")
+ .primaryKey()
+ .references(() => users.id),
+ modelId: text("model_id").notNull(),
+ promptCost: text("prompt_cost").notNull(),
+ completionCost: text("completion_cost").notNull(),
+ updatedAt: text("updated_at").notNull(),
+ }
+)
+
+// one row per streamText invocation
+export const agentUsage = sqliteTable("agent_usage", {
+ id: text("id").primaryKey(),
+ conversationId: text("conversation_id")
+ .notNull()
+ .references(() => agentConversations.id, {
+ onDelete: "cascade",
+ }),
+ userId: text("user_id")
+ .notNull()
+ .references(() => users.id),
+ modelId: text("model_id").notNull(),
+ promptTokens: integer("prompt_tokens")
+ .notNull()
+ .default(0),
+ completionTokens: integer("completion_tokens")
+ .notNull()
+ .default(0),
+ totalTokens: integer("total_tokens")
+ .notNull()
+ .default(0),
+ estimatedCost: text("estimated_cost").notNull(),
+ createdAt: text("created_at").notNull(),
+})
+
+export type AgentConfig = typeof agentConfig.$inferSelect
+export type NewAgentConfig = typeof agentConfig.$inferInsert
+export type AgentUsage = typeof agentUsage.$inferSelect
+export type NewAgentUsage = typeof agentUsage.$inferInsert
+export type UserModelPreference =
+ typeof userModelPreference.$inferSelect
+export type NewUserModelPreference =
+ typeof userModelPreference.$inferInsert
diff --git a/src/hooks/use-compass-chat.ts b/src/hooks/use-compass-chat.ts
index 25fe17d..52c44c8 100755
--- a/src/hooks/use-compass-chat.ts
+++ b/src/hooks/use-compass-chat.ts
@@ -13,6 +13,7 @@ import {
} from "@/lib/agent/chat-adapter"
interface UseCompassChatOptions {
+ readonly conversationId?: string | null
readonly onFinish?: (params: {
messages: ReadonlyArray
}) => void | Promise
@@ -37,6 +38,8 @@ export function useCompassChat(options?: UseCompassChatOptions) {
"x-current-page": pathname,
"x-timezone":
Intl.DateTimeFormat().resolvedOptions().timeZone,
+ "x-conversation-id":
+ options?.conversationId ?? "",
},
}),
onFinish: options?.onFinish,
diff --git a/src/lib/agent/provider.ts b/src/lib/agent/provider.ts
index 7b15c05..3d6de47 100755
--- a/src/lib/agent/provider.ts
+++ b/src/lib/agent/provider.ts
@@ -1,7 +1,69 @@
import { createOpenRouter } from "@openrouter/ai-sdk-provider"
import { getCloudflareContext } from "@opennextjs/cloudflare"
+import { eq } from "drizzle-orm"
+import { getDb } from "@/db"
+import {
+ agentConfig,
+ userModelPreference,
+} from "@/db/schema-ai-config"
-const MODEL_ID = "qwen/qwen3-coder-next"
+export const DEFAULT_MODEL_ID = "qwen/qwen3-coder-next"
+
+export async function getActiveModelId(
+ db: ReturnType
+): Promise {
+ const config = await db
+ .select({ modelId: agentConfig.modelId })
+ .from(agentConfig)
+ .where(eq(agentConfig.id, "global"))
+ .get()
+
+ return config?.modelId ?? DEFAULT_MODEL_ID
+}
+
+export function createModelFromId(
+ apiKey: string,
+ modelId: string
+) {
+ const openrouter = createOpenRouter({ apiKey })
+ return openrouter(modelId, {
+ provider: { allow_fallbacks: false },
+ })
+}
+
+export async function resolveModelForUser(
+ db: ReturnType,
+ userId: string
+): Promise {
+ const config = await db
+ .select()
+ .from(agentConfig)
+ .where(eq(agentConfig.id, "global"))
+ .get()
+
+ if (!config) return DEFAULT_MODEL_ID
+
+ const globalModelId = config.modelId
+ const ceiling = config.maxCostPerMillion
+ ? parseFloat(config.maxCostPerMillion)
+ : null
+
+ const pref = await db
+ .select()
+ .from(userModelPreference)
+ .where(eq(userModelPreference.userId, userId))
+ .get()
+
+ if (!pref) return globalModelId
+
+ if (ceiling !== null) {
+ const outputPerMillion =
+ parseFloat(pref.completionCost) * 1_000_000
+ if (outputPerMillion > ceiling) return globalModelId
+ }
+
+ return pref.modelId
+}
export async function getAgentModel() {
const { env } = await getCloudflareContext()
@@ -14,10 +76,8 @@ export async function getAgentModel() {
)
}
- const openrouter = createOpenRouter({ apiKey })
- return openrouter(MODEL_ID, {
- provider: {
- allow_fallbacks: false,
- },
- })
+ const db = getDb(env.DB)
+ const modelId = await getActiveModelId(db)
+
+ return createModelFromId(apiKey, modelId)
}
diff --git a/src/lib/agent/usage.ts b/src/lib/agent/usage.ts
new file mode 100755
index 0000000..af7b431
--- /dev/null
+++ b/src/lib/agent/usage.ts
@@ -0,0 +1,58 @@
+import { eq } from "drizzle-orm"
+import type { LanguageModelUsage } from "ai"
+import type { getDb } from "@/db"
+import { agentConfig, agentUsage } from "@/db/schema-ai-config"
+
+interface StreamResult {
+ readonly totalUsage: PromiseLike
+}
+
+export async function saveStreamUsage(
+ db: ReturnType,
+ conversationId: string,
+ userId: string,
+ modelId: string,
+ result: StreamResult
+): Promise {
+ try {
+ const usage = await result.totalUsage
+
+ const promptTokens = usage.inputTokens ?? 0
+ const completionTokens = usage.outputTokens ?? 0
+ const totalTokens = usage.totalTokens ?? 0
+
+ const config = await db
+ .select({
+ promptCost: agentConfig.promptCost,
+ completionCost: agentConfig.completionCost,
+ })
+ .from(agentConfig)
+ .where(eq(agentConfig.id, "global"))
+ .get()
+
+ const promptRate = config
+ ? parseFloat(config.promptCost)
+ : 0
+ const completionRate = config
+ ? parseFloat(config.completionCost)
+ : 0
+
+ const estimatedCost =
+ promptTokens * promptRate +
+ completionTokens * completionRate
+
+ await db.insert(agentUsage).values({
+ id: crypto.randomUUID(),
+ conversationId,
+ userId,
+ modelId,
+ promptTokens,
+ completionTokens,
+ totalTokens,
+ estimatedCost: estimatedCost.toFixed(8),
+ createdAt: new Date().toISOString(),
+ })
+ } catch {
+ // usage tracking must never break the chat
+ }
+}
diff --git a/src/lib/permissions.ts b/src/lib/permissions.ts
index a4cdf68..6379ce6 100755
--- a/src/lib/permissions.ts
+++ b/src/lib/permissions.ts
@@ -13,6 +13,7 @@ export type Resource =
| "customer"
| "vendor"
| "finance"
+ | "agent"
export type Action = "create" | "read" | "update" | "delete" | "approve"
@@ -36,6 +37,7 @@ const PERMISSIONS: RolePermissions = {
customer: ["create", "read", "update", "delete"],
vendor: ["create", "read", "update", "delete"],
finance: ["create", "read", "update", "delete", "approve"],
+ agent: ["create", "read", "update", "delete"],
},
office: {
project: ["create", "read", "update"],
@@ -50,6 +52,7 @@ const PERMISSIONS: RolePermissions = {
customer: ["create", "read", "update"],
vendor: ["create", "read", "update"],
finance: ["create", "read", "update"],
+ agent: ["read"],
},
field: {
project: ["read"],
@@ -64,6 +67,7 @@ const PERMISSIONS: RolePermissions = {
customer: ["read"],
vendor: ["read"],
finance: ["read"],
+ agent: ["read"],
},
client: {
project: ["read"],
@@ -78,6 +82,7 @@ const PERMISSIONS: RolePermissions = {
customer: ["read"],
vendor: ["read"],
finance: ["read"],
+ agent: [],
},
}