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.
This commit is contained in:
Etienne
2026-05-19 15:19:24 +02:00
committed by GitHub
parent d5ff9eb515
commit 827f24df2b
6 changed files with 88 additions and 23 deletions
@@ -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,
@@ -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,
@@ -22,6 +22,7 @@ import { SdkProviderFactoryService } from 'src/engine/metadata-modules/ai/ai-mod
exports: [
DefaultAiCatalogService,
AiModelRegistryService,
AiModelPreferencesService,
AiModelConfigService,
SdkProviderFactoryService,
ModelsDevCatalogService,
@@ -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<void> {
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<string> {
const prefs = this.getPreferences();
return new Set(prefs.recommendedModels ?? []);
return new Set(this.getPreferences().recommendedModels ?? []);
}
async setModelAdminEnabled(modelId: string, enabled: boolean): Promise<void> {
@@ -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<void> {
await this.twentyConfigService.set('AI_MODEL_PREFERENCES', prefs);
}
private async fetchPreferences(
filePath: string,
): Promise<AiModelPreferences> {
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));
}
}
@@ -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(),
});
@@ -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,
};