remove ai-model-preferences var env and config (#20859)

Split the single AI_MODEL_PREFERENCES JSON config into 4 array configs
and migrates existing workspace data.
This commit is contained in:
Etienne
2026-05-27 08:47:47 +02:00
committed by GitHub
parent 9d6c5b7d58
commit 4a82cddad6
13 changed files with 449 additions and 74 deletions
@@ -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,
@@ -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),
@@ -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', () => {
@@ -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<T> {
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]: {
@@ -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<void> {
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<string> {
@@ -130,16 +97,23 @@ export class AiModelPreferencesService implements OnModuleInit {
}
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));
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 ?? [],
),
]);
}
}
@@ -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,