From 827f24df2b09afbe70bed56b7084815ababa6a0e Mon Sep 17 00:00:00 2001 From: Etienne <45695613+etiennejouan@users.noreply.github.com> Date: Tue, 19 May 2026 15:19:24 +0200 Subject: [PATCH] fix(ai) - add ai model preferences fallback (#20704) **Problem** AI_MODEL_PREFERENCES, JSON env var is not supported + IS_CONFIG_VARIABLES_IN_DB_ENABLED=false in twenty cloud server -> No option to set AI_MODEL_PREFERENCES **Solution** AI_MODEL_PREFERENCES supports three override sources beyond the hardcoded code defaults, in priority order: - DB (IS_CONFIG_VARIABLES_IN_DB_ENABLED=true), the only writable source; admin-panel mutations persist here - ENV not usable in Twenty Cloud, which does not handle JSON-format env vars - **Introduced in this PR** --> File (AI_MODEL_PREFERENCES_STORAGE_PATH), a read-only startup fallback, the only viable override in Cloud/self-managed deployments where DB config is disabled and JSON env vars are unsupported. --- .../admin-panel/admin-panel.resolver.ts | 4 +- .../twenty-config/config-variables.ts | 13 +++- .../ai/ai-models/ai-models.module.ts | 1 + .../services/ai-model-preferences.service.ts | 73 +++++++++++++++---- .../types/ai-model-preferences.schema.ts | 8 ++ .../load-default-model-preferences.util.ts | 12 ++- 6 files changed, 88 insertions(+), 23 deletions(-) create mode 100644 packages/twenty-server/src/engine/metadata-modules/ai/ai-models/types/ai-model-preferences.schema.ts diff --git a/packages/twenty-server/src/engine/core-modules/admin-panel/admin-panel.resolver.ts b/packages/twenty-server/src/engine/core-modules/admin-panel/admin-panel.resolver.ts index 6e284604b6..efdd0f8c28 100644 --- a/packages/twenty-server/src/engine/core-modules/admin-panel/admin-panel.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/admin-panel/admin-panel.resolver.ts @@ -66,6 +66,7 @@ import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.g import { UserAuthGuard } from 'src/engine/guards/user-auth.guard'; import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; import { MODEL_FAMILY_LABELS } from 'src/engine/metadata-modules/ai/ai-models/constants/model-family-labels.const'; +import { AiModelPreferencesService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-preferences.service'; import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service'; import { DefaultAiCatalogService } from 'src/engine/metadata-modules/ai/ai-models/services/default-ai-catalog.service'; import { ModelsDevCatalogService } from 'src/engine/metadata-modules/ai/ai-models/services/models-dev-catalog.service'; @@ -108,6 +109,7 @@ export class AdminPanelResolver { private featureFlagService: FeatureFlagService, private readonly twentyConfigService: TwentyConfigService, private readonly aiModelRegistryService: AiModelRegistryService, + private readonly aiModelPreferencesService: AiModelPreferencesService, private readonly defaultAiCatalogService: DefaultAiCatalogService, private readonly modelsDevCatalogService: ModelsDevCatalogService, private readonly usageAnalyticsService: UsageAnalyticsService, @@ -263,7 +265,7 @@ export class AdminPanelResolver { }), ); - const prefs = this.twentyConfigService.get('AI_MODEL_PREFERENCES'); + const prefs = this.aiModelPreferencesService.getPreferences(); return { models, 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 93d386c804..3b7299bee5 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 @@ -44,7 +44,7 @@ import { } 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 { loadDefaultModelPreferences } from 'src/engine/metadata-modules/ai/ai-models/utils/load-default-model-preferences.util'; +import { DEFAULT_MODEL_PREFERENCES } from 'src/engine/metadata-modules/ai/ai-models/utils/load-default-model-preferences.util'; export class ConfigVariables { @ConfigVariablesMetadata({ @@ -1429,7 +1429,16 @@ export class ConfigVariables { type: ConfigVariableType.JSON, }) @IsOptional() - AI_MODEL_PREFERENCES: AiModelPreferences = loadDefaultModelPreferences(); + AI_MODEL_PREFERENCES: AiModelPreferences = DEFAULT_MODEL_PREFERENCES; + + @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, + }) + @IsOptional() + AI_MODEL_PREFERENCES_STORAGE_PATH?: string; @ConfigVariablesMetadata({ group: ConfigVariablesGroup.SERVER_CONFIG, diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/ai-models.module.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/ai-models.module.ts index c13be8f1a2..21dd89b960 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/ai-models.module.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/ai-models.module.ts @@ -22,6 +22,7 @@ import { SdkProviderFactoryService } from 'src/engine/metadata-modules/ai/ai-mod exports: [ DefaultAiCatalogService, AiModelRegistryService, + AiModelPreferencesService, AiModelConfigService, SdkProviderFactoryService, ModelsDevCatalogService, 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 ff8c0f2a68..97f8d895b6 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,21 +1,56 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, Logger, type OnModuleInit } from '@nestjs/common'; +import { FileStorageDriverFactory } from 'src/engine/core-modules/file-storage/file-storage-driver.factory'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; -import { AiModelRole } from 'src/engine/metadata-modules/ai/ai-models/types/ai-model-role.enum'; +import { ConfigSource } from 'src/engine/core-modules/twenty-config/enums/config-source.enum'; +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 { - constructor(private readonly twentyConfigService: TwentyConfigService) {} +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; + } + + this.filePreferences = await this.fetchPreferences(storagePath); + this.logger.log( + `AI_MODEL_PREF - Loaded AI model preferences from storage: ${storagePath}`, + ); + } getPreferences(): AiModelPreferences { - return this.twentyConfigService.get('AI_MODEL_PREFERENCES'); + 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') + ); } getRecommendedModelIds(): Set { - const prefs = this.getPreferences(); - - return new Set(prefs.recommendedModels ?? []); + return new Set(this.getPreferences().recommendedModels ?? []); } async setModelAdminEnabled(modelId: string, enabled: boolean): Promise { @@ -53,11 +88,10 @@ export class AiModelPreferencesService { role === AiModelRole.FAST ? 'defaultFastModels' : 'defaultSmartModels'; const current = prefs[key] ?? []; - const filtered = current.filter((id) => id !== modelId); - prefs[key] = [modelId, ...filtered]; + prefs[key] = [modelId, ...current.filter((id) => id !== modelId)]; - await this.twentyConfigService.set('AI_MODEL_PREFERENCES', prefs); + await this.persistPreferences(prefs); } private async togglePreferenceList( @@ -79,13 +113,26 @@ export class AiModelPreferencesService { if (add) { const existing = new Set(current); - const toAdd = modelIds.filter((id) => !existing.has(id)); - prefs[key] = [...current, ...toAdd]; + prefs[key] = [...current, ...modelIds.filter((id) => !existing.has(id))]; } else { prefs[key] = current.filter((id) => !idSet.has(id)); } + await this.persistPreferences(prefs); + } + + 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)); + } } diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/types/ai-model-preferences.schema.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/types/ai-model-preferences.schema.ts new file mode 100644 index 0000000000..d196ee86d7 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/types/ai-model-preferences.schema.ts @@ -0,0 +1,8 @@ +import { z } from 'zod'; + +export const aiModelPreferencesSchema = z.object({ + disabledModels: z.array(z.string()).optional(), + recommendedModels: z.array(z.string()).optional(), + defaultFastModels: z.array(z.string()).optional(), + defaultSmartModels: z.array(z.string()).optional(), +}); 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 965698a714..d0038bcd98 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 @@ -27,11 +27,9 @@ const DEFAULT_RECOMMENDED_MODELS = [ 'xai/grok-4', ]; -export const loadDefaultModelPreferences = (): AiModelPreferences => { - return { - disabledModels: [], - recommendedModels: DEFAULT_RECOMMENDED_MODELS, - defaultFastModels: DEFAULT_FAST_MODELS, - defaultSmartModels: DEFAULT_SMART_MODELS, - }; +export const DEFAULT_MODEL_PREFERENCES: AiModelPreferences = { + disabledModels: [], + recommendedModels: DEFAULT_RECOMMENDED_MODELS, + defaultFastModels: DEFAULT_FAST_MODELS, + defaultSmartModels: DEFAULT_SMART_MODELS, };