feat: replace hardcoded AI model constants with JSON seed catalog (#18818)

## Summary

- Replaces per-provider TypeScript constant files
(`openai-models.const.ts`, `anthropic-models.const.ts`, etc.) with a
single `ai-providers.json` catalog as the source of truth
- Adds runtime model discovery via AI SDK for self-hosted providers,
with `models.dev` enrichment for pricing/capabilities
- Introduces composite model IDs (`provider/modelId`) for canonical,
conflict-free identification
- Simplifies provider configuration: API keys are injected from
environment variables (e.g., `OPENAI_API_KEY`)
- Adds admin panel UI for provider management (add/remove/test), model
discovery, recommended model configuration, and default fast/smart model
selection per workspace
- Removes deprecated config variables (`AI_DISABLED_MODEL_IDS`,
`AUTO_ENABLE_NEW_AI_MODELS`, etc.)
- Adds database migration for composite model ID format

## Test plan

- [ ] Server typecheck passes
- [ ] Frontend typecheck passes
- [ ] Server unit tests pass
- [ ] Frontend unit tests pass
- [ ] CI pipeline green
- [ ] Admin panel AI tab loads correctly
- [ ] Provider discovery works for configured providers
- [ ] Model recommendation toggles persist
- [ ] Default fast/smart model selection works


Made with [Cursor](https://cursor.com)
This commit is contained in:
Félix Malfait
2026-03-21 16:03:58 +01:00
committed by GitHub
parent 50a0bef0e4
commit 908aefe7c1
181 changed files with 7580 additions and 2748 deletions
@@ -3,11 +3,8 @@ import { Test, type TestingModule } from '@nestjs/testing';
import { SupportDriver } from 'src/engine/core-modules/twenty-config/interfaces/support.interface';
import { ClientConfigService } from 'src/engine/core-modules/client-config/services/client-config.service';
import {
InferenceProvider,
ModelFamily,
type ModelId,
} from 'src/engine/metadata-modules/ai/ai-models/constants/ai-models.const';
import { ModelFamily } from 'src/engine/metadata-modules/ai/ai-models/types/model-family.enum';
import { type ModelId } from 'src/engine/metadata-modules/ai/ai-models/types/model-id.type';
import { ClientConfigController } from './client-config.controller';
@@ -51,10 +48,10 @@ describe('ClientConfigController', () => {
},
aiModels: [
{
modelId: 'gpt-4o' as ModelId,
modelId: 'openai/gpt-4o' as ModelId,
label: 'GPT-4o',
modelFamily: ModelFamily.OPENAI,
inferenceProvider: InferenceProvider.OPENAI,
modelFamily: ModelFamily.GPT,
sdkPackage: '@ai-sdk/openai' as const,
inputCostPerMillionTokensInCredits: 2500000,
outputCostPerMillionTokensInCredits: 10000000,
},
@@ -1,5 +1,6 @@
import { Field, ObjectType, registerEnumType } from '@nestjs/graphql';
import { type AiSdkPackage } from 'twenty-shared/ai';
import { FeatureFlagKey } from 'twenty-shared/types';
import { SupportDriver } from 'src/engine/core-modules/twenty-config/interfaces/support.interface';
@@ -7,24 +8,22 @@ import { SupportDriver } from 'src/engine/core-modules/twenty-config/interfaces/
import { BillingTrialPeriodDTO } from 'src/engine/core-modules/billing/dtos/billing-trial-period.dto';
import { CaptchaDriverType } from 'src/engine/core-modules/captcha/interfaces';
import { AuthProvidersDTO } from 'src/engine/core-modules/workspace/dtos/public-workspace-data.dto';
import {
InferenceProvider,
ModelFamily,
ModelId,
} from 'src/engine/metadata-modules/ai/ai-models/constants/ai-models.const';
import { AiModelRole } from 'src/engine/metadata-modules/ai/ai-models/types/ai-model-role.enum';
import { ModelFamily } from 'src/engine/metadata-modules/ai/ai-models/types/model-family.enum';
import { type ModelId } from 'src/engine/metadata-modules/ai/ai-models/types/model-id.type';
registerEnumType(FeatureFlagKey, {
name: 'FeatureFlagKey',
});
registerEnumType(InferenceProvider, {
name: 'InferenceProvider',
});
registerEnumType(ModelFamily, {
name: 'ModelFamily',
});
registerEnumType(AiModelRole, {
name: 'AiModelRole',
});
@ObjectType()
export class NativeModelCapabilities {
@Field(() => Boolean, { nullable: true })
@@ -37,6 +36,7 @@ export class NativeModelCapabilities {
@ObjectType()
export class ClientAIModelConfig {
@Field(() => String)
// Composite model id (`provider/modelName`) for this workspace; matches registry and admin APIs.
modelId: ModelId;
@Field(() => String)
@@ -45,8 +45,11 @@ export class ClientAIModelConfig {
@Field(() => ModelFamily, { nullable: true })
modelFamily?: ModelFamily;
@Field(() => InferenceProvider)
inferenceProvider: InferenceProvider;
@Field({ nullable: true })
modelFamilyLabel?: string;
@Field(() => String, { nullable: true })
sdkPackage: AiSdkPackage | null;
@Field(() => Number)
inputCostPerMillionTokensInCredits: number;
@@ -58,15 +61,22 @@ export class ClientAIModelConfig {
nativeCapabilities?: NativeModelCapabilities;
@Field(() => Boolean, { nullable: true })
deprecated?: boolean;
isDeprecated?: boolean;
@Field(() => Boolean, { nullable: true })
isRecommended?: boolean;
@Field(() => String, { nullable: true })
providerName?: string;
@Field(() => String, { nullable: true })
dataResidency?: string;
}
@ObjectType()
export class AdminAIModelConfig {
@Field(() => String)
// Composite model id (`provider/modelName`) used for toggles, defaults, and registry lookups.
modelId: string;
@Field(() => String)
@@ -75,8 +85,11 @@ export class AdminAIModelConfig {
@Field(() => ModelFamily, { nullable: true })
modelFamily?: ModelFamily;
@Field(() => InferenceProvider)
inferenceProvider: InferenceProvider;
@Field({ nullable: true })
modelFamilyLabel?: string;
@Field(() => String, { nullable: true })
sdkPackage: AiSdkPackage | null;
@Field(() => Boolean)
isAvailable: boolean;
@@ -85,19 +98,49 @@ export class AdminAIModelConfig {
isAdminEnabled: boolean;
@Field(() => Boolean, { nullable: true })
deprecated?: boolean;
isDeprecated?: boolean;
@Field(() => Boolean, { nullable: true })
isRecommended?: boolean;
@Field(() => Number, { nullable: true })
contextWindowTokens?: number;
@Field(() => Number, { nullable: true })
maxOutputTokens?: number;
@Field(() => Number, { nullable: true })
inputCostPerMillionTokens?: number;
@Field(() => Number, { nullable: true })
outputCostPerMillionTokens?: number;
@Field(() => String, { nullable: true })
providerName?: string;
@Field(() => String, { nullable: true })
providerLabel?: string;
@Field(() => String, { nullable: true })
// Bare SDK model name from the provider definition (`AiProviderModelConfig.name`), not the composite `modelId`.
name?: string;
@Field(() => String, { nullable: true })
dataResidency?: string;
}
@ObjectType('AdminAIModels')
export class AdminAIModelsDTO {
@Field(() => Boolean)
autoEnableNewModels: boolean;
@Field(() => [AdminAIModelConfig])
models: AdminAIModelConfig[];
@Field(() => String, { nullable: true })
// Composite model id for the default “smart” role (`provider/modelName`).
defaultSmartModelId?: string;
@Field(() => String, { nullable: true })
// Composite model id for the default “fast” role (`provider/modelName`).
defaultFastModelId?: string;
}
@ObjectType()
@@ -35,6 +35,8 @@ describe('ClientConfigService', () => {
provide: AiModelRegistryService,
useValue: {
getAdminFilteredModels: jest.fn().mockReturnValue([]),
getRecommendedModelIds: jest.fn().mockReturnValue(new Set()),
getModelConfig: jest.fn().mockReturnValue(undefined),
},
},
],
@@ -1,24 +1,28 @@
import { Injectable } from '@nestjs/common';
import { isNonEmptyString } from '@sniptt/guards';
import { type AiSdkPackage } from 'twenty-shared/ai';
import {
AI_SDK_ANTHROPIC,
AI_SDK_BEDROCK,
AI_SDK_OPENAI,
} from 'src/engine/metadata-modules/ai/ai-models/constants/ai-sdk-package.const';
import { NodeEnvironment } from 'src/engine/core-modules/twenty-config/interfaces/node-environment.interface';
import { SupportDriver } from 'src/engine/core-modules/twenty-config/interfaces/support.interface';
import {
type ClientAIModelConfig,
type ClientConfig,
type NativeModelCapabilities,
} from 'src/engine/core-modules/client-config/client-config.entity';
import { DomainServerConfigService } from 'src/engine/core-modules/domain/domain-server-config/services/domain-server-config.service';
import { PUBLIC_FEATURE_FLAGS } from 'src/engine/core-modules/feature-flag/constants/public-feature-flag.const';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { convertDollarsToBillingCredits } from 'src/engine/metadata-modules/ai/ai-billing/utils/convert-dollars-to-billing-credits.util';
import {
AI_MODELS,
DEFAULT_FAST_MODEL,
DEFAULT_SMART_MODEL,
InferenceProvider,
} from 'src/engine/metadata-modules/ai/ai-models/constants/ai-models.const';
import { DEFAULT_FAST_MODEL } from 'src/engine/metadata-modules/ai/ai-models/types/default-fast-model.const';
import { DEFAULT_SMART_MODEL } from 'src/engine/metadata-modules/ai/ai-models/types/default-smart-model.const';
import { MODEL_FAMILY_LABELS } from 'src/engine/metadata-modules/ai/ai-models/constants/model-family-labels.const';
import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service';
@Injectable()
@@ -29,6 +33,19 @@ export class ClientConfigService {
private aiModelRegistryService: AiModelRegistryService,
) {}
private deriveNativeCapabilities(
sdkPackage?: AiSdkPackage,
): NativeModelCapabilities | undefined {
switch (sdkPackage) {
case AI_SDK_OPENAI:
case AI_SDK_ANTHROPIC:
case AI_SDK_BEDROCK:
return { webSearch: true };
default:
return undefined;
}
}
private isCloudflareIntegrationEnabled(): boolean {
return (
!!this.twentyConfigService.get('CLOUDFLARE_API_KEY') &&
@@ -45,31 +62,42 @@ export class ClientConfigService {
const availableModels =
this.aiModelRegistryService.getAdminFilteredModels();
const recommendedModelIds =
this.aiModelRegistryService.getRecommendedModelIds();
const aiModels: ClientAIModelConfig[] = availableModels.map(
(registeredModel) => {
const builtInModel = AI_MODELS.find(
(m) => m.modelId === registeredModel.modelId,
const modelConfig = this.aiModelRegistryService.getModelConfig(
registeredModel.modelId,
);
const modelFamily = modelConfig?.modelFamily;
return {
modelId: registeredModel.modelId,
label: builtInModel?.label || registeredModel.modelId,
modelFamily: builtInModel?.modelFamily,
inferenceProvider: registeredModel.inferenceProvider,
nativeCapabilities: builtInModel?.nativeCapabilities,
inputCostPerMillionTokensInCredits: builtInModel
label: modelConfig?.label || registeredModel.modelId,
modelFamily,
modelFamilyLabel: modelFamily
? MODEL_FAMILY_LABELS[modelFamily]
: undefined,
sdkPackage: registeredModel.sdkPackage,
providerName: registeredModel.providerName,
nativeCapabilities: this.deriveNativeCapabilities(
registeredModel.sdkPackage,
),
inputCostPerMillionTokensInCredits: modelConfig
? convertDollarsToBillingCredits(
builtInModel.inputCostPerMillionTokens,
modelConfig.inputCostPerMillionTokens,
)
: 0,
outputCostPerMillionTokensInCredits: builtInModel
outputCostPerMillionTokensInCredits: modelConfig
? convertDollarsToBillingCredits(
builtInModel.outputCostPerMillionTokens,
modelConfig.outputCostPerMillionTokens,
)
: 0,
deprecated: builtInModel?.deprecated,
isRecommended: builtInModel?.isRecommended,
isDeprecated: modelConfig?.isDeprecated,
isRecommended: recommendedModelIds.has(registeredModel.modelId),
dataResidency: modelConfig?.dataResidency,
};
},
);
@@ -77,9 +105,8 @@ export class ClientConfigService {
if (aiModels.length > 0) {
const defaultSpeedModel =
this.aiModelRegistryService.getDefaultSpeedModel();
const defaultSpeedModelConfig = AI_MODELS.find(
(m) => m.modelId === defaultSpeedModel?.modelId,
);
const defaultSpeedModelConfig =
this.aiModelRegistryService.getModelConfig(defaultSpeedModel?.modelId);
const defaultSpeedModelLabel =
defaultSpeedModelConfig?.label ||
defaultSpeedModel?.modelId ||
@@ -87,9 +114,10 @@ export class ClientConfigService {
const defaultPerformanceModel =
this.aiModelRegistryService.getDefaultPerformanceModel();
const defaultPerformanceModelConfig = AI_MODELS.find(
(m) => m.modelId === defaultPerformanceModel?.modelId,
);
const defaultPerformanceModelConfig =
this.aiModelRegistryService.getModelConfig(
defaultPerformanceModel?.modelId,
);
const defaultPerformanceModelLabel =
defaultPerformanceModelConfig?.label ||
defaultPerformanceModel?.modelId ||
@@ -99,14 +127,14 @@ export class ClientConfigService {
{
modelId: DEFAULT_SMART_MODEL,
label: `Best (${defaultPerformanceModelLabel})`,
inferenceProvider: InferenceProvider.NONE,
sdkPackage: null,
inputCostPerMillionTokensInCredits: 0,
outputCostPerMillionTokensInCredits: 0,
},
{
modelId: DEFAULT_FAST_MODEL,
label: `Best (${defaultSpeedModelLabel})`,
inferenceProvider: InferenceProvider.NONE,
sdkPackage: null,
inputCostPerMillionTokensInCredits: 0,
outputCostPerMillionTokensInCredits: 0,
},