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]: {