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:
+5
-8
@@ -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,
|
||||
},
|
||||
|
||||
+61
-18
@@ -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()
|
||||
|
||||
+2
@@ -35,6 +35,8 @@ describe('ClientConfigService', () => {
|
||||
provide: AiModelRegistryService,
|
||||
useValue: {
|
||||
getAdminFilteredModels: jest.fn().mockReturnValue([]),
|
||||
getRecommendedModelIds: jest.fn().mockReturnValue(new Set()),
|
||||
getModelConfig: jest.fn().mockReturnValue(undefined),
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
+54
-26
@@ -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,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user