diff --git a/packages/twenty-server/.env.example b/packages/twenty-server/.env.example index 2a034c41c9..04d5a3e4c1 100644 --- a/packages/twenty-server/.env.example +++ b/packages/twenty-server/.env.example @@ -96,5 +96,8 @@ FRONTEND_URL=http://localhost:3001 # Add custom providers (private gateway, extra regions, etc.): # AI_PROVIDERS='{"my-gateway":{"type":"openai-compatible","baseUrl":"...","apiKey":"..."}}' # -# Override model preferences (disabled, recommended, defaults): -# AI_MODEL_PREFERENCES='{"recommendedModels":["openai/gpt-5.2"],"defaultFastModels":["openai/gpt-5-mini"]}' +# Comma-separated model IDs (JSON array syntax also accepted, e.g. '["a","b"]'): +# AI_MODELS_DEFAULT_FAST=openai/gpt-5-mini,anthropic/claude-haiku-4-5-20251001 +# AI_MODELS_DEFAULT_SMART=openai/gpt-5.2,anthropic/claude-sonnet-4-6 +# AI_MODELS_DEFAULT_RECOMMENDED=openai/gpt-5.2,openai/gpt-4.1,anthropic/claude-sonnet-4-6 +# AI_MODELS_DEFAULT_DISABLED= diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-9/2-9-instance-command-slow-1799000010000-migrate-ai-model-preferences.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-9/2-9-instance-command-slow-1799000010000-migrate-ai-model-preferences.ts new file mode 100644 index 0000000000..9bf4168d6a --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-9/2-9-instance-command-slow-1799000010000-migrate-ai-model-preferences.ts @@ -0,0 +1,118 @@ +import { Logger } from '@nestjs/common'; + +import { isDefined } from 'twenty-shared/utils'; +import { DataSource, IsNull, QueryRunner } from 'typeorm'; + +import { + KeyValuePairEntity, + KeyValuePairType, +} from 'src/engine/core-modules/key-value-pair/key-value-pair.entity'; +import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator'; +import { SlowInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/slow-instance-command.interface'; +import { aiModelPreferencesSchema } from 'src/engine/metadata-modules/ai/ai-models/types/ai-model-preferences.schema'; + +const NEW_KEYS = [ + 'AI_MODELS_DEFAULT_FAST', + 'AI_MODELS_DEFAULT_SMART', + 'AI_MODELS_DEFAULT_RECOMMENDED', + 'AI_MODELS_DEFAULT_DISABLED', +] as const; + +const PREFERENCE_KEY_MAP = { + AI_MODELS_DEFAULT_FAST: 'defaultFastModels', + AI_MODELS_DEFAULT_SMART: 'defaultSmartModels', + AI_MODELS_DEFAULT_RECOMMENDED: 'recommendedModels', + AI_MODELS_DEFAULT_DISABLED: 'disabledModels', +} as const; + +@RegisteredInstanceCommand('2.9.0', 1799000010000, { type: 'slow' }) +export class MigrateAiModelPreferencesSlowInstanceCommand + implements SlowInstanceCommand +{ + private readonly logger = new Logger( + MigrateAiModelPreferencesSlowInstanceCommand.name, + ); + + async runDataMigration(dataSource: DataSource): Promise { + const keyValuePairRepository = dataSource.getRepository(KeyValuePairEntity); + + const existingRow = await keyValuePairRepository.findOne({ + where: { + type: KeyValuePairType.CONFIG_VARIABLE, + key: 'AI_MODEL_PREFERENCES', + userId: IsNull(), + workspaceId: IsNull(), + }, + }); + + if (!isDefined(existingRow)) { + this.logger.log( + 'No server-level AI_MODEL_PREFERENCES row found, skipping', + ); + + return; + } + + const parseResult = aiModelPreferencesSchema.safeParse(existingRow.value); + + if (!parseResult.success) { + this.logger.error( + `Failed to parse server-level AI_MODEL_PREFERENCES: ${parseResult.error.message}`, + ); + + return; + } + + const prefs = parseResult.data; + + this.logger.log('Migrating server-level AI_MODEL_PREFERENCES'); + + await dataSource.transaction(async (manager) => { + const transactionalRepository = manager.getRepository(KeyValuePairEntity); + + for (const newKey of NEW_KEYS) { + const prefField = PREFERENCE_KEY_MAP[newKey]; + const value = prefs[prefField]; + + if (!isDefined(value) || value.length === 0) { + continue; + } + + const existingNewKeyCount = await transactionalRepository.count({ + where: { + type: KeyValuePairType.CONFIG_VARIABLE, + key: newKey, + userId: IsNull(), + workspaceId: IsNull(), + }, + }); + + if (existingNewKeyCount > 0) { + continue; + } + + await transactionalRepository.insert({ + key: newKey, + value: value as unknown as JSON, + type: KeyValuePairType.CONFIG_VARIABLE, + userId: null, + workspaceId: null, + }); + } + + await transactionalRepository.delete({ id: existingRow.id }); + }); + + this.logger.log( + 'Migrated server-level AI_MODEL_PREFERENCES to 4 individual vars', + ); + } + + public async up(_queryRunner: QueryRunner): Promise { + return; + } + + public async down(_queryRunner: QueryRunner): Promise { + return; + } +} diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-9/2-9-upgrade-version-command.module.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-9/2-9-upgrade-version-command.module.ts new file mode 100644 index 0000000000..ea78de774d --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-9/2-9-upgrade-version-command.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { WorkspaceIteratorModule } from 'src/database/commands/command-runners/workspace-iterator.module'; +import { MigrateAiModelPreferencesCommand } from 'src/database/commands/upgrade-version-command/2-9/2-9-workspace-command-1799000000000-migrate-ai-model-preferences.command'; +import { KeyValuePairEntity } from 'src/engine/core-modules/key-value-pair/key-value-pair.entity'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([KeyValuePairEntity]), + WorkspaceIteratorModule, + ], + providers: [MigrateAiModelPreferencesCommand], +}) +export class V2_9_UpgradeVersionCommandModule {} diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-9/2-9-workspace-command-1799000000000-migrate-ai-model-preferences.command.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-9/2-9-workspace-command-1799000000000-migrate-ai-model-preferences.command.ts new file mode 100644 index 0000000000..deb106e013 --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-9/2-9-workspace-command-1799000000000-migrate-ai-model-preferences.command.ts @@ -0,0 +1,136 @@ +import { InjectRepository } from '@nestjs/typeorm'; + +import { Command } from 'nest-commander'; +import { IsNull, Repository } from 'typeorm'; + +import { isArray, isDefined } from 'class-validator'; +import { ActiveOrSuspendedWorkspaceCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspace.command-runner'; +import { WorkspaceIteratorService } from 'src/database/commands/command-runners/workspace-iterator.service'; +import { type RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspace.command-runner'; +import { + KeyValuePairEntity, + KeyValuePairType, +} from 'src/engine/core-modules/key-value-pair/key-value-pair.entity'; +import { RegisteredWorkspaceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-workspace-command.decorator'; +import { aiModelPreferencesSchema } from 'src/engine/metadata-modules/ai/ai-models/types/ai-model-preferences.schema'; + +const NEW_KEYS = [ + 'AI_MODELS_DEFAULT_FAST', + 'AI_MODELS_DEFAULT_SMART', + 'AI_MODELS_DEFAULT_RECOMMENDED', + 'AI_MODELS_DEFAULT_DISABLED', +] as const; + +const PREFERENCE_KEY_MAP = { + AI_MODELS_DEFAULT_FAST: 'defaultFastModels', + AI_MODELS_DEFAULT_SMART: 'defaultSmartModels', + AI_MODELS_DEFAULT_RECOMMENDED: 'recommendedModels', + AI_MODELS_DEFAULT_DISABLED: 'disabledModels', +} as const; + +@RegisteredWorkspaceCommand('2.9.0', 1799000000000) +@Command({ + name: 'upgrade:2-9:migrate-ai-model-preferences', + description: + 'Migrate AI_MODEL_PREFERENCES config var to the four individual AI_MODELS_DEFAULT_* vars, per workspace', +}) +export class MigrateAiModelPreferencesCommand extends ActiveOrSuspendedWorkspaceCommandRunner { + constructor( + protected readonly workspaceIteratorService: WorkspaceIteratorService, + @InjectRepository(KeyValuePairEntity) + private readonly keyValuePairRepository: Repository, + ) { + super(workspaceIteratorService); + } + + override async runOnWorkspace({ + workspaceId, + options, + }: RunOnWorkspaceArgs): Promise { + const isDryRun = options.dryRun ?? false; + + const existingPreferencesRow = await this.keyValuePairRepository.findOne({ + where: { + key: 'AI_MODEL_PREFERENCES', + type: KeyValuePairType.CONFIG_VARIABLE, + workspaceId, + userId: IsNull(), + }, + }); + + if (existingPreferencesRow === null) { + this.logger.log( + `No AI_MODEL_PREFERENCES row found for workspace ${workspaceId}, skipping`, + ); + + return; + } + + const parseResult = aiModelPreferencesSchema.safeParse( + existingPreferencesRow.value, + ); + + if (!parseResult.success) { + this.logger.error( + `Failed to parse AI_MODEL_PREFERENCES for workspace ${workspaceId}: ${parseResult.error.message}`, + ); + + return; + } + + const prefs = parseResult.data; + + this.logger.log( + `${isDryRun ? '[DRY RUN] ' : ''}Migrating AI_MODEL_PREFERENCES for workspace ${workspaceId}`, + ); + + for (const newKey of NEW_KEYS) { + const prefField = PREFERENCE_KEY_MAP[newKey]; + if ( + !isDefined(prefField) || + (isArray(prefs[prefField]) && prefs[prefField].length === 0) + ) { + continue; + } + const value = prefs[prefField]; + + if (isDryRun) { + this.logger.log( + `[DRY RUN] Would insert ${newKey} = ${JSON.stringify(value)} for workspace ${workspaceId}`, + ); + continue; + } + + const existingNewKeyRow = await this.keyValuePairRepository.findOne({ + where: { + key: newKey, + type: KeyValuePairType.CONFIG_VARIABLE, + workspaceId, + userId: IsNull(), + }, + }); + + if (existingNewKeyRow !== null) { + continue; + } + + await this.keyValuePairRepository.insert({ + key: newKey, + value: value as unknown as JSON, + type: KeyValuePairType.CONFIG_VARIABLE, + workspaceId, + userId: null, + }); + } + + if (!isDryRun) { + await this.keyValuePairRepository.delete({ + id: existingPreferencesRow.id, + }); + + this.logger.log( + `Migrated AI_MODEL_PREFERENCES to 4 individual vars for workspace ${workspaceId}`, + ); + } + } +} diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/instance-commands.constant.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/instance-commands.constant.ts index 2ded841978..97ed228082 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/instance-commands.constant.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/instance-commands.constant.ts @@ -55,6 +55,7 @@ import { AddChannelSyncStageIndexesFastInstanceCommand } from 'src/database/comm import { FinalizeRolePermissionFlagCutoverFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-7/2-7-instance-command-fast-1779600000000-finalize-role-permission-flag-cutover'; import { AddSubFieldNameToIndexFieldMetadataFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-8/2-8-instance-command-fast-1798200000000-add-sub-field-name-to-index-field-metadata'; import { DropFieldMetadataIsUniqueColumnFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-8/2-8-instance-command-fast-1798300000000-drop-field-metadata-is-unique-column'; +import { MigrateAiModelPreferencesSlowInstanceCommand } from 'src/database/commands/upgrade-version-command/2-9/2-9-instance-command-slow-1799000010000-migrate-ai-model-preferences'; export const INSTANCE_COMMANDS = [ AddViewFieldGroupIdIndexOnViewFieldFastInstanceCommand, @@ -112,4 +113,5 @@ export const INSTANCE_COMMANDS = [ FinalizeRolePermissionFlagCutoverFastInstanceCommand, AddSubFieldNameToIndexFieldMetadataFastInstanceCommand, DropFieldMetadataIsUniqueColumnFastInstanceCommand, + MigrateAiModelPreferencesSlowInstanceCommand, ]; diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/workspace-command-provider.module.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/workspace-command-provider.module.ts index 8181d62cc2..96b1dd0b92 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/workspace-command-provider.module.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/workspace-command-provider.module.ts @@ -11,6 +11,7 @@ import { V2_4_UpgradeVersionCommandModule } from 'src/database/commands/upgrade- import { V2_5_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/2-5/2-5-upgrade-version-command.module'; import { V2_7_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/2-7/2-7-upgrade-version-command.module'; import { V2_8_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/2-8/2-8-upgrade-version-command.module'; +import { V2_9_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/2-9/2-9-upgrade-version-command.module'; @Module({ imports: [ @@ -25,6 +26,7 @@ import { V2_8_UpgradeVersionCommandModule } from 'src/database/commands/upgrade- V2_5_UpgradeVersionCommandModule, V2_7_UpgradeVersionCommandModule, V2_8_UpgradeVersionCommandModule, + V2_9_UpgradeVersionCommandModule, ], }) export class WorkspaceCommandProviderModule {} diff --git a/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts b/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts index 3cca56c594..79f3c5c28f 100644 --- a/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts +++ b/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts @@ -42,9 +42,13 @@ import { ConfigVariableException, ConfigVariableExceptionCode, } from 'src/engine/core-modules/twenty-config/twenty-config.exception'; -import { type AiModelPreferences } from 'src/engine/metadata-modules/ai/ai-models/types/ai-model-preferences.type'; import { type AiProvidersConfig } from 'src/engine/metadata-modules/ai/ai-models/types/ai-providers-config.type'; -import { DEFAULT_MODEL_PREFERENCES } from 'src/engine/metadata-modules/ai/ai-models/utils/load-default-model-preferences.util'; +import { + DEFAULT_DISABLED_MODELS, + DEFAULT_FAST_MODELS, + DEFAULT_RECOMMENDED_MODELS, + DEFAULT_SMART_MODELS, +} from 'src/engine/metadata-modules/ai/ai-models/utils/load-default-model-preferences.util'; export class ConfigVariables { @ConfigVariablesMetadata({ @@ -1435,20 +1439,38 @@ export class ConfigVariables { @ConfigVariablesMetadata({ group: ConfigVariablesGroup.LLM, description: - 'AI model admin preferences: disabled models, recommended models, and default fast/smart model lists. Managed via admin panel or env.', - type: ConfigVariableType.JSON, + 'Ordered list of fast model IDs to use as defaults. Managed via admin panel or env.', + type: ConfigVariableType.ARRAY, }) @IsOptional() - AI_MODEL_PREFERENCES: AiModelPreferences = DEFAULT_MODEL_PREFERENCES; + AI_MODELS_DEFAULT_FAST: string[] = DEFAULT_FAST_MODELS; @ConfigVariablesMetadata({ group: ConfigVariablesGroup.LLM, description: - 'Storage path for AI model preferences fallback (e.g. config/ai-model-preferences.json). Loaded at startup and used only when no value is set via AI_MODEL_PREFERENCES env var or the database.', - type: ConfigVariableType.STRING, + 'Ordered list of smart model IDs to use as defaults. Managed via admin panel or env.', + type: ConfigVariableType.ARRAY, }) @IsOptional() - AI_MODEL_PREFERENCES_STORAGE_PATH?: string; + AI_MODELS_DEFAULT_SMART: string[] = DEFAULT_SMART_MODELS; + + @ConfigVariablesMetadata({ + group: ConfigVariablesGroup.LLM, + description: + 'List of recommended model IDs shown to workspaces using curated model selection. Managed via admin panel or env.', + type: ConfigVariableType.ARRAY, + }) + @IsOptional() + AI_MODELS_DEFAULT_RECOMMENDED: string[] = DEFAULT_RECOMMENDED_MODELS; + + @ConfigVariablesMetadata({ + group: ConfigVariablesGroup.LLM, + description: + 'List of model IDs disabled by default. Disabled models cannot be used by any workspace. Managed via admin panel or env.', + type: ConfigVariableType.ARRAY, + }) + @IsOptional() + AI_MODELS_DEFAULT_DISABLED: string[] = DEFAULT_DISABLED_MODELS; @ConfigVariablesMetadata({ group: ConfigVariablesGroup.SERVER_CONFIG, diff --git a/packages/twenty-server/src/engine/core-modules/twenty-config/decorators/cast-to-meter-driver.decorator.ts b/packages/twenty-server/src/engine/core-modules/twenty-config/decorators/cast-to-meter-driver.decorator.ts index e8d2512eef..fdbad5a1eb 100644 --- a/packages/twenty-server/src/engine/core-modules/twenty-config/decorators/cast-to-meter-driver.decorator.ts +++ b/packages/twenty-server/src/engine/core-modules/twenty-config/decorators/cast-to-meter-driver.decorator.ts @@ -1,3 +1,4 @@ +import { isNonEmptyString } from '@sniptt/guards'; import { Transform } from 'class-transformer'; import { MeterDriver } from 'src/engine/core-modules/metrics/types/meter-driver.type'; @@ -6,7 +7,7 @@ export const CastToMeterDriverArray = () => Transform(({ value }: { value: string }) => toMeterDriverArray(value)); const toMeterDriverArray = (value: string | undefined) => { - if (typeof value === 'string') { + if (isNonEmptyString(value)) { const rawMeterDrivers = value.split(',').map((driver) => driver.trim()); const isInvalid = rawMeterDrivers.some( (driver) => !Object.values(MeterDriver).includes(driver as MeterDriver), diff --git a/packages/twenty-server/src/engine/core-modules/twenty-config/utils/__tests__/type-transformers.registry.spec.ts b/packages/twenty-server/src/engine/core-modules/twenty-config/utils/__tests__/type-transformers.registry.spec.ts index 2389ea89b8..482283ba55 100644 --- a/packages/twenty-server/src/engine/core-modules/twenty-config/utils/__tests__/type-transformers.registry.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/twenty-config/utils/__tests__/type-transformers.registry.spec.ts @@ -1,4 +1,9 @@ +import { plainToInstance } from 'class-transformer'; +import { validateSync } from 'class-validator'; + +import { ConfigVariablesMetadata } from 'src/engine/core-modules/twenty-config/decorators/config-variables-metadata.decorator'; import { ConfigVariableType } from 'src/engine/core-modules/twenty-config/enums/config-variable-type.enum'; +import { ConfigVariablesGroup } from 'src/engine/core-modules/twenty-config/enums/config-variables-group.enum'; import { typeTransformers } from 'src/engine/core-modules/twenty-config/utils/type-transformers.registry'; describe('Type Transformers Registry', () => { @@ -141,6 +146,75 @@ describe('Type Transformers Registry', () => { }).toThrow(); }); }); + + describe('class-transformer transformer (env-loading path)', () => { + class TestArrayConfig { + @ConfigVariablesMetadata({ + group: ConfigVariablesGroup.ADVANCED_SETTINGS, + description: 'Test array', + type: ConfigVariableType.ARRAY, + }) + ARRAY_VALUE: string[] = []; + } + + const transformAndValidate = (raw: unknown) => { + const instance = plainToInstance(TestArrayConfig, { + ARRAY_VALUE: raw, + }); + const errors = validateSync(instance, { strictGroups: true }); + + return { instance, errors }; + }; + + it('should parse comma-separated string into array', () => { + const { instance, errors } = transformAndValidate('a,b,c'); + + expect(instance.ARRAY_VALUE).toEqual(['a', 'b', 'c']); + expect(errors).toHaveLength(0); + }); + + it('should parse JSON-encoded array string', () => { + const { instance, errors } = transformAndValidate('["a","b","c"]'); + + expect(instance.ARRAY_VALUE).toEqual(['a', 'b', 'c']); + expect(errors).toHaveLength(0); + }); + + it('should parse empty JSON array string', () => { + const { instance, errors } = transformAndValidate('[]'); + + expect(instance.ARRAY_VALUE).toEqual([]); + expect(errors).toHaveLength(0); + }); + + it('should wrap a single value in an array', () => { + const { instance, errors } = transformAndValidate('openai/gpt-4.1'); + + expect(instance.ARRAY_VALUE).toEqual(['openai/gpt-4.1']); + expect(errors).toHaveLength(0); + }); + + it('should trim whitespace around comma-separated items', () => { + const { instance, errors } = transformAndValidate(' a , b , c '); + + expect(instance.ARRAY_VALUE).toEqual(['a', 'b', 'c']); + expect(errors).toHaveLength(0); + }); + + it('should drop empty entries from comma-separated input', () => { + const { instance, errors } = transformAndValidate('a,,b,'); + + expect(instance.ARRAY_VALUE).toEqual(['a', 'b']); + expect(errors).toHaveLength(0); + }); + + it('should keep already-array values unchanged', () => { + const { instance, errors } = transformAndValidate(['a', 'b']); + + expect(instance.ARRAY_VALUE).toEqual(['a', 'b']); + expect(errors).toHaveLength(0); + }); + }); }); describe('Enum Transformer', () => { diff --git a/packages/twenty-server/src/engine/core-modules/twenty-config/utils/type-transformers.registry.ts b/packages/twenty-server/src/engine/core-modules/twenty-config/utils/type-transformers.registry.ts index ea60cf7c09..f1c892561c 100644 --- a/packages/twenty-server/src/engine/core-modules/twenty-config/utils/type-transformers.registry.ts +++ b/packages/twenty-server/src/engine/core-modules/twenty-config/utils/type-transformers.registry.ts @@ -2,9 +2,11 @@ import { Transform } from 'class-transformer'; import { IsArray, IsBoolean, + isDefined, IsEnum, IsNumber, IsObject, + isString, IsString, } from 'class-validator'; @@ -15,6 +17,7 @@ import { } from 'src/engine/core-modules/twenty-config/twenty-config.exception'; import { type ConfigVariableOptions } from 'src/engine/core-modules/twenty-config/types/config-variable-options.type'; import { configTransformers } from 'src/engine/core-modules/twenty-config/utils/config-transformers.util'; +import { tryParseJsonArray } from 'src/utils/try-parse-json-array'; export interface TypeTransformer { toApp: (value: unknown, options?: ConfigVariableOptions) => T | undefined; @@ -193,7 +196,21 @@ export const typeTransformers: Record< getValidators: (): PropertyDecorator[] => [IsArray()], - getTransformers: (): PropertyDecorator[] => [], + getTransformers: (): PropertyDecorator[] => [ + Transform(({ value }) => { + if (Array.isArray(value)) return value; + if (!isString(value)) return value; + + const fromJson = tryParseJsonArray(value); + + if (isDefined(fromJson)) return fromJson; + + return value + .split(',') + .map((item) => item.trim()) + .filter((item) => item.length > 0); + }), + ], }, [ConfigVariableType.ENUM]: { diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/services/ai-model-preferences.service.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/services/ai-model-preferences.service.ts index e9a74d6172..bcb4aa44bb 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/services/ai-model-preferences.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/services/ai-model-preferences.service.ts @@ -1,59 +1,26 @@ -import { Injectable, Logger, type OnModuleInit } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; -import { FileStorageDriverFactory } from 'src/engine/core-modules/file-storage/file-storage-driver.factory'; -import { ConfigSource } from 'src/engine/core-modules/twenty-config/enums/config-source.enum'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; -import { aiModelPreferencesSchema } from 'src/engine/metadata-modules/ai/ai-models/types/ai-model-preferences.schema'; import { type AiModelPreferences } from 'src/engine/metadata-modules/ai/ai-models/types/ai-model-preferences.type'; import { AiModelRole } from 'src/engine/metadata-modules/ai/ai-models/types/ai-model-role.enum'; -import { streamToBuffer } from 'src/utils/stream-to-buffer'; @Injectable() -export class AiModelPreferencesService implements OnModuleInit { - private readonly logger = new Logger(AiModelPreferencesService.name); - private filePreferences: AiModelPreferences | null = null; - - constructor( - private readonly twentyConfigService: TwentyConfigService, - private readonly fileStorageDriverFactory: FileStorageDriverFactory, - ) {} - - async onModuleInit(): Promise { - const storagePath = this.twentyConfigService.get( - 'AI_MODEL_PREFERENCES_STORAGE_PATH', - ); - - if (!storagePath) { - return; - } - - try { - this.filePreferences = await this.fetchPreferences(storagePath); - this.logger.log( - `AI_MODEL_PREF - Loaded AI model preferences from storage: ${storagePath}`, - ); - } catch (error: unknown) { - const message = error instanceof Error ? error.message : String(error); - this.logger.error( - `AI_MODEL_PREF - Failed to load AI model preferences from storage: ${message}`, - ); - } - } +export class AiModelPreferencesService { + constructor(private readonly twentyConfigService: TwentyConfigService) {} getPreferences(): AiModelPreferences { - const { source } = - this.twentyConfigService.getVariableWithMetadata( - 'AI_MODEL_PREFERENCES', - ) ?? {}; - - if (source !== ConfigSource.DEFAULT) { - return this.twentyConfigService.get('AI_MODEL_PREFERENCES'); - } - - return ( - this.filePreferences ?? - this.twentyConfigService.get('AI_MODEL_PREFERENCES') - ); + return { + defaultFastModels: this.twentyConfigService.get('AI_MODELS_DEFAULT_FAST'), + defaultSmartModels: this.twentyConfigService.get( + 'AI_MODELS_DEFAULT_SMART', + ), + recommendedModels: this.twentyConfigService.get( + 'AI_MODELS_DEFAULT_RECOMMENDED', + ), + disabledModels: this.twentyConfigService.get( + 'AI_MODELS_DEFAULT_DISABLED', + ), + }; } getRecommendedModelIds(): Set { @@ -130,16 +97,23 @@ export class AiModelPreferencesService implements OnModuleInit { } private async persistPreferences(prefs: AiModelPreferences): Promise { - await this.twentyConfigService.set('AI_MODEL_PREFERENCES', prefs); - } - - private async fetchPreferences( - filePath: string, - ): Promise { - const driver = this.fileStorageDriverFactory.getCurrentDriver(); - const stream = await driver.readFile({ filePath }); - const body = (await streamToBuffer(stream)).toString('utf-8'); - - return aiModelPreferencesSchema.parse(JSON.parse(body)); + await Promise.all([ + this.twentyConfigService.set( + 'AI_MODELS_DEFAULT_FAST', + prefs.defaultFastModels ?? [], + ), + this.twentyConfigService.set( + 'AI_MODELS_DEFAULT_SMART', + prefs.defaultSmartModels ?? [], + ), + this.twentyConfigService.set( + 'AI_MODELS_DEFAULT_RECOMMENDED', + prefs.recommendedModels ?? [], + ), + this.twentyConfigService.set( + 'AI_MODELS_DEFAULT_DISABLED', + prefs.disabledModels ?? [], + ), + ]); } } diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/utils/load-default-model-preferences.util.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/utils/load-default-model-preferences.util.ts index d0038bcd98..0815293414 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/utils/load-default-model-preferences.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/utils/load-default-model-preferences.util.ts @@ -2,7 +2,7 @@ // instead of hardcoding model IDs that become stale as models evolve import { type AiModelPreferences } from 'src/engine/metadata-modules/ai/ai-models/types/ai-model-preferences.type'; -const DEFAULT_FAST_MODELS = [ +export const DEFAULT_FAST_MODELS = [ 'openai/gpt-5-mini', 'anthropic/claude-haiku-4-5-20251001', 'google/gemini-3-flash-preview', @@ -10,7 +10,7 @@ const DEFAULT_FAST_MODELS = [ 'mistral/mistral-large-latest', ]; -const DEFAULT_SMART_MODELS = [ +export const DEFAULT_SMART_MODELS = [ 'openai/gpt-5.2', 'anthropic/claude-sonnet-4-6', 'google/gemini-3.1-pro-preview', @@ -18,7 +18,7 @@ const DEFAULT_SMART_MODELS = [ 'mistral/mistral-large-latest', ]; -const DEFAULT_RECOMMENDED_MODELS = [ +export const DEFAULT_RECOMMENDED_MODELS = [ 'openai/gpt-5.2', 'openai/gpt-4.1', 'anthropic/claude-opus-4-6', @@ -27,6 +27,8 @@ const DEFAULT_RECOMMENDED_MODELS = [ 'xai/grok-4', ]; +export const DEFAULT_DISABLED_MODELS: string[] = []; + export const DEFAULT_MODEL_PREFERENCES: AiModelPreferences = { disabledModels: [], recommendedModels: DEFAULT_RECOMMENDED_MODELS, diff --git a/packages/twenty-server/src/utils/try-parse-json-array.ts b/packages/twenty-server/src/utils/try-parse-json-array.ts new file mode 100644 index 0000000000..503d76e435 --- /dev/null +++ b/packages/twenty-server/src/utils/try-parse-json-array.ts @@ -0,0 +1,9 @@ +export const tryParseJsonArray = (value: string): unknown[] | null => { + try { + const parsed = JSON.parse(value); + + return Array.isArray(parsed) ? parsed : null; + } catch { + return null; + } +};