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:
@@ -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,
|
||||
|
||||
+2
-1
@@ -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),
|
||||
|
||||
+74
@@ -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', () => {
|
||||
|
||||
+18
-1
@@ -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]: {
|
||||
|
||||
+33
-59
@@ -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 ?? [],
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
+5
-3
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user