feat: Add AI Agent workflow action node (#12650)

https://github.com/user-attachments/assets/8593e488-cb00-4fd2-b903-5ba5766e0254

---------

Co-authored-by: Antoine Moreaux <moreaux.antoine@gmail.com>
Co-authored-by: martmull <martmull@hotmail.fr>
Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
Co-authored-by: Baptiste Devessier <baptiste@devessier.fr>
Co-authored-by: Joseph Chiang <josephj6802@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Guillim <guillim@users.noreply.github.com>
Co-authored-by: Raphaël Bosi <71827178+bosiraphael@users.noreply.github.com>
Co-authored-by: Lucas Bordeau <bordeau.lucas@gmail.com>
Co-authored-by: Marie <51697796+ijreilly@users.noreply.github.com>
Co-authored-by: Naifer <161821705+omarNaifer12@users.noreply.github.com>
Co-authored-by: prastoin <paul@twenty.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@twenty.com>
Co-authored-by: Thomas Trompette <thomas.trompette@sfr.fr>
Co-authored-by: Etienne <45695613+etiennejouan@users.noreply.github.com>
Co-authored-by: Ajay A Adsule <103304466+AjayAdsule@users.noreply.github.com>
Co-authored-by: bosiraphael <raphael.bosi@gmail.com>
Co-authored-by: Charles Bochet <charles@twenty.com>
Co-authored-by: Marty <91310557+real-marty@users.noreply.github.com>
Co-authored-by: Félix Malfait <felix@twenty.com>
Co-authored-by: Charles Bochet <charlesBochet@users.noreply.github.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Paul Rastoin <45004772+prastoin@users.noreply.github.com>
Co-authored-by: Weiko <corentin@twenty.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: nitin <142569587+ehconitin@users.noreply.github.com>
This commit is contained in:
Abdul Rahman
2025-06-23 01:12:04 +05:30
committed by GitHub
parent 22e126869c
commit 65df511179
75 changed files with 2268 additions and 30 deletions

View File

@ -9,6 +9,7 @@ import { AI_DRIVER } from 'src/engine/core-modules/ai/ai.constants';
import { AiService } from 'src/engine/core-modules/ai/ai.service';
import { AiController } from 'src/engine/core-modules/ai/controllers/ai.controller';
import { OpenAIDriver } from 'src/engine/core-modules/ai/drivers/openai.driver';
import { AIBillingService } from 'src/engine/core-modules/ai/services/ai-billing.service';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
@Global()
@ -33,8 +34,8 @@ export class AiModule {
module: AiModule,
imports: [FeatureFlagModule],
controllers: [AiController],
providers: [AiService, provider],
exports: [AiService],
providers: [AiService, AIBillingService, provider],
exports: [AiService, AIBillingService],
};
}
}

View File

@ -0,0 +1,65 @@
export enum ModelProvider {
OPENAI = 'openai',
ANTHROPIC = 'anthropic',
}
export type ModelId =
| 'gpt-4o'
| 'gpt-4o-mini'
| 'gpt-4-turbo'
| 'claude-opus-4-20250514'
| 'claude-sonnet-4-20250514'
| 'claude-3-5-haiku-20241022';
export interface AIModelConfig {
modelId: ModelId;
label: string;
provider: ModelProvider;
inputCostPer1kTokensInCents: number;
outputCostPer1kTokensInCents: number;
}
export const AI_MODELS: AIModelConfig[] = [
{
modelId: 'gpt-4o',
label: 'GPT-4o',
provider: ModelProvider.OPENAI,
inputCostPer1kTokensInCents: 0.25,
outputCostPer1kTokensInCents: 1.0,
},
{
modelId: 'gpt-4o-mini',
label: 'GPT-4o Mini',
provider: ModelProvider.OPENAI,
inputCostPer1kTokensInCents: 0.015,
outputCostPer1kTokensInCents: 0.06,
},
{
modelId: 'gpt-4-turbo',
label: 'GPT-4 Turbo',
provider: ModelProvider.OPENAI,
inputCostPer1kTokensInCents: 1.0,
outputCostPer1kTokensInCents: 3.0,
},
{
modelId: 'claude-opus-4-20250514',
label: 'Claude Opus 4',
provider: ModelProvider.ANTHROPIC,
inputCostPer1kTokensInCents: 1.5,
outputCostPer1kTokensInCents: 7.5,
},
{
modelId: 'claude-sonnet-4-20250514',
label: 'Claude Sonnet 4',
provider: ModelProvider.ANTHROPIC,
inputCostPer1kTokensInCents: 0.3,
outputCostPer1kTokensInCents: 1.5,
},
{
modelId: 'claude-3-5-haiku-20241022',
label: 'Claude Haiku 3.5',
provider: ModelProvider.ANTHROPIC,
inputCostPer1kTokensInCents: 0.08,
outputCostPer1kTokensInCents: 0.4,
},
];

View File

@ -0,0 +1,2 @@
// Configuration: $0.001 = 1 credit
export const DOLLAR_TO_CREDIT_MULTIPLIER = 1000; // 1 / 0.001 = 1000 credits per dollar

View File

@ -0,0 +1,90 @@
import { Test, 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 { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter';
import { AIBillingService } from './ai-billing.service';
describe('AIBillingService', () => {
let service: AIBillingService;
let mockWorkspaceEventEmitter: jest.Mocked<WorkspaceEventEmitter>;
const mockTokenUsage = {
promptTokens: 1000,
completionTokens: 500,
totalTokens: 1500,
};
beforeEach(async () => {
const mockEventEmitterMethods = {
emitCustomBatchEvent: jest.fn(),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
AIBillingService,
{
provide: WorkspaceEventEmitter,
useValue: mockEventEmitterMethods,
},
],
}).compile();
service = module.get<AIBillingService>(AIBillingService);
mockWorkspaceEventEmitter = module.get(WorkspaceEventEmitter);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('calculateCost', () => {
it('should calculate cost correctly for valid model and token usage', async () => {
const costInCents = await service.calculateCost('gpt-4o', mockTokenUsage);
// Expected: (1000/1000 * 0.25) + (500/1000 * 1.0) = 0.25 + 0.5 = 0.75 cents
expect(costInCents).toBe(0.75);
});
it('should calculate cost correctly with different token usage', async () => {
const differentTokenUsage = {
promptTokens: 2000,
completionTokens: 1000,
totalTokens: 3000,
};
const costInCents = await service.calculateCost(
'gpt-4o',
differentTokenUsage,
);
// Expected: (2000/1000 * 0.25) + (1000/1000 * 1.0) = 0.5 + 1.0 = 1.5 cents
expect(costInCents).toBe(1.5);
});
});
describe('calculateAndBillUsage', () => {
it('should calculate cost and emit billing event when model exists', async () => {
await service.calculateAndBillUsage(
'gpt-4o',
mockTokenUsage,
'workspace-1',
);
// Expected credits: (0.75 cents / 100) * 1000 = 0.0075 * 1000 = 7.5 credits, rounded to 8
expect(
mockWorkspaceEventEmitter.emitCustomBatchEvent,
).toHaveBeenCalledWith(
BILLING_FEATURE_USED,
[
{
eventName: BillingMeterEventName.WORKFLOW_NODE_RUN,
value: 8,
},
],
'workspace-1',
);
});
});
});

View File

@ -0,0 +1,72 @@
import { Injectable, Logger } from '@nestjs/common';
import { ModelId } from 'src/engine/core-modules/ai/constants/ai-models.const';
import { DOLLAR_TO_CREDIT_MULTIPLIER } from 'src/engine/core-modules/ai/constants/dollar-to-credit-multiplier';
import { getAIModelById } from 'src/engine/core-modules/ai/utils/get-ai-model-by-id';
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 { BillingUsageEvent } from 'src/engine/core-modules/billing/types/billing-usage-event.type';
import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter';
export interface TokenUsage {
promptTokens: number;
completionTokens: number;
totalTokens: number;
}
@Injectable()
export class AIBillingService {
private readonly logger = new Logger(AIBillingService.name);
constructor(private readonly workspaceEventEmitter: WorkspaceEventEmitter) {}
async calculateCost(modelId: ModelId, usage: TokenUsage): Promise<number> {
const model = getAIModelById(modelId);
if (!model) {
throw new Error(`AI model with id ${modelId} not found`);
}
const inputCost =
(usage.promptTokens / 1000) * model.inputCostPer1kTokensInCents;
const outputCost =
(usage.completionTokens / 1000) * model.outputCostPer1kTokensInCents;
const totalCost = inputCost + outputCost;
this.logger.log(
`Calculated cost for model ${modelId}: ${totalCost} cents (input: ${inputCost}, output: ${outputCost})`,
);
return totalCost;
}
async calculateAndBillUsage(
modelId: ModelId,
usage: TokenUsage,
workspaceId: string,
): Promise<void> {
const costInCents = await this.calculateCost(modelId, usage);
const costInDollars = costInCents / 100;
const creditsUsed = Math.round(costInDollars * DOLLAR_TO_CREDIT_MULTIPLIER);
this.sendAiTokenUsageEvent(workspaceId, creditsUsed);
}
private sendAiTokenUsageEvent(
workspaceId: string,
creditsUsed: number,
): void {
this.workspaceEventEmitter.emitCustomBatchEvent<BillingUsageEvent>(
BILLING_FEATURE_USED,
[
{
eventName: BillingMeterEventName.WORKFLOW_NODE_RUN,
value: creditsUsed,
},
],
workspaceId,
);
}
}

View File

@ -0,0 +1,7 @@
/**
* Converts cost in cents to cost in credits
* Formula: credits = cents / 100 * 1000 = cents * 10
* @param cents - Cost in cents (real cost)
* @returns Cost in credits (end-user cost)
*/
export const convertCentsToCredits = (cents: number): number => cents * 10;

View File

@ -0,0 +1,9 @@
import {
AI_MODELS,
AIModelConfig,
ModelId,
} from 'src/engine/core-modules/ai/constants/ai-models.const';
export const getAIModelById = (modelId: ModelId): AIModelConfig | undefined => {
return AI_MODELS.find((model) => model.modelId === modelId);
};

View File

@ -3,6 +3,7 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AiModule } from 'src/engine/core-modules/ai/ai.module';
import { BillingResolver } from 'src/engine/core-modules/billing/billing.resolver';
import { BillingAddWorkflowSubscriptionItemCommand } from 'src/engine/core-modules/billing/commands/billing-add-workflow-subscription-item.command';
import { BillingSyncCustomerDataCommand } from 'src/engine/core-modules/billing/commands/billing-sync-customer-data.command';
@ -41,6 +42,7 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi
DomainManagerModule,
MessageQueueModule,
PermissionsModule,
AiModule,
TypeOrmModule.forFeature(
[
BillingSubscription,

View File

@ -2,6 +2,10 @@ import { Test, TestingModule } from '@nestjs/testing';
import { SupportDriver } from 'src/engine/core-modules/twenty-config/interfaces/support.interface';
import {
ModelId,
ModelProvider,
} from 'src/engine/core-modules/ai/constants/ai-models.const';
import { ClientConfigService } from 'src/engine/core-modules/client-config/services/client-config.service';
import { ClientConfigController } from './client-config.controller';
@ -44,6 +48,15 @@ describe('ClientConfigController', () => {
},
],
},
aiModels: [
{
modelId: 'gpt-4o' as ModelId,
label: 'GPT-4o',
provider: ModelProvider.OPENAI,
inputCostPer1kTokensInCredits: 2.5,
outputCostPer1kTokensInCredits: 10.0,
},
],
authProviders: {
google: true,
magicLink: false,
@ -92,8 +105,8 @@ describe('ClientConfigController', () => {
const result = await controller.getClientConfig();
expect(clientConfigService.getClientConfig).toHaveBeenCalled();
expect(result).toEqual(mockClientConfig);
expect(clientConfigService.getClientConfig).toHaveBeenCalled();
});
});
});

View File

@ -2,6 +2,10 @@ import { Field, ObjectType, registerEnumType } from '@nestjs/graphql';
import { SupportDriver } from 'src/engine/core-modules/twenty-config/interfaces/support.interface';
import {
ModelId,
ModelProvider,
} from 'src/engine/core-modules/ai/constants/ai-models.const';
import { BillingTrialPeriodDTO } from 'src/engine/core-modules/billing/dtos/billing-trial-period.dto';
import { CaptchaDriverType } from 'src/engine/core-modules/captcha/interfaces';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
@ -11,6 +15,28 @@ registerEnumType(FeatureFlagKey, {
name: 'FeatureFlagKey',
});
registerEnumType(ModelProvider, {
name: 'ModelProvider',
});
@ObjectType()
export class ClientAIModelConfig {
@Field(() => String)
modelId: ModelId;
@Field(() => String)
label: string;
@Field(() => ModelProvider)
provider: ModelProvider;
@Field(() => Number)
inputCostPer1kTokensInCredits: number;
@Field(() => Number)
outputCostPer1kTokensInCredits: number;
}
@ObjectType()
class Billing {
@Field(() => Boolean)
@ -88,6 +114,9 @@ export class ClientConfig {
@Field(() => Billing, { nullable: false })
billing: Billing;
@Field(() => [ClientAIModelConfig])
aiModels: ClientAIModelConfig[];
@Field(() => Boolean)
signInPrefilled: boolean;

View File

@ -9,6 +9,10 @@ import { DomainManagerService } from 'src/engine/core-modules/domain-manager/ser
import { PUBLIC_FEATURE_FLAGS } from 'src/engine/core-modules/feature-flag/constants/public-feature-flag.const';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
jest.mock('src/engine/core-modules/ai/constants/ai-models.const', () => ({
AI_MODELS: [],
}));
describe('ClientConfigService', () => {
let service: ClientConfigService;
let twentyConfigService: TwentyConfigService;
@ -107,6 +111,7 @@ describe('ClientConfigService', () => {
},
],
},
aiModels: [],
authProviders: {
google: true,
magicLink: false,
@ -164,6 +169,7 @@ describe('ClientConfigService', () => {
expect(result.debugMode).toBe(false);
expect(result.canManageFeatureFlags).toBe(false);
expect(result.aiModels).toEqual([]);
});
it('should handle missing captcha driver', async () => {
@ -180,6 +186,7 @@ describe('ClientConfigService', () => {
expect(result.captcha.provider).toBeUndefined();
expect(result.captcha.siteKey).toBe('site-key');
expect(result.aiModels).toEqual([]);
});
it('should handle missing support driver', async () => {
@ -194,6 +201,7 @@ describe('ClientConfigService', () => {
const result = await service.getClientConfig();
expect(result.support.supportDriver).toBe(SupportDriver.NONE);
expect(result.aiModels).toEqual([]);
});
it('should handle billing enabled with feature flags', async () => {

View File

@ -3,7 +3,15 @@ import { Injectable } from '@nestjs/common';
import { NodeEnvironment } from 'src/engine/core-modules/twenty-config/interfaces/node-environment.interface';
import { SupportDriver } from 'src/engine/core-modules/twenty-config/interfaces/support.interface';
import { ClientConfig } from 'src/engine/core-modules/client-config/client-config.entity';
import {
AI_MODELS,
ModelProvider,
} from 'src/engine/core-modules/ai/constants/ai-models.const';
import { convertCentsToCredits } from 'src/engine/core-modules/ai/utils/ai-cost.utils';
import {
ClientAIModelConfig,
ClientConfig,
} from 'src/engine/core-modules/client-config/client-config.entity';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
import { PUBLIC_FEATURE_FLAGS } from 'src/engine/core-modules/feature-flag/constants/public-feature-flag.const';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
@ -18,6 +26,32 @@ export class ClientConfigService {
async getClientConfig(): Promise<ClientConfig> {
const captchaProvider = this.twentyConfigService.get('CAPTCHA_DRIVER');
const supportDriver = this.twentyConfigService.get('SUPPORT_DRIVER');
const openaiApiKey = this.twentyConfigService.get('OPENAI_API_KEY');
const anthropicApiKey = this.twentyConfigService.get('ANTHROPIC_API_KEY');
const aiModels = AI_MODELS.reduce<ClientAIModelConfig[]>((acc, model) => {
const isAvailable =
(model.provider === ModelProvider.OPENAI && openaiApiKey) ||
(model.provider === ModelProvider.ANTHROPIC && anthropicApiKey);
if (!isAvailable) {
return acc;
}
acc.push({
modelId: model.modelId,
label: model.label,
provider: model.provider,
inputCostPer1kTokensInCredits: convertCentsToCredits(
model.inputCostPer1kTokensInCents,
),
outputCostPer1kTokensInCredits: convertCentsToCredits(
model.outputCostPer1kTokensInCents,
),
});
return acc;
}, []);
const clientConfig: ClientConfig = {
billing: {
@ -38,6 +72,7 @@ export class ClientConfigService {
},
],
},
aiModels,
authProviders: {
google: this.twentyConfigService.get('AUTH_GOOGLE_ENABLED'),
magicLink: false,

View File

@ -984,6 +984,14 @@ export class ConfigVariables {
})
OPENAI_API_KEY: string;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.LLM,
isSensitive: true,
description: 'API key for Anthropic integration',
type: ConfigVariableType.STRING,
})
ANTHROPIC_API_KEY: string;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.ServerConfig,
description: 'Enable or disable multi-workspace support',

View File

@ -1,6 +1,8 @@
import { UseFilters, UseGuards } from '@nestjs/common';
import { Args, Mutation, Resolver } from '@nestjs/graphql';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { CreateWorkflowVersionStepInput } from 'src/engine/core-modules/workflow/dtos/create-workflow-version-step-input.dto';
import { DeleteWorkflowVersionStepInput } from 'src/engine/core-modules/workflow/dtos/delete-workflow-version-step-input.dto';
import { SubmitFormStepInput } from 'src/engine/core-modules/workflow/dtos/submit-form-step-input.dto';
@ -15,6 +17,7 @@ import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { SettingPermissionType } from 'src/engine/metadata-modules/permissions/constants/setting-permission-type.constants';
import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/permissions/utils/permissions-graphql-api-exception.filter';
import { WorkflowVersionStepWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-step/workflow-version-step.workspace-service';
import { WorkflowActionType } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
import { WorkflowRunWorkspaceService } from 'src/modules/workflow/workflow-runner/workflow-run/workflow-run.workspace-service';
@Resolver()
@ -28,6 +31,7 @@ export class WorkflowStepResolver {
constructor(
private readonly workflowVersionStepWorkspaceService: WorkflowVersionStepWorkspaceService,
private readonly workflowRunWorkspaceService: WorkflowRunWorkspaceService,
private readonly featureFlagService: FeatureFlagService,
) {}
@Mutation(() => WorkflowActionDTO)
@ -36,6 +40,19 @@ export class WorkflowStepResolver {
@Args('input')
input: CreateWorkflowVersionStepInput,
): Promise<WorkflowActionDTO> {
if (input.stepType === WorkflowActionType.AI_AGENT) {
const isAiEnabled = await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_AI_ENABLED,
workspaceId,
);
if (!isAiEnabled) {
throw new Error(
'AI features are not available in your current workspace. Please contact support to enable them.',
);
}
}
return this.workflowVersionStepWorkspaceService.createWorkflowVersionStep({
workspaceId,
input,

View File

@ -1,5 +1,6 @@
import { Module } from '@nestjs/common';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { WorkflowTriggerController } from 'src/engine/core-modules/workflow/controllers/workflow-trigger.controller';
import { WorkflowBuilderResolver } from 'src/engine/core-modules/workflow/resolvers/workflow-builder.resolver';
import { WorkflowStepResolver } from 'src/engine/core-modules/workflow/resolvers/workflow-step.resolver';
@ -14,6 +15,7 @@ import { WorkflowTriggerModule } from 'src/modules/workflow/workflow-trigger/wor
@Module({
imports: [
FeatureFlagModule,
WorkflowTriggerModule,
WorkflowBuilderModule,
WorkflowCommonModule,

View File

@ -22,6 +22,7 @@ import { KeyValuePair } from 'src/engine/core-modules/key-value-pair/key-value-p
import { PostgresCredentials } from 'src/engine/core-modules/postgres-credentials/postgres-credentials.entity';
import { WorkspaceSSOIdentityProvider } from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity';
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { AgentEntity } from 'src/engine/metadata-modules/agent/agent.entity';
import { RoleDTO } from 'src/engine/metadata-modules/role/dtos/role.dto';
registerEnumType(WorkspaceActivationStatus, {
@ -121,6 +122,11 @@ export class Workspace {
)
workspaceSSOIdentityProviders: Relation<WorkspaceSSOIdentityProvider[]>;
@OneToMany(() => AgentEntity, (agent) => agent.workspace, {
onDelete: 'CASCADE',
})
agents: Relation<AgentEntity[]>;
@Field()
@Column({ default: 1 })
metadataVersion: number;