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:
@@ -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()
|
||||
|
||||
+5
-3
@@ -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',
|
||||
|
||||
+22
@@ -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;
|
||||
}
|
||||
|
||||
+49
@@ -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,
|
||||
|
||||
+2
@@ -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],
|
||||
|
||||
+16
@@ -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);
|
||||
|
||||
+8
-3
@@ -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,
|
||||
|
||||
+5
-2
@@ -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(
|
||||
|
||||
+1
@@ -101,4 +101,5 @@ export interface AIModelConfig {
|
||||
twitterSearch?: boolean;
|
||||
};
|
||||
deprecated?: boolean;
|
||||
isRecommended?: boolean;
|
||||
}
|
||||
|
||||
+2
@@ -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',
|
||||
|
||||
+1
@@ -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',
|
||||
|
||||
+2
@@ -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',
|
||||
|
||||
+1
@@ -23,6 +23,7 @@ export const XAI_MODELS: AIModelConfig[] = [
|
||||
webSearch: true,
|
||||
twitterSearch: true,
|
||||
},
|
||||
isRecommended: true,
|
||||
},
|
||||
{
|
||||
modelId: 'grok-4-1-fast-reasoning',
|
||||
|
||||
+95
@@ -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();
|
||||
}
|
||||
|
||||
+35
@@ -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);
|
||||
};
|
||||
Reference in New Issue
Block a user