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:
@@ -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,
|
||||
|
||||
+60
-13
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
+8
@@ -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(),
|
||||
});
|
||||
+5
-7
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user