Replace agent handoff system with planning-based router (#16003)
## Overview This PR replaces the dynamic agent handoff system with a more predictable planning-based router that decides upfront how to handle multi-agent coordination. ## Major Changes ### 🔄 Architecture Shift: Handoffs → Planning **Removed:** - `AgentHandoffEntity` and handoff tracking system - `AgentHandoffService` and `AgentHandoffExecutorService` - Dynamic agent-to-agent transfers during execution - Handoff tool generation and description templates **Added:** - `AiRouterService` with two strategies: `simple` (single agent) and `planned` (multi-agent) - `AgentPlanExecutorService` for executing multi-step plans - Plan validation (cycle detection, dependency resolution) - `UnifiedRouterResult` type with discriminated union ### 🤖 New Standard Agents Added two new specialized agents: - **Researcher Agent**: Web search, fact-finding, competitive intelligence - **Code Agent**: TypeScript function generation for serverless workflows ### 🏗️ Router Refactoring (Latest) Split router responsibilities into focused services: - `AiRouterStrategyDeciderService`: Decides simple vs planned strategy - `AiRouterPlanGeneratorService`: Generates and validates execution plans - `AiRouterService`: Coordinates between services (reduced from 426→275 lines) ### ⚙️ Configuration Improvements - Added `outputStrategy` to agent definitions (`direct` vs `synthesize`) - Removed hardcoded special cases for workflow-builder - Added `plannerModel` field to workspace entity - Increased `MAX_STEPS` from 10 to 25 for complex workflows ### 📝 Agent Prompt Refinements Significantly simplified prompts for better clarity: - Workflow Builder: 51→36 lines - Helper: 49→28 lines - Data Manipulator: Enhanced with sorting guidance ### 🔍 Enhanced Debugging - Plan reasoning and step count in data message parts - Router debug info with token usage tracking - Better logging throughout execution pipeline ## Benefits 1. **Simpler Mental Model**: Router decides upfront vs dynamic transfers 2. **Better Predictability**: Users see the plan before execution 3. **Cleaner Architecture**: SRP with focused services 4. **Configuration Over Code**: Agent behavior via config, not hardcoded logic 5. **Plan Validation**: Catches invalid dependencies and cycles ## Migration Notes - Database migration removes `agentHandoff` table - Adds `plannerModel` column to workspace table - No API breaking changes (agent endpoints unchanged) ## Testing - Integration tests updated to remove handoff dependencies - Agent tool test utilities simplified - Plan validation covered by new logic ## Next Steps (Future PRs) - Parallel execution of independent plan steps - Dynamic re-planning based on results - Plan caching for common routing patterns - Error recovery strategies in plan executor
This commit is contained in:
@@ -1,65 +0,0 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { AiController } from 'src/engine/core-modules/ai/controllers/ai.controller';
|
||||
import { McpController } from 'src/engine/core-modules/ai/controllers/mcp.controller';
|
||||
import { AIBillingService } from 'src/engine/core-modules/ai/services/ai-billing.service';
|
||||
import { AiModelRegistryService } from 'src/engine/core-modules/ai/services/ai-model-registry.service';
|
||||
import { AiService } from 'src/engine/core-modules/ai/services/ai.service';
|
||||
import { McpService } from 'src/engine/core-modules/ai/services/mcp.service';
|
||||
import { ToolAdapterService } from 'src/engine/core-modules/ai/services/tool-adapter.service';
|
||||
import { ToolService } from 'src/engine/core-modules/ai/services/tool.service';
|
||||
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
|
||||
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
|
||||
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
|
||||
import { FileModule } from 'src/engine/core-modules/file/file.module';
|
||||
import { RecordCrudModule } from 'src/engine/core-modules/record-crud/record-crud.module';
|
||||
import { ToolModule } from 'src/engine/core-modules/tool/tool.module';
|
||||
import { SearchArticlesTool } from 'src/engine/core-modules/tool/tools/search-articles-tool/search-articles-tool';
|
||||
import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module';
|
||||
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
|
||||
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
|
||||
import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module';
|
||||
import { WorkspacePermissionsCacheModule } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.module';
|
||||
import { TwentyORMModule } from 'src/engine/twenty-orm/twenty-orm.module';
|
||||
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
|
||||
import { MessagingModule } from 'src/modules/messaging/messaging.module';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([RoleEntity, FileEntity]),
|
||||
FileModule,
|
||||
TokenModule,
|
||||
FeatureFlagModule,
|
||||
RecordCrudModule,
|
||||
ObjectMetadataModule,
|
||||
WorkspacePermissionsCacheModule,
|
||||
WorkspaceCacheStorageModule,
|
||||
UserRoleModule,
|
||||
TwentyORMModule,
|
||||
MessagingModule,
|
||||
PermissionsModule,
|
||||
ToolModule,
|
||||
],
|
||||
controllers: [AiController, McpController],
|
||||
providers: [
|
||||
AiService,
|
||||
AiModelRegistryService,
|
||||
ToolService,
|
||||
ToolAdapterService,
|
||||
AIBillingService,
|
||||
McpService,
|
||||
SearchArticlesTool,
|
||||
],
|
||||
exports: [
|
||||
AiService,
|
||||
AiModelRegistryService,
|
||||
AIBillingService,
|
||||
ToolService,
|
||||
ToolAdapterService,
|
||||
McpService,
|
||||
SearchArticlesTool,
|
||||
],
|
||||
})
|
||||
export class AiModule {}
|
||||
@@ -1,139 +0,0 @@
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
|
||||
import { AiModelRegistryService } from 'src/engine/core-modules/ai/services/ai-model-registry.service';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
|
||||
import { AI_MODELS, ModelProvider } from './ai-models.const';
|
||||
|
||||
describe('AI_MODELS', () => {
|
||||
it('should contain all expected models', () => {
|
||||
expect(AI_MODELS).toHaveLength(9);
|
||||
expect(AI_MODELS.map((model) => model.modelId)).toEqual([
|
||||
'gpt-4o',
|
||||
'gpt-4o-mini',
|
||||
'gpt-4-turbo',
|
||||
'claude-opus-4-20250514',
|
||||
'claude-sonnet-4-20250514',
|
||||
'claude-3-5-haiku-20241022',
|
||||
'grok-3',
|
||||
'grok-3-mini',
|
||||
'grok-4',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
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 auto', () => {
|
||||
MOCK_CONFIG_SERVICE.get.mockReturnValue('gpt-4o');
|
||||
|
||||
expect(() => SERVICE.getEffectiveModelConfig('auto')).toThrow(
|
||||
'No AI models are available. Please configure at least one provider.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return effective model config for auto when models are available', () => {
|
||||
MOCK_CONFIG_SERVICE.get.mockReturnValue('gpt-4o');
|
||||
|
||||
jest.spyOn(SERVICE, 'getAvailableModels').mockReturnValue([
|
||||
{
|
||||
modelId: 'gpt-4o',
|
||||
provider: ModelProvider.OPENAI,
|
||||
model: {} as any,
|
||||
},
|
||||
]);
|
||||
|
||||
jest.spyOn(SERVICE, 'getModel').mockReturnValue({
|
||||
modelId: 'gpt-4o',
|
||||
provider: ModelProvider.OPENAI,
|
||||
model: {} as any,
|
||||
});
|
||||
|
||||
const RESULT = SERVICE.getEffectiveModelConfig('auto');
|
||||
|
||||
expect(RESULT).toBeDefined();
|
||||
expect(RESULT.modelId).toBe('gpt-4o');
|
||||
expect(RESULT.provider).toBe(ModelProvider.OPENAI);
|
||||
});
|
||||
|
||||
it('should return effective model config for auto with custom model', () => {
|
||||
MOCK_CONFIG_SERVICE.get.mockReturnValue('mistral');
|
||||
|
||||
jest.spyOn(SERVICE, 'getAvailableModels').mockReturnValue([
|
||||
{
|
||||
modelId: 'mistral',
|
||||
provider: ModelProvider.OPENAI_COMPATIBLE,
|
||||
model: {} as any,
|
||||
},
|
||||
]);
|
||||
|
||||
jest.spyOn(SERVICE, 'getModel').mockReturnValue({
|
||||
modelId: 'mistral',
|
||||
provider: ModelProvider.OPENAI_COMPATIBLE,
|
||||
model: {} as any,
|
||||
});
|
||||
|
||||
const RESULT = SERVICE.getEffectiveModelConfig('auto');
|
||||
|
||||
expect(RESULT).toBeDefined();
|
||||
expect(RESULT.modelId).toBe('mistral');
|
||||
expect(RESULT.provider).toBe(ModelProvider.OPENAI_COMPATIBLE);
|
||||
expect(RESULT.label).toBe('mistral');
|
||||
expect(RESULT.inputCostPer1kTokensInCents).toBe(0);
|
||||
expect(RESULT.outputCostPer1kTokensInCents).toBe(0);
|
||||
});
|
||||
|
||||
it('should return effective model config for specific model', () => {
|
||||
const RESULT = SERVICE.getEffectiveModelConfig('gpt-4o-mini');
|
||||
|
||||
expect(RESULT).toBeDefined();
|
||||
expect(RESULT.modelId).toBe('gpt-4o-mini');
|
||||
expect(RESULT.provider).toBe(ModelProvider.OPENAI);
|
||||
});
|
||||
|
||||
it('should return effective model config for custom model', () => {
|
||||
// Mock that the custom model exists in registry
|
||||
jest.spyOn(SERVICE, 'getModel').mockReturnValue({
|
||||
modelId: 'mistral',
|
||||
provider: ModelProvider.OPENAI_COMPATIBLE,
|
||||
model: {} as any,
|
||||
});
|
||||
|
||||
const RESULT = SERVICE.getEffectiveModelConfig('mistral');
|
||||
|
||||
expect(RESULT).toBeDefined();
|
||||
expect(RESULT.modelId).toBe('mistral');
|
||||
expect(RESULT.provider).toBe(ModelProvider.OPENAI_COMPATIBLE);
|
||||
expect(RESULT.label).toBe('mistral');
|
||||
expect(RESULT.inputCostPer1kTokensInCents).toBe(0);
|
||||
expect(RESULT.outputCostPer1kTokensInCents).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',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,219 +0,0 @@
|
||||
export enum ModelProvider {
|
||||
NONE = 'none',
|
||||
OPENAI = 'openai',
|
||||
ANTHROPIC = 'anthropic',
|
||||
OPENAI_COMPATIBLE = 'open_ai_compatible',
|
||||
XAI = 'xai',
|
||||
}
|
||||
|
||||
export type ModelId =
|
||||
| 'auto'
|
||||
| 'gpt-4o'
|
||||
| 'gpt-4o-mini'
|
||||
| 'gpt-4-turbo'
|
||||
| 'claude-opus-4-20250514'
|
||||
| 'claude-sonnet-4-20250514'
|
||||
| 'claude-3-5-haiku-20241022'
|
||||
| 'grok-3'
|
||||
| 'grok-3-mini'
|
||||
| 'grok-4'
|
||||
| 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 interface AIModelConfig {
|
||||
modelId: ModelId;
|
||||
label: string;
|
||||
description: string;
|
||||
provider: ModelProvider;
|
||||
inputCostPer1kTokensInCents: number;
|
||||
outputCostPer1kTokensInCents: number;
|
||||
contextWindowTokens: number;
|
||||
maxOutputTokens: number;
|
||||
supportedFileTypes?: SupportedFileType[];
|
||||
doesSupportThinking?: boolean;
|
||||
nativeCapabilities?: {
|
||||
webSearch?: boolean;
|
||||
twitterSearch?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export const AI_MODELS: AIModelConfig[] = [
|
||||
{
|
||||
modelId: 'gpt-4o',
|
||||
label: 'GPT-4o',
|
||||
description:
|
||||
'Most advanced multimodal model with strong reasoning, vision, and coding capabilities',
|
||||
provider: ModelProvider.OPENAI,
|
||||
inputCostPer1kTokensInCents: 0.25,
|
||||
outputCostPer1kTokensInCents: 1.0,
|
||||
contextWindowTokens: 128000,
|
||||
maxOutputTokens: 16384,
|
||||
supportedFileTypes: ['image/png', 'image/jpeg', 'image/gif', 'image/webp'],
|
||||
nativeCapabilities: {
|
||||
webSearch: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
modelId: 'gpt-4o-mini',
|
||||
label: 'GPT-4o Mini',
|
||||
description:
|
||||
'Fast and cost-efficient model for lightweight tasks and high-volume operations',
|
||||
provider: ModelProvider.OPENAI,
|
||||
inputCostPer1kTokensInCents: 0.015,
|
||||
outputCostPer1kTokensInCents: 0.06,
|
||||
contextWindowTokens: 128000,
|
||||
maxOutputTokens: 16384,
|
||||
supportedFileTypes: ['image/png', 'image/jpeg', 'image/gif', 'image/webp'],
|
||||
nativeCapabilities: {
|
||||
webSearch: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
modelId: 'gpt-4-turbo',
|
||||
label: 'GPT-4 Turbo',
|
||||
description:
|
||||
'Previous generation high-performance model with vision capabilities',
|
||||
provider: ModelProvider.OPENAI,
|
||||
inputCostPer1kTokensInCents: 1.0,
|
||||
outputCostPer1kTokensInCents: 3.0,
|
||||
contextWindowTokens: 128000,
|
||||
maxOutputTokens: 4096,
|
||||
supportedFileTypes: ['image/png', 'image/jpeg', 'image/gif', 'image/webp'],
|
||||
nativeCapabilities: {
|
||||
webSearch: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
modelId: 'claude-opus-4-20250514',
|
||||
label: 'Claude Opus 4',
|
||||
description:
|
||||
'Most powerful Claude model with extended thinking for complex reasoning tasks',
|
||||
provider: ModelProvider.ANTHROPIC,
|
||||
inputCostPer1kTokensInCents: 1.5,
|
||||
outputCostPer1kTokensInCents: 7.5,
|
||||
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,
|
||||
},
|
||||
},
|
||||
{
|
||||
modelId: 'claude-sonnet-4-20250514',
|
||||
label: 'Claude Sonnet 4',
|
||||
description:
|
||||
'Balanced model with strong performance and extended thinking capabilities',
|
||||
provider: ModelProvider.ANTHROPIC,
|
||||
inputCostPer1kTokensInCents: 0.3,
|
||||
outputCostPer1kTokensInCents: 1.5,
|
||||
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,
|
||||
},
|
||||
},
|
||||
{
|
||||
modelId: 'claude-3-5-haiku-20241022',
|
||||
label: 'Claude Haiku 3.5',
|
||||
description:
|
||||
'Fast and efficient model optimized for speed and cost-effectiveness',
|
||||
provider: ModelProvider.ANTHROPIC,
|
||||
inputCostPer1kTokensInCents: 0.08,
|
||||
outputCostPer1kTokensInCents: 0.4,
|
||||
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,
|
||||
},
|
||||
},
|
||||
{
|
||||
modelId: 'grok-3',
|
||||
label: 'Grok-3',
|
||||
description:
|
||||
'Advanced model with web and Twitter search, optimized for real-time information',
|
||||
provider: ModelProvider.XAI,
|
||||
inputCostPer1kTokensInCents: 0.3,
|
||||
outputCostPer1kTokensInCents: 1.5,
|
||||
contextWindowTokens: 131072,
|
||||
maxOutputTokens: 8192,
|
||||
supportedFileTypes: ['image/png', 'image/jpeg', 'image/gif', 'image/webp'],
|
||||
nativeCapabilities: {
|
||||
webSearch: true,
|
||||
twitterSearch: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
modelId: 'grok-3-mini',
|
||||
label: 'Grok-3 Mini',
|
||||
description:
|
||||
'Lightweight model with web and Twitter search for fast, cost-effective operations',
|
||||
provider: ModelProvider.XAI,
|
||||
inputCostPer1kTokensInCents: 0.03,
|
||||
outputCostPer1kTokensInCents: 0.05,
|
||||
contextWindowTokens: 131072,
|
||||
maxOutputTokens: 8192,
|
||||
supportedFileTypes: ['image/png', 'image/jpeg', 'image/gif', 'image/webp'],
|
||||
nativeCapabilities: {
|
||||
webSearch: true,
|
||||
twitterSearch: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
modelId: 'grok-4',
|
||||
label: 'Grok-4',
|
||||
description:
|
||||
'Most capable Grok model with enhanced reasoning, web and Twitter search',
|
||||
provider: ModelProvider.XAI,
|
||||
inputCostPer1kTokensInCents: 0.5,
|
||||
outputCostPer1kTokensInCents: 2.5,
|
||||
contextWindowTokens: 131072,
|
||||
maxOutputTokens: 8192,
|
||||
supportedFileTypes: ['image/png', 'image/jpeg', 'image/gif', 'image/webp'],
|
||||
nativeCapabilities: {
|
||||
webSearch: true,
|
||||
twitterSearch: true,
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -1,5 +0,0 @@
|
||||
export const AI_TELEMETRY_CONFIG = {
|
||||
isEnabled: true,
|
||||
recordInputs: true,
|
||||
recordOutputs: true,
|
||||
};
|
||||
-2
@@ -1,2 +0,0 @@
|
||||
// Configuration: $0.00001 = 1 credit
|
||||
export const DOLLAR_TO_CREDIT_MULTIPLIER = 1000000; // 1 / 0.000001 = 1000000 credits per dollar
|
||||
@@ -1,10 +0,0 @@
|
||||
export const MCP_SERVER_METADATA = {
|
||||
metadata: {
|
||||
info: 'Objects structure your business entities in Twenty. **Standard Objects** (e.g. People, Companies, Opportunities) are built‑in, pre‑configured data models. **Custom Objects** let you define entities specific to your needs (like Rockets, Properties, etc.). **Fields** work like spreadsheet columns and can be standard or custom. Always use the `fields` and `objects` parameters to select only the data you need—this **strongly reduces response size and token usage**, improving performance.',
|
||||
},
|
||||
protocolVersion: '2024-11-05',
|
||||
serverInfo: {
|
||||
name: 'Twenty MCP Server',
|
||||
version: '0.0.1',
|
||||
},
|
||||
};
|
||||
@@ -1,190 +0,0 @@
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
|
||||
import { openai } from '@ai-sdk/openai';
|
||||
|
||||
import { ModelProvider } from 'src/engine/core-modules/ai/constants/ai-models.const';
|
||||
import { AIBillingService } from 'src/engine/core-modules/ai/services/ai-billing.service';
|
||||
import { AiModelRegistryService } from 'src/engine/core-modules/ai/services/ai-model-registry.service';
|
||||
import { AiService } from 'src/engine/core-modules/ai/services/ai.service';
|
||||
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
|
||||
|
||||
import { AiController } from './ai.controller';
|
||||
|
||||
describe('AiController', () => {
|
||||
let controller: AiController;
|
||||
let aiService: jest.Mocked<AiService>;
|
||||
let featureFlagService: jest.Mocked<FeatureFlagService>;
|
||||
let aiBillingService: jest.Mocked<AIBillingService>;
|
||||
let aiModelRegistryService: jest.Mocked<AiModelRegistryService>;
|
||||
|
||||
beforeEach(async () => {
|
||||
const mockAiService = {
|
||||
streamText: jest.fn(),
|
||||
getModel: jest.fn(),
|
||||
};
|
||||
|
||||
const mockFeatureFlagService = {
|
||||
isFeatureEnabled: jest.fn().mockResolvedValue(true),
|
||||
};
|
||||
|
||||
const mockAIBillingService = {
|
||||
calculateAndBillUsage: jest.fn(),
|
||||
};
|
||||
|
||||
const mockAiModelRegistryService = {
|
||||
getDefaultPerformanceModel: jest.fn().mockReturnValue({
|
||||
modelId: 'gpt-4o',
|
||||
provider: ModelProvider.OPENAI,
|
||||
model: openai('gpt-4o'),
|
||||
}),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [AiController],
|
||||
providers: [
|
||||
{
|
||||
provide: AiService,
|
||||
useValue: mockAiService,
|
||||
},
|
||||
{
|
||||
provide: FeatureFlagService,
|
||||
useValue: mockFeatureFlagService,
|
||||
},
|
||||
{
|
||||
provide: AIBillingService,
|
||||
useValue: mockAIBillingService,
|
||||
},
|
||||
{
|
||||
provide: AiModelRegistryService,
|
||||
useValue: mockAiModelRegistryService,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
controller = module.get<AiController>(AiController);
|
||||
aiService = module.get(AiService);
|
||||
featureFlagService = module.get(FeatureFlagService);
|
||||
aiBillingService = module.get(AIBillingService);
|
||||
aiModelRegistryService = module.get(AiModelRegistryService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(controller).toBeDefined();
|
||||
});
|
||||
|
||||
describe('chat', () => {
|
||||
const mockWorkspace = { id: 'workspace-1' } as any;
|
||||
|
||||
it('should handle valid chat request and bill usage', async () => {
|
||||
const mockRequest = {
|
||||
messages: [{ role: 'user' as const, content: 'Hello' }],
|
||||
temperature: 0.7,
|
||||
maxOutputTokens: 100,
|
||||
};
|
||||
|
||||
const mockRes = {
|
||||
setHeader: jest.fn(),
|
||||
write: jest.fn(),
|
||||
end: jest.fn(),
|
||||
} as any;
|
||||
|
||||
const mockModel = openai('gpt-4o');
|
||||
|
||||
aiModelRegistryService.getDefaultPerformanceModel.mockReturnValue({
|
||||
modelId: 'gpt-4o',
|
||||
provider: ModelProvider.OPENAI,
|
||||
model: mockModel,
|
||||
});
|
||||
|
||||
const mockUsage = {
|
||||
inputTokens: 10,
|
||||
outputTokens: 20,
|
||||
totalTokens: 30,
|
||||
};
|
||||
|
||||
const mockStreamTextResult = {
|
||||
usage: Promise.resolve(mockUsage),
|
||||
pipeUIMessageStreamToResponse: jest.fn(),
|
||||
};
|
||||
|
||||
aiService.streamText.mockReturnValue(mockStreamTextResult as any);
|
||||
|
||||
await controller.chat(mockRequest, mockWorkspace, mockRes);
|
||||
// Wait a microtask so the usage.then billing call fires
|
||||
await Promise.resolve();
|
||||
|
||||
expect(featureFlagService.isFeatureEnabled).toHaveBeenCalled();
|
||||
expect(aiService.streamText).toHaveBeenCalledWith({
|
||||
messages: mockRequest.messages,
|
||||
options: {
|
||||
temperature: 0.7,
|
||||
maxOutputTokens: 100,
|
||||
model: mockModel,
|
||||
},
|
||||
});
|
||||
expect(
|
||||
mockStreamTextResult.pipeUIMessageStreamToResponse,
|
||||
).toHaveBeenCalledWith(mockRes);
|
||||
expect(aiBillingService.calculateAndBillUsage).toHaveBeenCalledWith(
|
||||
mockModel.modelId,
|
||||
mockUsage,
|
||||
mockWorkspace.id,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error for empty messages', async () => {
|
||||
const mockRequest = {
|
||||
messages: [],
|
||||
};
|
||||
|
||||
const mockRes = {} as any;
|
||||
|
||||
await expect(
|
||||
controller.chat(mockRequest, mockWorkspace, mockRes),
|
||||
).rejects.toThrow('Messages array is required and cannot be empty');
|
||||
|
||||
expect(aiBillingService.calculateAndBillUsage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle service errors', async () => {
|
||||
const mockRequest = {
|
||||
messages: [{ role: 'user' as const, content: 'Hello' }],
|
||||
};
|
||||
|
||||
const mockRes = {} as any;
|
||||
|
||||
aiModelRegistryService.getDefaultPerformanceModel.mockReturnValue({
|
||||
modelId: 'gpt-4o',
|
||||
provider: ModelProvider.OPENAI,
|
||||
model: openai('gpt-4o'),
|
||||
});
|
||||
aiService.streamText.mockImplementation(() => {
|
||||
throw new Error('Service error');
|
||||
});
|
||||
|
||||
await expect(
|
||||
controller.chat(mockRequest, mockWorkspace, mockRes),
|
||||
).rejects.toThrow(
|
||||
'An error occurred while processing your request: Service error',
|
||||
);
|
||||
|
||||
expect(aiBillingService.calculateAndBillUsage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw error when AI feature is disabled', async () => {
|
||||
featureFlagService.isFeatureEnabled.mockResolvedValue(false);
|
||||
|
||||
const mockRequest = {
|
||||
messages: [{ role: 'user' as const, content: 'Hello' }],
|
||||
};
|
||||
|
||||
const mockRes = {} as any;
|
||||
|
||||
await expect(
|
||||
controller.chat(mockRequest, mockWorkspace, mockRes),
|
||||
).rejects.toThrow('AI feature is not enabled for this workspace');
|
||||
|
||||
expect(aiBillingService.calculateAndBillUsage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,99 +0,0 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
Post,
|
||||
Res,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
|
||||
import { type ModelMessage } from 'ai';
|
||||
import { Response } from 'express';
|
||||
|
||||
import { AIBillingService } from 'src/engine/core-modules/ai/services/ai-billing.service';
|
||||
import { AiModelRegistryService } from 'src/engine/core-modules/ai/services/ai-model-registry.service';
|
||||
import { AiService } from 'src/engine/core-modules/ai/services/ai.service';
|
||||
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 { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
|
||||
import { CustomPermissionGuard } from 'src/engine/guards/custom-permission.guard';
|
||||
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||
|
||||
interface ChatRequest {
|
||||
messages: ModelMessage[];
|
||||
temperature?: number;
|
||||
maxOutputTokens?: number;
|
||||
}
|
||||
|
||||
@Controller('chat')
|
||||
@UseGuards(WorkspaceAuthGuard, CustomPermissionGuard)
|
||||
export class AiController {
|
||||
constructor(
|
||||
private readonly aiService: AiService,
|
||||
private readonly featureFlagService: FeatureFlagService,
|
||||
private readonly aiBillingService: AIBillingService,
|
||||
private readonly aiModelRegistryService: AiModelRegistryService,
|
||||
) {}
|
||||
|
||||
@Post()
|
||||
async chat(
|
||||
@Body() request: ChatRequest,
|
||||
@AuthWorkspace() workspace: WorkspaceEntity,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
const isAiEnabled = await this.featureFlagService.isFeatureEnabled(
|
||||
FeatureFlagKey.IS_AI_ENABLED,
|
||||
workspace.id,
|
||||
);
|
||||
|
||||
if (!isAiEnabled) {
|
||||
throw new HttpException(
|
||||
'AI feature is not enabled for this workspace',
|
||||
HttpStatus.FORBIDDEN,
|
||||
);
|
||||
}
|
||||
|
||||
const { messages, temperature, maxOutputTokens } = request;
|
||||
|
||||
if (!messages || messages.length === 0) {
|
||||
throw new HttpException(
|
||||
'Messages array is required and cannot be empty',
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const registeredModel =
|
||||
this.aiModelRegistryService.getDefaultPerformanceModel();
|
||||
|
||||
const result = this.aiService.streamText({
|
||||
messages,
|
||||
options: {
|
||||
temperature,
|
||||
maxOutputTokens,
|
||||
model: registeredModel.model,
|
||||
},
|
||||
});
|
||||
|
||||
result.usage.then((usage) => {
|
||||
this.aiBillingService.calculateAndBillUsage(
|
||||
registeredModel.modelId,
|
||||
usage,
|
||||
workspace.id,
|
||||
);
|
||||
});
|
||||
|
||||
result.pipeUIMessageStreamToResponse(res);
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Unknown error occurred';
|
||||
|
||||
throw new HttpException(
|
||||
`An error occurred while processing your request: ${errorMessage}`,
|
||||
HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,172 +0,0 @@
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
|
||||
import { McpService } from 'src/engine/core-modules/ai/services/mcp.service';
|
||||
import { type JsonRpc } from 'src/engine/core-modules/ai/dtos/json-rpc';
|
||||
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { MCP_SERVER_METADATA } from 'src/engine/core-modules/ai/constants/mcp.const';
|
||||
import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service';
|
||||
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
|
||||
import { HttpExceptionHandlerService } from 'src/engine/core-modules/exception-handler/http-exception-handler.service';
|
||||
|
||||
import { McpController } from './mcp.controller';
|
||||
|
||||
describe('McpController', () => {
|
||||
let controller: McpController;
|
||||
let mcpService: jest.Mocked<McpService>;
|
||||
|
||||
beforeEach(async () => {
|
||||
const mockMcpService = {
|
||||
handleMCPCoreQuery: jest.fn(),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [McpController],
|
||||
providers: [
|
||||
{
|
||||
provide: McpService,
|
||||
useValue: mockMcpService,
|
||||
},
|
||||
{
|
||||
provide: AccessTokenService,
|
||||
useValue: jest.fn(),
|
||||
},
|
||||
{
|
||||
provide: WorkspaceCacheStorageService,
|
||||
useValue: jest.fn(),
|
||||
},
|
||||
{
|
||||
provide: HttpExceptionHandlerService,
|
||||
useValue: {
|
||||
handleError: jest.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
controller = module.get<McpController>(McpController);
|
||||
mcpService = module.get(McpService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(controller).toBeDefined();
|
||||
});
|
||||
|
||||
describe('handleMcpCore', () => {
|
||||
const mockWorkspace = { id: 'workspace-1' } as WorkspaceEntity;
|
||||
const mockUserWorkspaceId = 'user-workspace-1';
|
||||
const mockApiKey = 'api-key-1';
|
||||
|
||||
it('should call mcpService.handleMCPCoreQuery with correct parameters', async () => {
|
||||
const mockRequest: JsonRpc = {
|
||||
jsonrpc: '2.0',
|
||||
method: 'tools/call',
|
||||
params: { name: 'testTool', arguments: { arg1: 'value1' } },
|
||||
id: '123',
|
||||
};
|
||||
|
||||
const mockResponse = {
|
||||
id: '123',
|
||||
jsonrpc: '2.0',
|
||||
result: {
|
||||
content: [{ type: 'text', text: '{"result":"success"}' }],
|
||||
isError: false,
|
||||
},
|
||||
};
|
||||
|
||||
mcpService.handleMCPCoreQuery.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await controller.handleMcpCore(
|
||||
mockRequest,
|
||||
mockWorkspace,
|
||||
mockApiKey,
|
||||
mockUserWorkspaceId,
|
||||
);
|
||||
|
||||
expect(mcpService.handleMCPCoreQuery).toHaveBeenCalledWith(mockRequest, {
|
||||
workspace: mockWorkspace,
|
||||
userWorkspaceId: mockUserWorkspaceId,
|
||||
apiKey: mockApiKey,
|
||||
});
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
it('should handle initialize method', async () => {
|
||||
const mockRequest: JsonRpc = {
|
||||
jsonrpc: '2.0',
|
||||
method: 'initialize',
|
||||
id: '123',
|
||||
};
|
||||
|
||||
const mockResponse = {
|
||||
id: '123',
|
||||
jsonrpc: '2.0',
|
||||
result: {
|
||||
...MCP_SERVER_METADATA,
|
||||
capabilities: {
|
||||
tools: { listChanged: false },
|
||||
resources: { listChanged: false },
|
||||
prompts: { listChanged: false },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
mcpService.handleMCPCoreQuery.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await controller.handleMcpCore(
|
||||
mockRequest,
|
||||
mockWorkspace,
|
||||
mockApiKey,
|
||||
mockUserWorkspaceId,
|
||||
);
|
||||
|
||||
expect(mcpService.handleMCPCoreQuery).toHaveBeenCalledWith(mockRequest, {
|
||||
workspace: mockWorkspace,
|
||||
userWorkspaceId: mockUserWorkspaceId,
|
||||
apiKey: mockApiKey,
|
||||
});
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
it('should handle tools listing', async () => {
|
||||
const mockRequest: JsonRpc = {
|
||||
jsonrpc: '2.0',
|
||||
method: 'tools/list',
|
||||
id: '123',
|
||||
};
|
||||
|
||||
const mockResponse = {
|
||||
id: '123',
|
||||
jsonrpc: '2.0',
|
||||
result: {
|
||||
...MCP_SERVER_METADATA,
|
||||
capabilities: {
|
||||
tools: { listChanged: false },
|
||||
},
|
||||
tools: [
|
||||
{
|
||||
name: 'testTool',
|
||||
description: 'A test tool',
|
||||
inputSchema: { type: 'object', properties: {} },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
mcpService.handleMCPCoreQuery.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await controller.handleMcpCore(
|
||||
mockRequest,
|
||||
mockWorkspace,
|
||||
mockApiKey,
|
||||
mockUserWorkspaceId,
|
||||
);
|
||||
|
||||
expect(mcpService.handleMCPCoreQuery).toHaveBeenCalledWith(mockRequest, {
|
||||
workspace: mockWorkspace,
|
||||
userWorkspaceId: mockUserWorkspaceId,
|
||||
apiKey: mockApiKey,
|
||||
});
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,48 +0,0 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Post,
|
||||
UseFilters,
|
||||
UseGuards,
|
||||
UsePipes,
|
||||
ValidationPipe,
|
||||
} from '@nestjs/common';
|
||||
|
||||
import { RestApiExceptionFilter } from 'src/engine/api/rest/rest-api-exception.filter';
|
||||
import { JsonRpc } from 'src/engine/core-modules/ai/dtos/json-rpc';
|
||||
import { McpService } from 'src/engine/core-modules/ai/services/mcp.service';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { AuthApiKey } from 'src/engine/decorators/auth/auth-api-key.decorator';
|
||||
import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-workspace-id.decorator';
|
||||
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
|
||||
import { JwtAuthGuard } from 'src/engine/guards/jwt-auth.guard';
|
||||
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
|
||||
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||
|
||||
@Controller('mcp')
|
||||
@UseGuards(JwtAuthGuard, WorkspaceAuthGuard, NoPermissionGuard)
|
||||
@UseFilters(RestApiExceptionFilter)
|
||||
export class McpController {
|
||||
constructor(private readonly mcpService: McpService) {}
|
||||
|
||||
@Post()
|
||||
@UsePipes(
|
||||
new ValidationPipe({
|
||||
transform: true,
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
}),
|
||||
)
|
||||
async handleMcpCore(
|
||||
@Body() body: JsonRpc,
|
||||
@AuthWorkspace() workspace: WorkspaceEntity,
|
||||
@AuthApiKey() apiKey: string | undefined,
|
||||
@AuthUserWorkspaceId() userWorkspaceId: string | undefined,
|
||||
) {
|
||||
return await this.mcpService.handleMCPCoreQuery(body, {
|
||||
workspace,
|
||||
userWorkspaceId,
|
||||
apiKey,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import {
|
||||
ValidatorConstraint,
|
||||
type ValidatorConstraintInterface,
|
||||
} from 'class-validator';
|
||||
|
||||
@ValidatorConstraint({ name: 'string-or-number', async: false })
|
||||
export class IsNumberOrString implements ValidatorConstraintInterface {
|
||||
validate(value: unknown) {
|
||||
return typeof value === 'number' || typeof value === 'string';
|
||||
}
|
||||
|
||||
defaultMessage() {
|
||||
return '($value) must be number or string';
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import {
|
||||
IsDefined,
|
||||
IsNotEmpty,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
Validate,
|
||||
} from 'class-validator';
|
||||
|
||||
import { IsNumberOrString } from 'src/engine/core-modules/ai/decorators/string-or-number';
|
||||
|
||||
export class JsonRpc {
|
||||
@IsString()
|
||||
@Matches(/^2\.0$/, { message: 'jsonrpc must be exactly "2.0"' })
|
||||
jsonrpc = '2.0';
|
||||
|
||||
@IsDefined({ message: 'method is required' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
method: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
params?: {
|
||||
name: string;
|
||||
arguments: unknown;
|
||||
};
|
||||
|
||||
@IsOptional()
|
||||
@Validate(IsNumberOrString)
|
||||
id: string | number;
|
||||
}
|
||||
-104
@@ -1,104 +0,0 @@
|
||||
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 { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter';
|
||||
import { AIBillingService } from 'src/engine/core-modules/ai/services/ai-billing.service';
|
||||
import { AiModelRegistryService } from 'src/engine/core-modules/ai/services/ai-model-registry.service';
|
||||
|
||||
describe('AIBillingService', () => {
|
||||
let service: AIBillingService;
|
||||
let mockWorkspaceEventEmitter: jest.Mocked<WorkspaceEventEmitter>;
|
||||
|
||||
const mockTokenUsage = {
|
||||
inputTokens: 1000,
|
||||
outputTokens: 500,
|
||||
totalTokens: 1500,
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const mockEventEmitterMethods = {
|
||||
emitCustomBatchEvent: jest.fn(),
|
||||
};
|
||||
|
||||
const mockAiModelRegistryMethods = {
|
||||
getEffectiveModelConfig: jest.fn().mockReturnValue({
|
||||
modelId: 'gpt-4o',
|
||||
label: 'GPT-4o',
|
||||
provider: 'openai',
|
||||
inputCostPer1kTokensInCents: 0.25,
|
||||
outputCostPer1kTokensInCents: 1.0,
|
||||
}),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
AIBillingService,
|
||||
{
|
||||
provide: WorkspaceEventEmitter,
|
||||
useValue: mockEventEmitterMethods,
|
||||
},
|
||||
{
|
||||
provide: AiModelRegistryService,
|
||||
useValue: mockAiModelRegistryMethods,
|
||||
},
|
||||
],
|
||||
}).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 = {
|
||||
inputTokens: 2000,
|
||||
outputTokens: 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: 7500,
|
||||
},
|
||||
],
|
||||
'workspace-1',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
-393
@@ -1,393 +0,0 @@
|
||||
import { HttpException, HttpStatus } from '@nestjs/common';
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
|
||||
import { jsonSchema } from 'ai';
|
||||
|
||||
import { MCP_SERVER_METADATA } from 'src/engine/core-modules/ai/constants/mcp.const';
|
||||
import { type JsonRpc } from 'src/engine/core-modules/ai/dtos/json-rpc';
|
||||
import { McpService } from 'src/engine/core-modules/ai/services/mcp.service';
|
||||
import { ToolService } from 'src/engine/core-modules/ai/services/tool.service';
|
||||
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 { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { ADMIN_ROLE_LABEL } from 'src/engine/metadata-modules/permissions/constants/admin-role-label.constants';
|
||||
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
|
||||
import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service';
|
||||
|
||||
describe('McpService', () => {
|
||||
let service: McpService;
|
||||
let featureFlagService: jest.Mocked<FeatureFlagService>;
|
||||
let toolService: jest.Mocked<ToolService>;
|
||||
let userRoleService: jest.Mocked<UserRoleService>;
|
||||
|
||||
const mockWorkspace = { id: 'workspace-1' } as WorkspaceEntity;
|
||||
const mockUserWorkspaceId = 'user-workspace-1';
|
||||
const mockRoleId = 'role-1';
|
||||
const mockAdminRoleId = 'admin-role-1';
|
||||
const mockApiKey = 'api-key-1';
|
||||
|
||||
beforeEach(async () => {
|
||||
const mockFeatureFlagService = {
|
||||
isFeatureEnabled: jest.fn(),
|
||||
};
|
||||
|
||||
const mockToolService = {
|
||||
listTools: jest.fn(),
|
||||
};
|
||||
|
||||
const mockUserRoleService = {
|
||||
getRoleIdForUserWorkspace: jest.fn(),
|
||||
};
|
||||
|
||||
const mockAdminRole = {
|
||||
id: mockAdminRoleId,
|
||||
label: ADMIN_ROLE_LABEL,
|
||||
} as RoleEntity;
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
McpService,
|
||||
{
|
||||
provide: FeatureFlagService,
|
||||
useValue: mockFeatureFlagService,
|
||||
},
|
||||
{
|
||||
provide: ToolService,
|
||||
useValue: mockToolService,
|
||||
},
|
||||
{
|
||||
provide: UserRoleService,
|
||||
useValue: mockUserRoleService,
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(RoleEntity),
|
||||
useValue: {
|
||||
find: jest.fn().mockResolvedValue([mockAdminRole]),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<McpService>(McpService);
|
||||
featureFlagService = module.get(FeatureFlagService);
|
||||
toolService = module.get(ToolService);
|
||||
userRoleService = module.get(UserRoleService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe('checkAiEnabled', () => {
|
||||
it('should not throw when AI is enabled', async () => {
|
||||
featureFlagService.isFeatureEnabled.mockResolvedValue(true);
|
||||
|
||||
await expect(
|
||||
service.checkAiEnabled('workspace-1'),
|
||||
).resolves.not.toThrow();
|
||||
expect(featureFlagService.isFeatureEnabled).toHaveBeenCalledWith(
|
||||
FeatureFlagKey.IS_AI_ENABLED,
|
||||
'workspace-1',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw when AI is disabled', async () => {
|
||||
featureFlagService.isFeatureEnabled.mockResolvedValue(false);
|
||||
|
||||
await expect(service.checkAiEnabled('workspace-1')).rejects.toThrow(
|
||||
new HttpException(
|
||||
'AI feature is not enabled for this workspace',
|
||||
HttpStatus.FORBIDDEN,
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleInitialize', () => {
|
||||
it('should return correct initialization response', () => {
|
||||
const requestId = '123';
|
||||
const result = service.handleInitialize(requestId);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
id: requestId,
|
||||
jsonrpc: '2.0',
|
||||
result: expect.objectContaining({
|
||||
...MCP_SERVER_METADATA,
|
||||
capabilities: {
|
||||
tools: { listChanged: false },
|
||||
resources: { listChanged: false },
|
||||
prompts: { listChanged: false },
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRoleId', () => {
|
||||
it('should return role ID when available', async () => {
|
||||
userRoleService.getRoleIdForUserWorkspace.mockResolvedValue(mockRoleId);
|
||||
|
||||
const result = await service.getRoleId('workspace-1', 'user-workspace-1');
|
||||
|
||||
expect(result).toBe(mockRoleId);
|
||||
expect(userRoleService.getRoleIdForUserWorkspace).toHaveBeenCalledWith({
|
||||
workspaceId: 'workspace-1',
|
||||
userWorkspaceId: 'user-workspace-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw when userWorkspaceId is missing and no apiKey is provided', async () => {
|
||||
await expect(service.getRoleId('workspace-1', undefined)).rejects.toThrow(
|
||||
new HttpException('User workspace ID missing', HttpStatus.FORBIDDEN),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw when role ID is missing', async () => {
|
||||
userRoleService.getRoleIdForUserWorkspace.mockResolvedValue(undefined);
|
||||
|
||||
await expect(
|
||||
service.getRoleId('workspace-1', 'user-workspace-1'),
|
||||
).rejects.toThrow(
|
||||
new HttpException('Role ID missing', HttpStatus.FORBIDDEN),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return admin role ID when apiKey is provided', async () => {
|
||||
const result = await service.getRoleId(
|
||||
'workspace-1',
|
||||
undefined,
|
||||
mockApiKey,
|
||||
);
|
||||
|
||||
expect(result).toBe(mockAdminRoleId);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleMCPCoreQuery', () => {
|
||||
it('should handle initialize method', async () => {
|
||||
featureFlagService.isFeatureEnabled.mockResolvedValue(true);
|
||||
|
||||
const mockRequest: JsonRpc = {
|
||||
jsonrpc: '2.0',
|
||||
method: 'initialize',
|
||||
id: '123',
|
||||
};
|
||||
|
||||
const result = await service.handleMCPCoreQuery(mockRequest, {
|
||||
workspace: mockWorkspace,
|
||||
userWorkspaceId: mockUserWorkspaceId,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
id: '123',
|
||||
jsonrpc: '2.0',
|
||||
result: expect.objectContaining({
|
||||
...MCP_SERVER_METADATA,
|
||||
capabilities: {
|
||||
tools: { listChanged: false },
|
||||
resources: { listChanged: false },
|
||||
prompts: { listChanged: false },
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle tools/call method with userWorkspaceId', async () => {
|
||||
featureFlagService.isFeatureEnabled.mockResolvedValue(true);
|
||||
userRoleService.getRoleIdForUserWorkspace.mockResolvedValue(mockRoleId);
|
||||
|
||||
const mockTool = {
|
||||
description: 'Test tool',
|
||||
inputSchema: jsonSchema({ type: 'object', properties: {} }),
|
||||
execute: jest.fn().mockResolvedValue({ result: 'success' }),
|
||||
};
|
||||
|
||||
const mockToolsMap = {
|
||||
testTool: mockTool,
|
||||
};
|
||||
|
||||
toolService.listTools.mockResolvedValue(mockToolsMap);
|
||||
|
||||
const mockRequest: JsonRpc = {
|
||||
jsonrpc: '2.0',
|
||||
method: 'tools/call',
|
||||
params: { name: 'testTool', arguments: { arg1: 'value1' } },
|
||||
id: '123',
|
||||
};
|
||||
|
||||
const result = await service.handleMCPCoreQuery(mockRequest, {
|
||||
workspace: mockWorkspace,
|
||||
userWorkspaceId: mockUserWorkspaceId,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
id: '123',
|
||||
jsonrpc: '2.0',
|
||||
result: {
|
||||
...MCP_SERVER_METADATA,
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify({ result: 'success' }),
|
||||
},
|
||||
],
|
||||
isError: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockTool.execute).toHaveBeenCalledWith(
|
||||
{ arg1: 'value1' },
|
||||
{ toolCallId: '1', messages: [] },
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle tools/call method with apiKey', async () => {
|
||||
featureFlagService.isFeatureEnabled.mockResolvedValue(true);
|
||||
|
||||
const mockTool = {
|
||||
description: 'Test tool',
|
||||
inputSchema: jsonSchema({ type: 'object', properties: {} }),
|
||||
execute: jest.fn().mockResolvedValue({ result: 'success' }),
|
||||
};
|
||||
|
||||
const mockToolsMap = {
|
||||
testTool: mockTool,
|
||||
};
|
||||
|
||||
toolService.listTools.mockResolvedValue(mockToolsMap);
|
||||
|
||||
const mockRequest: JsonRpc = {
|
||||
jsonrpc: '2.0',
|
||||
method: 'tools/call',
|
||||
params: { name: 'testTool', arguments: { arg1: 'value1' } },
|
||||
id: '123',
|
||||
};
|
||||
|
||||
const result = await service.handleMCPCoreQuery(mockRequest, {
|
||||
workspace: mockWorkspace,
|
||||
apiKey: mockApiKey,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
id: '123',
|
||||
jsonrpc: '2.0',
|
||||
result: {
|
||||
...MCP_SERVER_METADATA,
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify({ result: 'success' }),
|
||||
},
|
||||
],
|
||||
isError: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(toolService.listTools).toHaveBeenCalledWith(
|
||||
{ unionOf: [mockAdminRoleId] },
|
||||
mockWorkspace.id,
|
||||
);
|
||||
expect(mockTool.execute).toHaveBeenCalledWith(
|
||||
{ arg1: 'value1' },
|
||||
{ toolCallId: '1', messages: [] },
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle tools listing', async () => {
|
||||
featureFlagService.isFeatureEnabled.mockResolvedValue(true);
|
||||
userRoleService.getRoleIdForUserWorkspace.mockResolvedValue(mockRoleId);
|
||||
|
||||
const mockToolsMap = {
|
||||
testTool: {
|
||||
description: 'Test tool',
|
||||
inputSchema: jsonSchema({ type: 'object', properties: {} }),
|
||||
},
|
||||
};
|
||||
|
||||
toolService.listTools.mockResolvedValue(mockToolsMap);
|
||||
|
||||
const mockRequest: JsonRpc = {
|
||||
jsonrpc: '2.0',
|
||||
method: 'tools/list',
|
||||
id: '123',
|
||||
};
|
||||
|
||||
const result = await service.handleMCPCoreQuery(mockRequest, {
|
||||
workspace: mockWorkspace,
|
||||
userWorkspaceId: mockUserWorkspaceId,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
id: '123',
|
||||
jsonrpc: '2.0',
|
||||
result: expect.objectContaining({
|
||||
...MCP_SERVER_METADATA,
|
||||
capabilities: {
|
||||
tools: { listChanged: false },
|
||||
},
|
||||
tools: [
|
||||
{
|
||||
name: 'testTool',
|
||||
description: 'Test tool',
|
||||
inputSchema: { type: 'object', properties: {} },
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle error when AI is disabled', async () => {
|
||||
featureFlagService.isFeatureEnabled.mockResolvedValue(false);
|
||||
|
||||
const mockRequest: JsonRpc = {
|
||||
jsonrpc: '2.0',
|
||||
method: 'tools/list',
|
||||
id: '123',
|
||||
};
|
||||
|
||||
const result = await service.handleMCPCoreQuery(mockRequest, {
|
||||
workspace: mockWorkspace,
|
||||
userWorkspaceId: mockUserWorkspaceId,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
id: '123',
|
||||
jsonrpc: '2.0',
|
||||
error: {
|
||||
...MCP_SERVER_METADATA,
|
||||
code: HttpStatus.FORBIDDEN,
|
||||
message: 'AI feature is not enabled for this workspace',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle error when tool is not found', async () => {
|
||||
featureFlagService.isFeatureEnabled.mockResolvedValue(true);
|
||||
userRoleService.getRoleIdForUserWorkspace.mockResolvedValue(mockRoleId);
|
||||
toolService.listTools.mockResolvedValue({});
|
||||
|
||||
const mockRequest: JsonRpc = {
|
||||
jsonrpc: '2.0',
|
||||
method: 'tools/call',
|
||||
params: { name: 'nonExistentTool', arguments: {} },
|
||||
id: '123',
|
||||
};
|
||||
|
||||
const result = await service.handleMCPCoreQuery(mockRequest, {
|
||||
workspace: mockWorkspace,
|
||||
userWorkspaceId: mockUserWorkspaceId,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
id: '123',
|
||||
jsonrpc: '2.0',
|
||||
error: {
|
||||
...MCP_SERVER_METADATA,
|
||||
code: HttpStatus.NOT_FOUND,
|
||||
message: "Tool 'nonExistentTool' not found",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
-172
@@ -1,172 +0,0 @@
|
||||
import { Test } from '@nestjs/testing';
|
||||
|
||||
import { jsonSchema } from 'ai';
|
||||
|
||||
import { ToolAdapterService } from 'src/engine/core-modules/ai/services/tool-adapter.service';
|
||||
import { ToolType } from 'src/engine/core-modules/tool/enums/tool-type.enum';
|
||||
import { ToolRegistryService } from 'src/engine/core-modules/tool/services/tool-registry.service';
|
||||
import { type ToolInput } from 'src/engine/core-modules/tool/types/tool-input.type';
|
||||
import { type Tool } from 'src/engine/core-modules/tool/types/tool.type';
|
||||
import { PermissionFlagType } from 'src/engine/metadata-modules/permissions/constants/permission-flag-type.constants';
|
||||
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
|
||||
import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config';
|
||||
|
||||
const createMockToolRegistry = () => ({
|
||||
getAllToolTypes: jest.fn(),
|
||||
getTool: jest.fn(),
|
||||
});
|
||||
|
||||
const createMockPermissions = () => ({
|
||||
hasToolPermission: jest.fn<
|
||||
Promise<boolean>,
|
||||
[RolePermissionConfig, string, PermissionFlagType]
|
||||
>(),
|
||||
});
|
||||
|
||||
describe('ToolAdapterService', () => {
|
||||
let mockRegistry: ReturnType<typeof createMockToolRegistry>;
|
||||
let mockPermissions: ReturnType<typeof createMockPermissions>;
|
||||
let service: ToolAdapterService;
|
||||
|
||||
// Shared tools
|
||||
const unflaggedToolExecute = jest.fn(async (input: ToolInput) => ({
|
||||
success: true,
|
||||
message: 'Tool executed successfully',
|
||||
result: { echoed: input },
|
||||
}));
|
||||
const unflaggedTool: Tool = {
|
||||
description: 'HTTP Request tool',
|
||||
inputSchema: jsonSchema({ type: 'object', properties: {} }),
|
||||
execute: unflaggedToolExecute,
|
||||
};
|
||||
|
||||
const flaggedToolExecute = jest.fn(async (input: ToolInput) => ({
|
||||
success: true,
|
||||
message: 'Tool executed successfully',
|
||||
result: { sent: input },
|
||||
}));
|
||||
const flaggedTool: Tool = {
|
||||
description: 'Send Email tool',
|
||||
inputSchema: jsonSchema({ type: 'object', properties: {} }),
|
||||
execute: flaggedToolExecute,
|
||||
flag: PermissionFlagType.SEND_EMAIL_TOOL,
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
mockRegistry = createMockToolRegistry();
|
||||
mockPermissions = createMockPermissions();
|
||||
|
||||
// Setup mock tool responses
|
||||
mockRegistry.getAllToolTypes.mockReturnValue([
|
||||
ToolType.HTTP_REQUEST,
|
||||
ToolType.SEND_EMAIL,
|
||||
]);
|
||||
mockRegistry.getTool.mockImplementation((type: ToolType) => {
|
||||
if (type === ToolType.HTTP_REQUEST) return unflaggedTool;
|
||||
if (type === ToolType.SEND_EMAIL) return flaggedTool;
|
||||
throw new Error('Tool not found in mock');
|
||||
});
|
||||
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
providers: [
|
||||
ToolAdapterService,
|
||||
{
|
||||
provide: ToolRegistryService,
|
||||
useValue: mockRegistry,
|
||||
},
|
||||
{
|
||||
provide: PermissionsService,
|
||||
useValue: mockPermissions,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = moduleRef.get(ToolAdapterService);
|
||||
});
|
||||
|
||||
it('should include unflagged tools regardless of role/workspace', async () => {
|
||||
const toolsNoContext = await service.getTools();
|
||||
|
||||
expect(Object.keys(toolsNoContext)).toContain('http_request');
|
||||
|
||||
const toolsWithPartialContext = await service.getTools({
|
||||
unionOf: ['role-1'],
|
||||
});
|
||||
|
||||
expect(Object.keys(toolsWithPartialContext)).toContain('http_request');
|
||||
});
|
||||
|
||||
it('should not include flagged tools when role/workspace are missing', async () => {
|
||||
const toolsNoContext = await service.getTools();
|
||||
|
||||
expect(Object.keys(toolsNoContext)).not.toContain('send_email');
|
||||
|
||||
const toolsRoleOnly = await service.getTools({
|
||||
unionOf: ['role-1'],
|
||||
});
|
||||
|
||||
expect(Object.keys(toolsRoleOnly)).not.toContain('send_email');
|
||||
|
||||
const toolsWorkspaceOnly = await service.getTools(undefined, 'ws-1');
|
||||
|
||||
expect(Object.keys(toolsWorkspaceOnly)).not.toContain('send_email');
|
||||
});
|
||||
|
||||
it('should include flagged tools when permission is granted', async () => {
|
||||
mockPermissions.hasToolPermission.mockResolvedValueOnce(true);
|
||||
|
||||
const tools = await service.getTools({ unionOf: ['role-1'] }, 'ws-1');
|
||||
|
||||
expect(mockPermissions.hasToolPermission).toHaveBeenCalledWith(
|
||||
{ unionOf: ['role-1'] },
|
||||
'ws-1',
|
||||
PermissionFlagType.SEND_EMAIL_TOOL,
|
||||
);
|
||||
|
||||
expect(Object.keys(tools)).toContain('send_email');
|
||||
});
|
||||
|
||||
it('should exclude flagged tools when permission is denied', async () => {
|
||||
mockPermissions.hasToolPermission.mockResolvedValueOnce(false);
|
||||
|
||||
const tools = await service.getTools({ unionOf: ['role-1'] }, 'ws-1');
|
||||
|
||||
expect(Object.keys(tools)).not.toContain('send_email');
|
||||
});
|
||||
|
||||
it('should lowercase tool type keys in the returned ToolSet', async () => {
|
||||
const tools = await service.getTools();
|
||||
|
||||
const keys = Object.keys(tools);
|
||||
|
||||
expect(keys).toContain('http_request');
|
||||
expect(keys).not.toContain(ToolType.HTTP_REQUEST); // ensure enum raw value not used as-is
|
||||
});
|
||||
|
||||
it('should forward execute input correctly and return underlying result', async () => {
|
||||
const tools = await service.getTools();
|
||||
|
||||
const input = { url: 'https://example.com', method: 'GET' } as ToolInput;
|
||||
const result = await tools['http_request'].execute?.(
|
||||
{ input },
|
||||
{
|
||||
toolCallId: 'test-tool-call-id',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: 'content',
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
// Ensure wrapper forwards only parameters.input
|
||||
expect(unflaggedToolExecute).toHaveBeenCalledWith(input);
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
message: 'Tool executed successfully',
|
||||
result: { echoed: input },
|
||||
});
|
||||
});
|
||||
});
|
||||
-164
@@ -1,164 +0,0 @@
|
||||
import { Test } from '@nestjs/testing';
|
||||
|
||||
import { ToolService } from 'src/engine/core-modules/ai/services/tool.service';
|
||||
import { CreateRecordService } from 'src/engine/core-modules/record-crud/services/create-record.service';
|
||||
import { DeleteRecordService } from 'src/engine/core-modules/record-crud/services/delete-record.service';
|
||||
import { FindRecordsService } from 'src/engine/core-modules/record-crud/services/find-records.service';
|
||||
import { UpdateRecordService } from 'src/engine/core-modules/record-crud/services/update-record.service';
|
||||
import { RecordInputTransformerService } from 'src/engine/core-modules/record-transformer/services/record-input-transformer.service';
|
||||
import { ObjectMetadataServiceV2 } from 'src/engine/metadata-modules/object-metadata/object-metadata-v2.service';
|
||||
import { WorkspacePermissionsCacheService } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.service';
|
||||
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
|
||||
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
|
||||
import { getMockObjectMetadataEntity } from 'src/utils/__test__/get-object-metadata-entity.mock';
|
||||
|
||||
// Minimal mock repository type
|
||||
const createMockRepository = () => ({
|
||||
find: jest.fn(),
|
||||
findOne: jest.fn(),
|
||||
save: jest.fn(),
|
||||
update: jest.fn(),
|
||||
softDelete: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
remove: jest.fn(),
|
||||
});
|
||||
|
||||
describe('ToolService', () => {
|
||||
const workspaceId = 'ws_1';
|
||||
const roleId = 'role_1';
|
||||
|
||||
let service: ToolService;
|
||||
let permissionsCacheService: WorkspacePermissionsCacheService;
|
||||
|
||||
const testObject = getMockObjectMetadataEntity({
|
||||
workspaceId: '',
|
||||
id: 'obj_1',
|
||||
nameSingular: 'testObject',
|
||||
namePlural: 'testObjects',
|
||||
labelSingular: 'Test Object',
|
||||
labelPlural: 'Test Objects',
|
||||
isActive: true,
|
||||
isSystem: false,
|
||||
fields: [],
|
||||
});
|
||||
|
||||
const mockRepo = createMockRepository();
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.resetAllMocks();
|
||||
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
providers: [
|
||||
ToolService,
|
||||
{
|
||||
provide: TwentyORMGlobalManager,
|
||||
useValue: {
|
||||
getRepositoryForWorkspace: jest.fn().mockResolvedValue(mockRepo),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: ObjectMetadataServiceV2,
|
||||
useValue: {
|
||||
findManyWithinWorkspace: jest.fn().mockResolvedValue([testObject]),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: WorkspacePermissionsCacheService,
|
||||
useValue: {
|
||||
getRolesPermissionsFromCache: jest.fn().mockResolvedValue({
|
||||
data: {
|
||||
[roleId]: {
|
||||
[testObject.id]: {
|
||||
canReadObjectRecords: true,
|
||||
canUpdateObjectRecords: true,
|
||||
canSoftDeleteObjectRecords: true,
|
||||
canDestroyObjectRecords: false,
|
||||
restrictedFields: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: RecordInputTransformerService,
|
||||
useValue: {
|
||||
process: jest.fn(async ({ recordInput }) => recordInput),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: WorkspaceCacheStorageService,
|
||||
useValue: {
|
||||
getObjectMetadataMapsOrThrow: jest.fn().mockResolvedValue({
|
||||
byId: {
|
||||
[testObject.id]: {
|
||||
...testObject,
|
||||
fieldsById: {},
|
||||
fieldIdByJoinColumnName: {},
|
||||
fieldIdByName: {},
|
||||
indexMetadatas: [],
|
||||
},
|
||||
},
|
||||
idByNameSingular: { [testObject.nameSingular]: testObject.id },
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: CreateRecordService,
|
||||
useValue: { execute: jest.fn() },
|
||||
},
|
||||
{
|
||||
provide: UpdateRecordService,
|
||||
useValue: { execute: jest.fn() },
|
||||
},
|
||||
{
|
||||
provide: DeleteRecordService,
|
||||
useValue: { execute: jest.fn() },
|
||||
},
|
||||
{
|
||||
provide: FindRecordsService,
|
||||
useValue: { execute: jest.fn() },
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = moduleRef.get(ToolService);
|
||||
permissionsCacheService = moduleRef.get(WorkspacePermissionsCacheService);
|
||||
});
|
||||
|
||||
describe('listTools', () => {
|
||||
it('should return tools based on role permissions', async () => {
|
||||
const tools = await service.listTools({ unionOf: [roleId] }, workspaceId);
|
||||
|
||||
expect(
|
||||
permissionsCacheService.getRolesPermissionsFromCache,
|
||||
).toHaveBeenCalledWith({ workspaceId });
|
||||
|
||||
// Verify tool keys
|
||||
expect(tools['create_testObject']).toBeDefined();
|
||||
expect(tools['update_testObject']).toBeDefined();
|
||||
expect(tools['find_testObject']).toBeDefined();
|
||||
expect(tools['soft_delete_testObject']).toBeDefined();
|
||||
expect(tools['soft_delete_many_testObject']).toBeDefined();
|
||||
|
||||
// Ensure the execute functions are wired
|
||||
expect(typeof tools['create_testObject'].execute).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('softDeleteManyRecords', () => {
|
||||
it('should error when filter is invalid', async () => {
|
||||
const result = await (service as any).softDeleteManyRecords(
|
||||
'testObject',
|
||||
{},
|
||||
workspaceId,
|
||||
roleId,
|
||||
);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe(
|
||||
'Filter with record IDs is required for bulk soft delete',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,72 +0,0 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { LanguageModelUsage } from 'ai';
|
||||
|
||||
import { type ModelId } from 'src/engine/core-modules/ai/constants/ai-models.const';
|
||||
import { AiModelRegistryService } from 'src/engine/core-modules/ai/services/ai-model-registry.service';
|
||||
import { convertCentsToBillingCredits } from 'src/engine/core-modules/ai/utils/convert-cents-to-billing-credits.util';
|
||||
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 { type BillingUsageEvent } from 'src/engine/core-modules/billing/types/billing-usage-event.type';
|
||||
import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter';
|
||||
|
||||
@Injectable()
|
||||
export class AIBillingService {
|
||||
private readonly logger = new Logger(AIBillingService.name);
|
||||
|
||||
constructor(
|
||||
private readonly workspaceEventEmitter: WorkspaceEventEmitter,
|
||||
private readonly aiModelRegistryService: AiModelRegistryService,
|
||||
) {}
|
||||
|
||||
async calculateCost(
|
||||
modelId: ModelId,
|
||||
usage: LanguageModelUsage,
|
||||
): Promise<number> {
|
||||
const model = this.aiModelRegistryService.getEffectiveModelConfig(modelId);
|
||||
|
||||
if (!model) {
|
||||
throw new Error(`AI model with id ${modelId} not found`);
|
||||
}
|
||||
|
||||
const inputCost =
|
||||
((usage.inputTokens ?? 0) / 1000) * model.inputCostPer1kTokensInCents;
|
||||
const outputCost =
|
||||
((usage.outputTokens ?? 0) / 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: LanguageModelUsage,
|
||||
workspaceId: string,
|
||||
): Promise<void> {
|
||||
const costInCents = await this.calculateCost(modelId, usage);
|
||||
const creditsUsed = Math.round(convertCentsToBillingCredits(costInCents));
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
-266
@@ -1,266 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { anthropic } from '@ai-sdk/anthropic';
|
||||
import { createOpenAI, openai } from '@ai-sdk/openai';
|
||||
import { xai } from '@ai-sdk/xai';
|
||||
import { type LanguageModel } from 'ai';
|
||||
|
||||
import {
|
||||
AI_MODELS,
|
||||
ModelProvider,
|
||||
type AIModelConfig,
|
||||
} from 'src/engine/core-modules/ai/constants/ai-models.const';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
|
||||
export interface RegisteredAIModel {
|
||||
modelId: string;
|
||||
provider: ModelProvider;
|
||||
model: LanguageModel;
|
||||
doesSupportThinking?: boolean;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AiModelRegistryService {
|
||||
private modelRegistry: Map<string, RegisteredAIModel> = new Map();
|
||||
|
||||
constructor(private twentyConfigService: TwentyConfigService) {
|
||||
this.buildModelRegistry();
|
||||
}
|
||||
|
||||
private buildModelRegistry(): void {
|
||||
this.modelRegistry.clear();
|
||||
|
||||
const openaiApiKey = this.twentyConfigService.get('OPENAI_API_KEY');
|
||||
|
||||
if (openaiApiKey) {
|
||||
this.registerOpenAIModels();
|
||||
}
|
||||
|
||||
const anthropicApiKey = this.twentyConfigService.get('ANTHROPIC_API_KEY');
|
||||
|
||||
if (anthropicApiKey) {
|
||||
this.registerAnthropicModels();
|
||||
}
|
||||
|
||||
const xaiApiKey = this.twentyConfigService.get('XAI_API_KEY');
|
||||
|
||||
if (xaiApiKey) {
|
||||
this.registerXaiModels();
|
||||
}
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private registerOpenAIModels(): void {
|
||||
const openaiModels = AI_MODELS.filter(
|
||||
(model) => model.provider === ModelProvider.OPENAI,
|
||||
);
|
||||
|
||||
openaiModels.forEach((modelConfig) => {
|
||||
this.modelRegistry.set(modelConfig.modelId, {
|
||||
modelId: modelConfig.modelId,
|
||||
provider: ModelProvider.OPENAI,
|
||||
model: openai(modelConfig.modelId),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private registerAnthropicModels(): void {
|
||||
const anthropicModels = AI_MODELS.filter(
|
||||
(model) => model.provider === ModelProvider.ANTHROPIC,
|
||||
);
|
||||
|
||||
anthropicModels.forEach((modelConfig) => {
|
||||
this.modelRegistry.set(modelConfig.modelId, {
|
||||
modelId: modelConfig.modelId,
|
||||
provider: ModelProvider.ANTHROPIC,
|
||||
model: anthropic(modelConfig.modelId),
|
||||
doesSupportThinking: modelConfig.doesSupportThinking,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private registerXaiModels(): void {
|
||||
const xaiModels = AI_MODELS.filter(
|
||||
(model) => model.provider === ModelProvider.XAI,
|
||||
);
|
||||
|
||||
xaiModels.forEach((modelConfig) => {
|
||||
this.modelRegistry.set(modelConfig.modelId, {
|
||||
modelId: modelConfig.modelId,
|
||||
provider: ModelProvider.XAI,
|
||||
model: xai(modelConfig.modelId),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
provider: ModelProvider.OPENAI_COMPATIBLE,
|
||||
model: provider(modelId),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
getModel(modelId: string): RegisteredAIModel | undefined {
|
||||
return this.modelRegistry.get(modelId);
|
||||
}
|
||||
|
||||
getAvailableModels(): RegisteredAIModel[] {
|
||||
return Array.from(this.modelRegistry.values());
|
||||
}
|
||||
|
||||
getDefaultSpeedModel(): RegisteredAIModel {
|
||||
const defaultModelId = this.twentyConfigService.get(
|
||||
'DEFAULT_AI_SPEED_MODEL_ID',
|
||||
);
|
||||
let model = this.getModel(defaultModelId);
|
||||
|
||||
if (!model) {
|
||||
const availableModels = this.getAvailableModels();
|
||||
|
||||
model = availableModels[0];
|
||||
}
|
||||
|
||||
return model;
|
||||
}
|
||||
|
||||
getDefaultPerformanceModel(): RegisteredAIModel {
|
||||
const defaultModelId = this.twentyConfigService.get(
|
||||
'DEFAULT_AI_PERFORMANCE_MODEL_ID',
|
||||
);
|
||||
let model = this.getModel(defaultModelId);
|
||||
|
||||
if (!model) {
|
||||
const availableModels = this.getAvailableModels();
|
||||
|
||||
model = availableModels[0];
|
||||
}
|
||||
|
||||
return model;
|
||||
}
|
||||
|
||||
getEffectiveModelConfig(modelId: string): AIModelConfig {
|
||||
if (modelId === 'auto') {
|
||||
const defaultModel = this.getDefaultPerformanceModel();
|
||||
|
||||
if (!defaultModel) {
|
||||
throw new Error(
|
||||
'No AI models are available. Please configure at least one provider.',
|
||||
);
|
||||
}
|
||||
|
||||
const modelConfig = AI_MODELS.find(
|
||||
(model) => model.modelId === defaultModel.modelId,
|
||||
);
|
||||
|
||||
if (modelConfig) {
|
||||
return modelConfig;
|
||||
}
|
||||
|
||||
return this.createDefaultConfigForCustomModel(defaultModel);
|
||||
}
|
||||
|
||||
const predefinedModel = AI_MODELS.find(
|
||||
(model) => model.modelId === modelId,
|
||||
);
|
||||
|
||||
if (predefinedModel) {
|
||||
return predefinedModel;
|
||||
}
|
||||
|
||||
const registeredModel = this.getModel(modelId);
|
||||
|
||||
if (registeredModel) {
|
||||
return this.createDefaultConfigForCustomModel(registeredModel);
|
||||
}
|
||||
|
||||
throw new Error(`Model with ID ${modelId} not found`);
|
||||
}
|
||||
|
||||
private createDefaultConfigForCustomModel(
|
||||
registeredModel: RegisteredAIModel,
|
||||
): AIModelConfig {
|
||||
return {
|
||||
modelId: registeredModel.modelId,
|
||||
label: registeredModel.modelId,
|
||||
description: `Custom model: ${registeredModel.modelId}`,
|
||||
provider: registeredModel.provider,
|
||||
inputCostPer1kTokensInCents: 0,
|
||||
outputCostPer1kTokensInCents: 0,
|
||||
contextWindowTokens: 128000,
|
||||
maxOutputTokens: 4096,
|
||||
};
|
||||
}
|
||||
|
||||
// Force refresh the registry (useful if config changes)
|
||||
refreshRegistry(): void {
|
||||
this.buildModelRegistry();
|
||||
}
|
||||
|
||||
async resolveModelForAgent(agent: { modelId: string } | null) {
|
||||
const aiModel = this.getEffectiveModelConfig(agent?.modelId ?? 'auto');
|
||||
|
||||
await this.validateApiKey(aiModel.provider);
|
||||
const registeredModel = this.getModel(aiModel.modelId);
|
||||
|
||||
if (!registeredModel) {
|
||||
throw new Error(`Model ${aiModel.modelId} not found in registry`);
|
||||
}
|
||||
|
||||
return registeredModel;
|
||||
}
|
||||
|
||||
async validateApiKey(provider: ModelProvider): Promise<void> {
|
||||
let apiKey: string | undefined;
|
||||
|
||||
switch (provider) {
|
||||
case ModelProvider.OPENAI:
|
||||
apiKey = this.twentyConfigService.get('OPENAI_API_KEY');
|
||||
break;
|
||||
case ModelProvider.ANTHROPIC:
|
||||
apiKey = this.twentyConfigService.get('ANTHROPIC_API_KEY');
|
||||
break;
|
||||
case ModelProvider.XAI:
|
||||
apiKey = this.twentyConfigService.get('XAI_API_KEY');
|
||||
break;
|
||||
case ModelProvider.OPENAI_COMPATIBLE:
|
||||
apiKey = this.twentyConfigService.get('OPENAI_COMPATIBLE_API_KEY');
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
if (!apiKey) {
|
||||
throw new Error(`${provider.toUpperCase()} 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/core-modules/ai/constants/ai-telemetry.const';
|
||||
import { AiModelRegistryService } from 'src/engine/core-modules/ai/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;
|
||||
};
|
||||
}) {
|
||||
return streamText({
|
||||
model: options.model,
|
||||
messages,
|
||||
temperature: options?.temperature,
|
||||
maxOutputTokens: options?.maxOutputTokens,
|
||||
experimental_telemetry: AI_TELEMETRY_CONFIG,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,244 +0,0 @@
|
||||
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { type ToolSet } from 'ai';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { type JsonRpc } from 'src/engine/core-modules/ai/dtos/json-rpc';
|
||||
import { ToolService } from 'src/engine/core-modules/ai/services/tool.service';
|
||||
import { wrapJsonRpcResponse } from 'src/engine/core-modules/ai/utils/wrap-jsonrpc-response.util';
|
||||
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 { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
|
||||
import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service';
|
||||
import { ADMIN_ROLE } from 'src/engine/workspace-manager/workspace-sync-metadata/standard-roles/roles/admin-role';
|
||||
|
||||
@Injectable()
|
||||
export class McpService {
|
||||
constructor(
|
||||
private readonly featureFlagService: FeatureFlagService,
|
||||
private readonly toolService: ToolService,
|
||||
private readonly userRoleService: UserRoleService,
|
||||
@InjectRepository(RoleEntity)
|
||||
private readonly roleRepository: Repository<RoleEntity>,
|
||||
) {}
|
||||
|
||||
async checkAiEnabled(workspaceId: string): Promise<void> {
|
||||
const isAiEnabled = await this.featureFlagService.isFeatureEnabled(
|
||||
FeatureFlagKey.IS_AI_ENABLED,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
if (!isAiEnabled) {
|
||||
throw new HttpException(
|
||||
'AI feature is not enabled for this workspace',
|
||||
HttpStatus.FORBIDDEN,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
handleInitialize(requestId: string | number) {
|
||||
return wrapJsonRpcResponse(requestId, {
|
||||
result: {
|
||||
capabilities: {
|
||||
tools: { listChanged: false },
|
||||
resources: { listChanged: false },
|
||||
prompts: { listChanged: false },
|
||||
},
|
||||
tools: [],
|
||||
resources: [],
|
||||
prompts: [],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getRoleId(
|
||||
workspaceId: string,
|
||||
userWorkspaceId?: string,
|
||||
apiKey?: string,
|
||||
) {
|
||||
if (apiKey) {
|
||||
const roles = await this.roleRepository.find({
|
||||
where: {
|
||||
workspaceId,
|
||||
standardId: ADMIN_ROLE.standardId,
|
||||
},
|
||||
});
|
||||
|
||||
if (roles.length === 0) {
|
||||
throw new HttpException('Admin role not found', HttpStatus.FORBIDDEN);
|
||||
}
|
||||
|
||||
return roles[0].id;
|
||||
}
|
||||
|
||||
if (!userWorkspaceId) {
|
||||
throw new HttpException(
|
||||
'User workspace ID missing',
|
||||
HttpStatus.FORBIDDEN,
|
||||
);
|
||||
}
|
||||
|
||||
const roleId = await this.userRoleService.getRoleIdForUserWorkspace({
|
||||
workspaceId,
|
||||
userWorkspaceId,
|
||||
});
|
||||
|
||||
if (!roleId) {
|
||||
throw new HttpException('Role ID missing', HttpStatus.FORBIDDEN);
|
||||
}
|
||||
|
||||
return roleId;
|
||||
}
|
||||
|
||||
async handleMCPCoreQuery(
|
||||
{ id, method, params }: JsonRpc,
|
||||
{
|
||||
workspace,
|
||||
userWorkspaceId,
|
||||
apiKey,
|
||||
}: {
|
||||
workspace: WorkspaceEntity;
|
||||
userWorkspaceId?: string;
|
||||
apiKey?: string;
|
||||
},
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
await this.checkAiEnabled(workspace.id);
|
||||
|
||||
if (method === 'initialize') {
|
||||
return this.handleInitialize(id);
|
||||
}
|
||||
|
||||
if (method === 'ping') {
|
||||
return wrapJsonRpcResponse(
|
||||
id,
|
||||
{
|
||||
result: {},
|
||||
},
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
const roleId = await this.getRoleId(
|
||||
workspace.id,
|
||||
userWorkspaceId,
|
||||
apiKey,
|
||||
);
|
||||
|
||||
const toolSet = await this.toolService.listTools(
|
||||
{ unionOf: [roleId] },
|
||||
workspace.id,
|
||||
);
|
||||
|
||||
if (method === 'tools/call' && params) {
|
||||
return await this.handleToolCall(id, toolSet, params);
|
||||
}
|
||||
|
||||
if (method === 'tools/list') {
|
||||
return await this.handleToolsListing(id, toolSet);
|
||||
}
|
||||
|
||||
if (method === 'prompts/list') {
|
||||
return wrapJsonRpcResponse(id, {
|
||||
result: {
|
||||
capabilities: {
|
||||
prompts: { listChanged: false },
|
||||
},
|
||||
prompts: [],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (method === 'resources/list') {
|
||||
return wrapJsonRpcResponse(id, {
|
||||
result: {
|
||||
capabilities: {
|
||||
resources: { listChanged: false },
|
||||
},
|
||||
resources: [],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return wrapJsonRpcResponse(id, {
|
||||
result: {},
|
||||
});
|
||||
} catch (error) {
|
||||
return wrapJsonRpcResponse(id, {
|
||||
error: {
|
||||
code: error.status || HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
message: error.message || 'Failed to execute tool',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async handleToolCall(
|
||||
id: string | number,
|
||||
toolSet: ToolSet,
|
||||
params: Record<string, unknown>,
|
||||
) {
|
||||
const toolName = params.name as keyof typeof toolSet;
|
||||
const tool = toolSet[toolName];
|
||||
|
||||
if (isDefined(tool) && isDefined(tool.execute)) {
|
||||
return wrapJsonRpcResponse(id, {
|
||||
result: {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(
|
||||
await tool.execute(params.arguments, {
|
||||
toolCallId: '1',
|
||||
messages: [],
|
||||
}),
|
||||
),
|
||||
},
|
||||
],
|
||||
isError: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
throw new HttpException(
|
||||
`Tool '${params.name}' not found`,
|
||||
HttpStatus.NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
private handleToolsListing(id: string | number, toolSet: ToolSet) {
|
||||
const toolsArray = Object.entries(toolSet)
|
||||
.filter(([, def]) => !!def.inputSchema)
|
||||
.map(([name, def]) => {
|
||||
// Unwrap the AI SDK's jsonSchema wrapper if present
|
||||
// The AI SDK serializes schemas as { jsonSchema: {...} } but MCP expects {...} directly
|
||||
const inputSchema = def.inputSchema;
|
||||
const unwrappedSchema =
|
||||
inputSchema &&
|
||||
typeof inputSchema === 'object' &&
|
||||
'jsonSchema' in inputSchema
|
||||
? inputSchema.jsonSchema
|
||||
: inputSchema;
|
||||
|
||||
return {
|
||||
name,
|
||||
description: def.description,
|
||||
inputSchema: unwrappedSchema,
|
||||
};
|
||||
});
|
||||
|
||||
return wrapJsonRpcResponse(id, {
|
||||
result: {
|
||||
capabilities: {
|
||||
tools: { listChanged: false },
|
||||
},
|
||||
tools: toolsArray,
|
||||
resources: [],
|
||||
prompts: [],
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { type ToolSet } from 'ai';
|
||||
|
||||
import { ToolRegistryService } from 'src/engine/core-modules/tool/services/tool-registry.service';
|
||||
import { type ToolInput } from 'src/engine/core-modules/tool/types/tool-input.type';
|
||||
import { type Tool } from 'src/engine/core-modules/tool/types/tool.type';
|
||||
import { type PermissionFlagType } from 'src/engine/metadata-modules/permissions/constants/permission-flag-type.constants';
|
||||
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
|
||||
import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config';
|
||||
|
||||
@Injectable()
|
||||
export class ToolAdapterService {
|
||||
constructor(
|
||||
private readonly toolRegistry: ToolRegistryService,
|
||||
private readonly permissionsService: PermissionsService,
|
||||
) {}
|
||||
|
||||
async getTools(
|
||||
rolePermissionConfig?: RolePermissionConfig,
|
||||
workspaceId?: string,
|
||||
): Promise<ToolSet> {
|
||||
const tools: ToolSet = {};
|
||||
|
||||
for (const toolType of this.toolRegistry.getAllToolTypes()) {
|
||||
const tool = this.toolRegistry.getTool(toolType);
|
||||
|
||||
if (!tool.flag) {
|
||||
tools[toolType.toLowerCase()] = this.createToolSet(tool);
|
||||
} else if (rolePermissionConfig && workspaceId) {
|
||||
const hasPermission = await this.permissionsService.hasToolPermission(
|
||||
rolePermissionConfig,
|
||||
workspaceId,
|
||||
tool.flag as PermissionFlagType,
|
||||
);
|
||||
|
||||
if (hasPermission) {
|
||||
tools[toolType.toLowerCase()] = this.createToolSet(tool);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tools;
|
||||
}
|
||||
|
||||
private createToolSet(tool: Tool) {
|
||||
return {
|
||||
description: tool.description,
|
||||
inputSchema: tool.inputSchema,
|
||||
execute: async (parameters: { input: ToolInput }) =>
|
||||
tool.execute(parameters.input),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,324 +0,0 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { type ToolSet } from 'ai';
|
||||
import { type ActorMetadata } from 'twenty-shared/types';
|
||||
|
||||
import { CreateRecordService } from 'src/engine/core-modules/record-crud/services/create-record.service';
|
||||
import { DeleteRecordService } from 'src/engine/core-modules/record-crud/services/delete-record.service';
|
||||
import { FindRecordsService } from 'src/engine/core-modules/record-crud/services/find-records.service';
|
||||
import { UpdateRecordService } from 'src/engine/core-modules/record-crud/services/update-record.service';
|
||||
import { generateCreateRecordInputSchema } from 'src/engine/core-modules/record-crud/utils/generate-create-record-input-schema.util';
|
||||
import { generateUpdateRecordInputSchema } from 'src/engine/core-modules/record-crud/utils/generate-update-record-input-schema.util';
|
||||
import { BulkDeleteToolInputSchema } from 'src/engine/core-modules/record-crud/zod-schemas/bulk-delete-tool.zod-schema';
|
||||
import { generateFindToolInputSchema } from 'src/engine/core-modules/record-crud/zod-schemas/find-tool.zod-schema';
|
||||
import { SoftDeleteToolInputSchema } from 'src/engine/core-modules/record-crud/zod-schemas/soft-delete-tool.zod-schema';
|
||||
import { FindOneToolInputSchema } from 'src/engine/core-modules/record-crud/zod-schemas/find-one-tool.zod-schema';
|
||||
import { isWorkflowRelatedObject } from 'src/engine/metadata-modules/agent/utils/is-workflow-related-object.util';
|
||||
import {
|
||||
type ToolHints,
|
||||
type ToolOperation,
|
||||
} from 'src/engine/metadata-modules/ai-router/types/tool-hints.interface';
|
||||
import { ObjectMetadataServiceV2 } from 'src/engine/metadata-modules/object-metadata/object-metadata-v2.service';
|
||||
import { WorkspacePermissionsCacheService } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.service';
|
||||
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
|
||||
import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config';
|
||||
import { computePermissionIntersection } from 'src/engine/twenty-orm/utils/compute-permission-intersection.util';
|
||||
|
||||
@Injectable()
|
||||
export class ToolService {
|
||||
private readonly logger = new Logger(ToolService.name);
|
||||
|
||||
constructor(
|
||||
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
|
||||
private readonly objectMetadataService: ObjectMetadataServiceV2,
|
||||
protected readonly workspacePermissionsCacheService: WorkspacePermissionsCacheService,
|
||||
private readonly createRecordService: CreateRecordService,
|
||||
private readonly updateRecordService: UpdateRecordService,
|
||||
private readonly deleteRecordService: DeleteRecordService,
|
||||
private readonly findRecordsService: FindRecordsService,
|
||||
) {}
|
||||
|
||||
// Generates AI tools for database operations based on workspace objects and permissions
|
||||
// Supports filtering by object names and operation types via toolHints
|
||||
// Returns a map of tool names to tool definitions
|
||||
async listTools(
|
||||
rolePermissionConfig: RolePermissionConfig,
|
||||
workspaceId: string,
|
||||
actorContext?: ActorMetadata,
|
||||
toolHints?: ToolHints,
|
||||
): Promise<ToolSet> {
|
||||
const tools: ToolSet = {};
|
||||
|
||||
const { data: rolesPermissions } =
|
||||
await this.workspacePermissionsCacheService.getRolesPermissionsFromCache({
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
let objectPermissions;
|
||||
|
||||
if ('unionOf' in rolePermissionConfig) {
|
||||
if (rolePermissionConfig.unionOf.length === 1) {
|
||||
objectPermissions = rolesPermissions[rolePermissionConfig.unionOf[0]];
|
||||
} else {
|
||||
// TODO: Implement union logic for multiple roles
|
||||
throw new Error(
|
||||
'Union permission logic for multiple roles not yet implemented',
|
||||
);
|
||||
}
|
||||
} else if ('intersectionOf' in rolePermissionConfig) {
|
||||
const allRolePermissions = rolePermissionConfig.intersectionOf.map(
|
||||
(roleId: string) => rolesPermissions[roleId],
|
||||
);
|
||||
|
||||
objectPermissions =
|
||||
allRolePermissions.length === 1
|
||||
? allRolePermissions[0]
|
||||
: computePermissionIntersection(allRolePermissions);
|
||||
} else {
|
||||
return tools;
|
||||
}
|
||||
|
||||
const allObjectMetadata =
|
||||
await this.objectMetadataService.findManyWithinWorkspace(workspaceId, {
|
||||
where: {
|
||||
isActive: true,
|
||||
isSystem: false,
|
||||
},
|
||||
relations: ['fields'],
|
||||
});
|
||||
|
||||
let filteredObjectMetadata = allObjectMetadata.filter(
|
||||
(objectMetadata) => !isWorkflowRelatedObject(objectMetadata),
|
||||
);
|
||||
|
||||
if (toolHints?.relevantObjects && toolHints.relevantObjects.length > 0) {
|
||||
const relevantSet = new Set(toolHints.relevantObjects);
|
||||
const originalCount = filteredObjectMetadata.length;
|
||||
|
||||
filteredObjectMetadata = filteredObjectMetadata.filter(
|
||||
(obj) =>
|
||||
relevantSet.has(obj.nameSingular) || relevantSet.has(obj.namePlural),
|
||||
);
|
||||
|
||||
this.logger.log(
|
||||
`Tool filtering: reduced from ${originalCount} to ${filteredObjectMetadata.length} objects based on hints: ${toolHints.relevantObjects.join(', ')}`,
|
||||
);
|
||||
|
||||
if (filteredObjectMetadata.length === 0) {
|
||||
this.logger.warn(
|
||||
`Tool filtering resulted in 0 objects. Hints may be incorrect: ${toolHints.relevantObjects.join(', ')}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const operationsSet = toolHints?.operations
|
||||
? new Set(toolHints.operations)
|
||||
: null;
|
||||
|
||||
const shouldIncludeOperation = (operation: ToolOperation) =>
|
||||
!operationsSet || operationsSet.has(operation);
|
||||
|
||||
const shouldIncludeFind = shouldIncludeOperation('find');
|
||||
const shouldIncludeCreate = shouldIncludeOperation('create');
|
||||
const shouldIncludeUpdate = shouldIncludeOperation('update');
|
||||
const shouldIncludeDelete = shouldIncludeOperation('delete');
|
||||
|
||||
filteredObjectMetadata.forEach((objectMetadata) => {
|
||||
const objectPermission = objectPermissions[objectMetadata.id];
|
||||
|
||||
if (!objectPermission) {
|
||||
return;
|
||||
}
|
||||
|
||||
const restrictedFields = objectPermission.restrictedFields;
|
||||
|
||||
if (shouldIncludeFind && objectPermission.canReadObjectRecords) {
|
||||
tools[`find_${objectMetadata.nameSingular}`] = {
|
||||
description: `Search for ${objectMetadata.labelSingular} records using flexible filtering criteria. Supports exact matches, pattern matching, ranges, and null checks. Use limit/offset for pagination and orderBy for sorting. To find by ID, use filter: { id: { eq: "record-id" } }. Returns an array of matching records with their full data.`,
|
||||
inputSchema: generateFindToolInputSchema(
|
||||
objectMetadata,
|
||||
restrictedFields,
|
||||
),
|
||||
execute: async (parameters) => {
|
||||
const { limit, offset, orderBy, ...filter } = parameters.input;
|
||||
|
||||
return this.findRecordsService.execute({
|
||||
objectName: objectMetadata.nameSingular,
|
||||
filter,
|
||||
orderBy,
|
||||
limit,
|
||||
offset,
|
||||
workspaceId,
|
||||
rolePermissionConfig,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
tools[`find_one_${objectMetadata.nameSingular}`] = {
|
||||
description: `Retrieve a single ${objectMetadata.labelSingular} record by its unique ID. Use this when you know the exact record ID and need the complete record data. Returns the full record or an error if not found.`,
|
||||
inputSchema: FindOneToolInputSchema,
|
||||
execute: async (parameters) => {
|
||||
return this.findRecordsService.execute({
|
||||
objectName: objectMetadata.nameSingular,
|
||||
filter: { id: { eq: parameters.input.id } },
|
||||
limit: 1,
|
||||
workspaceId,
|
||||
rolePermissionConfig,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (objectPermission.canUpdateObjectRecords) {
|
||||
if (shouldIncludeCreate) {
|
||||
tools[`create_${objectMetadata.nameSingular}`] = {
|
||||
description: `Create a new ${objectMetadata.labelSingular} record. Provide all required fields and any optional fields you want to set. The system will automatically handle timestamps and IDs. Returns the created record with all its data.`,
|
||||
inputSchema: generateCreateRecordInputSchema(
|
||||
objectMetadata,
|
||||
restrictedFields,
|
||||
),
|
||||
execute: async (parameters) => {
|
||||
return this.createRecordService.execute({
|
||||
objectName: objectMetadata.nameSingular,
|
||||
objectRecord: parameters.input,
|
||||
workspaceId,
|
||||
rolePermissionConfig,
|
||||
createdBy: actorContext,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (shouldIncludeUpdate) {
|
||||
tools[`update_${objectMetadata.nameSingular}`] = {
|
||||
description: `Update an existing ${objectMetadata.labelSingular} record. Provide the record ID and only the fields you want to change. Unspecified fields will remain unchanged. Returns the updated record with all current data.`,
|
||||
inputSchema: generateUpdateRecordInputSchema(
|
||||
objectMetadata,
|
||||
restrictedFields,
|
||||
),
|
||||
execute: async (parameters) => {
|
||||
const { id, ...allFields } = parameters.input;
|
||||
|
||||
const objectRecord = Object.fromEntries(
|
||||
Object.entries(allFields).filter(
|
||||
([, value]) => value !== undefined,
|
||||
),
|
||||
);
|
||||
|
||||
return this.updateRecordService.execute({
|
||||
objectName: objectMetadata.nameSingular,
|
||||
objectRecordId: id,
|
||||
objectRecord,
|
||||
workspaceId,
|
||||
rolePermissionConfig,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldIncludeDelete && objectPermission.canSoftDeleteObjectRecords) {
|
||||
tools[`soft_delete_${objectMetadata.nameSingular}`] = {
|
||||
description: `Soft delete a ${objectMetadata.labelSingular} record by marking it as deleted. The record remains in the database but is hidden from normal queries. This is reversible and preserves all data. Use this for temporary removal.`,
|
||||
inputSchema: SoftDeleteToolInputSchema,
|
||||
execute: async (parameters) => {
|
||||
return this.deleteRecordService.execute({
|
||||
objectName: objectMetadata.nameSingular,
|
||||
objectRecordId: parameters.input.id,
|
||||
workspaceId,
|
||||
rolePermissionConfig,
|
||||
soft: true,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
tools[`soft_delete_many_${objectMetadata.nameSingular}`] = {
|
||||
description: `Soft delete multiple ${objectMetadata.labelSingular} records at once by providing an array of record IDs. All records are marked as deleted but remain in the database. This is efficient for bulk operations and preserves all data.`,
|
||||
inputSchema: BulkDeleteToolInputSchema,
|
||||
execute: async (parameters) => {
|
||||
return this.softDeleteManyRecords(
|
||||
objectMetadata.nameSingular,
|
||||
parameters.input,
|
||||
workspaceId,
|
||||
rolePermissionConfig,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
if (operationsSet) {
|
||||
this.logger.log(
|
||||
`Tool filtering: included operations [${Array.from(operationsSet).join(', ')}]`,
|
||||
);
|
||||
}
|
||||
|
||||
return tools;
|
||||
}
|
||||
|
||||
private async softDeleteManyRecords(
|
||||
objectName: string,
|
||||
parameters: Record<string, unknown>,
|
||||
workspaceId: string,
|
||||
rolePermissionConfig: RolePermissionConfig,
|
||||
) {
|
||||
try {
|
||||
const repository =
|
||||
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
|
||||
workspaceId,
|
||||
objectName,
|
||||
rolePermissionConfig,
|
||||
);
|
||||
|
||||
const { filter } = parameters;
|
||||
|
||||
if (!filter || typeof filter !== 'object' || !('id' in filter)) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Failed to soft delete many ${objectName}: Filter with record IDs is required`,
|
||||
error: 'Filter with record IDs is required for bulk soft delete',
|
||||
};
|
||||
}
|
||||
|
||||
const idFilter = filter.id as Record<string, unknown>;
|
||||
const recordIds = idFilter.in;
|
||||
|
||||
if (!Array.isArray(recordIds) || recordIds.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Failed to soft delete many ${objectName}: At least one record ID is required`,
|
||||
error: 'At least one record ID is required for bulk soft delete',
|
||||
};
|
||||
}
|
||||
|
||||
const existingRecords = await repository.find({
|
||||
where: { id: { in: recordIds } },
|
||||
});
|
||||
|
||||
if (existingRecords.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Failed to soft delete many ${objectName}: No records found with the provided IDs`,
|
||||
error: 'No records found to soft delete',
|
||||
};
|
||||
}
|
||||
|
||||
await repository.softDelete({ id: { in: recordIds } });
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Successfully soft deleted ${existingRecords.length} ${objectName} records`,
|
||||
result: {
|
||||
count: existingRecords.length,
|
||||
deletedIds: recordIds,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Failed to soft delete many ${objectName}`,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
-120
@@ -1,120 +0,0 @@
|
||||
import {
|
||||
ILike,
|
||||
In,
|
||||
IsNull,
|
||||
LessThan,
|
||||
LessThanOrEqual,
|
||||
Like,
|
||||
MoreThan,
|
||||
MoreThanOrEqual,
|
||||
Not,
|
||||
} from 'typeorm';
|
||||
|
||||
import {
|
||||
buildWhereConditions,
|
||||
parseFilterCondition,
|
||||
} from 'src/engine/core-modules/ai/utils/find-records-filters.utils';
|
||||
|
||||
describe('find-records-filters.utils', () => {
|
||||
describe('parseFilterCondition', () => {
|
||||
it('should handle eq', () => {
|
||||
expect(parseFilterCondition({ eq: 10 })).toBe(10);
|
||||
});
|
||||
|
||||
it('should handle neq', () => {
|
||||
expect(parseFilterCondition({ neq: 5 })).toEqual(Not(5));
|
||||
});
|
||||
|
||||
it('should handle gt/gte/lt/lte', () => {
|
||||
expect(parseFilterCondition({ gt: 1 })).toEqual(MoreThan(1));
|
||||
expect(parseFilterCondition({ gte: 2 })).toEqual(MoreThanOrEqual(2));
|
||||
expect(parseFilterCondition({ lt: 3 })).toEqual(LessThan(3));
|
||||
expect(parseFilterCondition({ lte: 4 })).toEqual(LessThanOrEqual(4));
|
||||
});
|
||||
|
||||
it('should handle in', () => {
|
||||
expect(parseFilterCondition({ in: ['a', 'b'] })).toEqual(In(['a', 'b']));
|
||||
});
|
||||
|
||||
it('should handle like/ilike', () => {
|
||||
expect(parseFilterCondition({ like: '%foo%' })).toEqual(Like('%foo%'));
|
||||
expect(parseFilterCondition({ ilike: '%bar%' })).toEqual(ILike('%bar%'));
|
||||
});
|
||||
|
||||
it('should handle startsWith', () => {
|
||||
expect(parseFilterCondition({ startsWith: 'pre' })).toEqual(Like('pre%'));
|
||||
});
|
||||
|
||||
it('should handle is NULL and NOT_NULL', () => {
|
||||
expect(parseFilterCondition({ is: 'NULL' })).toEqual(IsNull());
|
||||
expect(parseFilterCondition({ is: 'NOT_NULL' })).toEqual(Not(IsNull()));
|
||||
});
|
||||
|
||||
it('should handle isEmptyArray', () => {
|
||||
expect(parseFilterCondition({ isEmptyArray: true })).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle containsIlike (uses Like with wildcards)', () => {
|
||||
const result = parseFilterCondition({ containsIlike: 'mid' });
|
||||
|
||||
expect(result).toEqual(Like('%mid%'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildWhereConditions', () => {
|
||||
it('should build where conditions from mixed criteria', () => {
|
||||
const where = buildWhereConditions({
|
||||
// primitive values
|
||||
id: '123',
|
||||
active: true,
|
||||
count: 7,
|
||||
// skip falsy-empty values
|
||||
skipUndefined: undefined,
|
||||
skipNull: null,
|
||||
skipEmptyString: '',
|
||||
// operator objects
|
||||
name: { ilike: '%alpha%' },
|
||||
createdAt: { gte: '2024-01-01' },
|
||||
score: { lte: 99 },
|
||||
tags: { in: ['a', 'b'] },
|
||||
prefix: { startsWith: 'PRE' },
|
||||
nothing: { is: 'NULL' },
|
||||
notNothing: { is: 'NOT_NULL' },
|
||||
contains: { containsIlike: 'mid' },
|
||||
// nested relation-like object
|
||||
account: {
|
||||
name: { startsWith: 'ACME' },
|
||||
size: { gte: 100 },
|
||||
country: '', // should be omitted
|
||||
},
|
||||
// arrays should pass through when not an operator object
|
||||
list: [1, 2, 3],
|
||||
});
|
||||
|
||||
expect(where.id).toBe('123');
|
||||
expect(where.active).toBe(true);
|
||||
expect(where.count).toBe(7);
|
||||
|
||||
expect(where.name).toEqual(ILike('%alpha%'));
|
||||
expect(where.createdAt).toEqual(MoreThanOrEqual('2024-01-01'));
|
||||
expect(where.score).toEqual(LessThanOrEqual(99));
|
||||
expect(where.tags).toEqual(In(['a', 'b']));
|
||||
expect(where.prefix).toEqual(Like('PRE%'));
|
||||
expect(where.nothing).toEqual(IsNull());
|
||||
expect(where.notNothing).toEqual(Not(IsNull()));
|
||||
expect(where.contains).toEqual(Like('%mid%'));
|
||||
|
||||
expect(where.account).toEqual({
|
||||
name: Like('ACME%'),
|
||||
size: MoreThanOrEqual(100),
|
||||
});
|
||||
|
||||
expect(where.list).toEqual([1, 2, 3]);
|
||||
|
||||
// Ensure skipped values are not present
|
||||
expect('skipUndefined' in where).toBe(false);
|
||||
expect('skipNull' in where).toBe(false);
|
||||
expect('skipEmptyString' in where).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
-8
@@ -1,8 +0,0 @@
|
||||
import { DOLLAR_TO_CREDIT_MULTIPLIER } from 'src/engine/core-modules/ai/constants/dollar-to-credit-multiplier';
|
||||
|
||||
// Converts cost in cents to cost in credits
|
||||
// Formula: credits = (cents / 100) * DOLLAR_TO_CREDIT_MULTIPLIER
|
||||
// Where DOLLAR_TO_CREDIT_MULTIPLIER = 1000000 (so $0.00001 = 1 credit)
|
||||
// Example: 1 cent = (1 / 100) * 1000000 = 10000 credits
|
||||
export const convertCentsToBillingCredits = (cents: number): number =>
|
||||
(cents / 100) * DOLLAR_TO_CREDIT_MULTIPLIER;
|
||||
-112
@@ -1,112 +0,0 @@
|
||||
import {
|
||||
ILike,
|
||||
In,
|
||||
IsNull,
|
||||
LessThan,
|
||||
LessThanOrEqual,
|
||||
Like,
|
||||
MoreThan,
|
||||
MoreThanOrEqual,
|
||||
Not,
|
||||
} from 'typeorm';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
type FilterObject = Record<string, unknown>;
|
||||
|
||||
type WhereRecord = Record<string, unknown>;
|
||||
|
||||
const isSkippableValue = (value: unknown): boolean =>
|
||||
!isDefined(value) || value === '';
|
||||
|
||||
const isPlainObject = (value: unknown): value is FilterObject =>
|
||||
typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
|
||||
export const parseFilterCondition = (filterValue: FilterObject): unknown => {
|
||||
if ('eq' in filterValue) {
|
||||
return filterValue.eq;
|
||||
}
|
||||
if ('neq' in filterValue) {
|
||||
return Not(filterValue.neq as unknown);
|
||||
}
|
||||
if ('gt' in filterValue) {
|
||||
return MoreThan(filterValue.gt as unknown as number | string | Date);
|
||||
}
|
||||
if ('gte' in filterValue) {
|
||||
return MoreThanOrEqual(
|
||||
filterValue.gte as unknown as number | string | Date,
|
||||
);
|
||||
}
|
||||
if ('lt' in filterValue) {
|
||||
return LessThan(filterValue.lt as unknown as number | string | Date);
|
||||
}
|
||||
if ('lte' in filterValue) {
|
||||
return LessThanOrEqual(
|
||||
filterValue.lte as unknown as number | string | Date,
|
||||
);
|
||||
}
|
||||
if ('in' in filterValue) {
|
||||
const values = (filterValue as { in: unknown }).in;
|
||||
|
||||
return Array.isArray(values) ? In(values as unknown[]) : null;
|
||||
}
|
||||
if ('like' in filterValue) {
|
||||
return Like(filterValue.like as string);
|
||||
}
|
||||
if ('ilike' in filterValue) {
|
||||
return ILike(filterValue.ilike as string);
|
||||
}
|
||||
if ('startsWith' in filterValue) {
|
||||
return Like(`${String(filterValue.startsWith)}%`);
|
||||
}
|
||||
if ('is' in filterValue) {
|
||||
const v = (filterValue as { is: unknown }).is;
|
||||
|
||||
if (v === 'NULL') return IsNull();
|
||||
if (v === 'NOT_NULL') return Not(IsNull());
|
||||
}
|
||||
if ('isEmptyArray' in filterValue) {
|
||||
return [];
|
||||
}
|
||||
if ('containsIlike' in filterValue) {
|
||||
return Like(`%${String(filterValue.containsIlike)}%`);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const buildWhereConditions = (
|
||||
searchCriteria: FilterObject,
|
||||
): WhereRecord => {
|
||||
return Object.entries(searchCriteria).reduce<WhereRecord>(
|
||||
(acc, [key, value]) => {
|
||||
if (isSkippableValue(value)) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
if (isPlainObject(value)) {
|
||||
// Direct operator-based condition (eq, ilike, etc.)
|
||||
const filterCondition = parseFilterCondition(value as FilterObject);
|
||||
|
||||
if (isDefined(filterCondition)) {
|
||||
acc[key] = filterCondition;
|
||||
|
||||
return acc;
|
||||
}
|
||||
|
||||
// Otherwise, try to build nested conditions
|
||||
const nestedConditions = buildWhereConditions(value);
|
||||
|
||||
if (Object.keys(nestedConditions).length > 0) {
|
||||
acc[key] = nestedConditions;
|
||||
}
|
||||
|
||||
return acc;
|
||||
}
|
||||
|
||||
acc[key] = value as unknown;
|
||||
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
);
|
||||
};
|
||||
@@ -1,28 +0,0 @@
|
||||
import { MCP_SERVER_METADATA } from 'src/engine/core-modules/ai/constants/mcp.const';
|
||||
|
||||
export const wrapJsonRpcResponse = (
|
||||
id: string | number,
|
||||
payload:
|
||||
| Record<'result', Record<string, unknown>>
|
||||
| Record<'error', Record<string, unknown>>,
|
||||
omitMetadata = false,
|
||||
) => {
|
||||
const body =
|
||||
'result' in payload
|
||||
? {
|
||||
result: omitMetadata
|
||||
? payload.result
|
||||
: { ...payload.result, ...MCP_SERVER_METADATA },
|
||||
}
|
||||
: {
|
||||
error: omitMetadata
|
||||
? payload.error
|
||||
: { ...payload.error, ...MCP_SERVER_METADATA },
|
||||
};
|
||||
|
||||
return {
|
||||
id,
|
||||
jsonrpc: '2.0',
|
||||
...body,
|
||||
};
|
||||
};
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
|
||||
import { ApplicationVariableEntity } from 'src/engine/core-modules/applicationVariable/application-variable.entity';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { AgentEntity } from 'src/engine/metadata-modules/agent/agent.entity';
|
||||
import { AgentEntity } from 'src/engine/metadata-modules/ai-agent/entities/agent.entity';
|
||||
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
|
||||
import { ServerlessFunctionEntity } from 'src/engine/metadata-modules/serverless-function/serverless-function.entity';
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { ApplicationEntity } from 'src/engine/core-modules/application/applicati
|
||||
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
|
||||
import { WorkspaceFlatApplicationMapCacheService } from 'src/engine/core-modules/application/services/workspace-flat-application-map-cache.service';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { AgentEntity } from 'src/engine/metadata-modules/agent/agent.entity';
|
||||
import { AgentEntity } from 'src/engine/metadata-modules/ai-agent/entities/agent.entity';
|
||||
import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module';
|
||||
|
||||
@Module({
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
import { ApplicationVariableEntityDTO } from 'src/engine/core-modules/applicationVariable/dtos/application-variable.dto';
|
||||
import { AgentDTO } from 'src/engine/metadata-modules/agent/dtos/agent.dto';
|
||||
import { AgentDTO } from 'src/engine/metadata-modules/ai-agent/dtos/agent.dto';
|
||||
import { ObjectMetadataDTO } from 'src/engine/metadata-modules/object-metadata/dtos/object-metadata.dto';
|
||||
import { ServerlessFunctionDTO } from 'src/engine/metadata-modules/serverless-function/dtos/serverless-function.dto';
|
||||
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { AiModule } from 'src/engine/core-modules/ai/ai.module';
|
||||
import { AiBillingModule } from 'src/engine/metadata-modules/ai-billing/ai-billing.module';
|
||||
import { AiModelsModule } from 'src/engine/metadata-modules/ai-models/ai-models.module';
|
||||
import { AiToolsModule } from 'src/engine/metadata-modules/ai-tools/ai-tools.module';
|
||||
import { BillingResolver } from 'src/engine/core-modules/billing/billing.resolver';
|
||||
import { BillingSyncCustomerDataCommand } from 'src/engine/core-modules/billing/commands/billing-sync-customer-data.command';
|
||||
import { BillingSyncPlansDataCommand } from 'src/engine/core-modules/billing/commands/billing-sync-plans-data.command';
|
||||
@@ -42,7 +44,9 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi
|
||||
StripeModule,
|
||||
MessageQueueModule,
|
||||
PermissionsModule,
|
||||
AiModule,
|
||||
AiBillingModule,
|
||||
AiModelsModule,
|
||||
AiToolsModule,
|
||||
WorkspaceDomainsModule,
|
||||
TypeOrmModule.forFeature([
|
||||
BillingSubscriptionEntity,
|
||||
|
||||
+1
@@ -71,6 +71,7 @@ export class BillingUsageService {
|
||||
eventName: billingEvents[0].eventName,
|
||||
value: billingEvents[0].value,
|
||||
stripeCustomerId: workspaceStripeCustomer.stripeCustomerId,
|
||||
dimensions: billingEvents[0].dimensions,
|
||||
});
|
||||
} catch (error) {
|
||||
throw new BillingException(
|
||||
|
||||
+21
-4
@@ -5,6 +5,7 @@ import { Injectable, Logger } from '@nestjs/common';
|
||||
import type Stripe from 'stripe';
|
||||
|
||||
import { type BillingMeterEventName } from 'src/engine/core-modules/billing/enums/billing-meter-event-names';
|
||||
import { type BillingDimensions } from 'src/engine/core-modules/billing/types/billing-dimensions.type';
|
||||
import { StripeSDKService } from 'src/engine/core-modules/billing/stripe/stripe-sdk/services/stripe-sdk.service';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
|
||||
@@ -29,17 +30,33 @@ export class StripeBillingMeterEventService {
|
||||
eventName,
|
||||
value,
|
||||
stripeCustomerId,
|
||||
dimensions,
|
||||
}: {
|
||||
eventName: BillingMeterEventName;
|
||||
value: number;
|
||||
stripeCustomerId: string;
|
||||
dimensions?: BillingDimensions;
|
||||
}) {
|
||||
const payload: Record<string, string> = {
|
||||
value: value.toString(),
|
||||
stripe_customer_id: stripeCustomerId,
|
||||
};
|
||||
|
||||
if (dimensions) {
|
||||
payload.execution_type = dimensions.execution_type;
|
||||
|
||||
if (dimensions.resource_id !== undefined) {
|
||||
payload.resource_id = dimensions.resource_id || 'none';
|
||||
}
|
||||
|
||||
if (dimensions.execution_context_1 !== undefined) {
|
||||
payload.execution_context_1 = dimensions.execution_context_1 || 'none';
|
||||
}
|
||||
}
|
||||
|
||||
await this.stripe.billing.meterEvents.create({
|
||||
event_name: eventName,
|
||||
payload: {
|
||||
value: value.toString(),
|
||||
stripe_customer_id: stripeCustomerId,
|
||||
},
|
||||
payload,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
export type BillingExecutionType =
|
||||
| 'workflow_execution'
|
||||
| 'code_execution'
|
||||
| 'ai_token';
|
||||
|
||||
export type BillingDimensions = {
|
||||
execution_type: BillingExecutionType;
|
||||
resource_id?: string | null;
|
||||
execution_context_1?: string | null;
|
||||
};
|
||||
+2
@@ -3,8 +3,10 @@
|
||||
import { type NonNegative } from 'type-fest';
|
||||
|
||||
import { type BillingMeterEventName } from 'src/engine/core-modules/billing/enums/billing-meter-event-names';
|
||||
import { type BillingDimensions } from 'src/engine/core-modules/billing/types/billing-dimensions.type';
|
||||
|
||||
export type BillingUsageEvent = {
|
||||
eventName: BillingMeterEventName;
|
||||
value: NonNegative<number>;
|
||||
dimensions?: BillingDimensions;
|
||||
};
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ import { SupportDriver } from 'src/engine/core-modules/twenty-config/interfaces/
|
||||
import {
|
||||
type ModelId,
|
||||
ModelProvider,
|
||||
} from 'src/engine/core-modules/ai/constants/ai-models.const';
|
||||
} from 'src/engine/metadata-modules/ai-models/constants/ai-models.const';
|
||||
import { ClientConfigService } from 'src/engine/core-modules/client-config/services/client-config.service';
|
||||
|
||||
import { ClientConfigController } from './client-config.controller';
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ import { SupportDriver } from 'src/engine/core-modules/twenty-config/interfaces/
|
||||
import {
|
||||
ModelId,
|
||||
ModelProvider,
|
||||
} from 'src/engine/core-modules/ai/constants/ai-models.const';
|
||||
} from 'src/engine/metadata-modules/ai-models/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';
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ import { Test, type TestingModule } from '@nestjs/testing';
|
||||
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 { AiModelRegistryService } from 'src/engine/core-modules/ai/services/ai-model-registry.service';
|
||||
import { AiModelRegistryService } from 'src/engine/metadata-modules/ai-models/services/ai-model-registry.service';
|
||||
import { CaptchaDriverType } from 'src/engine/core-modules/captcha/interfaces';
|
||||
import { ClientConfigService } from 'src/engine/core-modules/client-config/services/client-config.service';
|
||||
import { DomainServerConfigService } from 'src/engine/core-modules/domain/domain-server-config/services/domain-server-config.service';
|
||||
|
||||
+44
-13
@@ -5,12 +5,6 @@ import { isNonEmptyString } from '@sniptt/guards';
|
||||
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 {
|
||||
AI_MODELS,
|
||||
ModelProvider,
|
||||
} from 'src/engine/core-modules/ai/constants/ai-models.const';
|
||||
import { AiModelRegistryService } from 'src/engine/core-modules/ai/services/ai-model-registry.service';
|
||||
import { convertCentsToBillingCredits } from 'src/engine/core-modules/ai/utils/convert-cents-to-billing-credits.util';
|
||||
import {
|
||||
type ClientAIModelConfig,
|
||||
type ClientConfig,
|
||||
@@ -18,6 +12,14 @@ import {
|
||||
import { DomainServerConfigService } from 'src/engine/core-modules/domain/domain-server-config/services/domain-server-config.service';
|
||||
import { PUBLIC_FEATURE_FLAGS } from 'src/engine/core-modules/feature-flag/constants/public-feature-flag.const';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { convertCentsToBillingCredits } from 'src/engine/metadata-modules/ai-billing/utils/convert-cents-to-billing-credits.util';
|
||||
import {
|
||||
AI_MODELS,
|
||||
DEFAULT_FAST_MODEL,
|
||||
DEFAULT_SMART_MODEL,
|
||||
ModelProvider,
|
||||
} from 'src/engine/metadata-modules/ai-models/constants/ai-models.const';
|
||||
import { AiModelRegistryService } from 'src/engine/metadata-modules/ai-models/services/ai-model-registry.service';
|
||||
|
||||
@Injectable()
|
||||
export class ClientConfigService {
|
||||
@@ -62,13 +64,42 @@ export class ClientConfigService {
|
||||
);
|
||||
|
||||
if (aiModels.length > 0) {
|
||||
aiModels.unshift({
|
||||
modelId: 'auto',
|
||||
label: 'Auto',
|
||||
provider: ModelProvider.NONE,
|
||||
inputCostPer1kTokensInCredits: 0,
|
||||
outputCostPer1kTokensInCredits: 0,
|
||||
});
|
||||
const defaultSpeedModel =
|
||||
this.aiModelRegistryService.getDefaultSpeedModel();
|
||||
const defaultSpeedModelConfig = AI_MODELS.find(
|
||||
(m) => m.modelId === defaultSpeedModel?.modelId,
|
||||
);
|
||||
const defaultSpeedModelLabel =
|
||||
defaultSpeedModelConfig?.label ||
|
||||
defaultSpeedModel?.modelId ||
|
||||
'Default';
|
||||
|
||||
const defaultPerformanceModel =
|
||||
this.aiModelRegistryService.getDefaultPerformanceModel();
|
||||
const defaultPerformanceModelConfig = AI_MODELS.find(
|
||||
(m) => m.modelId === defaultPerformanceModel?.modelId,
|
||||
);
|
||||
const defaultPerformanceModelLabel =
|
||||
defaultPerformanceModelConfig?.label ||
|
||||
defaultPerformanceModel?.modelId ||
|
||||
'Default';
|
||||
|
||||
aiModels.unshift(
|
||||
{
|
||||
modelId: DEFAULT_SMART_MODEL,
|
||||
label: `Smart (${defaultPerformanceModelLabel})`,
|
||||
provider: ModelProvider.NONE,
|
||||
inputCostPer1kTokensInCredits: 0,
|
||||
outputCostPer1kTokensInCredits: 0,
|
||||
},
|
||||
{
|
||||
modelId: DEFAULT_FAST_MODEL,
|
||||
label: `Fast (${defaultSpeedModelLabel})`,
|
||||
provider: ModelProvider.NONE,
|
||||
inputCostPer1kTokensInCredits: 0,
|
||||
outputCostPer1kTokensInCredits: 0,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const clientConfig: ClientConfig = {
|
||||
|
||||
@@ -5,7 +5,9 @@ import { EventEmitterModule } from '@nestjs/event-emitter';
|
||||
import { WorkspaceQueryRunnerModule } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-runner.module';
|
||||
import { ActorModule } from 'src/engine/core-modules/actor/actor.module';
|
||||
import { AdminPanelModule } from 'src/engine/core-modules/admin-panel/admin-panel.module';
|
||||
import { AiModule } from 'src/engine/core-modules/ai/ai.module';
|
||||
import { AiModelsModule } from 'src/engine/metadata-modules/ai-models/ai-models.module';
|
||||
import { AiToolsModule } from 'src/engine/metadata-modules/ai-tools/ai-tools.module';
|
||||
import { AiBillingModule } from 'src/engine/metadata-modules/ai-billing/ai-billing.module';
|
||||
import { ApiKeyModule } from 'src/engine/core-modules/api-key/api-key.module';
|
||||
import { AppTokenModule } from 'src/engine/core-modules/app-token/app-token.module';
|
||||
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
|
||||
@@ -127,7 +129,9 @@ import { FileModule } from './file/file.module';
|
||||
wildcard: true,
|
||||
}),
|
||||
CacheStorageModule,
|
||||
AiModule,
|
||||
AiModelsModule,
|
||||
AiToolsModule,
|
||||
AiBillingModule,
|
||||
ServerlessModule.forRootAsync({
|
||||
useFactory: serverlessModuleFactory,
|
||||
inject: [TwentyConfigService, FileStorageService],
|
||||
|
||||
+3
-1
@@ -19,5 +19,7 @@ export const getSelectedColumnsFromRestrictedFields = (
|
||||
},
|
||||
});
|
||||
|
||||
return Object.keys(selectableFields);
|
||||
return Object.keys(selectableFields).filter(
|
||||
(columnName) => selectableFields[columnName],
|
||||
);
|
||||
};
|
||||
|
||||
+13
-5
@@ -1,8 +1,8 @@
|
||||
import {
|
||||
FieldMetadataType,
|
||||
type RestrictedFieldsPermissions,
|
||||
type FieldMetadataSettings,
|
||||
FieldMetadataType,
|
||||
NumberDataType,
|
||||
type RestrictedFieldsPermissions,
|
||||
} from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { z } from 'zod';
|
||||
@@ -31,7 +31,7 @@ const isFieldAvailable = (field: FieldMetadataEntity, forResponse: boolean) => {
|
||||
const getFieldZodType = (field: FieldMetadataEntity): z.ZodTypeAny => {
|
||||
switch (field.type) {
|
||||
case FieldMetadataType.UUID:
|
||||
return z.string().uuid();
|
||||
return z.uuidv4();
|
||||
|
||||
case FieldMetadataType.TEXT:
|
||||
case FieldMetadataType.RICH_TEXT:
|
||||
@@ -41,7 +41,7 @@ const getFieldZodType = (field: FieldMetadataEntity): z.ZodTypeAny => {
|
||||
return z.string().datetime();
|
||||
|
||||
case FieldMetadataType.DATE:
|
||||
return z.string().date();
|
||||
return z.date();
|
||||
|
||||
case FieldMetadataType.NUMBER: {
|
||||
const settings =
|
||||
@@ -95,7 +95,11 @@ export const generateRecordPropertiesZodSchema = (
|
||||
isFieldMetadataEntityOfType(field, FieldMetadataType.RELATION) &&
|
||||
field.settings?.relationType === RelationType.MANY_TO_ONE
|
||||
) {
|
||||
shape[`${field.name}Id`] = z.string().uuid();
|
||||
const uuidSchema = z.uuidv4();
|
||||
|
||||
shape[`${field.name}Id`] = field.isNullable
|
||||
? uuidSchema.optional()
|
||||
: uuidSchema;
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -248,6 +252,10 @@ export const generateRecordPropertiesZodSchema = (
|
||||
fieldSchema = fieldSchema.describe(field.description);
|
||||
}
|
||||
|
||||
if (field.isNullable) {
|
||||
fieldSchema = fieldSchema.optional();
|
||||
}
|
||||
|
||||
shape[field.name] = fieldSchema;
|
||||
});
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
} from 'src/engine/core-modules/search/exceptions/search.exception';
|
||||
import { type RecordsWithObjectMetadataItem } from 'src/engine/core-modules/search/types/records-with-object-metadata-item';
|
||||
import { formatSearchTerms } from 'src/engine/core-modules/search/utils/format-search-terms';
|
||||
import { SEARCH_VECTOR_FIELD } from 'src/engine/metadata-modules/constants/search-vector-field.constants';
|
||||
import { SEARCH_VECTOR_FIELD } from 'src/engine/metadata-modules/search-field-metadata/constants/search-vector-field.constants';
|
||||
import { type ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
|
||||
import { generateObjectMetadataMaps } from 'src/engine/metadata-modules/utils/generate-object-metadata-maps.util';
|
||||
import { type WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository';
|
||||
|
||||
+6
-1
@@ -102,7 +102,12 @@ export class UpdateWorkspaceInput {
|
||||
@Field({ nullable: true })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
routerModel?: string;
|
||||
fastModel?: string;
|
||||
|
||||
@Field({ nullable: true })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
smartModel?: string;
|
||||
|
||||
@Field(() => [String], { nullable: true })
|
||||
@IsArray()
|
||||
|
||||
+2
-1
@@ -70,7 +70,8 @@ export class WorkspaceService extends TypeOrmQueryService<WorkspaceEntity> {
|
||||
editableProfileFields: PermissionFlagType.SECURITY,
|
||||
isTwoFactorAuthenticationEnforced: PermissionFlagType.SECURITY,
|
||||
defaultRoleId: PermissionFlagType.ROLES,
|
||||
routerModel: PermissionFlagType.WORKSPACE,
|
||||
fastModel: PermissionFlagType.WORKSPACE,
|
||||
smartModel: PermissionFlagType.WORKSPACE,
|
||||
};
|
||||
|
||||
constructor(
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
} from 'typeorm';
|
||||
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
import { ModelId } from 'src/engine/core-modules/ai/constants/ai-models.const';
|
||||
import { ApiKeyEntity } from 'src/engine/core-modules/api-key/api-key.entity';
|
||||
import { AppTokenEntity } from 'src/engine/core-modules/app-token/app-token.entity';
|
||||
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
|
||||
@@ -33,8 +32,12 @@ import { PublicDomainEntity } from 'src/engine/core-modules/public-domain/public
|
||||
import { WorkspaceSSOIdentityProviderEntity } from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity';
|
||||
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
import { WebhookEntity } from 'src/engine/core-modules/webhook/webhook.entity';
|
||||
import { AgentHandoffEntity } from 'src/engine/metadata-modules/agent/agent-handoff.entity';
|
||||
import { AgentEntity } from 'src/engine/metadata-modules/agent/agent.entity';
|
||||
import { AgentEntity } from 'src/engine/metadata-modules/ai-agent/entities/agent.entity';
|
||||
import {
|
||||
DEFAULT_FAST_MODEL,
|
||||
DEFAULT_SMART_MODEL,
|
||||
type ModelId,
|
||||
} from 'src/engine/metadata-modules/ai-models/constants/ai-models.const';
|
||||
import { RoleDTO } from 'src/engine/metadata-modules/role/dtos/role.dto';
|
||||
import { ViewFieldDTO } from 'src/engine/metadata-modules/view-field/dtos/view-field.dto';
|
||||
import { ViewFieldEntity } from 'src/engine/metadata-modules/view-field/entities/view-field.entity';
|
||||
@@ -173,11 +176,6 @@ export class WorkspaceEntity {
|
||||
})
|
||||
agents: Relation<AgentEntity[]>;
|
||||
|
||||
@OneToMany(() => AgentHandoffEntity, (handoff) => handoff.workspace, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
agentHandoffs: Relation<AgentHandoffEntity[]>;
|
||||
|
||||
@OneToMany(() => WebhookEntity, (webhook) => webhook.workspace)
|
||||
webhooks: Relation<WebhookEntity[]>;
|
||||
|
||||
@@ -284,12 +282,23 @@ export class WorkspaceEntity {
|
||||
version: string | null;
|
||||
|
||||
@Field(() => String, { nullable: false })
|
||||
@Column({ type: 'varchar', nullable: false, default: 'auto' })
|
||||
routerModel: ModelId;
|
||||
@Column({ type: 'varchar', nullable: false, default: DEFAULT_FAST_MODEL })
|
||||
fastModel: ModelId;
|
||||
|
||||
@Field(() => String, { nullable: false })
|
||||
@Column({ type: 'varchar', nullable: false, default: DEFAULT_SMART_MODEL })
|
||||
smartModel: ModelId;
|
||||
|
||||
@Column({ nullable: false, type: 'uuid' })
|
||||
workspaceCustomApplicationId: string;
|
||||
|
||||
// TODO: delete
|
||||
// This is deprecated
|
||||
// If we are in December 2025 you can remove this column from DB
|
||||
@Field(() => String, { nullable: false })
|
||||
@Column({ type: 'varchar', nullable: false, default: 'auto' })
|
||||
routerModel: ModelId;
|
||||
|
||||
@Field(() => ApplicationDTO, { nullable: true })
|
||||
@ManyToOne(() => ApplicationEntity, {
|
||||
onDelete: 'RESTRICT',
|
||||
|
||||
@@ -5,6 +5,7 @@ import { NestjsQueryGraphQLModule } from '@ptc-org/nestjs-query-graphql';
|
||||
import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm';
|
||||
|
||||
import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
|
||||
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
|
||||
import { AuditModule } from 'src/engine/core-modules/audit/audit.module';
|
||||
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
|
||||
import { BillingModule } from 'src/engine/core-modules/billing/billing.module';
|
||||
@@ -28,8 +29,7 @@ import { WorkspaceWorkspaceMemberListener } from 'src/engine/core-modules/worksp
|
||||
import { workspaceAutoResolverOpts } from 'src/engine/core-modules/workspace/workspace.auto-resolver-opts';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { WorkspaceResolver } from 'src/engine/core-modules/workspace/workspace.resolver';
|
||||
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
|
||||
import { AgentModule } from 'src/engine/metadata-modules/agent/agent.module';
|
||||
import { AiAgentModule } from 'src/engine/metadata-modules/ai-agent/ai-agent.module';
|
||||
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
|
||||
import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module';
|
||||
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
|
||||
@@ -64,7 +64,7 @@ import { WorkspaceManagerModule } from 'src/engine/workspace-manager/workspace-m
|
||||
PermissionsModule,
|
||||
WorkspaceCacheStorageModule,
|
||||
RoleModule,
|
||||
AgentModule,
|
||||
AiAgentModule,
|
||||
DnsManagerModule,
|
||||
WorkspaceDomainsModule,
|
||||
SubdomainManagerModule,
|
||||
|
||||
@@ -151,6 +151,13 @@ export class WorkspaceResolver {
|
||||
}
|
||||
}
|
||||
|
||||
@ResolveField(() => String, { nullable: true })
|
||||
async routerModel(
|
||||
@Parent() _workspace: WorkspaceEntity,
|
||||
): Promise<string | null> {
|
||||
return 'auto';
|
||||
}
|
||||
|
||||
@Mutation(() => SignedFileDTO)
|
||||
@UseGuards(
|
||||
WorkspaceAuthGuard,
|
||||
@@ -238,10 +245,17 @@ export class WorkspaceResolver {
|
||||
}
|
||||
|
||||
@ResolveField(() => String, { nullable: true })
|
||||
async routerModel(
|
||||
async fastModel(
|
||||
@Parent() workspace: WorkspaceEntity,
|
||||
): Promise<string | null> {
|
||||
return workspace.routerModel;
|
||||
return workspace.fastModel;
|
||||
}
|
||||
|
||||
@ResolveField(() => String, { nullable: true })
|
||||
async smartModel(
|
||||
@Parent() workspace: WorkspaceEntity,
|
||||
): Promise<string | null> {
|
||||
return workspace.smartModel;
|
||||
}
|
||||
|
||||
@ResolveField(() => ApplicationDTO, { nullable: true })
|
||||
|
||||
Reference in New Issue
Block a user