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
@@ -7,6 +7,7 @@ import { PermissionFlagType } from 'twenty-shared/constants';
import { AdminPanelHealthService } from 'src/engine/core-modules/admin-panel/admin-panel-health.service';
import { AdminPanelQueueService } from 'src/engine/core-modules/admin-panel/admin-panel-queue.service';
import { AdminPanelService } from 'src/engine/core-modules/admin-panel/admin-panel.service';
import { AdminAIModelsOutput } from 'src/engine/core-modules/client-config/client-config.entity';
import { ConfigVariableDTO } from 'src/engine/core-modules/admin-panel/dtos/config-variable.dto';
import { ConfigVariablesOutput } from 'src/engine/core-modules/admin-panel/dtos/config-variables.output';
import { DeleteJobsResponseDTO } from 'src/engine/core-modules/admin-panel/dtos/delete-jobs-response.dto';
@@ -30,6 +31,7 @@ import { type MessageQueue } from 'src/engine/core-modules/message-queue/message
import { type ConfigVariables } from 'src/engine/core-modules/twenty-config/config-variables';
import { ConfigVariableGraphqlApiExceptionFilter } from 'src/engine/core-modules/twenty-config/filters/config-variable-graphql-api-exception.filter';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { AdminPanelGuard } from 'src/engine/guards/admin-panel-guard';
import { ServerLevelImpersonateGuard } from 'src/engine/guards/server-level-impersonate.guard';
@@ -59,6 +61,7 @@ export class AdminPanelResolver {
private adminPanelQueueService: AdminPanelQueueService,
private featureFlagService: FeatureFlagService,
private readonly twentyConfigService: TwentyConfigService,
private readonly aiModelRegistryService: AiModelRegistryService,
) {}
@UseGuards(ServerLevelImpersonateGuard)
@@ -138,6 +141,41 @@ export class AdminPanelResolver {
return this.adminService.getVersionInfo();
}
@UseGuards(AdminPanelGuard)
@Query(() => AdminAIModelsOutput)
async getAdminAiModels(): Promise<AdminAIModelsOutput> {
const models = this.aiModelRegistryService
.getAllModelsWithStatus()
.map(({ modelConfig, isAvailable, isAdminEnabled }) => ({
modelId: modelConfig.modelId,
label: modelConfig.label,
modelFamily: modelConfig.modelFamily,
inferenceProvider: modelConfig.inferenceProvider,
isAvailable,
isAdminEnabled,
deprecated: modelConfig.deprecated,
isRecommended: modelConfig.isRecommended,
}));
return {
autoEnableNewModels: this.twentyConfigService.get(
'AI_AUTO_ENABLE_NEW_MODELS',
),
models,
};
}
@UseGuards(AdminPanelGuard)
@Mutation(() => Boolean)
async setAdminAiModelEnabled(
@Args('modelId', { type: () => String }) modelId: string,
@Args('enabled', { type: () => Boolean }) enabled: boolean,
): Promise<boolean> {
await this.aiModelRegistryService.setModelAdminEnabled(modelId, enabled);
return true;
}
@UseGuards(AdminPanelGuard)
@Query(() => ConfigVariableDTO)
async getDatabaseConfigVariable(
@@ -58,6 +58,45 @@ export class ClientAIModelConfig {
@Field(() => Boolean, { nullable: true })
deprecated?: boolean;
@Field(() => Boolean, { nullable: true })
isRecommended?: boolean;
}
@ObjectType()
export class AdminAIModelConfig {
@Field(() => String)
modelId: string;
@Field(() => String)
label: string;
@Field(() => ModelFamily, { nullable: true })
modelFamily?: ModelFamily;
@Field(() => InferenceProvider)
inferenceProvider: InferenceProvider;
@Field(() => Boolean)
isAvailable: boolean;
@Field(() => Boolean)
isAdminEnabled: boolean;
@Field(() => Boolean, { nullable: true })
deprecated?: boolean;
@Field(() => Boolean, { nullable: true })
isRecommended?: boolean;
}
@ObjectType()
export class AdminAIModelsOutput {
@Field(() => Boolean)
autoEnableNewModels: boolean;
@Field(() => [AdminAIModelConfig])
models: AdminAIModelConfig[];
}
@ObjectType()
@@ -43,7 +43,8 @@ export class ClientConfigService {
'CALENDAR_BOOKING_PAGE_ID',
);
const availableModels = this.aiModelRegistryService.getAvailableModels();
const availableModels =
this.aiModelRegistryService.getAdminFilteredModels();
const aiModels: ClientAIModelConfig[] = availableModels.map(
(registeredModel) => {
@@ -68,6 +69,7 @@ export class ClientConfigService {
)
: 0,
deprecated: builtInModel?.deprecated,
isRecommended: builtInModel?.isRecommended,
};
},
);
@@ -96,14 +98,14 @@ export class ClientConfigService {
aiModels.unshift(
{
modelId: DEFAULT_SMART_MODEL,
label: `Smart (${defaultPerformanceModelLabel})`,
label: `Best (${defaultPerformanceModelLabel})`,
inferenceProvider: InferenceProvider.NONE,
inputCostPerMillionTokensInCredits: 0,
outputCostPerMillionTokensInCredits: 0,
},
{
modelId: DEFAULT_FAST_MODEL,
label: `Fast (${defaultSpeedModelLabel})`,
label: `Best (${defaultSpeedModelLabel})`,
inferenceProvider: InferenceProvider.NONE,
inputCostPerMillionTokensInCredits: 0,
outputCostPerMillionTokensInCredits: 0,
@@ -1326,6 +1326,33 @@ export class ConfigVariables {
@IsOptional()
AWS_BEDROCK_SESSION_TOKEN: string;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.LLM,
description:
'When true, newly added models are automatically available to all workspaces',
type: ConfigVariableType.BOOLEAN,
})
@IsOptional()
AI_AUTO_ENABLE_NEW_MODELS = true;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.LLM,
description:
'Model IDs to disable (used when AI_AUTO_ENABLE_NEW_MODELS is true)',
type: ConfigVariableType.ARRAY,
})
@IsOptional()
AI_DISABLED_MODEL_IDS: string[] = [];
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.LLM,
description:
'Model IDs to enable (used when AI_AUTO_ENABLE_NEW_MODELS is false)',
type: ConfigVariableType.ARRAY,
})
@IsOptional()
AI_ENABLED_MODEL_IDS: string[] = [];
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.SERVER_CONFIG,
description: 'Enable or disable multi-workspace support',
@@ -127,4 +127,26 @@ export class UpdateWorkspaceInput {
@IsString({ each: true })
@IsOptional()
editableProfileFields?: string[];
@Field({ nullable: true })
@IsBoolean()
@IsOptional()
autoEnableNewAiModels?: boolean;
@Field(() => [String], { nullable: true })
@IsArray()
@IsString({ each: true })
@IsOptional()
disabledAiModelIds?: string[];
@Field(() => [String], { nullable: true })
@IsArray()
@IsString({ each: true })
@IsOptional()
enabledAiModelIds?: string[];
@Field({ nullable: true })
@IsBoolean()
@IsOptional()
useRecommendedModels?: boolean;
}
@@ -37,6 +37,8 @@ import {
WorkspaceExceptionCode,
WorkspaceNotFoundDefaultError,
} from 'src/engine/core-modules/workspace/workspace.exception';
import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service';
import { isModelAllowedByWorkspace } from 'src/engine/metadata-modules/ai/ai-models/utils/is-model-allowed.util';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { ALL_METADATA_ENTITY_BY_METADATA_NAME } from 'src/engine/metadata-modules/flat-entity/constant/all-metadata-entity-by-metadata-name.constant';
import { ALL_METADATA_NAMES_SORTED_ATOMICALLY } from 'src/engine/metadata-modules/flat-entity/constant/all-metadata-names-sorted-atomically.constant';
@@ -86,6 +88,10 @@ export class WorkspaceService extends TypeOrmQueryService<WorkspaceEntity> {
fastModel: PermissionFlagType.WORKSPACE,
smartModel: PermissionFlagType.WORKSPACE,
aiAdditionalInstructions: PermissionFlagType.WORKSPACE,
autoEnableNewAiModels: PermissionFlagType.AI_SETTINGS,
disabledAiModelIds: PermissionFlagType.AI_SETTINGS,
enabledAiModelIds: PermissionFlagType.AI_SETTINGS,
useRecommendedModels: PermissionFlagType.AI_SETTINGS,
};
constructor(
@@ -110,6 +116,7 @@ export class WorkspaceService extends TypeOrmQueryService<WorkspaceEntity> {
private readonly workspaceDataSourceService: WorkspaceDataSourceService,
private readonly customDomainManagerService: CustomDomainManagerService,
private readonly fileCorePictureService: FileCorePictureService,
private readonly aiModelRegistryService: AiModelRegistryService,
@InjectMessageQueue(MessageQueue.deleteCascadeQueue)
private readonly messageQueueService: MessageQueueService,
@InjectDataSource()
@@ -216,6 +223,48 @@ export class WorkspaceService extends TypeOrmQueryService<WorkspaceEntity> {
);
}
const isChangingModels =
isDefined(payload.smartModel) || isDefined(payload.fastModel);
const isChangingAvailability =
payload.useRecommendedModels !== undefined ||
payload.autoEnableNewAiModels !== undefined ||
payload.disabledAiModelIds !== undefined ||
payload.enabledAiModelIds !== undefined;
if (isChangingModels || isChangingAvailability) {
const effectiveWorkspace = {
useRecommendedModels:
payload.useRecommendedModels ?? workspace.useRecommendedModels,
autoEnableNewAiModels:
payload.autoEnableNewAiModels ?? workspace.autoEnableNewAiModels,
disabledAiModelIds:
payload.disabledAiModelIds ?? workspace.disabledAiModelIds,
enabledAiModelIds:
payload.enabledAiModelIds ?? workspace.enabledAiModelIds,
};
const modelsToValidate = [
payload.smartModel ?? workspace.smartModel,
payload.fastModel ?? workspace.fastModel,
].filter(isDefined);
for (const modelId of modelsToValidate) {
if (!this.aiModelRegistryService.isModelAdminAllowed(modelId)) {
throw new WorkspaceException(
'Selected model has been disabled by the administrator',
WorkspaceExceptionCode.ENVIRONMENT_VAR_NOT_ENABLED,
);
}
if (!isModelAllowedByWorkspace(modelId, effectiveWorkspace)) {
throw new WorkspaceException(
'Selected model is not available in this workspace',
WorkspaceExceptionCode.ENVIRONMENT_VAR_NOT_ENABLED,
);
}
}
}
let updatedWorkspace: WorkspaceEntity;
try {
@@ -314,6 +314,32 @@ export class WorkspaceEntity {
@Column({ type: 'text', nullable: true })
aiAdditionalInstructions: string | null;
@Field(() => Boolean, { nullable: false })
@Column({ type: 'boolean', nullable: false, default: true })
autoEnableNewAiModels: boolean;
@Field(() => [String], { nullable: true })
@Column({
type: 'varchar',
array: true,
nullable: false,
default: '{}',
})
disabledAiModelIds: string[];
@Field(() => [String], { nullable: true })
@Column({
type: 'varchar',
array: true,
nullable: false,
default: '{}',
})
enabledAiModelIds: string[];
@Field(() => Boolean, { nullable: false })
@Column({ type: 'boolean', nullable: false, default: true })
useRecommendedModels: boolean;
@Column({ nullable: false, type: 'uuid' })
workspaceCustomApplicationId: string;
@@ -260,6 +260,34 @@ export class WorkspaceResolver {
return workspace.smartModel;
}
@ResolveField(() => Boolean, { nullable: false })
async autoEnableNewAiModels(
@Parent() workspace: WorkspaceEntity,
): Promise<boolean> {
return workspace.autoEnableNewAiModels;
}
@ResolveField(() => [String], { nullable: true })
async disabledAiModelIds(
@Parent() workspace: WorkspaceEntity,
): Promise<string[]> {
return workspace.disabledAiModelIds;
}
@ResolveField(() => [String], { nullable: true })
async enabledAiModelIds(
@Parent() workspace: WorkspaceEntity,
): Promise<string[]> {
return workspace.enabledAiModelIds;
}
@ResolveField(() => Boolean, { nullable: false })
async useRecommendedModels(
@Parent() workspace: WorkspaceEntity,
): Promise<boolean> {
return workspace.useRecommendedModels;
}
@ResolveField(() => ApplicationDTO, { nullable: true })
async workspaceCustomApplication(
@Parent() workspace: WorkspaceEntity,