diff --git a/.env.example b/.env.example index 24c66efbb..0d60047a0 100644 --- a/.env.example +++ b/.env.example @@ -18,6 +18,12 @@ BETTER_AUTH_SECRET= #run `openssl rand -base64 32` to generate # Public URL of your app (used for authentication, trusted origins, and Slack redirects) BETTER_AUTH_URL=http://localhost:3000 +# Admin LLM settings +# Comma-separated list of providers to hide from non-admin users (e.g. bedrock,vertex) +# NAO_DISABLED_PROVIDERS=bedrock +# Default model for new users in "provider:modelId" format (e.g. anthropic:claude-sonnet-4-6) +# NAO_DEFAULT_MODEL=anthropic:claude-sonnet-4-6 + # LLM providers OPENAI_API_KEY=sk-123456789 ANTHROPIC_API_KEY=sk-123456789 diff --git a/apps/backend/migrations-postgres/0056_add_llm_settings.sql b/apps/backend/migrations-postgres/0056_add_llm_settings.sql new file mode 100644 index 000000000..5d63c2114 --- /dev/null +++ b/apps/backend/migrations-postgres/0056_add_llm_settings.sql @@ -0,0 +1 @@ +ALTER TABLE "project" ADD COLUMN "llm_settings" jsonb DEFAULT '{"disabledProviders":[]}' NOT NULL;--> statement-breakpoint diff --git a/apps/backend/migrations-sqlite/0056_add_llm_settings.sql b/apps/backend/migrations-sqlite/0056_add_llm_settings.sql new file mode 100644 index 000000000..647665170 --- /dev/null +++ b/apps/backend/migrations-sqlite/0056_add_llm_settings.sql @@ -0,0 +1,2 @@ +-- Add llm_settings column to project table for admin LLM configuration controls +ALTER TABLE project ADD COLUMN llm_settings TEXT NOT NULL DEFAULT '{"disabledProviders":[]}'; diff --git a/apps/backend/src/db/pg-schema.ts b/apps/backend/src/db/pg-schema.ts index b1f04c96d..48d49bcc1 100644 --- a/apps/backend/src/db/pg-schema.ts +++ b/apps/backend/src/db/pg-schema.ts @@ -4,6 +4,7 @@ import type { AnalyticsEventMetadata, CitationData, LlmProvider, + LlmSettings, RepoProvider, UserPreferences, } from '@nao/shared/types'; @@ -201,6 +202,7 @@ export const project = pgTable( whatsappSettings: jsonb('whatsapp_settings').$type(), mcpEndpointSettings: jsonb('mcp_endpoint_settings').$type(), displaySettings: jsonb('display_settings').$type(), + llmSettings: jsonb('llm_settings').$type().notNull().default({ disabledProviders: [] }), createdAt: timestamp('created_at').defaultNow().notNull(), updatedAt: timestamp('updated_at') diff --git a/apps/backend/src/db/sqlite-schema.ts b/apps/backend/src/db/sqlite-schema.ts index 43557f71d..c05347b88 100644 --- a/apps/backend/src/db/sqlite-schema.ts +++ b/apps/backend/src/db/sqlite-schema.ts @@ -1,5 +1,6 @@ import type { McpChartEmbedStoredConfig } from '@nao/shared'; import type { DisplaySettings } from '@nao/shared/date'; +import type { LlmSettings } from '@nao/shared/types'; import type { AnalyticsEventMetadata, CitationData, @@ -216,6 +217,7 @@ export const project = sqliteTable( whatsappSettings: text('whatsapp_settings', { mode: 'json' }).$type(), mcpEndpointSettings: text('mcp_endpoint_settings', { mode: 'json' }).$type(), displaySettings: text('display_settings', { mode: 'json' }).$type(), + llmSettings: text('llm_settings', { mode: 'json' }).$type().notNull().default({ disabledProviders: [] }), createdAt: integer('created_at', { mode: 'timestamp_ms' }) .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`) diff --git a/apps/backend/src/queries/project.queries.ts b/apps/backend/src/queries/project.queries.ts index ac3603b24..0ea8dca84 100644 --- a/apps/backend/src/queries/project.queries.ts +++ b/apps/backend/src/queries/project.queries.ts @@ -1,5 +1,5 @@ import { DEFAULT_DATE_FORMAT_SETTINGS, type DisplaySettings } from '@nao/shared/date'; -import type { UpdatedAtFilter, UserRole } from '@nao/shared/types'; +import type { LlmSettings, UpdatedAtFilter, UserRole } from '@nao/shared/types'; import { and, asc, desc, eq, gt, gte, lte, or, type SQL, sql } from 'drizzle-orm'; import type { AgentSettings, DBProject, DBProjectMember, NewProject, NewProjectMember } from '../db/abstractSchema'; @@ -267,6 +267,23 @@ export const updateDisplaySettings = async (projectId: string, settings: Display return next; }; +export const getLlmSettings = async (projectId: string): Promise => { + const project = await getProjectById(projectId); + return project?.llmSettings ?? { disabledProviders: [] }; +}; + +export const updateLlmSettings = async (projectId: string, settings: LlmSettings): Promise => { + const current = await getLlmSettings(projectId); + const next: LlmSettings = { + ...current, + ...settings, + disabledProviders: settings.disabledProviders ?? current.disabledProviders, + defaultModel: settings.defaultModel !== undefined ? settings.defaultModel : current.defaultModel, + }; + await db.update(s.project).set({ llmSettings: next }).where(eq(s.project.id, projectId)).execute(); + return next; +}; + export const getEnvVars = async (projectId: string): Promise> => { const project = await getProjectById(projectId); return (project?.envVars as Record) ?? {}; diff --git a/apps/backend/src/services/agent.ts b/apps/backend/src/services/agent.ts index eaae5d192..54a0f70f5 100644 --- a/apps/backend/src/services/agent.ts +++ b/apps/backend/src/services/agent.ts @@ -1,5 +1,5 @@ import { story } from '@nao/shared/tools'; -import type { LlmProvider, LlmSelectedModel } from '@nao/shared/types'; +import type { LlmProvider, LlmSelectedModel, LlmSettings } from '@nao/shared/types'; import { convertToModelMessages, createUIMessageStream, @@ -57,7 +57,10 @@ import { assertBudgetNotExceeded } from '../utils/budget'; import { HandlerError } from '../utils/error'; import { getDefaultModelId, + getEffectiveDefaultModel, + getEffectiveDisabledProviders, getEnvModelSelections, + getProjectAvailableModels, resolveAnnotationModelId, resolveProviderModel, resolveProviderSettings, @@ -273,6 +276,20 @@ export class AgentService { return modelSelection; } + const llmSettings: LlmSettings = await projectQueries.getLlmSettings(projectId); + + // Check for admin-configured default model + const adminDefault = getEffectiveDefaultModel(llmSettings); + if (adminDefault) { + const availableModels = await getProjectAvailableModels(projectId, llmSettings); + const match = availableModels.find( + (m) => m.provider === adminDefault.provider && m.modelId === adminDefault.modelId, + ); + if (match) { + return { provider: match.provider, modelId: match.modelId }; + } + } + // Get the first available provider config const configs = await llmConfigQueries.getProjectLlmConfigs(projectId); const config = configs.at(0); @@ -283,8 +300,9 @@ export class AgentService { }; } - // Fallback to env-based provider - const envSelection = getEnvModelSelections().at(0); + // Fallback to env-based provider (respecting disabled providers) + const disabledProviders = getEffectiveDisabledProviders(llmSettings); + const envSelection = getEnvModelSelections(disabledProviders).at(0); if (envSelection) { return envSelection; } diff --git a/apps/backend/src/trpc/project.routes.ts b/apps/backend/src/trpc/project.routes.ts index 9ee4a9521..77a63a8a6 100644 --- a/apps/backend/src/trpc/project.routes.ts +++ b/apps/backend/src/trpc/project.routes.ts @@ -1,5 +1,5 @@ import { DATE_FORMAT_PRESETS } from '@nao/shared/date'; -import type { LlmProvider } from '@nao/shared/types'; +import type { LlmProvider, LlmSettings } from '@nao/shared/types'; import { TRPCError } from '@trpc/server'; import { z } from 'zod/v4'; @@ -22,7 +22,14 @@ import { listAvailableTranscribeModels as getAvailableTranscribeModels } from '. import { AgentSettings } from '../types/agent-settings'; import { customModelMetadataSchema, llmConfigSchema, llmProviderSchema } from '../types/llm'; import { isValidIsoDateString } from '../utils/date'; -import { getEnvApiKey, getEnvBaseUrls, getEnvProviders, getProjectAvailableModels } from '../utils/llm'; +import { + getEffectiveDefaultModel, + getEffectiveDisabledProviders, + getEnvApiKey, + getEnvBaseUrls, + getEnvProviders, + getProjectAvailableModels, +} from '../utils/llm'; import { extractRequiredEnvVars } from '../utils/nao-config'; import { buildCredentialPreviews } from '../utils/utils'; import { @@ -128,7 +135,8 @@ export const projectRoutes = { if (!ctx.project) { return []; } - return getProjectAvailableModels(ctx.project.id); + const llmSettings = await projectQueries.getLlmSettings(ctx.project.id); + return getProjectAvailableModels(ctx.project.id, llmSettings); }), upsertLlmConfig: adminProtectedProcedure @@ -200,6 +208,49 @@ export const projectRoutes = { return { success: true }; }), + getLlmSettings: projectProtectedProcedure + .output( + z.object({ + disabledProviders: z.array(llmProviderSchema), + defaultModel: z + .object({ + provider: llmProviderSchema, + modelId: z.string(), + }) + .optional(), + }), + ) + .query(async ({ ctx }) => { + if (!ctx.project) { + return { disabledProviders: [] }; + } + const dbSettings = await projectQueries.getLlmSettings(ctx.project.id); + return { + disabledProviders: getEffectiveDisabledProviders(dbSettings), + defaultModel: getEffectiveDefaultModel(dbSettings), + }; + }), + + updateLlmSettings: adminProtectedProcedure + .input( + z.object({ + disabledProviders: z.array(llmProviderSchema), + defaultModel: z + .object({ + provider: llmProviderSchema, + modelId: z.string(), + }) + .optional(), + }), + ) + .mutation(async ({ ctx, input }) => { + await projectQueries.updateLlmSettings(ctx.project.id, { + disabledProviders: input.disabledProviders, + ...(input.defaultModel && { defaultModel: input.defaultModel }), + }); + return { success: true }; + }), + getSlackConfig: projectProtectedProcedure.query(async ({ ctx }) => { if (!ctx.project) { return { projectConfig: null, webhookUrl: '' }; diff --git a/apps/backend/src/utils/llm.ts b/apps/backend/src/utils/llm.ts index a4b43ffeb..2333aaa39 100644 --- a/apps/backend/src/utils/llm.ts +++ b/apps/backend/src/utils/llm.ts @@ -1,4 +1,4 @@ -import type { LlmProvider, LlmSelectedModel } from '@nao/shared/types'; +import type { LlmProvider, LlmSelectedModel, LlmSettings } from '@nao/shared/types'; import { createProviderModel, getDefaultModelId, LLM_PROVIDERS, type ProviderModelResult } from '../agents/providers'; import * as projectLlmConfigQueries from '../queries/project-llm-config.queries'; @@ -38,6 +38,52 @@ export function getEnvProviders(): LlmProvider[] { return (Object.keys(LLM_PROVIDERS) as LlmProvider[]).filter(hasEnvApiKey); } +/** + * Parse NAO_DISABLED_PROVIDERS env var (comma-separated list of provider names). + * Returns empty array when unset or invalid. + */ +export function getEnvDisabledProviders(): LlmProvider[] { + const val = process.env.NAO_DISABLED_PROVIDERS; + if (!val) return []; + const providers = val.split(',').map((s) => s.trim()) as LlmProvider[]; + return providers.filter((p) => (Object.keys(LLM_PROVIDERS) as LlmProvider[]).includes(p)); +} + +/** + * Parse NAO_DEFAULT_MODEL env var in "provider:modelId" format. + * Returns undefined when unset or malformed. + */ +export function getEnvDefaultModel(): LlmSelectedModel | undefined { + const val = process.env.NAO_DEFAULT_MODEL; + if (!val) return undefined; + const colonIdx = val.indexOf(':'); + if (colonIdx === -1) return undefined; + const provider = val.slice(0, colonIdx) as LlmProvider; + const modelId = val.slice(colonIdx + 1); + if (!(Object.keys(LLM_PROVIDERS) as LlmProvider[]).includes(provider) || !modelId) return undefined; + return { provider, modelId }; +} + +/** + * Compute the effective list of disabled providers. + * Both env var and DB settings apply (union) — the admin UI can add to the env list + * but cannot remove providers disabled by the environment. + */ +export function getEffectiveDisabledProviders(llmSettings?: LlmSettings): LlmProvider[] { + const envDisabled = getEnvDisabledProviders(); + const dbDisabled = llmSettings?.disabledProviders ?? []; + return [...new Set([...envDisabled, ...dbDisabled])]; +} + +/** + * Compute the effective default model. + * DB settings take precedence over env var so the admin can always change it in the UI. + * The env var acts as a fallback when no DB default is set. + */ +export function getEffectiveDefaultModel(llmSettings?: LlmSettings): LlmSelectedModel | undefined { + return llmSettings?.defaultModel ?? getEnvDefaultModel(); +} + /** Get base URLs set via environment, keyed by provider */ export function getEnvBaseUrls(): Record { return Object.fromEntries( @@ -68,12 +114,14 @@ export function getKnownModelIds(provider: LlmProvider): string[] { return LLM_PROVIDERS[provider].models.map((m) => m.id); } -/** Get model selections for all env-configured providers */ -export function getEnvModelSelections(): LlmSelectedModel[] { - return getEnvProviders().map((provider) => ({ - provider, - modelId: getDefaultModelId(provider), - })); +/** Get model selections for all env-configured providers, optionally excluding disabled ones */ +export function getEnvModelSelections(disabledProviders?: LlmProvider[]): LlmSelectedModel[] { + return getEnvProviders() + .filter((p) => !disabledProviders?.includes(p)) + .map((provider) => ({ + provider, + modelId: getDefaultModelId(provider), + })); } /** Resolve API key + base URL for a provider from DB config or env vars. */ @@ -167,12 +215,16 @@ export async function resolveAnnotationModelId( export const getProjectAvailableModels = async ( projectId: string, + llmSettings?: LlmSettings, ): Promise> => { + const disabledProviders = getEffectiveDisabledProviders(llmSettings); const configs = await projectLlmConfigQueries.getProjectLlmConfigs(projectId); const models: Array<{ provider: LlmProvider; modelId: string; name: string }> = []; for (const config of configs) { const provider = config.provider as LlmProvider; + if (disabledProviders.includes(provider)) continue; + const enabledModels = config.enabledModels ?? []; const customModels = config.customModels ?? []; @@ -192,8 +244,8 @@ export const getProjectAvailableModels = async ( } } - // Also add env-configured providers with their defaults - const envSelections = getEnvModelSelections() + // Also add env-configured providers with their defaults (skip disabled + already configured) + const envSelections = getEnvModelSelections(disabledProviders) .filter((s) => !configs.some((c) => c.provider === s.provider)) .map((s) => ({ ...s, name: getModelName(s.provider, s.modelId) })); models.push(...envSelections); diff --git a/apps/frontend/src/components/chat-input-model-select.tsx b/apps/frontend/src/components/chat-input-model-select.tsx index 88179500f..b4167abbb 100644 --- a/apps/frontend/src/components/chat-input-model-select.tsx +++ b/apps/frontend/src/components/chat-input-model-select.tsx @@ -10,6 +10,7 @@ import { trpc } from '@/main'; export function ChatInputModelSelect() { const { selectedModel, setSelectedModel } = useAgentContext(); const { data: availableModels, isPending } = useQuery(trpc.project.listAvailableTranscribeModels.queryOptions()); + const { data: llmSettings, isPending: isLlmSettingsPending } = useQuery(trpc.project.getLlmSettings.queryOptions()); const hasMultipleModels = Boolean(availableModels && availableModels.length > 1); // Set default model when available models load, or reset if current selection is no longer available @@ -18,14 +19,34 @@ export function ChatInputModelSelect() { return; } - const isCurrentSelectionValid = - selectedModel && - availableModels.some((m) => m.provider === selectedModel.provider && m.modelId === selectedModel.modelId); + // Wait for admin settings to resolve before auto-selecting + if (isLlmSettingsPending) return; + + // User has never selected a model — apply admin default if set + if (!selectedModel) { + const adminDefault = llmSettings?.defaultModel; + if (adminDefault) { + const match = availableModels.find( + (m) => m.provider === adminDefault.provider && m.modelId === adminDefault.modelId, + ); + if (match) { + setSelectedModel(match); + return; + } + } + setSelectedModel(availableModels[0]); + return; + } + + // User has an existing selection — reset if it's no longer available + const isCurrentSelectionValid = availableModels.some( + (m) => m.provider === selectedModel.provider && m.modelId === selectedModel.modelId, + ); if (!isCurrentSelectionValid) { setSelectedModel(availableModels[0]); } - }, [availableModels, selectedModel, setSelectedModel]); + }, [availableModels, selectedModel, setSelectedModel, llmSettings, isLlmSettingsPending]); const handleModelValueChange = useCallback( (value: string) => { diff --git a/apps/frontend/src/routes/_sidebar-layout.settings.project.models.tsx b/apps/frontend/src/routes/_sidebar-layout.settings.project.models.tsx index 243a5eb21..73532954d 100644 --- a/apps/frontend/src/routes/_sidebar-layout.settings.project.models.tsx +++ b/apps/frontend/src/routes/_sidebar-layout.settings.project.models.tsx @@ -1,8 +1,15 @@ import { createFileRoute } from '@tanstack/react-router'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { LlmProvidersSection } from '@/components/settings/llm-providers-section'; import { SettingsCard } from '@/components/ui/settings-card'; import { SettingsTranscribe } from '@/components/settings/settings-transcribe'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { usePermissions } from '@/hooks/use-permissions'; +import { trpc } from '@/main'; +import type { LlmProvider } from '@nao/shared/types'; +import { LLM_PROVIDERS } from '@nao/shared/types'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; export const Route = createFileRoute('/_sidebar-layout/settings/project/models')({ component: ProjectModelsTabPage, @@ -19,7 +26,128 @@ function ProjectModelsTabPage() { > + {isAdmin && } ); } + +function LlmAdminSettings() { + const queryClient = useQueryClient(); + const allProviders = useQuery(trpc.project.listAvailableTranscribeModels.queryOptions()); + const llmSettings = useQuery(trpc.project.getLlmSettings.queryOptions()); + const updateLlmSettings = useMutation( + trpc.project.updateLlmSettings.mutationOptions({ + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: trpc.project.getLlmSettings.queryOptions().queryKey, + }); + queryClient.invalidateQueries({ + queryKey: trpc.project.listAvailableTranscribeModels.queryOptions().queryKey, + }); + }, + }), + ); + + const disabledProviders = llmSettings.data?.disabledProviders ?? []; + const defaultModel = llmSettings.data?.defaultModel; + const availableModels = allProviders.data ?? []; + const isMutating = updateLlmSettings.isPending; + + const toggleProvider = (provider: LlmProvider) => { + const next = disabledProviders.includes(provider) + ? disabledProviders.filter((p) => p !== provider) + : [...disabledProviders, provider]; + updateLlmSettings.mutate({ + disabledProviders: next, + defaultModel, + }); + }; + + const setDefaultModel = (value: string) => { + const colonIdx = value.indexOf(':'); + if (colonIdx === -1) return; + const provider = value.slice(0, colonIdx) as LlmProvider; + const modelId = value.slice(colonIdx + 1); + updateLlmSettings.mutate({ + disabledProviders, + defaultModel: { provider, modelId }, + }); + }; + + const clearDefaultModel = () => { + updateLlmSettings.mutate({ + disabledProviders, + defaultModel: undefined, + }); + }; + + return ( + +
+
+ +

+ Disabled providers will not appear in the model selector for non-admin users. +

+
+ {LLM_PROVIDERS.map((provider) => { + const isDisabled = disabledProviders.includes(provider); + return ( + toggleProvider(provider)} + > + {provider} + + ); + })} +
+
+ +
+ +

+ New users will see this model selected by default. When unset, the first available model is used. +

+
+ + {defaultModel && ( + + )} +
+
+
+
+ ); +} diff --git a/apps/shared/src/types.ts b/apps/shared/src/types.ts index ba4191d98..455397956 100644 --- a/apps/shared/src/types.ts +++ b/apps/shared/src/types.ts @@ -56,6 +56,11 @@ export type LlmSelectedModel = { modelId: string; }; +export interface LlmSettings { + disabledProviders: LlmProvider[]; + defaultModel?: LlmSelectedModel; +} + export type SummarySegment = | { type: 'text'; content: string } | { type: 'chart'; chartType: string; title: string; kpiCount?: number }