feat: add two-layer AI model availability filtering (#18170)

## Summary

- **Admin-level filtering**: New AI tab in admin panel with server-wide
model availability controls (whitelist/blacklist via
`AI_AUTO_ENABLE_NEW_MODELS`, `AI_DISABLED_MODEL_IDS`,
`AI_ENABLED_MODEL_IDS` config variables). Dedicated
`setAdminAiModelEnabled` mutation replaces frontend config-variable
manipulation. Filter dropdown to show/hide unconfigured and deprecated
models.
- **Workspace-level filtering**: Per-workspace controls with "Use best
models only" mode (curated list backed by `isRecommended` flag), or
custom whitelist/blacklist. Separate Smart/Fast model selectors with
"Best (...)" virtual options.
- **Security enforcement**: Both layers enforced at every backend
execution point — workspace update, agent create/update, chat execution.
Model ID validated against known models before config mutation. All
admin endpoints protected by `AdminPanelGuard`.

## Changes

### Backend (`twenty-server`)
- New config variables for admin-level model filtering
- `AiModelRegistryService`: `getAllModelsWithStatus()`,
`setModelAdminEnabled()` with model ID validation,
`isModelAdminAllowed()`
- `AdminPanelResolver`: `getAdminAiModels` query,
`setAdminAiModelEnabled` mutation
- `WorkspaceEntity`: new fields (`autoEnableNewAiModels`,
`disabledAiModelIds`, `enabledAiModelIds`, `useRecommendedModels`)
- `WorkspaceService`: model validation on `smartModel`/`fastModel`
updates
- `AgentResolver`: model availability checks on create/update
- `isModelAllowedByWorkspace` centralized utility
- `isRecommended` flag on model definitions
- Two TypeORM migrations

### Frontend (`twenty-front`)
- New `SettingsAdminAI` component with search, filter dropdown
(unconfigured/deprecated), and model toggle cards
- AI tab added to admin panel navigation
- `useWorkspaceAiModelAvailability` hook for workspace-level filtering
- `SettingsAIModelsTab` redesigned: merged sections, "Use best models
only" toggle, conditional available models list
- `getModelIcon`/`getModelProviderLabel` shared utilities with GraphQL
enum casing normalization
- Updated generated GraphQL types and mock data

## Test plan
- [ ] Toggle models on/off in admin panel AI tab and verify they
appear/disappear in workspace settings
- [ ] Enable "Use best models only" in workspace settings and verify
only recommended models are selectable
- [ ] Disable recommended mode and verify whitelist/blacklist toggles
work correctly
- [ ] Verify deprecated models hidden by default, shown greyed out when
filter enabled
- [ ] Verify unconfigured models hidden by default, shown disabled when
filter enabled
- [ ] Try setting a disabled model as Smart/Fast model — should be
rejected
- [ ] Try creating an agent with a disabled model — should be rejected
- [ ] Verify admin panel AI tab requires admin access


Made with [Cursor](https://cursor.com)

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Félix Malfait
2026-02-24 10:14:24 +01:00
committed by GitHub
parent c369baf63a
commit 9a3852bf04
41 changed files with 1320 additions and 101 deletions
@@ -8,6 +8,7 @@ import { AiAgentModule } from 'src/engine/metadata-modules/ai/ai-agent/ai-agent.
import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity';
import { AiBillingModule } from 'src/engine/metadata-modules/ai/ai-billing/ai-billing.module';
import { AiModelsModule } from 'src/engine/metadata-modules/ai/ai-models/ai-models.module';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { RoleTargetEntity } from 'src/engine/metadata-modules/role-target/role-target.entity';
import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module';
@@ -36,6 +37,7 @@ import { AgentAsyncExecutorService } from './services/agent-async-executor.servi
AgentMessagePartEntity,
AgentTurnEntity,
RoleTargetEntity,
WorkspaceEntity,
]),
],
providers: [AgentAsyncExecutorService, AgentActorContextService],
@@ -30,6 +30,7 @@ import { type AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entiti
import { repairToolCall } from 'src/engine/metadata-modules/ai/ai-agent/utils/repair-tool-call.util';
import { AI_TELEMETRY_CONFIG } from 'src/engine/metadata-modules/ai/ai-models/constants/ai-telemetry.const';
import { AgentModelConfigService } from 'src/engine/metadata-modules/ai/ai-models/services/agent-model-config.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service';
import { RoleTargetEntity } from 'src/engine/metadata-modules/role-target/role-target.entity';
import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config';
@@ -47,6 +48,8 @@ export class AgentAsyncExecutorService {
private readonly toolRegistry: ToolRegistryService,
@InjectRepository(RoleTargetEntity)
private readonly roleTargetRepository: Repository<RoleTargetEntity>,
@InjectRepository(WorkspaceEntity)
private readonly workspaceRepository: Repository<WorkspaceEntity>,
) {}
private extractRoleIds(
@@ -108,6 +111,19 @@ export class AgentAsyncExecutorService {
authContext?: WorkspaceAuthContext;
}): Promise<AgentExecutionResult> {
try {
if (agent) {
const workspace = await this.workspaceRepository.findOneBy({
id: agent.workspaceId,
});
if (workspace) {
this.aiModelRegistryService.validateModelAvailability(
agent.modelId,
workspace,
);
}
}
const registeredModel =
await this.aiModelRegistryService.resolveModelForAgent(agent);
@@ -2,6 +2,7 @@ import { UseGuards, UseInterceptors } from '@nestjs/common';
import { Args, Mutation, Query } from '@nestjs/graphql';
import { PermissionFlagType } from 'twenty-shared/constants';
import { isDefined } from 'twenty-shared/utils';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
@@ -15,6 +16,7 @@ import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.g
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { fromFlatAgentWithRoleIdToAgentDto } from 'src/engine/metadata-modules/flat-agent/utils/from-agent-entity-to-agent-dto.util';
import { WorkspaceMigrationGraphqlApiExceptionInterceptor } from 'src/engine/workspace-manager/workspace-migration/interceptors/workspace-migration-graphql-api-exception.interceptor';
import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service';
import { AgentService } from './agent.service';
@@ -35,7 +37,10 @@ import { AgentGraphqlApiExceptionInterceptor } from './interceptors/agent-graphq
)
@MetadataResolver()
export class AgentResolver {
constructor(private readonly agentService: AgentService) {}
constructor(
private readonly agentService: AgentService,
private readonly aiModelRegistryService: AiModelRegistryService,
) {}
@Query(() => [AgentDTO])
@RequireFeatureFlag(FeatureFlagKey.IS_AI_ENABLED)
@@ -67,11 +72,18 @@ export class AgentResolver {
@UseGuards(SettingsPermissionGuard(PermissionFlagType.AI_SETTINGS))
async createOneAgent(
@Args('input') input: CreateAgentInput,
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<AgentDTO> {
if (isDefined(input.modelId)) {
this.aiModelRegistryService.validateModelAvailability(
input.modelId,
workspace,
);
}
const createdAgent = await this.agentService.createOneAgent(
{ ...input, isCustom: true },
workspaceId,
workspace.id,
);
return fromFlatAgentWithRoleIdToAgentDto(createdAgent);
@@ -82,11 +94,18 @@ export class AgentResolver {
@UseGuards(SettingsPermissionGuard(PermissionFlagType.AI_SETTINGS))
async updateOneAgent(
@Args('input') input: UpdateAgentInput,
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<AgentDTO> {
if (isDefined(input.modelId)) {
this.aiModelRegistryService.validateModelAvailability(
input.modelId,
workspace,
);
}
const updatedAgent = await this.agentService.updateOneAgent({
input,
workspaceId,
workspaceId: workspace.id,
});
return fromFlatAgentWithRoleIdToAgentDto(updatedAgent);
@@ -64,15 +64,20 @@ export class AgentChatController {
@AuthWorkspace() workspace: WorkspaceEntity,
@Res() response: Response,
) {
const availableModels = this.aiModelRegistryService.getAvailableModels();
if (availableModels.length === 0) {
if (this.aiModelRegistryService.getAvailableModels().length === 0) {
throw new AgentException(
'No AI models are available. Please configure at least one AI provider API key (OPENAI_API_KEY, ANTHROPIC_API_KEY, or XAI_API_KEY).',
AgentExceptionCode.API_KEY_NOT_CONFIGURED,
);
}
const resolvedModelId = workspace.smartModel;
this.aiModelRegistryService.validateModelAvailability(
resolvedModelId,
workspace,
);
if (this.twentyConfigService.get('IS_BILLING_ENABLED')) {
const canBill = await this.billingService.canBillMeteredProduct(
workspace.id,
@@ -126,10 +126,13 @@ export class ChatExecutionService {
const preloadedToolNames = Object.keys(preloadedTools);
// Respect the workspace's model preference (Settings > AI > Model Router)
const modelId = workspace.smartModel;
this.aiModelRegistryService.validateModelAvailability(modelId, workspace);
const registeredModel =
await this.aiModelRegistryService.resolveModelForAgent({
modelId: workspace.smartModel,
modelId,
});
const modelConfig = this.aiModelRegistryService.getEffectiveModelConfig(
@@ -101,4 +101,5 @@ export interface AIModelConfig {
twitterSearch?: boolean;
};
deprecated?: boolean;
isRecommended?: boolean;
}
@@ -40,6 +40,7 @@ export const ANTHROPIC_MODELS: AIModelConfig[] = [
nativeCapabilities: {
webSearch: true,
},
isRecommended: true,
},
{
modelId: 'claude-sonnet-4-6',
@@ -75,6 +76,7 @@ export const ANTHROPIC_MODELS: AIModelConfig[] = [
nativeCapabilities: {
webSearch: true,
},
isRecommended: true,
},
{
modelId: 'claude-sonnet-4-5-20250929',
@@ -26,6 +26,7 @@ export const GOOGLE_MODELS: AIModelConfig[] = [
maxOutputTokens: 65536,
supportedFileTypes: ['image/png', 'image/jpeg', 'image/gif', 'image/webp'],
doesSupportThinking: true,
isRecommended: true,
},
{
modelId: 'gemini-3-flash-preview',
@@ -23,6 +23,7 @@ export const OPENAI_MODELS: AIModelConfig[] = [
nativeCapabilities: {
webSearch: true,
},
isRecommended: true,
},
{
modelId: 'gpt-5-mini',
@@ -55,6 +56,7 @@ export const OPENAI_MODELS: AIModelConfig[] = [
nativeCapabilities: {
webSearch: true,
},
isRecommended: true,
},
{
modelId: 'gpt-4.1-mini',
@@ -23,6 +23,7 @@ export const XAI_MODELS: AIModelConfig[] = [
webSearch: true,
twitterSearch: true,
},
isRecommended: true,
},
{
modelId: 'grok-4-1-fast-reasoning',
@@ -32,6 +32,10 @@ import { GROQ_MODELS } from 'src/engine/metadata-modules/ai/ai-models/constants/
import { MISTRAL_MODELS } from 'src/engine/metadata-modules/ai/ai-models/constants/mistral-models.const';
import { OPENAI_MODELS } from 'src/engine/metadata-modules/ai/ai-models/constants/openai-models.const';
import { XAI_MODELS } from 'src/engine/metadata-modules/ai/ai-models/constants/xai-models.const';
import {
isModelAllowedByWorkspace,
type WorkspaceModelAvailabilitySettings,
} from 'src/engine/metadata-modules/ai/ai-models/utils/is-model-allowed.util';
export interface RegisteredAIModel {
modelId: string;
@@ -371,6 +375,97 @@ export class AiModelRegistryService {
return providerToFamily[inferenceProvider] ?? ModelFamily.OPENAI;
}
isModelAdminAllowed(modelId: string): boolean {
if (modelId === DEFAULT_FAST_MODEL || modelId === DEFAULT_SMART_MODEL) {
return true;
}
const autoEnable = this.twentyConfigService.get(
'AI_AUTO_ENABLE_NEW_MODELS',
);
const disabledIds = this.twentyConfigService.get('AI_DISABLED_MODEL_IDS');
const enabledIds = this.twentyConfigService.get('AI_ENABLED_MODEL_IDS');
return autoEnable
? !disabledIds.includes(modelId)
: enabledIds.includes(modelId);
}
validateModelAvailability(
modelId: string,
workspace: WorkspaceModelAvailabilitySettings,
): void {
if (!this.isModelAdminAllowed(modelId)) {
throw new AgentException(
'The selected model has been disabled by the administrator.',
AgentExceptionCode.AGENT_EXECUTION_FAILED,
);
}
if (!isModelAllowedByWorkspace(modelId, workspace)) {
throw new AgentException(
'The selected model is not available in this workspace.',
AgentExceptionCode.AGENT_EXECUTION_FAILED,
);
}
}
getAdminFilteredModels(): RegisteredAIModel[] {
return this.getAvailableModels().filter((model) =>
this.isModelAdminAllowed(model.modelId),
);
}
getAllModelsWithStatus(): Array<{
modelConfig: AIModelConfig;
isAvailable: boolean;
isAdminEnabled: boolean;
}> {
return AI_MODELS.map((model) => ({
modelConfig: model,
isAvailable: this.modelRegistry.has(model.modelId),
isAdminEnabled: this.isModelAdminAllowed(model.modelId),
}));
}
async setModelAdminEnabled(modelId: string, enabled: boolean): Promise<void> {
const isKnownModel = AI_MODELS.some((model) => model.modelId === modelId);
if (!isKnownModel) {
throw new AgentException(
`Unknown model ID: ${modelId}`,
AgentExceptionCode.AGENT_EXECUTION_FAILED,
);
}
const autoEnable = this.twentyConfigService.get(
'AI_AUTO_ENABLE_NEW_MODELS',
);
const disabledIds = this.twentyConfigService.get('AI_DISABLED_MODEL_IDS');
const enabledIds = this.twentyConfigService.get('AI_ENABLED_MODEL_IDS');
if (autoEnable) {
const newDisabledIds = enabled
? disabledIds.filter((id) => id !== modelId)
: disabledIds.includes(modelId)
? disabledIds
: [...disabledIds, modelId];
await this.twentyConfigService.set(
'AI_DISABLED_MODEL_IDS',
newDisabledIds,
);
} else {
const newEnabledIds = enabled
? enabledIds.includes(modelId)
? enabledIds
: [...enabledIds, modelId]
: enabledIds.filter((id) => id !== modelId);
await this.twentyConfigService.set('AI_ENABLED_MODEL_IDS', newEnabledIds);
}
}
refreshRegistry(): void {
this.buildModelRegistry();
}
@@ -0,0 +1,35 @@
import {
AI_MODELS,
DEFAULT_FAST_MODEL,
DEFAULT_SMART_MODEL,
} from 'src/engine/metadata-modules/ai/ai-models/constants/ai-models.const';
export type WorkspaceModelAvailabilitySettings = {
useRecommendedModels: boolean;
autoEnableNewAiModels: boolean;
disabledAiModelIds: string[];
enabledAiModelIds: string[];
};
const RECOMMENDED_MODEL_IDS = new Set(
AI_MODELS.filter((model) => model.isRecommended).map(
(model) => model.modelId,
),
);
export const isModelAllowedByWorkspace = (
modelId: string,
workspace: WorkspaceModelAvailabilitySettings,
): boolean => {
if (modelId === DEFAULT_FAST_MODEL || modelId === DEFAULT_SMART_MODEL) {
return true;
}
if (workspace.useRecommendedModels) {
return RECOMMENDED_MODEL_IDS.has(modelId);
}
return workspace.autoEnableNewAiModels
? !workspace.disabledAiModelIds.includes(modelId)
: workspace.enabledAiModelIds.includes(modelId);
};