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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions apps/backend/migrations-postgres/0056_add_llm_settings.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE "project" ADD COLUMN "llm_settings" jsonb DEFAULT '{"disabledProviders":[]}' NOT NULL;--> statement-breakpoint
2 changes: 2 additions & 0 deletions apps/backend/migrations-sqlite/0056_add_llm_settings.sql
Original file line number Diff line number Diff line change
@@ -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":[]}';
2 changes: 2 additions & 0 deletions apps/backend/src/db/pg-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type {
AnalyticsEventMetadata,
CitationData,
LlmProvider,
LlmSettings,
RepoProvider,
UserPreferences,
} from '@nao/shared/types';
Expand Down Expand Up @@ -201,6 +202,7 @@ export const project = pgTable(
whatsappSettings: jsonb('whatsapp_settings').$type<WhatsappSettings>(),
mcpEndpointSettings: jsonb('mcp_endpoint_settings').$type<McpEndpointSettings>(),
displaySettings: jsonb('display_settings').$type<DisplaySettings>(),
llmSettings: jsonb('llm_settings').$type<LlmSettings>().notNull().default({ disabledProviders: [] }),

createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at')
Expand Down
2 changes: 2 additions & 0 deletions apps/backend/src/db/sqlite-schema.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -216,6 +217,7 @@ export const project = sqliteTable(
whatsappSettings: text('whatsapp_settings', { mode: 'json' }).$type<WhatsappSettings>(),
mcpEndpointSettings: text('mcp_endpoint_settings', { mode: 'json' }).$type<McpEndpointSettings>(),
displaySettings: text('display_settings', { mode: 'json' }).$type<DisplaySettings>(),
llmSettings: text('llm_settings', { mode: 'json' }).$type<LlmSettings>().notNull().default({ disabledProviders: [] }),

createdAt: integer('created_at', { mode: 'timestamp_ms' })
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
Expand Down
19 changes: 18 additions & 1 deletion apps/backend/src/queries/project.queries.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -267,6 +267,23 @@ export const updateDisplaySettings = async (projectId: string, settings: Display
return next;
};

export const getLlmSettings = async (projectId: string): Promise<LlmSettings> => {
const project = await getProjectById(projectId);
return project?.llmSettings ?? { disabledProviders: [] };
};

export const updateLlmSettings = async (projectId: string, settings: LlmSettings): Promise<LlmSettings> => {
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<Record<string, string>> => {
const project = await getProjectById(projectId);
return (project?.envVars as Record<string, string>) ?? {};
Expand Down
24 changes: 21 additions & 3 deletions apps/backend/src/services/agent.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -57,7 +57,10 @@ import { assertBudgetNotExceeded } from '../utils/budget';
import { HandlerError } from '../utils/error';
import {
getDefaultModelId,
getEffectiveDefaultModel,
getEffectiveDisabledProviders,
getEnvModelSelections,
getProjectAvailableModels,
resolveAnnotationModelId,
resolveProviderModel,
resolveProviderSettings,
Expand Down Expand Up @@ -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);
Expand All @@ -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;
}
Expand Down
57 changes: 54 additions & 3 deletions apps/backend/src/trpc/project.routes.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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: '' };
Expand Down
70 changes: 61 additions & 9 deletions apps/backend/src/utils/llm.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<string, string> {
return Object.fromEntries(
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -167,12 +215,16 @@ export async function resolveAnnotationModelId(

export const getProjectAvailableModels = async (
projectId: string,
llmSettings?: LlmSettings,
): Promise<Array<{ provider: LlmProvider; modelId: string; name: string }>> => {
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 ?? [];

Expand All @@ -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);
Expand Down
29 changes: 25 additions & 4 deletions apps/frontend/src/components/chat-input-model-select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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) => {
Expand Down
Loading
Loading