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
@@ -8,7 +8,7 @@ import {
AgentExceptionCode,
} from 'src/engine/metadata-modules/ai/ai-agent/agent.exception';
import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity';
import { type ModelId } from 'src/engine/metadata-modules/ai/ai-models/constants/ai-models.const';
import { type ModelId } from 'src/engine/metadata-modules/ai/ai-models/types/model-id.type';
import { type FlatRoleTarget } from 'src/engine/metadata-modules/flat-role-target/types/flat-role-target.type';
import { RoleTargetEntity } from 'src/engine/metadata-modules/role-target/role-target.entity';
import { RoleTargetService } from 'src/engine/metadata-modules/role-target/services/role-target.service';
@@ -83,7 +83,7 @@ describe('AiAgentRoleService', () => {
name: 'Test Agent',
description: 'Test agent for unit tests',
prompt: 'You are a test agent',
modelId: 'gpt-4o' as ModelId,
modelId: 'openai/gpt-4o' as ModelId,
workspaceId: testWorkspaceId,
createdAt: new Date(),
updatedAt: new Date(),
@@ -251,7 +251,7 @@ export class AgentService {
const {
flatAgentMaps: recomputedFlatAgentMaps,
flatRoleTargetByAgentIdMaps: recmputedFlatRoleTargetByAgentIdMaps,
flatRoleTargetByAgentIdMaps: recomputedFlatRoleTargetByAgentIdMaps,
} = await this.workspaceCacheService.getOrRecompute(workspaceId, [
'flatAgentMaps',
'flatRoleTargetByAgentIdMaps',
@@ -263,7 +263,7 @@ export class AgentService {
});
const existingRoleTarget =
recmputedFlatRoleTargetByAgentIdMaps[flatAgentToUpdate.id];
recomputedFlatRoleTargetByAgentIdMaps[flatAgentToUpdate.id];
return {
...updatedAgent,
@@ -11,7 +11,7 @@ import GraphQLJSON from 'graphql-type-json';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { ModelConfiguration } from 'src/engine/metadata-modules/ai/ai-agent/types/modelConfiguration';
import { ModelId } from 'src/engine/metadata-modules/ai/ai-models/constants/ai-models.const';
import { type ModelId } from 'src/engine/metadata-modules/ai/ai-models/types/model-id.type';
@ObjectType('Agent')
export class AgentDTO {
@@ -17,7 +17,7 @@ import { AgentResponseFormat } from 'src/engine/metadata-modules/ai/ai-agent/typ
import { ModelConfiguration } from 'src/engine/metadata-modules/ai/ai-agent/types/modelConfiguration';
import { AgentResponseFormatJson } from 'src/engine/metadata-modules/ai/ai-agent/validators/agent-response-format-json.validator';
import { AgentResponseFormatText } from 'src/engine/metadata-modules/ai/ai-agent/validators/agent-response-format-text.validator';
import { ModelId } from 'src/engine/metadata-modules/ai/ai-models/constants/ai-models.const';
import { type ModelId } from 'src/engine/metadata-modules/ai/ai-models/types/model-id.type';
@InputType()
export class CreateAgentInput {
@@ -17,7 +17,7 @@ import { AgentResponseFormat } from 'src/engine/metadata-modules/ai/ai-agent/typ
import { ModelConfiguration } from 'src/engine/metadata-modules/ai/ai-agent/types/modelConfiguration';
import { AgentResponseFormatJson } from 'src/engine/metadata-modules/ai/ai-agent/validators/agent-response-format-json.validator';
import { AgentResponseFormatText } from 'src/engine/metadata-modules/ai/ai-agent/validators/agent-response-format-text.validator';
import { ModelId } from 'src/engine/metadata-modules/ai/ai-models/constants/ai-models.const';
import { type ModelId } from 'src/engine/metadata-modules/ai/ai-models/types/model-id.type';
@InputType()
export class UpdateAgentInput {
@@ -10,10 +10,8 @@ import {
import { AgentResponseFormat } from 'src/engine/metadata-modules/ai/ai-agent/types/agent-response-format.type';
import { ModelConfiguration } from 'src/engine/metadata-modules/ai/ai-agent/types/modelConfiguration';
import {
DEFAULT_SMART_MODEL,
ModelId,
} from 'src/engine/metadata-modules/ai/ai-models/constants/ai-models.const';
import { DEFAULT_SMART_MODEL } from 'src/engine/metadata-modules/ai/ai-models/types/default-smart-model.const';
import { type ModelId } from 'src/engine/metadata-modules/ai/ai-models/types/model-id.type';
import { SyncableEntity } from 'src/engine/workspace-manager/types/syncable-entity.interface';
import { JsonbProperty } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/jsonb-property.type';
@@ -1,12 +1,12 @@
import { Module } from '@nestjs/common';
import { AIBillingService } from 'src/engine/metadata-modules/ai/ai-billing/services/ai-billing.service';
import { AiBillingService } from 'src/engine/metadata-modules/ai/ai-billing/services/ai-billing.service';
import { AiModelsModule } from 'src/engine/metadata-modules/ai/ai-models/ai-models.module';
import { WorkspaceEventEmitterModule } from 'src/engine/workspace-event-emitter/workspace-event-emitter.module';
@Module({
imports: [WorkspaceEventEmitterModule, AiModelsModule],
providers: [AIBillingService],
exports: [AIBillingService],
providers: [AiBillingService],
exports: [AiBillingService],
})
export class AiBillingModule {}
@@ -2,16 +2,13 @@ import { Test, type TestingModule } from '@nestjs/testing';
import { BILLING_FEATURE_USED } from 'src/engine/core-modules/billing/constants/billing-feature-used.constant';
import { BillingMeterEventName } from 'src/engine/core-modules/billing/enums/billing-meter-event-names';
import { AIBillingService } from 'src/engine/metadata-modules/ai/ai-billing/services/ai-billing.service';
import {
InferenceProvider,
ModelFamily,
} from 'src/engine/metadata-modules/ai/ai-models/constants/ai-models-types.const';
import { AiBillingService } from 'src/engine/metadata-modules/ai/ai-billing/services/ai-billing.service';
import { ModelFamily } from 'src/engine/metadata-modules/ai/ai-models/types/model-family.enum';
import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service';
import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter';
describe('AIBillingService', () => {
let service: AIBillingService;
describe('AiBillingService', () => {
let service: AiBillingService;
let mockWorkspaceEventEmitter: jest.Mocked<WorkspaceEventEmitter>;
let mockAiModelRegistryService: jest.Mocked<
Pick<AiModelRegistryService, 'getEffectiveModelConfig'>
@@ -20,8 +17,8 @@ describe('AIBillingService', () => {
const openaiModelConfig = {
modelId: 'gpt-4o',
label: 'GPT-4o',
modelFamily: ModelFamily.OPENAI,
inferenceProvider: InferenceProvider.OPENAI,
modelFamily: ModelFamily.GPT,
sdkPackage: '@ai-sdk/openai',
inputCostPerMillionTokens: 2.5,
outputCostPerMillionTokens: 10.0,
cachedInputCostPerMillionTokens: 1.25,
@@ -30,8 +27,8 @@ describe('AIBillingService', () => {
const anthropicModelConfig = {
modelId: 'claude-sonnet-4-5-20250929',
label: 'Claude Sonnet 4.5',
modelFamily: ModelFamily.ANTHROPIC,
inferenceProvider: InferenceProvider.ANTHROPIC,
modelFamily: ModelFamily.CLAUDE,
sdkPackage: '@ai-sdk/anthropic',
inputCostPerMillionTokens: 3.0,
outputCostPerMillionTokens: 15.0,
cachedInputCostPerMillionTokens: 0.3,
@@ -65,7 +62,7 @@ describe('AIBillingService', () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
AIBillingService,
AiBillingService,
{
provide: WorkspaceEventEmitter,
useValue: mockEventEmitterMethods,
@@ -77,7 +74,7 @@ describe('AIBillingService', () => {
],
}).compile();
service = module.get<AIBillingService>(AIBillingService);
service = module.get<AiBillingService>(AiBillingService);
mockWorkspaceEventEmitter = module.get(WorkspaceEventEmitter);
mockAiModelRegistryService = module.get(AiModelRegistryService);
});
@@ -7,7 +7,7 @@ import { BillingMeterEventName } from 'src/engine/core-modules/billing/enums/bil
import { type BillingUsageEvent } from 'src/engine/core-modules/billing/types/billing-usage-event.type';
import { computeCostBreakdown } from 'src/engine/metadata-modules/ai/ai-billing/utils/compute-cost-breakdown.util';
import { convertDollarsToBillingCredits } from 'src/engine/metadata-modules/ai/ai-billing/utils/convert-dollars-to-billing-credits.util';
import { type ModelId } from 'src/engine/metadata-modules/ai/ai-models/constants/ai-models-types.const';
import { type ModelId } from 'src/engine/metadata-modules/ai/ai-models/types/model-id.type';
import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service';
import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter';
@@ -17,8 +17,8 @@ export type BillingUsageInput = {
};
@Injectable()
export class AIBillingService {
private readonly logger = new Logger(AIBillingService.name);
export class AiBillingService {
private readonly logger = new Logger(AiBillingService.name);
constructor(
private readonly workspaceEventEmitter: WorkspaceEventEmitter,
@@ -27,11 +27,6 @@ export class AIBillingService {
calculateCost(modelId: ModelId, billingInput: BillingUsageInput): number {
const model = this.aiModelRegistryService.getEffectiveModelConfig(modelId);
if (!model) {
throw new Error(`AI model with id ${modelId} not found`);
}
const { usage, cacheCreationTokens = 0 } = billingInput;
const breakdown = computeCostBreakdown(model, {
@@ -1,7 +1,5 @@
import {
type AIModelConfig,
ModelFamily,
} from 'src/engine/metadata-modules/ai/ai-models/constants/ai-models-types.const';
import { type AIModelConfig } from 'src/engine/metadata-modules/ai/ai-models/types/ai-model-config.type';
import { ModelFamily } from 'src/engine/metadata-modules/ai/ai-models/types/model-family.enum';
export type TokenUsageInput = {
inputTokens?: number;
@@ -47,17 +45,17 @@ export const computeCostBreakdown = (
const cachedInputTokens = safeNumber(usage.cachedInputTokens);
const cacheCreationTokens = safeNumber(usage.cacheCreationTokens);
const isAnthropicFamily = model.modelFamily === ModelFamily.ANTHROPIC;
const isAnthropicTokenReporting = model.modelFamily === ModelFamily.CLAUDE;
const adjustedInputTokens = isAnthropicFamily
const adjustedInputTokens = isAnthropicTokenReporting
? rawInputTokens
: Math.max(0, rawInputTokens - cachedInputTokens);
const adjustedOutputTokens = isAnthropicFamily
const adjustedOutputTokens = isAnthropicTokenReporting
? rawOutputTokens
: Math.max(0, rawOutputTokens - reasoningTokens);
const totalInputTokens = isAnthropicFamily
const totalInputTokens = isAnthropicTokenReporting
? rawInputTokens + cachedInputTokens + cacheCreationTokens
: rawInputTokens + cacheCreationTokens;
@@ -66,7 +66,7 @@ export class AgentChatController {
) {
if (this.aiModelRegistryService.getAvailableModels().length === 0) {
throw new AgentException(
'No AI models are available. Please configure at least one AI provider API key (OPENAI_API_KEY, ANTHROPIC_API_KEY, or XAI_API_KEY).',
'No AI models are available. Configure at least one AI provider.',
AgentExceptionCode.API_KEY_NOT_CONFIGURED,
);
}
@@ -20,7 +20,7 @@ import { computeCostBreakdown } from 'src/engine/metadata-modules/ai/ai-billing/
import { convertDollarsToBillingCredits } from 'src/engine/metadata-modules/ai/ai-billing/utils/convert-dollars-to-billing-credits.util';
import { extractCacheCreationTokens } from 'src/engine/metadata-modules/ai/ai-billing/utils/extract-cache-creation-tokens.util';
import { toDisplayCredits } from 'src/engine/core-modules/billing/utils/to-display-credits.util';
import { type AIModelConfig } from 'src/engine/metadata-modules/ai/ai-models/constants/ai-models-types.const';
import { type AIModelConfig } from 'src/engine/metadata-modules/ai/ai-models/types/ai-model-config.type';
import { AgentChatThreadEntity } from 'src/engine/metadata-modules/ai/ai-chat/entities/agent-chat-thread.entity';
import { AgentChatService } from './agent-chat.service';
@@ -1,8 +1,5 @@
import { Injectable, Logger } from '@nestjs/common';
import { anthropic } from '@ai-sdk/anthropic';
import { groq } from '@ai-sdk/groq';
import { openai } from '@ai-sdk/openai';
import {
convertToModelMessages,
stepCountIs,
@@ -36,7 +33,7 @@ import { AgentActorContextService } from 'src/engine/metadata-modules/ai/ai-agen
import { AGENT_CONFIG } from 'src/engine/metadata-modules/ai/ai-agent/constants/agent-config.const';
import { type BrowsingContextType } from 'src/engine/metadata-modules/ai/ai-agent/types/browsingContext.type';
import { repairToolCall } from 'src/engine/metadata-modules/ai/ai-agent/utils/repair-tool-call.util';
import { AIBillingService } from 'src/engine/metadata-modules/ai/ai-billing/services/ai-billing.service';
import { AiBillingService } from 'src/engine/metadata-modules/ai/ai-billing/services/ai-billing.service';
import { extractCacheCreationTokensFromSteps } from 'src/engine/metadata-modules/ai/ai-billing/utils/extract-cache-creation-tokens.util';
import { SystemPromptBuilderService } from 'src/engine/metadata-modules/ai/ai-chat/services/system-prompt-builder.service';
import {
@@ -44,11 +41,17 @@ import {
type ExtractedFile,
} from 'src/engine/metadata-modules/ai/ai-chat/utils/extract-code-interpreter-files.util';
import {
type AIModelConfig,
InferenceProvider,
} from 'src/engine/metadata-modules/ai/ai-models/constants/ai-models.const';
AI_SDK_ANTHROPIC,
AI_SDK_BEDROCK,
AI_SDK_OPENAI,
} from 'src/engine/metadata-modules/ai/ai-models/constants/ai-sdk-package.const';
import { AI_TELEMETRY_CONFIG } from 'src/engine/metadata-modules/ai/ai-models/constants/ai-telemetry.const';
import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service';
import { type AIModelConfig } from 'src/engine/metadata-modules/ai/ai-models/types/ai-model-config.type';
import {
AiModelRegistryService,
type RegisteredAIModel,
} from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service';
import { SdkProviderFactoryService } from 'src/engine/metadata-modules/ai/ai-models/services/sdk-provider-factory.service';
import { SkillService } from 'src/engine/metadata-modules/skill/skill.service';
export type ChatExecutionOptions = {
@@ -72,11 +75,12 @@ export class ChatExecutionService {
private readonly toolRegistry: ToolRegistryService,
private readonly skillService: SkillService,
private readonly aiModelRegistryService: AiModelRegistryService,
private readonly aiBillingService: AIBillingService,
private readonly aiBillingService: AiBillingService,
private readonly agentActorContextService: AgentActorContextService,
private readonly workspaceDomainsService: WorkspaceDomainsService,
private readonly systemPromptBuilder: SystemPromptBuilderService,
private readonly exceptionHandlerService: ExceptionHandlerService,
private readonly sdkProviderFactory: SdkProviderFactoryService,
) {}
async streamChat({
@@ -138,7 +142,7 @@ export class ChatExecutionService {
);
const { tools: nativeSearchTools, callableToolNames: searchToolNames } =
this.getNativeWebSearchTools(registeredModel.inferenceProvider);
this.getNativeWebSearchTools(registeredModel);
// Direct tools: native provider tools + preloaded tools.
// These are callable directly AND as fallback through execute_tool.
@@ -203,9 +207,9 @@ export class ChatExecutionService {
role: 'system',
content: systemPrompt,
providerOptions:
registeredModel.inferenceProvider === InferenceProvider.ANTHROPIC
registeredModel.sdkPackage === AI_SDK_ANTHROPIC
? { anthropic: { cacheControl: { type: 'ephemeral' } } }
: registeredModel.inferenceProvider === InferenceProvider.BEDROCK
: registeredModel.sdkPackage === AI_SDK_BEDROCK
? { bedrock: { cacheControl: { type: 'ephemeral' } } }
: undefined,
};
@@ -325,46 +329,61 @@ export class ChatExecutionService {
return context;
}
private getNativeWebSearchTools(inferenceProvider: InferenceProvider): {
private getNativeWebSearchTools(model: RegisteredAIModel): {
tools: ToolSet;
callableToolNames: string[];
} {
switch (inferenceProvider) {
case InferenceProvider.ANTHROPIC:
return {
tools: { web_search: anthropic.tools.webSearch_20250305() },
callableToolNames: ['web_search'],
};
case InferenceProvider.BEDROCK: {
const bedrockProvider =
this.aiModelRegistryService.getBedrockProvider();
const empty = { tools: {}, callableToolNames: [] };
const providerName = model.providerName;
if (bedrockProvider) {
return {
tools: {
web_search:
bedrockProvider.tools.webSearch_20250305() as ToolSet[string],
},
callableToolNames: ['web_search'],
};
if (!providerName) {
return empty;
}
switch (model.sdkPackage) {
case AI_SDK_ANTHROPIC: {
const provider =
this.sdkProviderFactory.getRawAnthropicProvider(providerName);
if (!provider) {
return empty;
}
return { tools: {}, callableToolNames: [] };
}
case InferenceProvider.OPENAI:
return {
tools: { web_search: openai.tools.webSearch() },
tools: { web_search: provider.tools.webSearch_20250305() },
callableToolNames: ['web_search'],
};
case InferenceProvider.GROQ:
}
case AI_SDK_BEDROCK: {
const provider =
this.sdkProviderFactory.getRawBedrockProvider(providerName);
if (!provider) {
return empty;
}
return {
tools: {
web_search: groq.tools.browserSearch({}) as ToolSet[string],
web_search: provider.tools.webSearch_20250305() as ToolSet[string],
},
callableToolNames: [],
callableToolNames: ['web_search'],
};
}
case AI_SDK_OPENAI: {
const provider =
this.sdkProviderFactory.getRawOpenAIProvider(providerName);
if (!provider) {
return empty;
}
return {
tools: { web_search: provider.tools.webSearch() },
callableToolNames: ['web_search'],
};
}
default:
return { tools: {}, callableToolNames: [] };
return empty;
}
}
@@ -1,12 +1,27 @@
import { Global, Module } from '@nestjs/common';
import { AgentModelConfigService } from 'src/engine/metadata-modules/ai/ai-models/services/agent-model-config.service';
import { AiModelPreferencesService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-preferences.service';
import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service';
import { AiService } from 'src/engine/metadata-modules/ai/ai-models/services/ai.service';
import { ModelsDevCatalogService } from 'src/engine/metadata-modules/ai/ai-models/services/models-dev-catalog.service';
import { ProviderConfigService } from 'src/engine/metadata-modules/ai/ai-models/services/provider-config.service';
import { SdkProviderFactoryService } from 'src/engine/metadata-modules/ai/ai-models/services/sdk-provider-factory.service';
@Global()
@Module({
providers: [AiModelRegistryService, AiService, AgentModelConfigService],
exports: [AiModelRegistryService, AiService, AgentModelConfigService],
providers: [
ProviderConfigService,
SdkProviderFactoryService,
ModelsDevCatalogService,
AiModelPreferencesService,
AiModelRegistryService,
AgentModelConfigService,
],
exports: [
AiModelRegistryService,
AgentModelConfigService,
SdkProviderFactoryService,
ModelsDevCatalogService,
],
})
export class AiModelsModule {}
@@ -0,0 +1,258 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { AiModelPreferencesService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-preferences.service';
import { ProviderConfigService } from 'src/engine/metadata-modules/ai/ai-models/services/provider-config.service';
import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service';
import { SdkProviderFactoryService } from 'src/engine/metadata-modules/ai/ai-models/services/sdk-provider-factory.service';
import { buildCompositeModelId } from 'src/engine/metadata-modules/ai/ai-models/utils/composite-model-id.util';
import { loadDefaultAiProviders } from 'src/engine/metadata-modules/ai/ai-models/utils/load-default-ai-providers.util';
import { type AiProvidersConfig } from 'src/engine/metadata-modules/ai/ai-models/types/ai-providers-config.type';
import { DEFAULT_SMART_MODEL } from 'src/engine/metadata-modules/ai/ai-models/types/default-smart-model.const';
const DEFAULT_PROVIDERS: AiProvidersConfig = loadDefaultAiProviders();
const EXPECTED_PROVIDERS = ['openai', 'anthropic', 'google', 'xai', 'mistral'];
describe('Default AI Providers (ai-providers.json)', () => {
it('should have at least one model per provider', () => {
EXPECTED_PROVIDERS.forEach((providerName) => {
const config = DEFAULT_PROVIDERS[providerName];
expect(config).toBeDefined();
expect((config?.models?.length ?? 0) > 0).toBe(true);
});
});
it('should have all required fields for each model', () => {
Object.entries(DEFAULT_PROVIDERS).forEach(([, config]) => {
(config.models ?? []).forEach((model) => {
expect(model.name).toBeDefined();
expect(model.label).toBeDefined();
expect(model.inputCostPerMillionTokens).toBeDefined();
expect(model.outputCostPerMillionTokens).toBeDefined();
expect(model.contextWindowTokens).toBeGreaterThan(0);
expect(model.maxOutputTokens).toBeGreaterThan(0);
});
});
});
it('should have unique model IDs across all providers', () => {
const allCompositeIds: string[] = [];
Object.entries(DEFAULT_PROVIDERS).forEach(([key, config]) => {
(config.models ?? []).forEach((model) => {
allCompositeIds.push(buildCompositeModelId(key, model.name));
});
});
const unique = new Set(allCompositeIds);
expect(unique.size).toBe(allCompositeIds.length);
});
it('should have at least one non-deprecated model per provider', () => {
EXPECTED_PROVIDERS.forEach((providerName) => {
const config = DEFAULT_PROVIDERS[providerName];
const hasActiveModel = (config?.models ?? []).some(
(model) => !model.isDeprecated,
);
expect(hasActiveModel).toBe(true);
});
});
it('should have source set to catalog for all models', () => {
Object.entries(DEFAULT_PROVIDERS).forEach(([, config]) => {
(config.models ?? []).forEach((model) => {
expect(model.source).toBe('catalog');
});
});
});
it('should have npm field set for all providers', () => {
Object.entries(DEFAULT_PROVIDERS).forEach(([, config]) => {
expect(config.npm).toBeDefined();
expect(config.npm).toMatch(/^@ai-sdk\//);
});
});
});
describe('AiModelRegistryService', () => {
let service: AiModelRegistryService;
let mockConfigService: jest.Mocked<TwentyConfigService>;
let mockPreferencesService: {
getPreferences: jest.Mock;
getRecommendedModelIds: jest.Mock;
};
beforeEach(async () => {
mockConfigService = {
get: jest.fn().mockReturnValue({}),
} as any;
const mockProviderConfigService = {
getResolvedProviders: jest.fn().mockReturnValue({}),
};
mockPreferencesService = {
getPreferences: jest.fn().mockReturnValue({}),
getRecommendedModelIds: jest.fn().mockReturnValue(new Set()),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
AiModelRegistryService,
{
provide: TwentyConfigService,
useValue: mockConfigService,
},
{
provide: ProviderConfigService,
useValue: mockProviderConfigService,
},
{
provide: SdkProviderFactoryService,
useValue: { clearCache: jest.fn() },
},
{
provide: AiModelPreferencesService,
useValue: mockPreferencesService,
},
],
}).compile();
service = module.get<AiModelRegistryService>(AiModelRegistryService);
});
it('should throw when no models are available for DEFAULT_SMART_MODEL', () => {
expect(() => service.getEffectiveModelConfig(DEFAULT_SMART_MODEL)).toThrow(
'No AI models are available. Configure at least one AI provider.',
);
});
it('should return effective model config for DEFAULT_SMART_MODEL when models are available', () => {
jest.spyOn(service, 'getAvailableModels').mockReturnValue([
{
modelId: 'openai/gpt-5.2',
sdkPackage: '@ai-sdk/openai',
model: {} as any,
},
]);
jest.spyOn(service, 'getModel').mockReturnValue({
modelId: 'openai/gpt-5.2',
sdkPackage: '@ai-sdk/openai',
model: {} as any,
});
const result = service.getEffectiveModelConfig(DEFAULT_SMART_MODEL);
expect(result).toBeDefined();
expect(result.modelId).toBe('openai/gpt-5.2');
expect(result.sdkPackage).toBe('@ai-sdk/openai');
});
it('should return effective model config for DEFAULT_SMART_MODEL with custom model', () => {
jest.spyOn(service, 'getAvailableModels').mockReturnValue([
{
modelId: 'custom/mistral',
sdkPackage: '@ai-sdk/openai-compatible',
model: {} as any,
},
]);
jest.spyOn(service, 'getModel').mockReturnValue({
modelId: 'custom/mistral',
sdkPackage: '@ai-sdk/openai-compatible',
model: {} as any,
});
const result = service.getEffectiveModelConfig(DEFAULT_SMART_MODEL);
expect(result).toBeDefined();
expect(result.modelId).toBe('custom/mistral');
expect(result.sdkPackage).toBe('@ai-sdk/openai-compatible');
expect(result.label).toBe('custom/mistral');
expect(result.inputCostPerMillionTokens).toBe(0);
expect(result.outputCostPerMillionTokens).toBe(0);
});
it('should return effective model config for custom model', () => {
jest.spyOn(service, 'getModel').mockReturnValue({
modelId: 'custom/mistral',
sdkPackage: '@ai-sdk/openai-compatible',
model: {} as any,
});
const result = service.getEffectiveModelConfig('custom/mistral');
expect(result).toBeDefined();
expect(result.modelId).toBe('custom/mistral');
expect(result.sdkPackage).toBe('@ai-sdk/openai-compatible');
expect(result.label).toBe('custom/mistral');
expect(result.inputCostPerMillionTokens).toBe(0);
expect(result.outputCostPerMillionTokens).toBe(0);
});
it('should throw error for non-existent model', () => {
jest.spyOn(service, 'getModel').mockReturnValue(undefined);
expect(() => service.getEffectiveModelConfig('non-existent-model')).toThrow(
'Model with ID non-existent-model not found',
);
});
it('should find first available model from preferences list', () => {
mockPreferencesService.getPreferences.mockReturnValue({
defaultFastModels: [
'openai/gpt-5-mini',
'anthropic/claude-haiku-4-5-20251001',
'google/gemini-3-flash-preview',
],
});
const getModelSpy = jest
.spyOn(service, 'getModel')
.mockImplementation((modelId: string) => {
if (modelId === 'anthropic/claude-haiku-4-5-20251001') {
return {
modelId: 'anthropic/claude-haiku-4-5-20251001',
sdkPackage: '@ai-sdk/anthropic',
model: {} as any,
};
}
return undefined;
});
const result = service.getDefaultSpeedModel();
expect(result).toBeDefined();
expect(result.modelId).toBe('anthropic/claude-haiku-4-5-20251001');
expect(getModelSpy).toHaveBeenCalledWith('openai/gpt-5-mini');
expect(getModelSpy).toHaveBeenCalledWith(
'anthropic/claude-haiku-4-5-20251001',
);
});
it('should fall back to any available model if none in list are available', () => {
mockPreferencesService.getPreferences.mockReturnValue({
defaultFastModels: ['model-a', 'model-b', 'model-c'],
});
jest.spyOn(service, 'getModel').mockReturnValue(undefined);
jest.spyOn(service, 'getAvailableModels').mockReturnValue([
{
modelId: 'fallback-model',
sdkPackage: '@ai-sdk/openai-compatible',
model: {} as any,
},
]);
const result = service.getDefaultSpeedModel();
expect(result).toBeDefined();
expect(result.modelId).toBe('fallback-model');
});
});
@@ -1,106 +0,0 @@
export enum InferenceProvider {
NONE = 'none',
OPENAI = 'openai',
ANTHROPIC = 'anthropic',
BEDROCK = 'bedrock',
GOOGLE = 'google',
MISTRAL = 'mistral',
OPENAI_COMPATIBLE = 'open_ai_compatible',
XAI = 'xai',
GROQ = 'groq',
}
export enum ModelFamily {
OPENAI = 'openai',
ANTHROPIC = 'anthropic',
GOOGLE = 'google',
MISTRAL = 'mistral',
XAI = 'xai',
}
export const DEFAULT_FAST_MODEL = 'default-fast-model' as const;
export const DEFAULT_SMART_MODEL = 'default-smart-model' as const;
export type ModelId =
| typeof DEFAULT_FAST_MODEL
| typeof DEFAULT_SMART_MODEL
// OpenAI models
| 'gpt-5.2'
| 'gpt-5-mini'
| 'gpt-4.1'
| 'gpt-4.1-mini'
| 'gpt-4o'
| 'gpt-4o-mini'
| 'gpt-4-turbo'
| 'o3'
| 'o4-mini'
// Anthropic models
| 'claude-opus-4-6'
| 'claude-sonnet-4-6'
| 'claude-sonnet-4-5-20250929'
| 'claude-haiku-4-5-20251001'
| 'claude-opus-4-5-20251101'
| 'claude-opus-4-20250514'
| 'claude-sonnet-4-20250514'
| 'claude-3-5-haiku-20241022'
// xAI models
| 'grok-4'
| 'grok-4-1-fast-reasoning'
| 'grok-3'
| 'grok-3-mini'
// Google models
| 'gemini-3.1-pro-preview'
| 'gemini-3-flash-preview'
| 'gemini-3.1-flash-lite-preview'
| 'gemini-2.5-pro'
| 'gemini-2.5-flash'
// Bedrock models (Anthropic via AWS)
| 'anthropic.claude-opus-4-6-v1'
| 'anthropic.claude-sonnet-4-6'
// Groq models
| 'openai/gpt-oss-120b'
// Mistral models
| 'mistral-large-latest'
| string; // Allow custom model names
export type SupportedFileType =
| 'image/png'
| 'image/jpeg'
| 'image/gif'
| 'image/webp'
| 'application/pdf'
| 'text/plain'
| 'text/html'
| 'text/csv'
| 'application/json';
export type LongContextCost = {
inputCostPerMillionTokens: number;
outputCostPerMillionTokens: number;
cachedInputCostPerMillionTokens?: number;
cacheCreationCostPerMillionTokens?: number;
thresholdTokens: number;
};
export interface AIModelConfig {
modelId: ModelId;
label: string;
description: string;
modelFamily: ModelFamily;
inferenceProvider: InferenceProvider;
inputCostPerMillionTokens: number;
outputCostPerMillionTokens: number;
cachedInputCostPerMillionTokens?: number;
cacheCreationCostPerMillionTokens?: number;
longContextCost?: LongContextCost;
contextWindowTokens: number;
maxOutputTokens: number;
supportedFileTypes?: SupportedFileType[];
doesSupportThinking?: boolean;
nativeCapabilities?: {
webSearch?: boolean;
twitterSearch?: boolean;
};
deprecated?: boolean;
isRecommended?: boolean;
}
@@ -1,234 +0,0 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service';
import {
AI_MODELS,
DEFAULT_SMART_MODEL,
InferenceProvider,
} from './ai-models.const';
describe('AI_MODELS', () => {
it('should have at least one model per inference provider', () => {
const inferenceProviders = [
InferenceProvider.OPENAI,
InferenceProvider.ANTHROPIC,
InferenceProvider.BEDROCK,
InferenceProvider.GOOGLE,
InferenceProvider.XAI,
InferenceProvider.GROQ,
InferenceProvider.MISTRAL,
];
inferenceProviders.forEach((inferenceProvider) => {
const modelsForProvider = AI_MODELS.filter(
(model) => model.inferenceProvider === inferenceProvider,
);
expect(modelsForProvider.length).toBeGreaterThan(0);
});
});
it('should have all required fields for each model', () => {
AI_MODELS.forEach((model) => {
expect(model.modelId).toBeDefined();
expect(model.label).toBeDefined();
expect(model.description).toBeDefined();
expect(model.modelFamily).toBeDefined();
expect(model.inferenceProvider).toBeDefined();
expect(model.inputCostPerMillionTokens).toBeDefined();
expect(model.outputCostPerMillionTokens).toBeDefined();
expect(model.contextWindowTokens).toBeGreaterThan(0);
expect(model.maxOutputTokens).toBeGreaterThan(0);
});
});
it('should have unique model IDs', () => {
const modelIds = AI_MODELS.map((model) => model.modelId);
const uniqueModelIds = new Set(modelIds);
expect(uniqueModelIds.size).toBe(modelIds.length);
});
it('should have at least one non-deprecated model per inference provider', () => {
const inferenceProviders = [
InferenceProvider.OPENAI,
InferenceProvider.ANTHROPIC,
InferenceProvider.BEDROCK,
InferenceProvider.GOOGLE,
InferenceProvider.XAI,
InferenceProvider.GROQ,
InferenceProvider.MISTRAL,
];
inferenceProviders.forEach((inferenceProvider) => {
const activeModelsForProvider = AI_MODELS.filter(
(model) =>
model.inferenceProvider === inferenceProvider && !model.deprecated,
);
expect(activeModelsForProvider.length).toBeGreaterThan(0);
});
});
});
describe('AiModelRegistryService', () => {
let SERVICE: AiModelRegistryService;
let MOCK_CONFIG_SERVICE: jest.Mocked<TwentyConfigService>;
beforeEach(async () => {
MOCK_CONFIG_SERVICE = {
get: jest.fn(),
} as any;
const MODULE: TestingModule = await Test.createTestingModule({
providers: [
AiModelRegistryService,
{
provide: TwentyConfigService,
useValue: MOCK_CONFIG_SERVICE,
},
],
}).compile();
SERVICE = MODULE.get<AiModelRegistryService>(AiModelRegistryService);
});
it('should return effective model config for DEFAULT_SMART_MODEL', () => {
MOCK_CONFIG_SERVICE.get.mockReturnValue('gpt-5.2');
expect(() => SERVICE.getEffectiveModelConfig(DEFAULT_SMART_MODEL)).toThrow(
'No AI models are available. Please configure at least one AI provider (OPENAI_API_KEY, ANTHROPIC_API_KEY, AWS_BEDROCK_REGION, GOOGLE_API_KEY, XAI_API_KEY, GROQ_API_KEY, or MISTRAL_API_KEY).',
);
});
it('should return effective model config for DEFAULT_SMART_MODEL when models are available', () => {
MOCK_CONFIG_SERVICE.get.mockReturnValue('gpt-5.2');
jest.spyOn(SERVICE, 'getAvailableModels').mockReturnValue([
{
modelId: 'gpt-5.2',
inferenceProvider: InferenceProvider.OPENAI,
model: {} as any,
},
]);
jest.spyOn(SERVICE, 'getModel').mockReturnValue({
modelId: 'gpt-5.2',
inferenceProvider: InferenceProvider.OPENAI,
model: {} as any,
});
const RESULT = SERVICE.getEffectiveModelConfig(DEFAULT_SMART_MODEL);
expect(RESULT).toBeDefined();
expect(RESULT.modelId).toBe('gpt-5.2');
expect(RESULT.inferenceProvider).toBe(InferenceProvider.OPENAI);
});
it('should return effective model config for DEFAULT_SMART_MODEL with custom model', () => {
MOCK_CONFIG_SERVICE.get.mockReturnValue('mistral');
jest.spyOn(SERVICE, 'getAvailableModels').mockReturnValue([
{
modelId: 'mistral',
inferenceProvider: InferenceProvider.OPENAI_COMPATIBLE,
model: {} as any,
},
]);
jest.spyOn(SERVICE, 'getModel').mockReturnValue({
modelId: 'mistral',
inferenceProvider: InferenceProvider.OPENAI_COMPATIBLE,
model: {} as any,
});
const RESULT = SERVICE.getEffectiveModelConfig(DEFAULT_SMART_MODEL);
expect(RESULT).toBeDefined();
expect(RESULT.modelId).toBe('mistral');
expect(RESULT.inferenceProvider).toBe(InferenceProvider.OPENAI_COMPATIBLE);
expect(RESULT.label).toBe('mistral');
expect(RESULT.inputCostPerMillionTokens).toBe(0);
expect(RESULT.outputCostPerMillionTokens).toBe(0);
});
it('should return effective model config for specific model', () => {
const RESULT = SERVICE.getEffectiveModelConfig('gpt-5.2');
expect(RESULT).toBeDefined();
expect(RESULT.modelId).toBe('gpt-5.2');
expect(RESULT.inferenceProvider).toBe(InferenceProvider.OPENAI);
});
it('should return effective model config for custom model', () => {
jest.spyOn(SERVICE, 'getModel').mockReturnValue({
modelId: 'mistral',
inferenceProvider: InferenceProvider.OPENAI_COMPATIBLE,
model: {} as any,
});
const RESULT = SERVICE.getEffectiveModelConfig('mistral');
expect(RESULT).toBeDefined();
expect(RESULT.modelId).toBe('mistral');
expect(RESULT.inferenceProvider).toBe(InferenceProvider.OPENAI_COMPATIBLE);
expect(RESULT.label).toBe('mistral');
expect(RESULT.inputCostPerMillionTokens).toBe(0);
expect(RESULT.outputCostPerMillionTokens).toBe(0);
});
it('should throw error for non-existent model', () => {
jest.spyOn(SERVICE, 'getModel').mockReturnValue(undefined);
expect(() => SERVICE.getEffectiveModelConfig('non-existent-model')).toThrow(
'Model with ID non-existent-model not found',
);
});
it('should find first available model from comma-separated list', () => {
MOCK_CONFIG_SERVICE.get.mockReturnValue(
'gpt-5-mini,claude-haiku-4-5-20251001,gemini-3-flash-preview',
);
const getModelSpy = jest
.spyOn(SERVICE, 'getModel')
.mockImplementation((modelId: string) => {
if (modelId === 'claude-haiku-4-5-20251001') {
return {
modelId: 'claude-haiku-4-5-20251001',
inferenceProvider: InferenceProvider.ANTHROPIC,
model: {} as any,
};
}
return undefined;
});
const result = SERVICE.getDefaultSpeedModel();
expect(result).toBeDefined();
expect(result.modelId).toBe('claude-haiku-4-5-20251001');
expect(getModelSpy).toHaveBeenCalledWith('gpt-5-mini');
expect(getModelSpy).toHaveBeenCalledWith('claude-haiku-4-5-20251001');
});
it('should fall back to any available model if none in list are available', () => {
MOCK_CONFIG_SERVICE.get.mockReturnValue('model-a,model-b,model-c');
jest.spyOn(SERVICE, 'getModel').mockReturnValue(undefined);
jest.spyOn(SERVICE, 'getAvailableModels').mockReturnValue([
{
modelId: 'fallback-model',
inferenceProvider: InferenceProvider.OPENAI_COMPATIBLE,
model: {} as any,
},
]);
const result = SERVICE.getDefaultSpeedModel();
expect(result).toBeDefined();
expect(result.modelId).toBe('fallback-model');
});
});
@@ -1,28 +0,0 @@
export {
DEFAULT_FAST_MODEL,
DEFAULT_SMART_MODEL,
InferenceProvider,
ModelFamily,
type AIModelConfig,
type ModelId,
type SupportedFileType,
} from './ai-models-types.const';
import { type AIModelConfig } from './ai-models-types.const';
import { ANTHROPIC_MODELS } from './anthropic-models.const';
import { BEDROCK_MODELS } from './bedrock-models.const';
import { GOOGLE_MODELS } from './google-models.const';
import { GROQ_MODELS } from './groq-models.const';
import { MISTRAL_MODELS } from './mistral-models.const';
import { OPENAI_MODELS } from './openai-models.const';
import { XAI_MODELS } from './xai-models.const';
export const AI_MODELS: AIModelConfig[] = [
...OPENAI_MODELS,
...ANTHROPIC_MODELS,
...BEDROCK_MODELS,
...GOOGLE_MODELS,
...XAI_MODELS,
...GROQ_MODELS,
...MISTRAL_MODELS,
];
@@ -0,0 +1,7 @@
export const AI_SDK_OPENAI = '@ai-sdk/openai' as const;
export const AI_SDK_ANTHROPIC = '@ai-sdk/anthropic' as const;
export const AI_SDK_GOOGLE = '@ai-sdk/google' as const;
export const AI_SDK_MISTRAL = '@ai-sdk/mistral' as const;
export const AI_SDK_XAI = '@ai-sdk/xai' as const;
export const AI_SDK_BEDROCK = '@ai-sdk/amazon-bedrock' as const;
export const AI_SDK_OPENAI_COMPATIBLE = '@ai-sdk/openai-compatible' as const;
@@ -1,271 +0,0 @@
import {
type AIModelConfig,
InferenceProvider,
ModelFamily,
} from './ai-models-types.const';
export const ANTHROPIC_MODELS: AIModelConfig[] = [
// Active models
{
modelId: 'claude-opus-4-6',
label: 'Claude Opus 4.6',
description:
'Flagship Claude model for software engineering, scientific reasoning, and agent teams',
modelFamily: ModelFamily.ANTHROPIC,
inferenceProvider: InferenceProvider.ANTHROPIC,
inputCostPerMillionTokens: 5.0,
outputCostPerMillionTokens: 25.0,
cachedInputCostPerMillionTokens: 0.5,
cacheCreationCostPerMillionTokens: 6.25,
longContextCost: {
inputCostPerMillionTokens: 10.0,
outputCostPerMillionTokens: 37.5,
cachedInputCostPerMillionTokens: 1.0,
cacheCreationCostPerMillionTokens: 12.5,
thresholdTokens: 200_000,
},
contextWindowTokens: 1000000,
maxOutputTokens: 128000,
supportedFileTypes: [
'image/png',
'image/jpeg',
'image/gif',
'image/webp',
'application/pdf',
'text/plain',
'text/html',
'text/csv',
],
doesSupportThinking: true,
nativeCapabilities: {
webSearch: true,
},
isRecommended: true,
},
{
modelId: 'claude-sonnet-4-6',
label: 'Claude Sonnet 4.6',
description:
'Most capable Sonnet model with strong coding, computer use, and agent planning',
modelFamily: ModelFamily.ANTHROPIC,
inferenceProvider: InferenceProvider.ANTHROPIC,
inputCostPerMillionTokens: 3.0,
outputCostPerMillionTokens: 15.0,
cachedInputCostPerMillionTokens: 0.3,
cacheCreationCostPerMillionTokens: 3.75,
longContextCost: {
inputCostPerMillionTokens: 6.0,
outputCostPerMillionTokens: 22.5,
cachedInputCostPerMillionTokens: 0.6,
cacheCreationCostPerMillionTokens: 7.5,
thresholdTokens: 200_000,
},
contextWindowTokens: 200000,
maxOutputTokens: 64000,
supportedFileTypes: [
'image/png',
'image/jpeg',
'image/gif',
'image/webp',
'application/pdf',
'text/plain',
'text/html',
'text/csv',
],
doesSupportThinking: true,
nativeCapabilities: {
webSearch: true,
},
isRecommended: true,
},
{
modelId: 'claude-sonnet-4-5-20250929',
label: 'Claude Sonnet 4.5',
description:
'Previous gen Sonnet with 1M native context for long-context workflows',
modelFamily: ModelFamily.ANTHROPIC,
inferenceProvider: InferenceProvider.ANTHROPIC,
inputCostPerMillionTokens: 3.0,
outputCostPerMillionTokens: 15.0,
cachedInputCostPerMillionTokens: 0.3,
cacheCreationCostPerMillionTokens: 3.75,
longContextCost: {
inputCostPerMillionTokens: 6.0,
outputCostPerMillionTokens: 22.5,
cachedInputCostPerMillionTokens: 0.6,
cacheCreationCostPerMillionTokens: 7.5,
thresholdTokens: 200_000,
},
contextWindowTokens: 1000000,
maxOutputTokens: 64000,
supportedFileTypes: [
'image/png',
'image/jpeg',
'image/gif',
'image/webp',
'application/pdf',
'text/plain',
'text/html',
'text/csv',
],
doesSupportThinking: true,
nativeCapabilities: {
webSearch: true,
},
},
{
modelId: 'claude-haiku-4-5-20251001',
label: 'Claude Haiku 4.5',
description: 'Fast and cost-effective model for high-speed processing',
modelFamily: ModelFamily.ANTHROPIC,
inferenceProvider: InferenceProvider.ANTHROPIC,
inputCostPerMillionTokens: 1.0,
outputCostPerMillionTokens: 5.0,
cachedInputCostPerMillionTokens: 0.1,
cacheCreationCostPerMillionTokens: 1.25,
contextWindowTokens: 200000,
maxOutputTokens: 64000,
supportedFileTypes: [
'image/png',
'image/jpeg',
'image/gif',
'image/webp',
'application/pdf',
'text/plain',
'text/html',
'text/csv',
],
doesSupportThinking: false,
nativeCapabilities: {
webSearch: true,
},
},
// Deprecated models - kept for backward compatibility with existing agents
{
modelId: 'claude-opus-4-5-20251101',
label: 'Claude Opus 4.5',
description: 'Previous flagship model superseded by Opus 4.6',
modelFamily: ModelFamily.ANTHROPIC,
inferenceProvider: InferenceProvider.ANTHROPIC,
inputCostPerMillionTokens: 5.0,
outputCostPerMillionTokens: 25.0,
cachedInputCostPerMillionTokens: 0.5,
cacheCreationCostPerMillionTokens: 6.25,
longContextCost: {
inputCostPerMillionTokens: 10.0,
outputCostPerMillionTokens: 37.5,
cachedInputCostPerMillionTokens: 1.0,
cacheCreationCostPerMillionTokens: 12.5,
thresholdTokens: 200_000,
},
contextWindowTokens: 200000,
maxOutputTokens: 64000,
supportedFileTypes: [
'image/png',
'image/jpeg',
'image/gif',
'image/webp',
'application/pdf',
'text/plain',
'text/html',
'text/csv',
],
doesSupportThinking: true,
nativeCapabilities: {
webSearch: true,
},
deprecated: true,
},
{
modelId: 'claude-sonnet-4-20250514',
label: 'Claude Sonnet 4',
description: 'Previous gen Sonnet superseded by Sonnet 4.6',
modelFamily: ModelFamily.ANTHROPIC,
inferenceProvider: InferenceProvider.ANTHROPIC,
inputCostPerMillionTokens: 3.0,
outputCostPerMillionTokens: 15.0,
cachedInputCostPerMillionTokens: 0.3,
cacheCreationCostPerMillionTokens: 3.75,
longContextCost: {
inputCostPerMillionTokens: 6.0,
outputCostPerMillionTokens: 22.5,
cachedInputCostPerMillionTokens: 0.6,
cacheCreationCostPerMillionTokens: 7.5,
thresholdTokens: 200_000,
},
contextWindowTokens: 200000,
maxOutputTokens: 8192,
supportedFileTypes: [
'image/png',
'image/jpeg',
'image/gif',
'image/webp',
'application/pdf',
'text/plain',
'text/html',
'text/csv',
],
doesSupportThinking: true,
nativeCapabilities: {
webSearch: true,
},
deprecated: true,
},
{
modelId: 'claude-opus-4-20250514',
label: 'Claude Opus 4',
description: 'Legacy Opus model with extended thinking',
modelFamily: ModelFamily.ANTHROPIC,
inferenceProvider: InferenceProvider.ANTHROPIC,
inputCostPerMillionTokens: 15.0,
outputCostPerMillionTokens: 75.0,
cachedInputCostPerMillionTokens: 1.5,
cacheCreationCostPerMillionTokens: 18.75,
contextWindowTokens: 200000,
maxOutputTokens: 8192,
supportedFileTypes: [
'image/png',
'image/jpeg',
'image/gif',
'image/webp',
'application/pdf',
'text/plain',
'text/html',
'text/csv',
],
doesSupportThinking: true,
nativeCapabilities: {
webSearch: true,
},
deprecated: true,
},
{
modelId: 'claude-3-5-haiku-20241022',
label: 'Claude Haiku 3.5',
description: 'Legacy fast model superseded by Haiku 4.5',
modelFamily: ModelFamily.ANTHROPIC,
inferenceProvider: InferenceProvider.ANTHROPIC,
inputCostPerMillionTokens: 0.8,
outputCostPerMillionTokens: 4.0,
cachedInputCostPerMillionTokens: 0.08,
cacheCreationCostPerMillionTokens: 1.0,
contextWindowTokens: 200000,
maxOutputTokens: 8192,
supportedFileTypes: [
'image/png',
'image/jpeg',
'image/gif',
'image/webp',
'application/pdf',
'text/plain',
'text/html',
'text/csv',
],
doesSupportThinking: false,
nativeCapabilities: {
webSearch: true,
},
deprecated: true,
},
];
@@ -1,78 +0,0 @@
import {
type AIModelConfig,
InferenceProvider,
ModelFamily,
} from './ai-models-types.const';
export const BEDROCK_MODELS: AIModelConfig[] = [
{
modelId: 'anthropic.claude-opus-4-6-v1',
label: 'Claude Opus 4.6 (Bedrock)',
description:
'Flagship Claude model via AWS Bedrock for enterprise deployments',
modelFamily: ModelFamily.ANTHROPIC,
inferenceProvider: InferenceProvider.BEDROCK,
inputCostPerMillionTokens: 5.0,
outputCostPerMillionTokens: 25.0,
cachedInputCostPerMillionTokens: 0.5,
cacheCreationCostPerMillionTokens: 6.25,
longContextCost: {
inputCostPerMillionTokens: 10.0,
outputCostPerMillionTokens: 37.5,
cachedInputCostPerMillionTokens: 1.0,
cacheCreationCostPerMillionTokens: 12.5,
thresholdTokens: 200_000,
},
contextWindowTokens: 1000000,
maxOutputTokens: 128000,
supportedFileTypes: [
'image/png',
'image/jpeg',
'image/gif',
'image/webp',
'application/pdf',
'text/plain',
'text/html',
'text/csv',
],
doesSupportThinking: true,
nativeCapabilities: {
webSearch: true,
},
},
{
modelId: 'anthropic.claude-sonnet-4-6',
label: 'Claude Sonnet 4.6 (Bedrock)',
description:
'Balanced Claude model via AWS Bedrock with strong coding and agent planning',
modelFamily: ModelFamily.ANTHROPIC,
inferenceProvider: InferenceProvider.BEDROCK,
inputCostPerMillionTokens: 3.0,
outputCostPerMillionTokens: 15.0,
cachedInputCostPerMillionTokens: 0.3,
cacheCreationCostPerMillionTokens: 3.75,
longContextCost: {
inputCostPerMillionTokens: 6.0,
outputCostPerMillionTokens: 22.5,
cachedInputCostPerMillionTokens: 0.6,
cacheCreationCostPerMillionTokens: 7.5,
thresholdTokens: 200_000,
},
contextWindowTokens: 200000,
maxOutputTokens: 64000,
supportedFileTypes: [
'image/png',
'image/jpeg',
'image/gif',
'image/webp',
'application/pdf',
'text/plain',
'text/html',
'text/csv',
],
doesSupportThinking: true,
nativeCapabilities: {
webSearch: true,
},
},
];
@@ -1,2 +0,0 @@
// Configuration: $0.00_001 = 1 credit
export const DOLLAR_TO_CREDIT_MULTIPLIER = 1_000_000; // 1 / 0.00_0001 = 1_000_000 credits per dollar
@@ -1,96 +0,0 @@
import {
type AIModelConfig,
InferenceProvider,
ModelFamily,
} from './ai-models-types.const';
export const GOOGLE_MODELS: AIModelConfig[] = [
// Active models
{
modelId: 'gemini-3.1-pro-preview',
label: 'Gemini 3.1 Pro',
description:
'Most advanced Gemini model for reasoning, coding, and agentic workflows',
modelFamily: ModelFamily.GOOGLE,
inferenceProvider: InferenceProvider.GOOGLE,
inputCostPerMillionTokens: 2.0,
outputCostPerMillionTokens: 12.0,
cachedInputCostPerMillionTokens: 0.2,
longContextCost: {
inputCostPerMillionTokens: 4.0,
outputCostPerMillionTokens: 18.0,
cachedInputCostPerMillionTokens: 0.4,
thresholdTokens: 200_000,
},
contextWindowTokens: 1048576,
maxOutputTokens: 65536,
supportedFileTypes: ['image/png', 'image/jpeg', 'image/gif', 'image/webp'],
doesSupportThinking: true,
isRecommended: true,
},
{
modelId: 'gemini-3-flash-preview',
label: 'Gemini 3 Flash',
description: 'Fast frontier-class Gemini model at low cost with 1M context',
modelFamily: ModelFamily.GOOGLE,
inferenceProvider: InferenceProvider.GOOGLE,
inputCostPerMillionTokens: 0.5,
outputCostPerMillionTokens: 3.0,
cachedInputCostPerMillionTokens: 0.05,
contextWindowTokens: 1048576,
maxOutputTokens: 65536,
supportedFileTypes: ['image/png', 'image/jpeg', 'image/gif', 'image/webp'],
doesSupportThinking: true,
},
{
modelId: 'gemini-3.1-flash-lite-preview',
label: 'Gemini 3.1 Flash Lite',
description:
'Ultra-low-cost Gemini model for high-volume tasks with 1M context',
modelFamily: ModelFamily.GOOGLE,
inferenceProvider: InferenceProvider.GOOGLE,
inputCostPerMillionTokens: 0.25,
outputCostPerMillionTokens: 1.5,
contextWindowTokens: 1048576,
maxOutputTokens: 65536,
supportedFileTypes: ['image/png', 'image/jpeg', 'image/gif', 'image/webp'],
doesSupportThinking: true,
},
// Deprecated models - kept for backward compatibility with existing agents
{
modelId: 'gemini-2.5-pro',
label: 'Gemini 2.5 Pro',
description: 'Previous gen Gemini Pro superseded by 3.1 Pro',
modelFamily: ModelFamily.GOOGLE,
inferenceProvider: InferenceProvider.GOOGLE,
inputCostPerMillionTokens: 1.25,
outputCostPerMillionTokens: 10.0,
cachedInputCostPerMillionTokens: 0.315,
longContextCost: {
inputCostPerMillionTokens: 2.5,
outputCostPerMillionTokens: 15.0,
thresholdTokens: 200_000,
},
contextWindowTokens: 1048576,
maxOutputTokens: 65536,
supportedFileTypes: ['image/png', 'image/jpeg', 'image/gif', 'image/webp'],
doesSupportThinking: true,
deprecated: true,
},
{
modelId: 'gemini-2.5-flash',
label: 'Gemini 2.5 Flash',
description: 'Previous gen Flash superseded by Gemini 3 Flash',
modelFamily: ModelFamily.GOOGLE,
inferenceProvider: InferenceProvider.GOOGLE,
inputCostPerMillionTokens: 0.3,
outputCostPerMillionTokens: 2.5,
cachedInputCostPerMillionTokens: 0.075,
contextWindowTokens: 1048576,
maxOutputTokens: 65536,
supportedFileTypes: ['image/png', 'image/jpeg', 'image/gif', 'image/webp'],
doesSupportThinking: true,
deprecated: true,
},
];
@@ -1,21 +0,0 @@
import {
type AIModelConfig,
InferenceProvider,
ModelFamily,
} from './ai-models-types.const';
export const GROQ_MODELS: AIModelConfig[] = [
{
modelId: 'openai/gpt-oss-120b',
label: 'GPT-OSS 120B (Groq)',
description:
'Large-scale open-source model with ultra-fast inference via Groq',
modelFamily: ModelFamily.OPENAI,
inferenceProvider: InferenceProvider.GROQ,
inputCostPerMillionTokens: 0.15,
outputCostPerMillionTokens: 0.6,
cachedInputCostPerMillionTokens: 0.075,
contextWindowTokens: 128000,
maxOutputTokens: 16384,
},
];
@@ -1,20 +0,0 @@
import {
type AIModelConfig,
InferenceProvider,
ModelFamily,
} from './ai-models-types.const';
export const MISTRAL_MODELS: AIModelConfig[] = [
{
modelId: 'mistral-large-latest',
label: 'Mistral Large',
description:
'Flagship Mistral model with strong reasoning and 256K context',
modelFamily: ModelFamily.MISTRAL,
inferenceProvider: InferenceProvider.MISTRAL,
inputCostPerMillionTokens: 0.5,
outputCostPerMillionTokens: 1.5,
contextWindowTokens: 256000,
maxOutputTokens: 8192,
},
];
@@ -0,0 +1,9 @@
import { ModelFamily } from 'src/engine/metadata-modules/ai/ai-models/types/model-family.enum';
export const MODEL_FAMILY_LABELS: Record<string, string> = {
[ModelFamily.GPT]: 'GPT',
[ModelFamily.CLAUDE]: 'Claude',
[ModelFamily.GEMINI]: 'Gemini',
[ModelFamily.MISTRAL]: 'Mistral',
[ModelFamily.GROK]: 'Grok',
};
@@ -0,0 +1 @@
export const MODELS_DEV_API_URL = 'https://models.dev/api.json';
@@ -1,165 +0,0 @@
import {
type AIModelConfig,
InferenceProvider,
ModelFamily,
} from './ai-models-types.const';
export const OPENAI_MODELS: AIModelConfig[] = [
// Active models
{
modelId: 'gpt-5.2',
label: 'GPT-5.2',
description:
'Most advanced OpenAI model for coding, agentic tasks, and complex reasoning',
modelFamily: ModelFamily.OPENAI,
inferenceProvider: InferenceProvider.OPENAI,
inputCostPerMillionTokens: 1.75,
outputCostPerMillionTokens: 14.0,
cachedInputCostPerMillionTokens: 0.175,
contextWindowTokens: 400000,
maxOutputTokens: 128000,
supportedFileTypes: ['image/png', 'image/jpeg', 'image/gif', 'image/webp'],
doesSupportThinking: true,
nativeCapabilities: {
webSearch: true,
},
isRecommended: true,
},
{
modelId: 'gpt-5-mini',
label: 'GPT-5 Mini',
description: 'Fast and cost-efficient GPT-5 variant for well-defined tasks',
modelFamily: ModelFamily.OPENAI,
inferenceProvider: InferenceProvider.OPENAI,
inputCostPerMillionTokens: 0.25,
outputCostPerMillionTokens: 2.0,
cachedInputCostPerMillionTokens: 0.025,
contextWindowTokens: 128000,
maxOutputTokens: 32768,
supportedFileTypes: ['image/png', 'image/jpeg', 'image/gif', 'image/webp'],
nativeCapabilities: {
webSearch: true,
},
},
{
modelId: 'gpt-4.1',
label: 'GPT-4.1',
description: 'Strong model with 1M context, cost-effective output pricing',
modelFamily: ModelFamily.OPENAI,
inferenceProvider: InferenceProvider.OPENAI,
inputCostPerMillionTokens: 2.0,
outputCostPerMillionTokens: 8.0,
cachedInputCostPerMillionTokens: 0.5,
contextWindowTokens: 1047576,
maxOutputTokens: 32768,
supportedFileTypes: ['image/png', 'image/jpeg', 'image/gif', 'image/webp'],
nativeCapabilities: {
webSearch: true,
},
isRecommended: true,
},
{
modelId: 'gpt-4.1-mini',
label: 'GPT-4.1 Mini',
description: 'Budget-friendly model with 1M context for lightweight tasks',
modelFamily: ModelFamily.OPENAI,
inferenceProvider: InferenceProvider.OPENAI,
inputCostPerMillionTokens: 0.4,
outputCostPerMillionTokens: 1.6,
cachedInputCostPerMillionTokens: 0.1,
contextWindowTokens: 1047576,
maxOutputTokens: 32768,
supportedFileTypes: ['image/png', 'image/jpeg', 'image/gif', 'image/webp'],
nativeCapabilities: {
webSearch: true,
},
},
// Deprecated models - kept for backward compatibility with existing agents
{
modelId: 'o3',
label: 'o3',
description:
'Reasoning model for complex queries, coding, math, and science',
modelFamily: ModelFamily.OPENAI,
inferenceProvider: InferenceProvider.OPENAI,
inputCostPerMillionTokens: 2.0,
outputCostPerMillionTokens: 8.0,
cachedInputCostPerMillionTokens: 0.5,
contextWindowTokens: 200000,
maxOutputTokens: 100000,
supportedFileTypes: ['image/png', 'image/jpeg', 'image/gif', 'image/webp'],
doesSupportThinking: true,
nativeCapabilities: {
webSearch: true,
},
deprecated: true,
},
{
modelId: 'o4-mini',
label: 'o4-mini',
description:
'Cost-effective reasoning model for math, coding, and visual tasks',
modelFamily: ModelFamily.OPENAI,
inferenceProvider: InferenceProvider.OPENAI,
inputCostPerMillionTokens: 1.1,
outputCostPerMillionTokens: 4.4,
cachedInputCostPerMillionTokens: 0.275,
contextWindowTokens: 200000,
maxOutputTokens: 100000,
supportedFileTypes: ['image/png', 'image/jpeg', 'image/gif', 'image/webp'],
doesSupportThinking: true,
nativeCapabilities: {
webSearch: true,
},
deprecated: true,
},
{
modelId: 'gpt-4o',
label: 'GPT-4o',
description:
'Previous generation multimodal model with strong reasoning and vision',
modelFamily: ModelFamily.OPENAI,
inferenceProvider: InferenceProvider.OPENAI,
inputCostPerMillionTokens: 2.5,
outputCostPerMillionTokens: 10.0,
cachedInputCostPerMillionTokens: 1.25,
contextWindowTokens: 128000,
maxOutputTokens: 16384,
supportedFileTypes: ['image/png', 'image/jpeg', 'image/gif', 'image/webp'],
nativeCapabilities: {
webSearch: true,
},
deprecated: true,
},
{
modelId: 'gpt-4o-mini',
label: 'GPT-4o Mini',
description: 'Previous generation fast model for lightweight tasks',
modelFamily: ModelFamily.OPENAI,
inferenceProvider: InferenceProvider.OPENAI,
inputCostPerMillionTokens: 0.15,
outputCostPerMillionTokens: 0.6,
cachedInputCostPerMillionTokens: 0.075,
contextWindowTokens: 128000,
maxOutputTokens: 16384,
supportedFileTypes: ['image/png', 'image/jpeg', 'image/gif', 'image/webp'],
nativeCapabilities: {
webSearch: true,
},
deprecated: true,
},
{
modelId: 'gpt-4-turbo',
label: 'GPT-4 Turbo',
description: 'Legacy high-performance model with vision capabilities',
modelFamily: ModelFamily.OPENAI,
inferenceProvider: InferenceProvider.OPENAI,
inputCostPerMillionTokens: 10.0,
outputCostPerMillionTokens: 30.0,
contextWindowTokens: 128000,
maxOutputTokens: 4096,
supportedFileTypes: ['image/png', 'image/jpeg', 'image/gif', 'image/webp'],
deprecated: true,
},
];
@@ -1,87 +0,0 @@
import {
type AIModelConfig,
InferenceProvider,
ModelFamily,
} from './ai-models-types.const';
export const XAI_MODELS: AIModelConfig[] = [
// Active models
{
modelId: 'grok-4',
label: 'Grok-4',
description:
'Most capable Grok model with enhanced reasoning, web and Twitter search',
modelFamily: ModelFamily.XAI,
inferenceProvider: InferenceProvider.XAI,
inputCostPerMillionTokens: 3.0,
outputCostPerMillionTokens: 15.0,
cachedInputCostPerMillionTokens: 0.75,
contextWindowTokens: 256000,
maxOutputTokens: 8192,
supportedFileTypes: ['image/png', 'image/jpeg', 'image/gif', 'image/webp'],
nativeCapabilities: {
webSearch: true,
twitterSearch: true,
},
isRecommended: true,
},
{
modelId: 'grok-4-1-fast-reasoning',
label: 'Grok 4.1 Fast',
description:
'Next-generation tool-calling agent with 2M context for advanced agentic workflows',
modelFamily: ModelFamily.XAI,
inferenceProvider: InferenceProvider.XAI,
inputCostPerMillionTokens: 0.2,
outputCostPerMillionTokens: 0.5,
cachedInputCostPerMillionTokens: 0.05,
contextWindowTokens: 2000000,
maxOutputTokens: 8192,
supportedFileTypes: ['image/png', 'image/jpeg', 'image/gif', 'image/webp'],
doesSupportThinking: true,
nativeCapabilities: {
webSearch: true,
twitterSearch: true,
},
},
// Deprecated models - kept for backward compatibility with existing agents
{
modelId: 'grok-3',
label: 'Grok-3',
description:
'Advanced model with web and Twitter search, optimized for real-time information',
modelFamily: ModelFamily.XAI,
inferenceProvider: InferenceProvider.XAI,
inputCostPerMillionTokens: 3.0,
outputCostPerMillionTokens: 15.0,
cachedInputCostPerMillionTokens: 0.75,
contextWindowTokens: 131072,
maxOutputTokens: 8192,
supportedFileTypes: ['image/png', 'image/jpeg', 'image/gif', 'image/webp'],
nativeCapabilities: {
webSearch: true,
twitterSearch: true,
},
deprecated: true,
},
{
modelId: 'grok-3-mini',
label: 'Grok-3 Mini',
description:
'Lightweight model with web and Twitter search for fast, cost-effective operations',
modelFamily: ModelFamily.XAI,
inferenceProvider: InferenceProvider.XAI,
inputCostPerMillionTokens: 0.3,
outputCostPerMillionTokens: 0.5,
cachedInputCostPerMillionTokens: 0.07,
contextWindowTokens: 131072,
maxOutputTokens: 8192,
supportedFileTypes: ['image/png', 'image/jpeg', 'image/gif', 'image/webp'],
nativeCapabilities: {
webSearch: true,
twitterSearch: true,
},
deprecated: true,
},
];
@@ -1,34 +1,39 @@
import { Injectable } from '@nestjs/common';
import { anthropic } from '@ai-sdk/anthropic';
import { openai } from '@ai-sdk/openai';
import { ProviderOptions } from '@ai-sdk/provider-utils';
import { ToolSet } from 'ai';
import { AGENT_CONFIG } from 'src/engine/metadata-modules/ai/ai-agent/constants/agent-config.const';
import { InferenceProvider } from 'src/engine/metadata-modules/ai/ai-models/constants/ai-models.const';
import {
AI_SDK_ANTHROPIC,
AI_SDK_BEDROCK,
AI_SDK_OPENAI,
AI_SDK_XAI,
} from 'src/engine/metadata-modules/ai/ai-models/constants/ai-sdk-package.const';
import {
AiModelRegistryService,
RegisteredAIModel,
} from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service';
import { SdkProviderFactoryService } from 'src/engine/metadata-modules/ai/ai-models/services/sdk-provider-factory.service';
import { FlatAgentWithRoleId } from 'src/engine/metadata-modules/flat-agent/types/flat-agent.type';
@Injectable()
export class AgentModelConfigService {
constructor(
private readonly aiModelRegistryService: AiModelRegistryService,
private readonly sdkProviderFactory: SdkProviderFactoryService,
) {}
getProviderOptions(
model: RegisteredAIModel,
agent: FlatAgentWithRoleId,
): ProviderOptions {
switch (model.inferenceProvider) {
case InferenceProvider.XAI:
switch (model.sdkPackage) {
case AI_SDK_XAI:
return this.getXaiProviderOptions(agent);
case InferenceProvider.ANTHROPIC:
case AI_SDK_ANTHROPIC:
return this.getAnthropicProviderOptions(model);
case InferenceProvider.BEDROCK:
case AI_SDK_BEDROCK:
return this.getBedrockProviderOptions(model);
default:
return {};
@@ -45,16 +50,25 @@ export class AgentModelConfigService {
return tools;
}
switch (model.inferenceProvider) {
case InferenceProvider.ANTHROPIC:
switch (model.sdkPackage) {
case AI_SDK_ANTHROPIC:
if (agent.modelConfiguration.webSearch?.enabled) {
tools.web_search = anthropic.tools.webSearch_20250305();
const anthropicProvider = model.providerName
? this.sdkProviderFactory.getRawAnthropicProvider(
model.providerName,
)
: undefined;
if (anthropicProvider) {
tools.web_search = anthropicProvider.tools.webSearch_20250305();
}
}
break;
case InferenceProvider.BEDROCK: {
case AI_SDK_BEDROCK: {
if (agent.modelConfiguration.webSearch?.enabled) {
const bedrockProvider =
this.aiModelRegistryService.getBedrockProvider();
const bedrockProvider = model.providerName
? this.sdkProviderFactory.getRawBedrockProvider(model.providerName)
: undefined;
if (bedrockProvider) {
tools.web_search =
@@ -63,9 +77,15 @@ export class AgentModelConfigService {
}
break;
}
case InferenceProvider.OPENAI:
case AI_SDK_OPENAI:
if (agent.modelConfiguration.webSearch?.enabled) {
tools.web_search = openai.tools.webSearch();
const openaiProvider = model.providerName
? this.sdkProviderFactory.getRawOpenAIProvider(model.providerName)
: undefined;
if (openaiProvider) {
tools.web_search = openaiProvider.tools.webSearch();
}
}
break;
}
@@ -105,7 +125,7 @@ export class AgentModelConfigService {
private getAnthropicProviderOptions(
model: RegisteredAIModel,
): ProviderOptions {
if (!model.doesSupportThinking) {
if (!model.supportsReasoning) {
return {};
}
@@ -120,7 +140,7 @@ export class AgentModelConfigService {
}
private getBedrockProviderOptions(model: RegisteredAIModel): ProviderOptions {
if (!model.doesSupportThinking) {
if (!model.supportsReasoning) {
return {};
}
@@ -0,0 +1,63 @@
import { Injectable } from '@nestjs/common';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { AiModelRole } from 'src/engine/metadata-modules/ai/ai-models/types/ai-model-role.enum';
import { type AiModelPreferences } from 'src/engine/metadata-modules/ai/ai-models/types/ai-model-preferences.type';
@Injectable()
export class AiModelPreferencesService {
constructor(private readonly twentyConfigService: TwentyConfigService) {}
getPreferences(): AiModelPreferences {
return this.twentyConfigService.get('AI_MODEL_PREFERENCES');
}
getRecommendedModelIds(): Set<string> {
const prefs = this.getPreferences();
return new Set(prefs.recommendedModels ?? []);
}
async setModelAdminEnabled(modelId: string, enabled: boolean): Promise<void> {
await this.togglePreferenceList(modelId, 'disabledModels', !enabled);
}
async setModelRecommended(
modelId: string,
recommended: boolean,
): Promise<void> {
await this.togglePreferenceList(modelId, 'recommendedModels', recommended);
}
async setDefaultModel(role: AiModelRole, modelId: string): Promise<void> {
const prefs = { ...this.getPreferences() };
const key =
role === AiModelRole.FAST ? 'defaultFastModels' : 'defaultSmartModels';
const current = prefs[key] ?? [];
const filtered = current.filter((id) => id !== modelId);
prefs[key] = [modelId, ...filtered];
await this.twentyConfigService.set('AI_MODEL_PREFERENCES', prefs);
}
private async togglePreferenceList(
modelId: string,
key: 'disabledModels' | 'recommendedModels',
add: boolean,
): Promise<void> {
const prefs = { ...this.getPreferences() };
const current = prefs[key] ?? [];
if (add) {
if (!current.includes(modelId)) {
prefs[key] = [...current, modelId];
}
} else {
prefs[key] = current.filter((id) => id !== modelId);
}
await this.twentyConfigService.set('AI_MODEL_PREFERENCES', prefs);
}
}
@@ -1,37 +1,28 @@
import { Injectable } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import {
createAmazonBedrock,
type AmazonBedrockProvider,
} from '@ai-sdk/amazon-bedrock';
import { anthropic } from '@ai-sdk/anthropic';
import { google } from '@ai-sdk/google';
import { groq } from '@ai-sdk/groq';
import { mistral } from '@ai-sdk/mistral';
import { createOpenAI, openai } from '@ai-sdk/openai';
import { xai } from '@ai-sdk/xai';
import { type LanguageModel } from 'ai';
import { type AiSdkPackage } from 'twenty-shared/ai';
import { AiModelRole } from 'src/engine/metadata-modules/ai/ai-models/types/ai-model-role.enum';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import {
AgentException,
AgentExceptionCode,
} from 'src/engine/metadata-modules/ai/ai-agent/agent.exception';
import {
AI_MODELS,
DEFAULT_FAST_MODEL,
DEFAULT_SMART_MODEL,
InferenceProvider,
ModelFamily,
type AIModelConfig,
} from 'src/engine/metadata-modules/ai/ai-models/constants/ai-models.const';
import { ANTHROPIC_MODELS } from 'src/engine/metadata-modules/ai/ai-models/constants/anthropic-models.const';
import { BEDROCK_MODELS } from 'src/engine/metadata-modules/ai/ai-models/constants/bedrock-models.const';
import { GOOGLE_MODELS } from 'src/engine/metadata-modules/ai/ai-models/constants/google-models.const';
import { GROQ_MODELS } from 'src/engine/metadata-modules/ai/ai-models/constants/groq-models.const';
import { MISTRAL_MODELS } from 'src/engine/metadata-modules/ai/ai-models/constants/mistral-models.const';
import { OPENAI_MODELS } from 'src/engine/metadata-modules/ai/ai-models/constants/openai-models.const';
import { XAI_MODELS } from 'src/engine/metadata-modules/ai/ai-models/constants/xai-models.const';
import { AiModelPreferencesService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-preferences.service';
import { ProviderConfigService } from 'src/engine/metadata-modules/ai/ai-models/services/provider-config.service';
import { SdkProviderFactoryService } from 'src/engine/metadata-modules/ai/ai-models/services/sdk-provider-factory.service';
import { type AIModelConfig } from 'src/engine/metadata-modules/ai/ai-models/types/ai-model-config.type';
import { type AiProviderConfig } from 'src/engine/metadata-modules/ai/ai-models/types/ai-provider-config.type';
import { type AiProviderModelConfig } from 'src/engine/metadata-modules/ai/ai-models/types/ai-provider-model-config.type';
import { type AiProvidersConfig } from 'src/engine/metadata-modules/ai/ai-models/types/ai-providers-config.type';
import { DEFAULT_CONTEXT_WINDOW_TOKENS } from 'src/engine/metadata-modules/ai/ai-models/types/default-context-window-tokens.const';
import { DEFAULT_FAST_MODEL } from 'src/engine/metadata-modules/ai/ai-models/types/default-fast-model.const';
import { DEFAULT_MAX_OUTPUT_TOKENS } from 'src/engine/metadata-modules/ai/ai-models/types/default-max-output-tokens.const';
import { DEFAULT_SMART_MODEL } from 'src/engine/metadata-modules/ai/ai-models/types/default-smart-model.const';
import { buildCompositeModelId } from 'src/engine/metadata-modules/ai/ai-models/utils/composite-model-id.util';
import { inferModelFamily } from 'src/engine/metadata-modules/ai/ai-models/utils/infer-model-family.util';
import { isDefaultModelSentinel } from 'src/engine/metadata-modules/ai/ai-models/utils/is-default-model-sentinel.util';
import {
isModelAllowedByWorkspace,
type WorkspaceModelAvailabilitySettings,
@@ -39,201 +30,117 @@ import {
export interface RegisteredAIModel {
modelId: string;
inferenceProvider: InferenceProvider;
sdkPackage: AiSdkPackage;
model: LanguageModel;
doesSupportThinking?: boolean;
supportsReasoning?: boolean;
providerName?: string;
modelsDevName?: string;
}
@Injectable()
export class AiModelRegistryService {
private readonly logger = new Logger(AiModelRegistryService.name);
private modelRegistry: Map<string, RegisteredAIModel> = new Map();
private bedrockProvider: AmazonBedrockProvider | null = null;
private modelConfigCache: Map<string, AIModelConfig> = new Map();
private providerModelDefCache: Map<
string,
{ providerName: string; modelDef: AiProviderModelConfig }
> = new Map();
constructor(private twentyConfigService: TwentyConfigService) {
constructor(
private readonly providerConfigService: ProviderConfigService,
private readonly sdkProviderFactory: SdkProviderFactoryService,
private readonly preferencesService: AiModelPreferencesService,
) {
this.buildModelRegistry();
}
getBedrockProvider(): AmazonBedrockProvider | null {
return this.bedrockProvider;
}
private buildModelRegistry(): void {
this.modelRegistry.clear();
this.bedrockProvider = null;
this.sdkProviderFactory.clearCache();
this.modelConfigCache.clear();
this.providerModelDefCache.clear();
const openaiApiKey = this.twentyConfigService.get('OPENAI_API_KEY');
const providers = this.providerConfigService.getResolvedProviders();
if (openaiApiKey) {
this.registerOpenAIModels();
}
this.registerModelsFromProviders(providers);
}
const anthropicApiKey = this.twentyConfigService.get('ANTHROPIC_API_KEY');
private registerModelsFromProviders(providers: AiProvidersConfig): void {
for (const [providerKey, config] of Object.entries(providers)) {
if (!config.npm) {
this.logger.warn(
`Skipping provider "${providerKey}": missing npm field`,
);
continue;
}
if (anthropicApiKey) {
this.registerAnthropicModels();
}
const models = config.models ?? [];
const xaiApiKey = this.twentyConfigService.get('XAI_API_KEY');
if (models.length === 0) {
continue;
}
if (xaiApiKey) {
this.registerXaiModels();
}
const isConfigured = !!(config.apiKey || config.accessKeyId);
const groqApiKey = this.twentyConfigService.get('GROQ_API_KEY');
const sdkInstance = isConfigured
? this.sdkProviderFactory.createProvider(providerKey, config)
: undefined;
if (groqApiKey) {
this.registerGroqModels();
}
for (const modelDef of models) {
const compositeId = buildCompositeModelId(providerKey, modelDef.name);
const googleApiKey = this.twentyConfigService.get('GOOGLE_API_KEY');
this.modelConfigCache.set(
compositeId,
this.toAIModelConfig(compositeId, config, modelDef),
);
if (googleApiKey) {
this.registerGoogleModels();
}
this.providerModelDefCache.set(compositeId, {
providerName: providerKey,
modelDef,
});
const mistralApiKey = this.twentyConfigService.get('MISTRAL_API_KEY');
if (mistralApiKey) {
this.registerMistralModels();
}
const bedrockRegion = this.twentyConfigService.get('AWS_BEDROCK_REGION');
if (bedrockRegion) {
this.registerBedrockModels(bedrockRegion);
}
const openaiCompatibleBaseUrl = this.twentyConfigService.get(
'OPENAI_COMPATIBLE_BASE_URL',
);
const openaiCompatibleModelNames = this.twentyConfigService.get(
'OPENAI_COMPATIBLE_MODEL_NAMES',
);
if (openaiCompatibleBaseUrl && openaiCompatibleModelNames) {
this.registerOpenAICompatibleModels(
openaiCompatibleBaseUrl,
openaiCompatibleModelNames,
);
if (sdkInstance) {
this.modelRegistry.set(compositeId, {
modelId: compositeId,
sdkPackage: config.npm,
model: sdkInstance.createModel(modelDef.name),
supportsReasoning: modelDef.supportsReasoning,
providerName: providerKey,
modelsDevName: config.name,
});
}
}
}
}
private registerOpenAIModels(): void {
OPENAI_MODELS.forEach((modelConfig) => {
this.modelRegistry.set(modelConfig.modelId, {
modelId: modelConfig.modelId,
inferenceProvider: InferenceProvider.OPENAI,
model: openai(modelConfig.modelId),
doesSupportThinking: modelConfig.doesSupportThinking,
});
});
}
private registerAnthropicModels(): void {
ANTHROPIC_MODELS.forEach((modelConfig) => {
this.modelRegistry.set(modelConfig.modelId, {
modelId: modelConfig.modelId,
inferenceProvider: InferenceProvider.ANTHROPIC,
model: anthropic(modelConfig.modelId),
doesSupportThinking: modelConfig.doesSupportThinking,
});
});
}
private registerXaiModels(): void {
XAI_MODELS.forEach((modelConfig) => {
this.modelRegistry.set(modelConfig.modelId, {
modelId: modelConfig.modelId,
inferenceProvider: InferenceProvider.XAI,
model: xai(modelConfig.modelId),
doesSupportThinking: modelConfig.doesSupportThinking,
});
});
}
private registerGroqModels(): void {
GROQ_MODELS.forEach((modelConfig) => {
this.modelRegistry.set(modelConfig.modelId, {
modelId: modelConfig.modelId,
inferenceProvider: InferenceProvider.GROQ,
model: groq(modelConfig.modelId),
doesSupportThinking: modelConfig.doesSupportThinking,
});
});
}
private registerGoogleModels(): void {
GOOGLE_MODELS.forEach((modelConfig) => {
this.modelRegistry.set(modelConfig.modelId, {
modelId: modelConfig.modelId,
inferenceProvider: InferenceProvider.GOOGLE,
model: google(modelConfig.modelId),
doesSupportThinking: modelConfig.doesSupportThinking,
});
});
}
private registerMistralModels(): void {
MISTRAL_MODELS.forEach((modelConfig) => {
this.modelRegistry.set(modelConfig.modelId, {
modelId: modelConfig.modelId,
inferenceProvider: InferenceProvider.MISTRAL,
model: mistral(modelConfig.modelId),
doesSupportThinking: modelConfig.doesSupportThinking,
});
});
}
private registerBedrockModels(region: string): void {
const accessKeyId = this.twentyConfigService.get(
'AWS_BEDROCK_ACCESS_KEY_ID',
);
const secretAccessKey = this.twentyConfigService.get(
'AWS_BEDROCK_SECRET_ACCESS_KEY',
);
const sessionToken = this.twentyConfigService.get(
'AWS_BEDROCK_SESSION_TOKEN',
);
this.bedrockProvider = createAmazonBedrock({
region,
...(accessKeyId && secretAccessKey
? { accessKeyId, secretAccessKey, sessionToken }
: {}),
});
BEDROCK_MODELS.forEach((modelConfig) => {
this.modelRegistry.set(modelConfig.modelId, {
modelId: modelConfig.modelId,
inferenceProvider: InferenceProvider.BEDROCK,
model: this.bedrockProvider!(modelConfig.modelId),
doesSupportThinking: modelConfig.doesSupportThinking,
});
});
}
private registerOpenAICompatibleModels(
baseUrl: string,
modelNamesString: string,
): void {
const apiKey = this.twentyConfigService.get('OPENAI_COMPATIBLE_API_KEY');
const provider = createOpenAI({
baseURL: baseUrl,
apiKey: apiKey,
});
const modelNames = modelNamesString
.split(',')
.map((name) => name.trim())
.filter((name) => name.length > 0);
modelNames.forEach((modelId) => {
this.modelRegistry.set(modelId, {
modelId,
inferenceProvider: InferenceProvider.OPENAI_COMPATIBLE,
model: provider(modelId),
});
});
private toAIModelConfig(
compositeId: string,
providerConfig: AiProviderConfig,
modelDef: AiProviderModelConfig,
): AIModelConfig {
return {
modelId: compositeId,
label: modelDef.label,
sdkPackage: providerConfig.npm,
description: modelDef.description ?? compositeId,
modelFamily:
modelDef.modelFamily ??
inferModelFamily(providerConfig.name ?? '', modelDef.name),
dataResidency: providerConfig.dataResidency,
inputCostPerMillionTokens: modelDef.inputCostPerMillionTokens ?? 0,
outputCostPerMillionTokens: modelDef.outputCostPerMillionTokens ?? 0,
cachedInputCostPerMillionTokens: modelDef.cachedInputCostPerMillionTokens,
cacheCreationCostPerMillionTokens:
modelDef.cacheCreationCostPerMillionTokens,
longContextCost: modelDef.longContextCost,
contextWindowTokens:
modelDef.contextWindowTokens ?? DEFAULT_CONTEXT_WINDOW_TOKENS,
maxOutputTokens: modelDef.maxOutputTokens ?? DEFAULT_MAX_OUTPUT_TOKENS,
modalities: modelDef.modalities,
supportsReasoning: modelDef.supportsReasoning,
isDeprecated: modelDef.isDeprecated,
};
}
getModel(modelId: string): RegisteredAIModel | undefined {
@@ -244,14 +151,17 @@ export class AiModelRegistryService {
return Array.from(this.modelRegistry.values());
}
private getFirstAvailableModelFromList(
modelIdList: string,
): RegisteredAIModel | undefined {
const modelIds = modelIdList
.split(',')
.map((id) => id.trim())
.filter((id) => id.length > 0);
getModelConfig(modelId: string): AIModelConfig | undefined {
return this.modelConfigCache.get(modelId);
}
getRecommendedModelIds(): Set<string> {
return this.preferencesService.getRecommendedModelIds();
}
private getFirstAvailableModelFromList(
modelIds: string[],
): RegisteredAIModel | undefined {
for (const modelId of modelIds) {
const model = this.getModel(modelId);
@@ -264,42 +174,27 @@ export class AiModelRegistryService {
}
getDefaultSpeedModel(): RegisteredAIModel {
const defaultModelIds = this.twentyConfigService.get(
'DEFAULT_AI_SPEED_MODEL_ID',
);
let model = this.getFirstAvailableModelFromList(defaultModelIds);
if (!model) {
const availableModels = this.getAvailableModels();
model = availableModels[0];
}
if (!model) {
throw new AgentException(
'No AI models are available. Please configure at least one AI provider (OPENAI_API_KEY, ANTHROPIC_API_KEY, AWS_BEDROCK_REGION, GOOGLE_API_KEY, XAI_API_KEY, GROQ_API_KEY, or MISTRAL_API_KEY).',
AgentExceptionCode.API_KEY_NOT_CONFIGURED,
);
}
return model;
return this.getDefaultModelForRole(AiModelRole.FAST);
}
getDefaultPerformanceModel(): RegisteredAIModel {
const defaultModelIds = this.twentyConfigService.get(
'DEFAULT_AI_PERFORMANCE_MODEL_ID',
);
let model = this.getFirstAvailableModelFromList(defaultModelIds);
return this.getDefaultModelForRole(AiModelRole.SMART);
}
private getDefaultModelForRole(role: AiModelRole): RegisteredAIModel {
const prefs = this.preferencesService.getPreferences();
const preferenceKey =
role === AiModelRole.FAST ? 'defaultFastModels' : 'defaultSmartModels';
let model = this.getFirstAvailableModelFromList(prefs[preferenceKey] ?? []);
if (!model) {
const availableModels = this.getAvailableModels();
model = availableModels[0];
model = this.getAvailableModels()[0];
}
if (!model) {
throw new AgentException(
'No AI models are available. Please configure at least one AI provider (OPENAI_API_KEY, ANTHROPIC_API_KEY, AWS_BEDROCK_REGION, GOOGLE_API_KEY, XAI_API_KEY, GROQ_API_KEY, or MISTRAL_API_KEY).',
'No AI models are available. Configure at least one AI provider.',
AgentExceptionCode.API_KEY_NOT_CONFIGURED,
);
}
@@ -308,29 +203,22 @@ export class AiModelRegistryService {
}
getEffectiveModelConfig(modelId: string): AIModelConfig {
if (modelId === DEFAULT_FAST_MODEL || modelId === DEFAULT_SMART_MODEL) {
if (isDefaultModelSentinel(modelId)) {
const defaultModel =
modelId === DEFAULT_FAST_MODEL
? this.getDefaultSpeedModel()
: this.getDefaultPerformanceModel();
const modelConfig = AI_MODELS.find(
(model) => model.modelId === defaultModel.modelId,
return (
this.modelConfigCache.get(defaultModel.modelId) ??
this.createDefaultConfigForCustomModel(defaultModel)
);
if (modelConfig) {
return modelConfig;
}
return this.createDefaultConfigForCustomModel(defaultModel);
}
const predefinedModel = AI_MODELS.find(
(model) => model.modelId === modelId,
);
const config = this.modelConfigCache.get(modelId);
if (predefinedModel) {
return predefinedModel;
if (config) {
return config;
}
const registeredModel = this.getModel(modelId);
@@ -352,43 +240,27 @@ export class AiModelRegistryService {
modelId: registeredModel.modelId,
label: registeredModel.modelId,
description: `Custom model: ${registeredModel.modelId}`,
modelFamily: this.inferModelFamily(registeredModel.inferenceProvider),
inferenceProvider: registeredModel.inferenceProvider,
modelFamily: inferModelFamily(
registeredModel.modelsDevName ?? '',
registeredModel.modelId,
),
sdkPackage: registeredModel.sdkPackage,
inputCostPerMillionTokens: 0,
outputCostPerMillionTokens: 0,
contextWindowTokens: 128000,
maxOutputTokens: 4096,
contextWindowTokens: DEFAULT_CONTEXT_WINDOW_TOKENS,
maxOutputTokens: DEFAULT_MAX_OUTPUT_TOKENS,
};
}
private inferModelFamily(inferenceProvider: InferenceProvider): ModelFamily {
const providerToFamily: Partial<Record<InferenceProvider, ModelFamily>> = {
[InferenceProvider.OPENAI]: ModelFamily.OPENAI,
[InferenceProvider.ANTHROPIC]: ModelFamily.ANTHROPIC,
[InferenceProvider.BEDROCK]: ModelFamily.ANTHROPIC,
[InferenceProvider.GOOGLE]: ModelFamily.GOOGLE,
[InferenceProvider.MISTRAL]: ModelFamily.MISTRAL,
[InferenceProvider.XAI]: ModelFamily.XAI,
[InferenceProvider.GROQ]: ModelFamily.OPENAI,
};
return providerToFamily[inferenceProvider] ?? ModelFamily.OPENAI;
}
isModelAdminAllowed(modelId: string): boolean {
if (modelId === DEFAULT_FAST_MODEL || modelId === DEFAULT_SMART_MODEL) {
if (isDefaultModelSentinel(modelId)) {
return true;
}
const autoEnable = this.twentyConfigService.get(
'AI_AUTO_ENABLE_NEW_MODELS',
);
const disabledIds = this.twentyConfigService.get('AI_DISABLED_MODEL_IDS');
const enabledIds = this.twentyConfigService.get('AI_ENABLED_MODEL_IDS');
const prefs = this.preferencesService.getPreferences();
const disabledModels = prefs.disabledModels ?? [];
return autoEnable
? !disabledIds.includes(modelId)
: enabledIds.includes(modelId);
return !disabledModels.includes(modelId);
}
validateModelAvailability(
@@ -402,7 +274,13 @@ export class AiModelRegistryService {
);
}
if (!isModelAllowedByWorkspace(modelId, workspace)) {
if (
!isModelAllowedByWorkspace(
modelId,
workspace,
this.getRecommendedModelIds(),
)
) {
throw new AgentException(
'The selected model is not available in this workspace.',
AgentExceptionCode.AGENT_EXECUTION_FAILED,
@@ -420,111 +298,80 @@ export class AiModelRegistryService {
modelConfig: AIModelConfig;
isAvailable: boolean;
isAdminEnabled: boolean;
isRecommended: boolean;
providerName?: string;
name?: string;
}> {
return AI_MODELS.map((model) => ({
modelConfig: model,
isAvailable: this.modelRegistry.has(model.modelId),
isAdminEnabled: this.isModelAdminAllowed(model.modelId),
}));
const recommended = this.getRecommendedModelIds();
return Array.from(this.modelConfigCache.values()).map((modelConfig) => {
const registered = this.modelRegistry.get(modelConfig.modelId);
const cached = this.providerModelDefCache.get(modelConfig.modelId);
return {
modelConfig,
isAvailable: !!registered,
isAdminEnabled: this.isModelAdminAllowed(modelConfig.modelId),
isRecommended: recommended.has(modelConfig.modelId),
providerName: registered?.providerName ?? cached?.providerName,
name: cached?.modelDef.name,
};
});
}
async setModelAdminEnabled(modelId: string, enabled: boolean): Promise<void> {
const isKnownModel = AI_MODELS.some((model) => model.modelId === modelId);
this.validateModelInRegistry(modelId);
await this.preferencesService.setModelAdminEnabled(modelId, enabled);
}
if (!isKnownModel) {
async setModelRecommended(
modelId: string,
recommended: boolean,
): Promise<void> {
this.validateModelInRegistry(modelId);
await this.preferencesService.setModelRecommended(modelId, recommended);
}
async setDefaultModel(role: AiModelRole, modelId: string): Promise<void> {
this.validateModelInRegistry(modelId);
await this.preferencesService.setDefaultModel(role, modelId);
}
private validateModelInRegistry(modelId: string): void {
if (!this.providerModelDefCache.has(modelId)) {
throw new AgentException(
`Unknown model ID: ${modelId}`,
`Cannot update model "${modelId}": not found in registry`,
AgentExceptionCode.AGENT_EXECUTION_FAILED,
);
}
}
const autoEnable = this.twentyConfigService.get(
'AI_AUTO_ENABLE_NEW_MODELS',
);
const disabledIds = this.twentyConfigService.get('AI_DISABLED_MODEL_IDS');
const enabledIds = this.twentyConfigService.get('AI_ENABLED_MODEL_IDS');
getResolvedProvidersForAdmin(): AiProvidersConfig {
return this.providerConfigService.getResolvedProviders();
}
if (autoEnable) {
const newDisabledIds = enabled
? disabledIds.filter((id) => id !== modelId)
: disabledIds.includes(modelId)
? disabledIds
: [...disabledIds, modelId];
await this.twentyConfigService.set(
'AI_DISABLED_MODEL_IDS',
newDisabledIds,
);
} else {
const newEnabledIds = enabled
? enabledIds.includes(modelId)
? enabledIds
: [...enabledIds, modelId]
: enabledIds.filter((id) => id !== modelId);
await this.twentyConfigService.set('AI_ENABLED_MODEL_IDS', newEnabledIds);
}
getCatalogProviderNames(): Set<string> {
return this.providerConfigService.getCatalogProviderNames();
}
refreshRegistry(): void {
this.buildModelRegistry();
}
async resolveModelForAgent(agent: { modelId: string } | null) {
resolveModelForAgent(agent: { modelId: string } | null): RegisteredAIModel {
const aiModel = this.getEffectiveModelConfig(
agent?.modelId ?? DEFAULT_SMART_MODEL,
);
await this.validateApiKey(aiModel.inferenceProvider);
const registeredModel = this.getModel(aiModel.modelId);
if (!registeredModel) {
throw new AgentException(
`Model ${aiModel.modelId} not found in registry`,
AgentExceptionCode.AGENT_EXECUTION_FAILED,
`Model ${aiModel.modelId} not found in registry. Check that the corresponding AI provider is configured.`,
AgentExceptionCode.API_KEY_NOT_CONFIGURED,
);
}
return registeredModel;
}
async validateApiKey(inferenceProvider: InferenceProvider): Promise<void> {
let apiKey: string | undefined;
switch (inferenceProvider) {
case InferenceProvider.OPENAI:
apiKey = this.twentyConfigService.get('OPENAI_API_KEY');
break;
case InferenceProvider.ANTHROPIC:
apiKey = this.twentyConfigService.get('ANTHROPIC_API_KEY');
break;
case InferenceProvider.XAI:
apiKey = this.twentyConfigService.get('XAI_API_KEY');
break;
case InferenceProvider.GROQ:
apiKey = this.twentyConfigService.get('GROQ_API_KEY');
break;
case InferenceProvider.GOOGLE:
apiKey = this.twentyConfigService.get('GOOGLE_API_KEY');
break;
case InferenceProvider.MISTRAL:
apiKey = this.twentyConfigService.get('MISTRAL_API_KEY');
break;
case InferenceProvider.BEDROCK:
apiKey = this.twentyConfigService.get('AWS_BEDROCK_REGION');
break;
case InferenceProvider.OPENAI_COMPATIBLE:
apiKey = this.twentyConfigService.get('OPENAI_COMPATIBLE_API_KEY');
break;
default:
return;
}
if (!apiKey) {
throw new AgentException(
`${inferenceProvider.toUpperCase()} API key not configured. Please set the appropriate environment variable.`,
AgentExceptionCode.API_KEY_NOT_CONFIGURED,
);
}
}
}
@@ -1,47 +0,0 @@
import { Injectable } from '@nestjs/common';
import { LanguageModel, type ModelMessage, streamText } from 'ai';
import { AI_TELEMETRY_CONFIG } from 'src/engine/metadata-modules/ai/ai-models/constants/ai-telemetry.const';
import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service';
@Injectable()
export class AiService {
constructor(private aiModelRegistryService: AiModelRegistryService) {}
getModel(modelId: string | undefined) {
const registeredModel = modelId
? this.aiModelRegistryService.getModel(modelId)
: this.aiModelRegistryService.getDefaultPerformanceModel();
if (!registeredModel) {
throw new Error(
modelId
? `Model "${modelId}" is not available. Please check your configuration.`
: 'No AI models are available. Please configure at least one provider.',
);
}
return registeredModel.model;
}
streamText({
messages,
options,
}: {
messages: ModelMessage[];
options: {
temperature?: number;
maxOutputTokens?: number;
model: LanguageModel;
};
}): ReturnType<typeof streamText> {
return streamText({
model: options.model,
messages,
temperature: options?.temperature,
maxOutputTokens: options?.maxOutputTokens,
experimental_telemetry: AI_TELEMETRY_CONFIG,
});
}
}
@@ -0,0 +1,143 @@
import { Injectable, Logger } from '@nestjs/common';
import { inferAiSdkPackage } from 'twenty-shared/ai';
import { MODELS_DEV_API_URL } from 'src/engine/metadata-modules/ai/ai-models/constants/models-dev.const';
import { type ModelsDevData } from 'src/engine/metadata-modules/ai/ai-models/types/models-dev-data.type';
export type ModelsDevModelSuggestion = {
// models.dev catalog key for the model (often a bare id). Not the composite `provider/modelName` workspace id used in the registry.
modelId: string;
// Display name from the catalog (`model.name`), or the catalog key when absent.
name: string;
inputCostPerMillionTokens: number;
outputCostPerMillionTokens: number;
cachedInputCostPerMillionTokens?: number;
cacheCreationCostPerMillionTokens?: number;
contextWindowTokens: number;
maxOutputTokens: number;
modalities: string[];
supportsReasoning: boolean;
};
export type ModelsDevProviderSuggestion = {
id: string;
modelCount: number;
npm: string;
};
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
const NON_LANGUAGE_PATTERNS = [
'embed',
'tts',
'whisper',
'dall-e',
'moderation',
'text-to-speech',
'speech-to-text',
'imagen',
'aqa',
];
@Injectable()
export class ModelsDevCatalogService {
private readonly logger = new Logger(ModelsDevCatalogService.name);
private cache: ModelsDevData | null = null;
private cacheTimestamp = 0;
async getProviderSuggestions(): Promise<ModelsDevProviderSuggestion[]> {
const data = await this.getCachedData();
if (!data) {
return [];
}
return Object.entries(data)
.filter(([, provider]) => {
const models = Object.keys(provider.models ?? {});
return models.some((modelId) => this.isLanguageModel(modelId));
})
.map(([id, provider]) => ({
id,
modelCount: Object.keys(provider.models ?? {}).filter((modelId) =>
this.isLanguageModel(modelId),
).length,
npm: inferAiSdkPackage(id),
}))
.sort((a, b) => b.modelCount - a.modelCount);
}
async getModelSuggestions(
providerType: string,
): Promise<ModelsDevModelSuggestion[]> {
const data = await this.getCachedData();
if (!data) {
return [];
}
const providerData = data[providerType];
if (!providerData?.models) {
return [];
}
return Object.entries(providerData.models)
.filter(([modelId]) => this.isLanguageModel(modelId))
.map(([modelId, model]) => ({
modelId,
name: model.name ?? modelId,
inputCostPerMillionTokens: model.cost?.input ?? 0,
outputCostPerMillionTokens: model.cost?.output ?? 0,
cachedInputCostPerMillionTokens: model.cost?.cache_read,
cacheCreationCostPerMillionTokens: model.cost?.cache_write,
contextWindowTokens: model.limit?.context ?? 0,
maxOutputTokens: model.limit?.output ?? 0,
modalities: (model.modalities?.input ?? []).filter(
(modality) => modality !== 'text',
),
supportsReasoning: model.reasoning ?? false,
}));
}
private async getCachedData(): Promise<ModelsDevData | null> {
const now = Date.now();
if (this.cache && now - this.cacheTimestamp < CACHE_TTL_MS) {
return this.cache;
}
try {
const response = await fetch(MODELS_DEV_API_URL, {
signal: AbortSignal.timeout(15000),
});
if (!response.ok) {
this.logger.warn(
`models.dev API returned ${response.status}, using cached data`,
);
return this.cache;
}
this.cache = await response.json();
this.cacheTimestamp = now;
return this.cache;
} catch (error) {
this.logger.warn(
`Failed to fetch models.dev: ${error instanceof Error ? error.message : String(error)}`,
);
return this.cache;
}
}
private isLanguageModel(modelId: string): boolean {
const id = modelId.toLowerCase();
return !NON_LANGUAGE_PATTERNS.some((pattern) => id.includes(pattern));
}
}
@@ -0,0 +1,75 @@
import { Injectable } from '@nestjs/common';
import { type ConfigVariables } from 'src/engine/core-modules/twenty-config/config-variables';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { type AiProviderConfig } from 'src/engine/metadata-modules/ai/ai-models/types/ai-provider-config.type';
import { type AiProvidersConfig } from 'src/engine/metadata-modules/ai/ai-models/types/ai-providers-config.type';
import { extractConfigVariableName } from 'src/engine/metadata-modules/ai/ai-models/utils/extract-config-variable-name.util';
import { loadDefaultAiProviders } from 'src/engine/metadata-modules/ai/ai-models/utils/load-default-ai-providers.util';
@Injectable()
export class ProviderConfigService {
constructor(private readonly twentyConfigService: TwentyConfigService) {}
getCatalogProviderNames(): Set<string> {
return new Set(Object.keys(loadDefaultAiProviders()));
}
getResolvedProviders(): AiProvidersConfig {
const rawCatalog = loadDefaultAiProviders();
// Only resolve {{VAR}} templates in the committed catalog — never in
// user-supplied custom providers, to prevent config variable exfiltration.
const catalog = this.resolveTemplates(rawCatalog);
const custom = this.twentyConfigService.get('AI_PROVIDERS');
return { ...catalog, ...custom };
}
private resolveTemplates(providers: AiProvidersConfig): AiProvidersConfig {
const result: AiProvidersConfig = {};
for (const [name, config] of Object.entries(providers)) {
result[name] = this.resolveProviderTemplates(config);
}
return result;
}
private resolveProviderTemplates(config: AiProviderConfig): AiProviderConfig {
return {
...config,
apiKey: this.resolveTemplate(config.apiKey),
accessKeyId: this.resolveTemplate(config.accessKeyId),
secretAccessKey: this.resolveTemplate(config.secretAccessKey),
};
}
private resolveTemplate(value?: string): string | undefined {
if (!value) {
return value;
}
const varName = extractConfigVariableName(value);
if (!varName) {
return value;
}
// Registered config variables first (supports admin panel / DB overrides),
// then fall back to process.env for vars not in ConfigVariables
// (e.g. when CI replaces the catalog with custom provider entries).
try {
const resolved = this.twentyConfigService.get(
varName as keyof ConfigVariables,
) as string | undefined;
if (resolved) {
return resolved;
}
} catch {
// Not a registered config variable — fall through to env
}
return process.env[varName] || undefined;
}
}
@@ -0,0 +1,172 @@
import { Injectable } from '@nestjs/common';
import {
createAmazonBedrock,
type AmazonBedrockProvider,
} from '@ai-sdk/amazon-bedrock';
import { createAnthropic, type AnthropicProvider } from '@ai-sdk/anthropic';
import { createGoogleGenerativeAI } from '@ai-sdk/google';
import { createMistral } from '@ai-sdk/mistral';
import { createOpenAI, type OpenAIProvider } from '@ai-sdk/openai';
import { createXai } from '@ai-sdk/xai';
import { type LanguageModel } from 'ai';
import { type AiSdkPackage } from 'twenty-shared/ai';
import {
AI_SDK_ANTHROPIC,
AI_SDK_BEDROCK,
AI_SDK_GOOGLE,
AI_SDK_MISTRAL,
AI_SDK_OPENAI,
AI_SDK_OPENAI_COMPATIBLE,
AI_SDK_XAI,
} from 'src/engine/metadata-modules/ai/ai-models/constants/ai-sdk-package.const';
import { type AiProviderConfig } from 'src/engine/metadata-modules/ai/ai-models/types/ai-provider-config.type';
export type AiSdkProviderInstance = {
createModel: (modelId: string) => LanguageModel;
rawProvider: unknown;
sdkPackage: AiSdkPackage;
};
@Injectable()
export class SdkProviderFactoryService {
private readonly providerInstances = new Map<string, AiSdkProviderInstance>();
createProvider(
providerName: string,
config: AiProviderConfig,
): AiSdkProviderInstance {
const cached = this.providerInstances.get(providerName);
if (cached) {
return cached;
}
const instance = this.buildProviderInstance(config);
this.providerInstances.set(providerName, instance);
return instance;
}
getRawProvider<T>(
providerName: string,
...allowedPackages: string[]
): T | undefined {
const instance = this.providerInstances.get(providerName);
if (!instance || !allowedPackages.includes(instance.sdkPackage)) {
return undefined;
}
return instance.rawProvider as T;
}
getRawBedrockProvider(
providerName: string,
): AmazonBedrockProvider | undefined {
return this.getRawProvider<AmazonBedrockProvider>(
providerName,
AI_SDK_BEDROCK,
);
}
getRawAnthropicProvider(providerName: string): AnthropicProvider | undefined {
return this.getRawProvider<AnthropicProvider>(
providerName,
AI_SDK_ANTHROPIC,
);
}
getRawOpenAIProvider(providerName: string): OpenAIProvider | undefined {
return this.getRawProvider<OpenAIProvider>(
providerName,
AI_SDK_OPENAI,
AI_SDK_OPENAI_COMPATIBLE,
);
}
clearCache(): void {
this.providerInstances.clear();
}
private buildProviderInstance(
config: AiProviderConfig,
): AiSdkProviderInstance {
switch (config.npm) {
case AI_SDK_OPENAI:
return this.buildStandardProvider(config, createOpenAI);
case AI_SDK_ANTHROPIC:
return this.buildStandardProvider(config, createAnthropic);
case AI_SDK_GOOGLE:
return this.buildStandardProvider(config, createGoogleGenerativeAI);
case AI_SDK_MISTRAL:
return this.buildStandardProvider(config, createMistral);
case AI_SDK_XAI:
return this.buildStandardProvider(config, createXai);
case AI_SDK_BEDROCK:
return this.buildBedrockProvider(config);
case AI_SDK_OPENAI_COMPATIBLE:
return this.buildOpenAICompatibleProvider(config);
default:
throw new Error(`Unsupported SDK package: ${config.npm}`);
}
}
private buildStandardProvider(
config: AiProviderConfig,
factory: (opts: { apiKey?: string; baseURL?: string }) => CallableFunction,
): AiSdkProviderInstance {
const provider = factory({
...(config.apiKey && { apiKey: config.apiKey }),
...(config.baseUrl && { baseURL: config.baseUrl }),
});
return {
createModel: (modelId: string) =>
(provider as CallableFunction)(modelId) as LanguageModel,
rawProvider: provider,
sdkPackage: config.npm,
};
}
private buildBedrockProvider(
config: AiProviderConfig,
): AiSdkProviderInstance {
const provider = createAmazonBedrock({
region: config.region ?? 'us-east-1',
...(config.accessKeyId &&
config.secretAccessKey && {
accessKeyId: config.accessKeyId,
secretAccessKey: config.secretAccessKey,
sessionToken: config.sessionToken,
}),
});
return {
createModel: (modelId: string) => provider(modelId),
rawProvider: provider,
sdkPackage: AI_SDK_BEDROCK,
};
}
private buildOpenAICompatibleProvider(
config: AiProviderConfig,
): AiSdkProviderInstance {
if (!config.baseUrl) {
throw new Error('baseUrl is required for openai-compatible providers');
}
const provider = createOpenAI({
baseURL: config.baseUrl,
apiKey: config.apiKey ?? '',
});
return {
createModel: (modelId: string) => provider(modelId),
rawProvider: provider,
sdkPackage: AI_SDK_OPENAI_COMPATIBLE,
};
}
}
@@ -0,0 +1,24 @@
import { type AiSdkPackage, type DataResidency } from 'twenty-shared/ai';
import { type LongContextCost } from 'src/engine/metadata-modules/ai/ai-models/types/long-context-cost.type';
import { type ModelFamily } from 'src/engine/metadata-modules/ai/ai-models/types/model-family.enum';
// TODO: rename to AiModelConfig for consistency with service naming (AiModelRegistryService, etc.)
export type AIModelConfig = {
// Composite model id (`provider/modelName`) used in the registry and GraphQL; same shape as SDK routing when applicable.
modelId: string;
sdkPackage: AiSdkPackage;
label: string;
description: string;
modelFamily?: ModelFamily;
dataResidency?: DataResidency;
inputCostPerMillionTokens: number;
outputCostPerMillionTokens: number;
contextWindowTokens: number;
maxOutputTokens: number;
cachedInputCostPerMillionTokens?: number;
cacheCreationCostPerMillionTokens?: number;
longContextCost?: LongContextCost;
modalities?: string[];
supportsReasoning?: boolean;
isDeprecated?: boolean;
};
@@ -0,0 +1,7 @@
// Preference lists use composite model ids (`provider/modelName`), aligned with the registry.
export type AiModelPreferences = {
disabledModels?: string[];
recommendedModels?: string[];
defaultFastModels?: string[];
defaultSmartModels?: string[];
};
@@ -0,0 +1,4 @@
export enum AiModelRole {
FAST = 'fast',
SMART = 'smart',
}
@@ -0,0 +1,18 @@
import { type AiSdkPackage, type DataResidency } from 'twenty-shared/ai';
import { type AiProviderModelConfig } from 'src/engine/metadata-modules/ai/ai-models/types/ai-provider-model-config.type';
export type AiProviderConfig = {
npm: AiSdkPackage;
// Optional provider display/catalog name (e.g. models.dev label). Not a model name; per-model names live on `models[].name`.
name?: string;
label?: string;
apiKey?: string;
baseUrl?: string;
region?: string;
dataResidency?: DataResidency;
accessKeyId?: string;
secretAccessKey?: string;
sessionToken?: string;
models?: AiProviderModelConfig[];
};
@@ -0,0 +1,23 @@
import { type LongContextCost } from 'src/engine/metadata-modules/ai/ai-models/types/long-context-cost.type';
import { type ModelFamily } from 'src/engine/metadata-modules/ai/ai-models/types/model-family.enum';
export type AiModelSource = 'catalog' | 'manual';
export type AiProviderModelConfig = {
// Bare model name passed to the AI SDK (e.g. `gpt-4o`, `claude-3-opus`), not the composite `provider/modelName` id.
name: string;
label: string;
description?: string;
modelFamily?: ModelFamily;
inputCostPerMillionTokens?: number;
outputCostPerMillionTokens?: number;
cachedInputCostPerMillionTokens?: number;
cacheCreationCostPerMillionTokens?: number;
longContextCost?: LongContextCost;
contextWindowTokens?: number;
maxOutputTokens?: number;
modalities?: string[];
supportsReasoning?: boolean;
isDeprecated?: boolean;
source?: AiModelSource;
};
@@ -0,0 +1,3 @@
import { type AiProviderConfig } from 'src/engine/metadata-modules/ai/ai-models/types/ai-provider-config.type';
export type AiProvidersConfig = Record<string, AiProviderConfig>;
@@ -0,0 +1 @@
export const DEFAULT_CONTEXT_WINDOW_TOKENS = 128_000;
@@ -0,0 +1 @@
export const DEFAULT_FAST_MODEL = 'default-fast-model' as const;
@@ -0,0 +1 @@
export const DEFAULT_MAX_OUTPUT_TOKENS = 4_096;
@@ -0,0 +1 @@
export const DEFAULT_SMART_MODEL = 'default-smart-model' as const;
@@ -0,0 +1,7 @@
export type LongContextCost = {
inputCostPerMillionTokens: number;
outputCostPerMillionTokens: number;
cachedInputCostPerMillionTokens?: number;
cacheCreationCostPerMillionTokens?: number;
thresholdTokens: number;
};
@@ -0,0 +1,7 @@
export enum ModelFamily {
GPT = 'gpt',
CLAUDE = 'claude',
GEMINI = 'gemini',
MISTRAL = 'mistral',
GROK = 'grok',
}
@@ -0,0 +1,2 @@
// Composite workspace model identifier, usually `provider/modelName` (see `buildCompositeModelId`).
export type ModelId = string;
@@ -0,0 +1,3 @@
import { type ModelsDevProvider } from './models-dev-provider.type';
export type ModelsDevData = Record<string, ModelsDevProvider>;
@@ -0,0 +1,21 @@
export type ModelsDevModel = {
// Model id as returned by models.dev (may match the record key in `ModelsDevProvider.models`).
id: string;
// Human-readable model name from the catalog.
name: string;
family?: string;
status?: 'deprecated' | 'beta';
reasoning?: boolean;
tool_call?: boolean;
cost?: {
input?: number;
output?: number;
cache_read?: number;
cache_write?: number;
};
limit?: { context?: number; output?: number };
modalities?: { input?: string[]; output?: string[] };
knowledge?: string;
release?: string;
updated?: string;
};
@@ -0,0 +1,8 @@
import { type ModelsDevModel } from './models-dev-model.type';
export type ModelsDevProvider = {
// Provider id from models.dev (e.g. `openai`).
id: string;
// Keys are model identifiers in that providers catalog (bare ids), not composite `provider/modelName` workspace ids.
models: Record<string, ModelsDevModel>;
};
@@ -0,0 +1,12 @@
const COMPOSITE_SEPARATOR = '/';
export const buildCompositeModelId = (
providerName: string,
modelName: string,
): string => {
if (providerName === modelName.split(COMPOSITE_SEPARATOR)[0]) {
return modelName;
}
return `${providerName}${COMPOSITE_SEPARATOR}${modelName}`;
};
@@ -0,0 +1,13 @@
const CONFIG_VAR_TEMPLATE_REGEX = /^\{\{(\w+)\}\}$/;
export const extractConfigVariableName = (
value: string | undefined,
): string | undefined => {
if (!value) {
return undefined;
}
const match = CONFIG_VAR_TEMPLATE_REGEX.exec(value);
return match?.[1];
};
@@ -0,0 +1,35 @@
import { ModelFamily } from 'src/engine/metadata-modules/ai/ai-models/types/model-family.enum';
// Maps models.dev provider names to model families.
const NAME_TO_MODEL_FAMILY: Record<string, ModelFamily> = {
openai: ModelFamily.GPT,
anthropic: ModelFamily.CLAUDE,
google: ModelFamily.GEMINI,
mistral: ModelFamily.MISTRAL,
xai: ModelFamily.GROK,
};
// For aggregator providers (Groq, Bedrock, etc.), detect model family
// from the model's raw ID rather than assuming a fixed mapping.
const MODEL_ID_FAMILY_PATTERNS: [RegExp, ModelFamily][] = [
[/claude/i, ModelFamily.CLAUDE],
[/gpt|o[134]-|chatgpt/i, ModelFamily.GPT],
[/gemini/i, ModelFamily.GEMINI],
[/mistral|mixtral|pixtral/i, ModelFamily.MISTRAL],
[/grok/i, ModelFamily.GROK],
];
export const inferModelFamily = (
providerName: string,
modelName?: string,
): ModelFamily | undefined => {
if (modelName) {
for (const [pattern, family] of MODEL_ID_FAMILY_PATTERNS) {
if (pattern.test(modelName)) {
return family;
}
}
}
return NAME_TO_MODEL_FAMILY[providerName];
};
@@ -0,0 +1,5 @@
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';
export const isDefaultModelSentinel = (modelId: string): boolean =>
modelId === DEFAULT_FAST_MODEL || modelId === DEFAULT_SMART_MODEL;
@@ -1,35 +1,22 @@
import {
AI_MODELS,
DEFAULT_FAST_MODEL,
DEFAULT_SMART_MODEL,
} from 'src/engine/metadata-modules/ai/ai-models/constants/ai-models.const';
import { isDefaultModelSentinel } from 'src/engine/metadata-modules/ai/ai-models/utils/is-default-model-sentinel.util';
export type WorkspaceModelAvailabilitySettings = {
useRecommendedModels: boolean;
autoEnableNewAiModels: boolean;
disabledAiModelIds: string[];
enabledAiModelIds: string[];
};
const RECOMMENDED_MODEL_IDS = new Set(
AI_MODELS.filter((model) => model.isRecommended).map(
(model) => model.modelId,
),
);
export const isModelAllowedByWorkspace = (
modelId: string,
workspace: WorkspaceModelAvailabilitySettings,
recommendedModelIds?: Set<string>,
): boolean => {
if (modelId === DEFAULT_FAST_MODEL || modelId === DEFAULT_SMART_MODEL) {
if (isDefaultModelSentinel(modelId)) {
return true;
}
if (workspace.useRecommendedModels) {
return RECOMMENDED_MODEL_IDS.has(modelId);
return recommendedModelIds?.has(modelId) ?? false;
}
return workspace.autoEnableNewAiModels
? !workspace.disabledAiModelIds.includes(modelId)
: workspace.enabledAiModelIds.includes(modelId);
return workspace.enabledAiModelIds.includes(modelId);
};
@@ -0,0 +1,21 @@
import { type AiProviderModelConfig } from 'src/engine/metadata-modules/ai/ai-models/types/ai-provider-model-config.type';
import { type AiProvidersConfig } from 'src/engine/metadata-modules/ai/ai-models/types/ai-providers-config.type';
import defaultAiProviders from '../ai-providers.json';
export const loadDefaultAiProviders = (): AiProvidersConfig => {
const raw = defaultAiProviders as unknown as AiProvidersConfig;
const result: AiProvidersConfig = {};
for (const [key, config] of Object.entries(raw)) {
result[key] = {
...config,
name: key,
models: (config.models ?? []).map(
(model): AiProviderModelConfig => ({ ...model, source: 'catalog' }),
),
};
}
return result;
};
@@ -0,0 +1,37 @@
// TODO: derive default model preferences dynamically from the catalog
// 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 = [
'openai/gpt-5-mini',
'anthropic/claude-haiku-4-5-20251001',
'google/gemini-3-flash-preview',
'xai/grok-4-1-fast',
'mistral/mistral-large-latest',
];
const DEFAULT_SMART_MODELS = [
'openai/gpt-5.2',
'anthropic/claude-sonnet-4-6',
'google/gemini-3.1-pro-preview',
'xai/grok-4',
'mistral/mistral-large-latest',
];
const DEFAULT_RECOMMENDED_MODELS = [
'openai/gpt-5.2',
'openai/gpt-4.1',
'anthropic/claude-opus-4-6',
'anthropic/claude-sonnet-4-6',
'google/gemini-3.1-pro-preview',
'xai/grok-4',
];
export const loadDefaultModelPreferences = (): AiModelPreferences => {
return {
disabledModels: [],
recommendedModels: DEFAULT_RECOMMENDED_MODELS,
defaultFastModels: DEFAULT_FAST_MODELS,
defaultSmartModels: DEFAULT_SMART_MODELS,
};
};