From dcc2f12d839fa99cb58b66e3a4d0b770ffafb4bf Mon Sep 17 00:00:00 2001 From: fzowl Date: Mon, 23 Mar 2026 18:38:30 +0100 Subject: [PATCH 1/8] feat: add VoyageAI embeddings/rerank integration and MongoDB Atlas connection string support - Add VoyageAI tools: embeddings (voyage-3, voyage-3-large, etc.) and rerank (rerank-2, rerank-2-lite) - Add VoyageAI block with operation dropdown (Generate Embeddings / Rerank) - Add VoyageAI icon and register in tool/block registries - Enhance MongoDB with connection string mode for Atlas (mongodb+srv://) support - Add connection mode toggle to MongoDB block (Host & Port / Connection String) - Update all 6 MongoDB API routes to accept optional connectionString - Add 48 unit tests (VoyageAI tools, block config, MongoDB utils) --- .../sim/app/api/tools/mongodb/delete/route.ts | 10 +- .../app/api/tools/mongodb/execute/route.ts | 10 +- .../sim/app/api/tools/mongodb/insert/route.ts | 10 +- .../app/api/tools/mongodb/introspect/route.ts | 6 +- apps/sim/app/api/tools/mongodb/query/route.ts | 10 +- .../sim/app/api/tools/mongodb/update/route.ts | 10 +- apps/sim/app/api/tools/mongodb/utils.test.ts | 211 ++++++++++++++++ apps/sim/app/api/tools/mongodb/utils.ts | 10 + apps/sim/blocks/blocks/mongodb.ts | 41 ++- apps/sim/blocks/blocks/voyageai.test.ts | 144 +++++++++++ apps/sim/blocks/blocks/voyageai.ts | 158 ++++++++++++ apps/sim/blocks/registry.ts | 2 + apps/sim/components/icons.tsx | 18 ++ apps/sim/tools/mongodb/delete.ts | 6 + apps/sim/tools/mongodb/execute.ts | 6 + apps/sim/tools/mongodb/insert.ts | 6 + apps/sim/tools/mongodb/introspect.ts | 6 + apps/sim/tools/mongodb/query.ts | 6 + apps/sim/tools/mongodb/types.ts | 2 + apps/sim/tools/mongodb/update.ts | 6 + apps/sim/tools/registry.ts | 3 + apps/sim/tools/voyageai/embeddings.ts | 89 +++++++ apps/sim/tools/voyageai/index.ts | 7 + apps/sim/tools/voyageai/rerank.ts | 120 +++++++++ apps/sim/tools/voyageai/types.ts | 42 +++ apps/sim/tools/voyageai/voyageai.test.ts | 239 ++++++++++++++++++ 26 files changed, 1150 insertions(+), 28 deletions(-) create mode 100644 apps/sim/app/api/tools/mongodb/utils.test.ts create mode 100644 apps/sim/blocks/blocks/voyageai.test.ts create mode 100644 apps/sim/blocks/blocks/voyageai.ts create mode 100644 apps/sim/tools/voyageai/embeddings.ts create mode 100644 apps/sim/tools/voyageai/index.ts create mode 100644 apps/sim/tools/voyageai/rerank.ts create mode 100644 apps/sim/tools/voyageai/types.ts create mode 100644 apps/sim/tools/voyageai/voyageai.test.ts diff --git a/apps/sim/app/api/tools/mongodb/delete/route.ts b/apps/sim/app/api/tools/mongodb/delete/route.ts index 95dcf328cd0..f36f34bd3eb 100644 --- a/apps/sim/app/api/tools/mongodb/delete/route.ts +++ b/apps/sim/app/api/tools/mongodb/delete/route.ts @@ -8,11 +8,12 @@ import { createMongoDBConnection, sanitizeCollectionName, validateFilter } from const logger = createLogger('MongoDBDeleteAPI') const DeleteSchema = z.object({ - host: z.string().min(1, 'Host is required'), - port: z.coerce.number().int().positive('Port must be a positive integer'), + connectionString: z.string().optional(), + host: z.string().default(''), + port: z.coerce.number().int().nonnegative().default(27017), database: z.string().min(1, 'Database name is required'), - username: z.string().min(1, 'Username is required'), - password: z.string().min(1, 'Password is required'), + username: z.string().default(''), + password: z.string().default(''), authSource: z.string().optional(), ssl: z.enum(['disabled', 'required', 'preferred']).default('preferred'), collection: z.string().min(1, 'Collection name is required'), @@ -75,6 +76,7 @@ export async function POST(request: NextRequest) { } client = await createMongoDBConnection({ + connectionString: params.connectionString, host: params.host, port: params.port, database: params.database, diff --git a/apps/sim/app/api/tools/mongodb/execute/route.ts b/apps/sim/app/api/tools/mongodb/execute/route.ts index 666d4a45069..7ba10f53882 100644 --- a/apps/sim/app/api/tools/mongodb/execute/route.ts +++ b/apps/sim/app/api/tools/mongodb/execute/route.ts @@ -8,11 +8,12 @@ import { createMongoDBConnection, sanitizeCollectionName, validatePipeline } fro const logger = createLogger('MongoDBExecuteAPI') const ExecuteSchema = z.object({ - host: z.string().min(1, 'Host is required'), - port: z.coerce.number().int().positive('Port must be a positive integer'), + connectionString: z.string().optional(), + host: z.string().default(''), + port: z.coerce.number().int().nonnegative().default(27017), database: z.string().min(1, 'Database name is required'), - username: z.string().min(1, 'Username is required'), - password: z.string().min(1, 'Password is required'), + username: z.string().default(''), + password: z.string().default(''), authSource: z.string().optional(), ssl: z.enum(['disabled', 'required', 'preferred']).default('preferred'), collection: z.string().min(1, 'Collection name is required'), @@ -61,6 +62,7 @@ export async function POST(request: NextRequest) { const pipelineDoc = JSON.parse(params.pipeline) client = await createMongoDBConnection({ + connectionString: params.connectionString, host: params.host, port: params.port, database: params.database, diff --git a/apps/sim/app/api/tools/mongodb/insert/route.ts b/apps/sim/app/api/tools/mongodb/insert/route.ts index f7feafd615a..141ebfe8f66 100644 --- a/apps/sim/app/api/tools/mongodb/insert/route.ts +++ b/apps/sim/app/api/tools/mongodb/insert/route.ts @@ -8,11 +8,12 @@ import { createMongoDBConnection, sanitizeCollectionName } from '../utils' const logger = createLogger('MongoDBInsertAPI') const InsertSchema = z.object({ - host: z.string().min(1, 'Host is required'), - port: z.coerce.number().int().positive('Port must be a positive integer'), + connectionString: z.string().optional(), + host: z.string().default(''), + port: z.coerce.number().int().nonnegative().default(27017), database: z.string().min(1, 'Database name is required'), - username: z.string().min(1, 'Username is required'), - password: z.string().min(1, 'Password is required'), + username: z.string().default(''), + password: z.string().default(''), authSource: z.string().optional(), ssl: z.enum(['disabled', 'required', 'preferred']).default('preferred'), collection: z.string().min(1, 'Collection name is required'), @@ -54,6 +55,7 @@ export async function POST(request: NextRequest) { const sanitizedCollection = sanitizeCollectionName(params.collection) client = await createMongoDBConnection({ + connectionString: params.connectionString, host: params.host, port: params.port, database: params.database, diff --git a/apps/sim/app/api/tools/mongodb/introspect/route.ts b/apps/sim/app/api/tools/mongodb/introspect/route.ts index 67f281553e3..0740f7c0e54 100644 --- a/apps/sim/app/api/tools/mongodb/introspect/route.ts +++ b/apps/sim/app/api/tools/mongodb/introspect/route.ts @@ -8,8 +8,9 @@ import { createMongoDBConnection, executeIntrospect } from '../utils' const logger = createLogger('MongoDBIntrospectAPI') const IntrospectSchema = z.object({ - host: z.string().min(1, 'Host is required'), - port: z.coerce.number().int().positive('Port must be a positive integer'), + connectionString: z.string().optional(), + host: z.string().default(''), + port: z.coerce.number().int().nonnegative().default(27017), database: z.string().optional(), username: z.string().optional(), password: z.string().optional(), @@ -36,6 +37,7 @@ export async function POST(request: NextRequest) { ) client = await createMongoDBConnection({ + connectionString: params.connectionString, host: params.host, port: params.port, database: params.database || 'admin', diff --git a/apps/sim/app/api/tools/mongodb/query/route.ts b/apps/sim/app/api/tools/mongodb/query/route.ts index 06533e3a8f4..d1fe00a9312 100644 --- a/apps/sim/app/api/tools/mongodb/query/route.ts +++ b/apps/sim/app/api/tools/mongodb/query/route.ts @@ -8,11 +8,12 @@ import { createMongoDBConnection, sanitizeCollectionName, validateFilter } from const logger = createLogger('MongoDBQueryAPI') const QuerySchema = z.object({ - host: z.string().min(1, 'Host is required'), - port: z.coerce.number().int().positive('Port must be a positive integer'), + connectionString: z.string().optional(), + host: z.string().default(''), + port: z.coerce.number().int().nonnegative().default(27017), database: z.string().min(1, 'Database name is required'), - username: z.string().min(1, 'Username is required'), - password: z.string().min(1, 'Password is required'), + username: z.string().default(''), + password: z.string().default(''), authSource: z.string().optional(), ssl: z.enum(['disabled', 'required', 'preferred']).default('preferred'), collection: z.string().min(1, 'Collection name is required'), @@ -90,6 +91,7 @@ export async function POST(request: NextRequest) { } client = await createMongoDBConnection({ + connectionString: params.connectionString, host: params.host, port: params.port, database: params.database, diff --git a/apps/sim/app/api/tools/mongodb/update/route.ts b/apps/sim/app/api/tools/mongodb/update/route.ts index e6c0f867f7e..e73e3b76e53 100644 --- a/apps/sim/app/api/tools/mongodb/update/route.ts +++ b/apps/sim/app/api/tools/mongodb/update/route.ts @@ -8,11 +8,12 @@ import { createMongoDBConnection, sanitizeCollectionName, validateFilter } from const logger = createLogger('MongoDBUpdateAPI') const UpdateSchema = z.object({ - host: z.string().min(1, 'Host is required'), - port: z.coerce.number().int().positive('Port must be a positive integer'), + connectionString: z.string().optional(), + host: z.string().default(''), + port: z.coerce.number().int().nonnegative().default(27017), database: z.string().min(1, 'Database name is required'), - username: z.string().min(1, 'Username is required'), - password: z.string().min(1, 'Password is required'), + username: z.string().default(''), + password: z.string().default(''), authSource: z.string().optional(), ssl: z.enum(['disabled', 'required', 'preferred']).default('preferred'), collection: z.string().min(1, 'Collection name is required'), @@ -99,6 +100,7 @@ export async function POST(request: NextRequest) { } client = await createMongoDBConnection({ + connectionString: params.connectionString, host: params.host, port: params.port, database: params.database, diff --git a/apps/sim/app/api/tools/mongodb/utils.test.ts b/apps/sim/app/api/tools/mongodb/utils.test.ts new file mode 100644 index 00000000000..feb6b9d1e82 --- /dev/null +++ b/apps/sim/app/api/tools/mongodb/utils.test.ts @@ -0,0 +1,211 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockConnect, mockMongoClient, mockValidateDatabaseHost } = vi.hoisted(() => { + const mockConnect = vi.fn().mockResolvedValue(undefined) + const mockMongoClient = vi.fn().mockImplementation(() => ({ + connect: mockConnect, + db: vi.fn(), + close: vi.fn(), + })) + const mockValidateDatabaseHost = vi.fn().mockResolvedValue({ isValid: true }) + return { mockConnect, mockMongoClient, mockValidateDatabaseHost } +}) + +vi.mock('mongodb', () => ({ + MongoClient: mockMongoClient, +})) + +vi.mock('@/lib/core/security/input-validation.server', () => ({ + validateDatabaseHost: mockValidateDatabaseHost, +})) + +import { + createMongoDBConnection, + sanitizeCollectionName, + validateFilter, + validatePipeline, +} from '@/app/api/tools/mongodb/utils' + +describe('MongoDB Utils', () => { + beforeEach(() => { + vi.clearAllMocks() + mockValidateDatabaseHost.mockResolvedValue({ isValid: true }) + }) + + describe('createMongoDBConnection', () => { + it('should use connectionString directly when provided', async () => { + await createMongoDBConnection({ + connectionString: 'mongodb+srv://user:pass@cluster.mongodb.net/mydb', + host: '', + port: 27017, + database: 'mydb', + }) + + expect(mockMongoClient).toHaveBeenCalledWith( + 'mongodb+srv://user:pass@cluster.mongodb.net/mydb', + expect.objectContaining({ + connectTimeoutMS: 10000, + socketTimeoutMS: 10000, + maxPoolSize: 1, + }) + ) + expect(mockValidateDatabaseHost).not.toHaveBeenCalled() + expect(mockConnect).toHaveBeenCalled() + }) + + it('should build URI from host/port when no connectionString', async () => { + await createMongoDBConnection({ + host: 'localhost', + port: 27017, + database: 'testdb', + username: 'user', + password: 'pass', + }) + + expect(mockValidateDatabaseHost).toHaveBeenCalledWith('localhost', 'host') + expect(mockMongoClient).toHaveBeenCalledWith( + expect.stringContaining('mongodb://user:pass@localhost:27017/testdb'), + expect.any(Object) + ) + }) + + it('should skip host validation when connectionString is provided', async () => { + await createMongoDBConnection({ + connectionString: 'mongodb+srv://test@cluster.net/db', + host: '', + port: 0, + database: 'db', + }) + + expect(mockValidateDatabaseHost).not.toHaveBeenCalled() + }) + + it('should apply connection options with connectionString', async () => { + await createMongoDBConnection({ + connectionString: 'mongodb+srv://test@cluster.net/db', + host: '', + port: 0, + database: 'db', + }) + + expect(mockMongoClient).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + connectTimeoutMS: 10000, + socketTimeoutMS: 10000, + maxPoolSize: 1, + }) + ) + }) + + it('should include authSource in URI when provided', async () => { + await createMongoDBConnection({ + host: 'localhost', + port: 27017, + database: 'testdb', + authSource: 'admin', + }) + + expect(mockMongoClient).toHaveBeenCalledWith( + expect.stringContaining('authSource=admin'), + expect.any(Object) + ) + }) + + it('should include ssl in URI when required', async () => { + await createMongoDBConnection({ + host: 'localhost', + port: 27017, + database: 'testdb', + ssl: 'required', + }) + + expect(mockMongoClient).toHaveBeenCalledWith( + expect.stringContaining('ssl=true'), + expect.any(Object) + ) + }) + + it('should throw when host validation fails and no connectionString', async () => { + mockValidateDatabaseHost.mockResolvedValue({ + isValid: false, + error: 'Invalid host', + }) + + await expect( + createMongoDBConnection({ + host: 'bad-host', + port: 27017, + database: 'testdb', + }) + ).rejects.toThrow('Invalid host') + }) + }) + + describe('validateFilter', () => { + it('should accept valid filters', () => { + expect(validateFilter('{"status": "active"}')).toEqual({ isValid: true }) + }) + + it('should reject dangerous operators', () => { + const result = validateFilter('{"$where": "this.a > 1"}') + expect(result.isValid).toBe(false) + expect(result.error).toContain('dangerous operators') + }) + + it('should reject invalid JSON', () => { + const result = validateFilter('not json') + expect(result.isValid).toBe(false) + expect(result.error).toContain('Invalid JSON') + }) + }) + + describe('validatePipeline', () => { + it('should accept valid pipelines', () => { + expect(validatePipeline('[{"$match": {"status": "active"}}]')).toEqual({ isValid: true }) + }) + + it('should allow $vectorSearch stage', () => { + const pipeline = JSON.stringify([ + { + $vectorSearch: { + index: 'vector_index', + path: 'embedding', + queryVector: [0.1, 0.2, 0.3], + numCandidates: 100, + limit: 10, + }, + }, + ]) + expect(validatePipeline(pipeline)).toEqual({ isValid: true }) + }) + + it('should reject dangerous pipeline operators', () => { + const result = validatePipeline('[{"$merge": {"into": "other_collection"}}]') + expect(result.isValid).toBe(false) + }) + + it('should reject non-array pipelines', () => { + const result = validatePipeline('{"$match": {}}') + expect(result.isValid).toBe(false) + expect(result.error).toContain('must be an array') + }) + }) + + describe('sanitizeCollectionName', () => { + it('should accept valid collection names', () => { + expect(sanitizeCollectionName('users')).toBe('users') + expect(sanitizeCollectionName('my_collection')).toBe('my_collection') + expect(sanitizeCollectionName('_private')).toBe('_private') + }) + + it('should reject invalid collection names', () => { + expect(() => sanitizeCollectionName('invalid-name')).toThrow('Invalid collection name') + expect(() => sanitizeCollectionName('123start')).toThrow('Invalid collection name') + expect(() => sanitizeCollectionName('has space')).toThrow('Invalid collection name') + }) + }) +}) diff --git a/apps/sim/app/api/tools/mongodb/utils.ts b/apps/sim/app/api/tools/mongodb/utils.ts index 33e6af90ae7..5d45a173399 100644 --- a/apps/sim/app/api/tools/mongodb/utils.ts +++ b/apps/sim/app/api/tools/mongodb/utils.ts @@ -3,6 +3,16 @@ import { validateDatabaseHost } from '@/lib/core/security/input-validation.serve import type { MongoDBCollectionInfo, MongoDBConnectionConfig } from '@/tools/mongodb/types' export async function createMongoDBConnection(config: MongoDBConnectionConfig) { + if (config.connectionString) { + const client = new MongoClient(config.connectionString, { + connectTimeoutMS: 10000, + socketTimeoutMS: 10000, + maxPoolSize: 1, + }) + await client.connect() + return client + } + const hostValidation = await validateDatabaseHost(config.host, 'host') if (!hostValidation.isValid) { throw new Error(hostValidation.error) diff --git a/apps/sim/blocks/blocks/mongodb.ts b/apps/sim/blocks/blocks/mongodb.ts index cf1398f295e..b0bf99f8166 100644 --- a/apps/sim/blocks/blocks/mongodb.ts +++ b/apps/sim/blocks/blocks/mongodb.ts @@ -30,12 +30,32 @@ export const MongoDBBlock: BlockConfig 'query', }, + { + id: 'connectionMode', + title: 'Connection Mode', + type: 'dropdown', + options: [ + { label: 'Host & Port', id: 'host_port' }, + { label: 'Connection String (Atlas)', id: 'connection_string' }, + ], + value: () => 'host_port', + }, + { + id: 'connectionString', + title: 'Connection String', + type: 'short-input', + placeholder: 'mongodb+srv://user:password@cluster.mongodb.net/mydb', + password: true, + condition: { field: 'connectionMode', value: 'connection_string' }, + required: { field: 'connectionMode', value: 'connection_string' }, + }, { id: 'host', title: 'Host', type: 'short-input', placeholder: 'localhost or your.mongodb.host', - required: true, + condition: { field: 'connectionMode', value: 'connection_string', not: true }, + required: { field: 'connectionMode', value: 'connection_string', not: true }, }, { id: 'port', @@ -43,7 +63,8 @@ export const MongoDBBlock: BlockConfig '27017', - required: true, + condition: { field: 'connectionMode', value: 'connection_string', not: true }, + required: { field: 'connectionMode', value: 'connection_string', not: true }, }, { id: 'database', @@ -57,7 +78,8 @@ export const MongoDBBlock: BlockConfig { - const { operation, documents, ...rest } = params + const { operation, documents, connectionMode, ...rest } = params let parsedDocuments if (documents && typeof documents === 'string' && documents.trim()) { @@ -853,7 +876,7 @@ Return ONLY the MongoDB query filter as valid JSON - no explanations, no markdow parsedDocuments = documents } - const connectionConfig = { + const connectionConfig: Record = { host: rest.host, port: typeof rest.port === 'string' ? Number.parseInt(rest.port, 10) : rest.port || 27017, database: rest.database, @@ -863,6 +886,10 @@ Return ONLY the MongoDB query filter as valid JSON - no explanations, no markdow ssl: rest.ssl || 'preferred', } + if (rest.connectionString) { + connectionConfig.connectionString = rest.connectionString + } + const result: any = { ...connectionConfig } if (rest.collection) result.collection = rest.collection @@ -900,6 +927,8 @@ Return ONLY the MongoDB query filter as valid JSON - no explanations, no markdow }, inputs: { operation: { type: 'string', description: 'Database operation to perform' }, + connectionMode: { type: 'string', description: 'Connection mode (host_port or connection_string)' }, + connectionString: { type: 'string', description: 'Full MongoDB connection string' }, host: { type: 'string', description: 'MongoDB host' }, port: { type: 'string', description: 'MongoDB port' }, database: { type: 'string', description: 'Database name' }, diff --git a/apps/sim/blocks/blocks/voyageai.test.ts b/apps/sim/blocks/blocks/voyageai.test.ts new file mode 100644 index 00000000000..67e91299b40 --- /dev/null +++ b/apps/sim/blocks/blocks/voyageai.test.ts @@ -0,0 +1,144 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { VoyageAIBlock } from '@/blocks/blocks/voyageai' + +describe('VoyageAIBlock', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('block properties', () => { + it('should have required properties', () => { + expect(VoyageAIBlock.type).toBe('voyageai') + expect(VoyageAIBlock.name).toBe('Voyage AI') + expect(VoyageAIBlock.category).toBe('tools') + expect(VoyageAIBlock.icon).toBeDefined() + expect(VoyageAIBlock.tools.access).toEqual(['voyageai_embeddings', 'voyageai_rerank']) + }) + + it('should have subBlocks with correct operation conditions', () => { + const embeddingsBlocks = VoyageAIBlock.subBlocks.filter( + (sb) => + sb.condition && + typeof sb.condition === 'object' && + 'value' in sb.condition && + sb.condition.value === 'embeddings' + ) + const rerankBlocks = VoyageAIBlock.subBlocks.filter( + (sb) => + sb.condition && + typeof sb.condition === 'object' && + 'value' in sb.condition && + sb.condition.value === 'rerank' + ) + expect(embeddingsBlocks.length).toBeGreaterThan(0) + expect(rerankBlocks.length).toBeGreaterThan(0) + }) + }) + + describe('tools.config.tool', () => { + const toolFunction = VoyageAIBlock.tools.config?.tool + + if (!toolFunction) { + throw new Error('VoyageAIBlock.tools.config.tool is missing') + } + + it('should return voyageai_embeddings for embeddings operation', () => { + expect(toolFunction({ operation: 'embeddings' })).toBe('voyageai_embeddings') + }) + + it('should return voyageai_rerank for rerank operation', () => { + expect(toolFunction({ operation: 'rerank' })).toBe('voyageai_rerank') + }) + + it('should throw for invalid operation', () => { + expect(() => toolFunction({ operation: 'invalid' })).toThrow('Invalid Voyage AI operation') + }) + }) + + describe('tools.config.params', () => { + const paramsFunction = VoyageAIBlock.tools.config?.params + + if (!paramsFunction) { + throw new Error('VoyageAIBlock.tools.config.params is missing') + } + + it('should pass correct fields for embeddings operation', () => { + const result = paramsFunction({ + operation: 'embeddings', + apiKey: 'va-key', + input: 'hello world', + embeddingModel: 'voyage-3-large', + inputType: 'query', + }) + expect(result).toEqual({ + apiKey: 'va-key', + input: 'hello world', + model: 'voyage-3-large', + inputType: 'query', + }) + }) + + it('should omit inputType when not provided', () => { + const result = paramsFunction({ + operation: 'embeddings', + apiKey: 'va-key', + input: 'hello world', + embeddingModel: 'voyage-3', + }) + expect(result.inputType).toBeUndefined() + }) + + it('should parse JSON string documents for rerank', () => { + const result = paramsFunction({ + operation: 'rerank', + apiKey: 'va-key', + query: 'search query', + documents: '["doc1", "doc2"]', + rerankModel: 'rerank-2', + }) + expect(result).toEqual({ + apiKey: 'va-key', + query: 'search query', + documents: ['doc1', 'doc2'], + model: 'rerank-2', + }) + }) + + it('should handle array documents for rerank', () => { + const result = paramsFunction({ + operation: 'rerank', + apiKey: 'va-key', + query: 'search query', + documents: ['doc1', 'doc2'], + rerankModel: 'rerank-2', + }) + expect(result.documents).toEqual(['doc1', 'doc2']) + }) + + it('should convert topK string to number', () => { + const result = paramsFunction({ + operation: 'rerank', + apiKey: 'va-key', + query: 'search query', + documents: ['doc1'], + rerankModel: 'rerank-2', + topK: '5', + }) + expect(result.topK).toBe(5) + }) + + it('should omit topK when not provided', () => { + const result = paramsFunction({ + operation: 'rerank', + apiKey: 'va-key', + query: 'search query', + documents: ['doc1'], + rerankModel: 'rerank-2', + }) + expect(result.topK).toBeUndefined() + }) + }) +}) diff --git a/apps/sim/blocks/blocks/voyageai.ts b/apps/sim/blocks/blocks/voyageai.ts new file mode 100644 index 00000000000..b043d381706 --- /dev/null +++ b/apps/sim/blocks/blocks/voyageai.ts @@ -0,0 +1,158 @@ +import { VoyageAIIcon } from '@/components/icons' +import type { BlockConfig } from '@/blocks/types' +import { AuthMode, IntegrationType } from '@/blocks/types' + +export const VoyageAIBlock: BlockConfig = { + type: 'voyageai', + name: 'Voyage AI', + description: 'Generate embeddings and rerank with Voyage AI', + longDescription: + 'Integrate Voyage AI into the workflow. Generate embeddings from text or rerank documents by relevance.', + category: 'tools', + authMode: AuthMode.ApiKey, + integrationType: IntegrationType.AI, + tags: ['llm', 'vector-search'], + bgColor: '#1A1A2E', + icon: VoyageAIIcon, + subBlocks: [ + { + id: 'operation', + title: 'Operation', + type: 'dropdown', + options: [ + { label: 'Generate Embeddings', id: 'embeddings' }, + { label: 'Rerank', id: 'rerank' }, + ], + value: () => 'embeddings', + }, + { + id: 'input', + title: 'Input Text', + type: 'long-input', + placeholder: 'Enter text to generate embeddings for', + condition: { field: 'operation', value: 'embeddings' }, + required: true, + }, + { + id: 'embeddingModel', + title: 'Model', + type: 'dropdown', + options: [ + { label: 'voyage-3-large', id: 'voyage-3-large' }, + { label: 'voyage-3', id: 'voyage-3' }, + { label: 'voyage-3-lite', id: 'voyage-3-lite' }, + { label: 'voyage-code-3', id: 'voyage-code-3' }, + { label: 'voyage-finance-2', id: 'voyage-finance-2' }, + { label: 'voyage-law-2', id: 'voyage-law-2' }, + ], + condition: { field: 'operation', value: 'embeddings' }, + value: () => 'voyage-3', + }, + { + id: 'inputType', + title: 'Input Type', + type: 'dropdown', + options: [ + { label: 'Document', id: 'document' }, + { label: 'Query', id: 'query' }, + ], + condition: { field: 'operation', value: 'embeddings' }, + value: () => 'document', + mode: 'advanced', + }, + { + id: 'query', + title: 'Query', + type: 'long-input', + placeholder: 'Enter the query to rerank documents against', + condition: { field: 'operation', value: 'rerank' }, + required: true, + }, + { + id: 'documents', + title: 'Documents', + type: 'code', + placeholder: '["document 1 text", "document 2 text", ...]', + condition: { field: 'operation', value: 'rerank' }, + required: true, + }, + { + id: 'rerankModel', + title: 'Model', + type: 'dropdown', + options: [ + { label: 'rerank-2', id: 'rerank-2' }, + { label: 'rerank-2-lite', id: 'rerank-2-lite' }, + ], + condition: { field: 'operation', value: 'rerank' }, + value: () => 'rerank-2', + }, + { + id: 'topK', + title: 'Top K', + type: 'short-input', + placeholder: 'Number of top results (e.g. 10)', + condition: { field: 'operation', value: 'rerank' }, + mode: 'advanced', + }, + { + id: 'apiKey', + title: 'API Key', + type: 'short-input', + placeholder: 'Enter your Voyage AI API key', + password: true, + required: true, + }, + ], + tools: { + access: ['voyageai_embeddings', 'voyageai_rerank'], + config: { + tool: (params) => { + switch (params.operation) { + case 'embeddings': + return 'voyageai_embeddings' + case 'rerank': + return 'voyageai_rerank' + default: + throw new Error(`Invalid Voyage AI operation: ${params.operation}`) + } + }, + params: (params) => { + const result: Record = { apiKey: params.apiKey } + if (params.operation === 'embeddings') { + result.input = params.input + result.model = params.embeddingModel + if (params.inputType) { + result.inputType = params.inputType + } + } else { + result.query = params.query + result.documents = + typeof params.documents === 'string' ? JSON.parse(params.documents) : params.documents + result.model = params.rerankModel + if (params.topK) { + result.topK = Number(params.topK) + } + } + return result + }, + }, + }, + inputs: { + operation: { type: 'string', description: 'Operation to perform' }, + input: { type: 'string', description: 'Text to embed' }, + embeddingModel: { type: 'string', description: 'Embedding model' }, + inputType: { type: 'string', description: 'Input type (query or document)' }, + query: { type: 'string', description: 'Rerank query' }, + documents: { type: 'json', description: 'Documents to rerank' }, + rerankModel: { type: 'string', description: 'Rerank model' }, + topK: { type: 'number', description: 'Number of top results' }, + apiKey: { type: 'string', description: 'Voyage AI API key' }, + }, + outputs: { + embeddings: { type: 'json', description: 'Generated embedding vectors' }, + results: { type: 'json', description: 'Reranked results with scores' }, + model: { type: 'string', description: 'Model used' }, + usage: { type: 'json', description: 'Token usage' }, + }, +} diff --git a/apps/sim/blocks/registry.ts b/apps/sim/blocks/registry.ts index 074bb38b849..59558053423 100644 --- a/apps/sim/blocks/registry.ts +++ b/apps/sim/blocks/registry.ts @@ -186,6 +186,7 @@ import { VariablesBlock } from '@/blocks/blocks/variables' import { VercelBlock } from '@/blocks/blocks/vercel' import { VideoGeneratorBlock, VideoGeneratorV2Block } from '@/blocks/blocks/video_generator' import { VisionBlock, VisionV2Block } from '@/blocks/blocks/vision' +import { VoyageAIBlock } from '@/blocks/blocks/voyageai' import { WaitBlock } from '@/blocks/blocks/wait' import { WealthboxBlock } from '@/blocks/blocks/wealthbox' import { WebflowBlock } from '@/blocks/blocks/webflow' @@ -412,6 +413,7 @@ export const registry: Record = { video_generator_v2: VideoGeneratorV2Block, vision: VisionBlock, vision_v2: VisionV2Block, + voyageai: VoyageAIBlock, wait: WaitBlock, wealthbox: WealthboxBlock, webflow: WebflowBlock, diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx index 19f650044c3..85b1223626f 100644 --- a/apps/sim/components/icons.tsx +++ b/apps/sim/components/icons.tsx @@ -3770,6 +3770,24 @@ export function VariableIcon(props: SVGProps) { ) } +export function VoyageAIIcon(props: SVGProps) { + return ( + + + + ) +} + export function HumanInTheLoopIcon(props: SVGProps) { return ( = { version: '1.0', params: { + connectionString: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Full MongoDB connection string (e.g., mongodb+srv://user:pass@cluster.mongodb.net/db)', + }, host: { type: 'string', required: true, diff --git a/apps/sim/tools/mongodb/execute.ts b/apps/sim/tools/mongodb/execute.ts index 12e5d42f3fb..8c4d8f77162 100644 --- a/apps/sim/tools/mongodb/execute.ts +++ b/apps/sim/tools/mongodb/execute.ts @@ -8,6 +8,12 @@ export const executeTool: ToolConfig = { version: '1.0', params: { + connectionString: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Full MongoDB connection string (e.g., mongodb+srv://user:pass@cluster.mongodb.net/db)', + }, host: { type: 'string', required: true, diff --git a/apps/sim/tools/mongodb/insert.ts b/apps/sim/tools/mongodb/insert.ts index b129f653986..52ea1c12039 100644 --- a/apps/sim/tools/mongodb/insert.ts +++ b/apps/sim/tools/mongodb/insert.ts @@ -8,6 +8,12 @@ export const insertTool: ToolConfig = { version: '1.0', params: { + connectionString: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Full MongoDB connection string (e.g., mongodb+srv://user:pass@cluster.mongodb.net/db)', + }, host: { type: 'string', required: true, diff --git a/apps/sim/tools/mongodb/introspect.ts b/apps/sim/tools/mongodb/introspect.ts index 045a11107c7..ad6fd0bfc47 100644 --- a/apps/sim/tools/mongodb/introspect.ts +++ b/apps/sim/tools/mongodb/introspect.ts @@ -8,6 +8,12 @@ export const introspectTool: ToolConfig = { version: '1.0', params: { + connectionString: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Full MongoDB connection string (e.g., mongodb+srv://user:pass@cluster.mongodb.net/db)', + }, host: { type: 'string', required: true, diff --git a/apps/sim/tools/mongodb/types.ts b/apps/sim/tools/mongodb/types.ts index 2465458736c..44389e9acc1 100644 --- a/apps/sim/tools/mongodb/types.ts +++ b/apps/sim/tools/mongodb/types.ts @@ -8,6 +8,7 @@ export interface MongoDBConnectionConfig { password?: string authSource?: string ssl?: 'disabled' | 'required' | 'preferred' + connectionString?: string } export interface MongoDBQueryParams extends MongoDBConnectionConfig { @@ -49,6 +50,7 @@ export interface MongoDBIntrospectParams { password?: string authSource?: string ssl?: 'disabled' | 'required' | 'preferred' + connectionString?: string } export interface MongoDBCollectionInfo { diff --git a/apps/sim/tools/mongodb/update.ts b/apps/sim/tools/mongodb/update.ts index d1812e30999..a8a32934302 100644 --- a/apps/sim/tools/mongodb/update.ts +++ b/apps/sim/tools/mongodb/update.ts @@ -8,6 +8,12 @@ export const updateTool: ToolConfig = { version: '1.0', params: { + connectionString: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Full MongoDB connection string (e.g., mongodb+srv://user:pass@cluster.mongodb.net/db)', + }, host: { type: 'string', required: true, diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index 6aada16fbd5..76ad601e7db 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -2291,6 +2291,7 @@ import { veoVideoTool, } from '@/tools/video' import { visionTool, visionToolV2 } from '@/tools/vision' +import { voyageaiEmbeddingsTool, voyageaiRerankTool } from '@/tools/voyageai' import { wealthboxReadContactTool, wealthboxReadNoteTool, @@ -2541,6 +2542,8 @@ export const tools: Record = { gamma_list_folders: gammaListFoldersTool, vision_tool: visionTool, vision_tool_v2: visionToolV2, + voyageai_embeddings: voyageaiEmbeddingsTool, + voyageai_rerank: voyageaiRerankTool, file_parser: fileParseTool, file_parser_v2: fileParserV2Tool, file_parser_v3: fileParserV3Tool, diff --git a/apps/sim/tools/voyageai/embeddings.ts b/apps/sim/tools/voyageai/embeddings.ts new file mode 100644 index 00000000000..b87fe0134d9 --- /dev/null +++ b/apps/sim/tools/voyageai/embeddings.ts @@ -0,0 +1,89 @@ +import type { VoyageAIEmbeddingsParams, VoyageAIEmbeddingsResponse } from '@/tools/voyageai/types' +import type { ToolConfig } from '@/tools/types' + +export const embeddingsTool: ToolConfig = { + id: 'voyageai_embeddings', + name: 'Voyage AI Embeddings', + description: 'Generate embeddings from text using Voyage AI embedding models', + version: '1.0', + + params: { + input: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Text or array of texts to generate embeddings for', + }, + model: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Embedding model to use', + default: 'voyage-3', + }, + inputType: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Type of input: "query" for search queries, "document" for documents to be indexed', + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Voyage AI API key', + }, + }, + + request: { + method: 'POST', + url: () => 'https://api.voyageai.com/v1/embeddings', + headers: (params) => ({ + Authorization: `Bearer ${params.apiKey}`, + 'Content-Type': 'application/json', + }), + body: (params) => { + const body: Record = { + input: Array.isArray(params.input) ? params.input : [params.input], + model: params.model || 'voyage-3', + } + if (params.inputType) { + body.input_type = params.inputType + } + return body + }, + }, + + transformResponse: async (response) => { + const data = await response.json() + return { + success: true, + output: { + embeddings: data.data.map((item: { embedding: number[] }) => item.embedding), + model: data.model, + usage: { + total_tokens: data.usage.total_tokens, + }, + }, + } + }, + + outputs: { + success: { type: 'boolean', description: 'Operation success status' }, + output: { + type: 'object', + description: 'Embeddings generation results', + properties: { + embeddings: { type: 'array', description: 'Array of embedding vectors' }, + model: { type: 'string', description: 'Model used for generating embeddings' }, + usage: { + type: 'object', + description: 'Token usage information', + properties: { + total_tokens: { type: 'number', description: 'Total number of tokens used' }, + }, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/voyageai/index.ts b/apps/sim/tools/voyageai/index.ts new file mode 100644 index 00000000000..efac23a6418 --- /dev/null +++ b/apps/sim/tools/voyageai/index.ts @@ -0,0 +1,7 @@ +import { embeddingsTool } from '@/tools/voyageai/embeddings' +import { rerankTool } from '@/tools/voyageai/rerank' + +export const voyageaiEmbeddingsTool = embeddingsTool +export const voyageaiRerankTool = rerankTool + +export * from './types' diff --git a/apps/sim/tools/voyageai/rerank.ts b/apps/sim/tools/voyageai/rerank.ts new file mode 100644 index 00000000000..3ad130c1ab9 --- /dev/null +++ b/apps/sim/tools/voyageai/rerank.ts @@ -0,0 +1,120 @@ +import type { VoyageAIRerankParams, VoyageAIRerankResponse } from '@/tools/voyageai/types' +import type { ToolConfig } from '@/tools/types' + +export const rerankTool: ToolConfig = { + id: 'voyageai_rerank', + name: 'Voyage AI Rerank', + description: 'Rerank documents by relevance to a query using Voyage AI reranking models', + version: '1.0', + + params: { + query: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The query to rerank documents against', + }, + documents: { + type: 'array', + required: true, + visibility: 'user-or-llm', + description: 'Array of document strings to rerank', + }, + model: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Reranking model to use', + default: 'rerank-2', + }, + topK: { + type: 'number', + required: false, + visibility: 'user-only', + description: 'Number of top results to return', + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Voyage AI API key', + }, + }, + + request: { + method: 'POST', + url: () => 'https://api.voyageai.com/v1/rerank', + headers: (params) => ({ + Authorization: `Bearer ${params.apiKey}`, + 'Content-Type': 'application/json', + }), + body: (params) => { + const documents = + typeof params.documents === 'string' ? JSON.parse(params.documents) : params.documents + + const body: Record = { + query: params.query, + documents, + model: params.model || 'rerank-2', + } + if (params.topK) { + body.top_k = params.topK + } + return body + }, + }, + + transformResponse: async (response, params) => { + const data = await response.json() + const originalDocuments: string[] = params + ? typeof params.documents === 'string' + ? JSON.parse(params.documents) + : params.documents + : [] + + return { + success: true, + output: { + results: data.data.map((item: { index: number; relevance_score: number }) => ({ + index: item.index, + relevance_score: item.relevance_score, + document: originalDocuments[item.index] || '', + })), + model: data.model, + usage: { + total_tokens: data.usage.total_tokens, + }, + }, + } + }, + + outputs: { + success: { type: 'boolean', description: 'Operation success status' }, + output: { + type: 'object', + description: 'Reranking results', + properties: { + results: { + type: 'array', + description: 'Reranked documents with relevance scores', + items: { + type: 'object', + properties: { + index: { type: 'number', description: 'Original index of the document' }, + relevance_score: { type: 'number', description: 'Relevance score' }, + document: { type: 'string', description: 'Document text' }, + }, + }, + }, + model: { type: 'string', description: 'Model used for reranking' }, + usage: { + type: 'object', + description: 'Token usage information', + properties: { + total_tokens: { type: 'number', description: 'Total number of tokens used' }, + }, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/voyageai/types.ts b/apps/sim/tools/voyageai/types.ts new file mode 100644 index 00000000000..593875bf3c5 --- /dev/null +++ b/apps/sim/tools/voyageai/types.ts @@ -0,0 +1,42 @@ +import type { ToolResponse } from '@/tools/types' + +export interface VoyageAIEmbeddingsParams { + apiKey: string + input: string | string[] + model?: string + inputType?: 'query' | 'document' + truncation?: boolean +} + +export interface VoyageAIRerankParams { + apiKey: string + query: string + documents: string | string[] + model?: string + topK?: number + truncation?: boolean +} + +export interface VoyageAIEmbeddingsResponse extends ToolResponse { + output: { + embeddings: number[][] + model: string + usage: { + total_tokens: number + } + } +} + +export interface VoyageAIRerankResponse extends ToolResponse { + output: { + results: Array<{ + index: number + relevance_score: number + document: string + }> + model: string + usage: { + total_tokens: number + } + } +} diff --git a/apps/sim/tools/voyageai/voyageai.test.ts b/apps/sim/tools/voyageai/voyageai.test.ts new file mode 100644 index 00000000000..eed9bd67e96 --- /dev/null +++ b/apps/sim/tools/voyageai/voyageai.test.ts @@ -0,0 +1,239 @@ +/** + * @vitest-environment node + */ +import { ToolTester } from '@sim/testing/builders' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { embeddingsTool } from '@/tools/voyageai/embeddings' +import { rerankTool } from '@/tools/voyageai/rerank' + +describe('Voyage AI Embeddings Tool', () => { + let tester: ToolTester + + beforeEach(() => { + tester = new ToolTester(embeddingsTool as any) + }) + + afterEach(() => { + tester.cleanup() + vi.resetAllMocks() + }) + + describe('URL Construction', () => { + it('should return the VoyageAI embeddings endpoint', () => { + expect(tester.getRequestUrl({ apiKey: 'test-key', input: 'hello' })).toBe( + 'https://api.voyageai.com/v1/embeddings' + ) + }) + }) + + describe('Headers Construction', () => { + it('should include bearer auth and content type', () => { + const headers = tester.getRequestHeaders({ apiKey: 'va-test-key', input: 'hello' }) + expect(headers.Authorization).toBe('Bearer va-test-key') + expect(headers['Content-Type']).toBe('application/json') + }) + }) + + describe('Body Construction', () => { + it('should wrap single string input into array', () => { + const body = tester.getRequestBody({ apiKey: 'key', input: 'hello world' }) + expect(body.input).toEqual(['hello world']) + }) + + it('should pass array input directly', () => { + const body = tester.getRequestBody({ apiKey: 'key', input: ['text1', 'text2'] }) + expect(body.input).toEqual(['text1', 'text2']) + }) + + it('should use default model when not specified', () => { + const body = tester.getRequestBody({ apiKey: 'key', input: 'hello' }) + expect(body.model).toBe('voyage-3') + }) + + it('should use specified model', () => { + const body = tester.getRequestBody({ apiKey: 'key', input: 'hello', model: 'voyage-3-large' }) + expect(body.model).toBe('voyage-3-large') + }) + + it('should include input_type when provided', () => { + const body = tester.getRequestBody({ apiKey: 'key', input: 'hello', inputType: 'query' }) + expect(body.input_type).toBe('query') + }) + + it('should omit input_type when not provided', () => { + const body = tester.getRequestBody({ apiKey: 'key', input: 'hello' }) + expect(body.input_type).toBeUndefined() + }) + }) + + describe('Response Transformation', () => { + it('should extract embeddings, model, and usage', async () => { + tester.setup({ + data: [ + { embedding: [0.1, 0.2, 0.3], index: 0 }, + { embedding: [0.4, 0.5, 0.6], index: 1 }, + ], + model: 'voyage-3', + usage: { total_tokens: 10 }, + }) + + const result = await tester.execute({ apiKey: 'key', input: ['text1', 'text2'] }) + expect(result.success).toBe(true) + expect(result.output.embeddings).toEqual([ + [0.1, 0.2, 0.3], + [0.4, 0.5, 0.6], + ]) + expect(result.output.model).toBe('voyage-3') + expect(result.output.usage.total_tokens).toBe(10) + }) + }) + + describe('Error Handling', () => { + it('should handle error responses', async () => { + tester.setup({ error: 'Invalid API key' }, { ok: false, status: 401 }) + const result = await tester.execute({ apiKey: 'bad-key', input: 'hello' }) + expect(result.success).toBe(false) + }) + + it('should handle network errors', async () => { + tester.setupError('Network error') + const result = await tester.execute({ apiKey: 'key', input: 'hello' }) + expect(result.success).toBe(false) + expect(result.error).toContain('Network error') + }) + }) +}) + +describe('Voyage AI Rerank Tool', () => { + let tester: ToolTester + + beforeEach(() => { + tester = new ToolTester(rerankTool as any) + }) + + afterEach(() => { + tester.cleanup() + vi.resetAllMocks() + }) + + describe('URL Construction', () => { + it('should return the VoyageAI rerank endpoint', () => { + expect( + tester.getRequestUrl({ apiKey: 'key', query: 'test', documents: ['doc1'] }) + ).toBe('https://api.voyageai.com/v1/rerank') + }) + }) + + describe('Headers Construction', () => { + it('should include bearer auth and content type', () => { + const headers = tester.getRequestHeaders({ + apiKey: 'va-test-key', + query: 'test', + documents: ['doc1'], + }) + expect(headers.Authorization).toBe('Bearer va-test-key') + expect(headers['Content-Type']).toBe('application/json') + }) + }) + + describe('Body Construction', () => { + it('should send query, documents, and model', () => { + const body = tester.getRequestBody({ + apiKey: 'key', + query: 'what is AI?', + documents: ['AI is...', 'Machine learning is...'], + }) + expect(body.query).toBe('what is AI?') + expect(body.documents).toEqual(['AI is...', 'Machine learning is...']) + expect(body.model).toBe('rerank-2') + }) + + it('should parse JSON string documents', () => { + const body = tester.getRequestBody({ + apiKey: 'key', + query: 'test', + documents: '["doc1", "doc2"]', + }) + expect(body.documents).toEqual(['doc1', 'doc2']) + }) + + it('should include top_k when provided', () => { + const body = tester.getRequestBody({ + apiKey: 'key', + query: 'test', + documents: ['doc1'], + topK: 5, + }) + expect(body.top_k).toBe(5) + }) + + it('should omit top_k when not provided', () => { + const body = tester.getRequestBody({ + apiKey: 'key', + query: 'test', + documents: ['doc1'], + }) + expect(body.top_k).toBeUndefined() + }) + + it('should use specified model', () => { + const body = tester.getRequestBody({ + apiKey: 'key', + query: 'test', + documents: ['doc1'], + model: 'rerank-2-lite', + }) + expect(body.model).toBe('rerank-2-lite') + }) + }) + + describe('Response Transformation', () => { + it('should map results with index, score, and document text', async () => { + tester.setup({ + data: [ + { index: 1, relevance_score: 0.95 }, + { index: 0, relevance_score: 0.72 }, + ], + model: 'rerank-2', + usage: { total_tokens: 25 }, + }) + + const result = await tester.execute({ + apiKey: 'key', + query: 'what is AI?', + documents: ['Machine learning basics', 'AI is artificial intelligence'], + }) + + expect(result.success).toBe(true) + expect(result.output.results).toEqual([ + { index: 1, relevance_score: 0.95, document: 'AI is artificial intelligence' }, + { index: 0, relevance_score: 0.72, document: 'Machine learning basics' }, + ]) + expect(result.output.model).toBe('rerank-2') + expect(result.output.usage.total_tokens).toBe(25) + }) + }) + + describe('Error Handling', () => { + it('should handle error responses', async () => { + tester.setup({ error: 'Rate limited' }, { ok: false, status: 429 }) + const result = await tester.execute({ + apiKey: 'key', + query: 'test', + documents: ['doc1'], + }) + expect(result.success).toBe(false) + }) + + it('should handle network errors', async () => { + tester.setupError('Connection refused') + const result = await tester.execute({ + apiKey: 'key', + query: 'test', + documents: ['doc1'], + }) + expect(result.success).toBe(false) + expect(result.error).toContain('Connection refused') + }) + }) +}) From 2d28d8bd362df99ec22baabdb22435180c4cfdfd Mon Sep 17 00:00:00 2001 From: fzowl Date: Mon, 23 Mar 2026 19:01:29 +0100 Subject: [PATCH 2/8] test: comprehensive unit tests (160), integration tests (13) for VoyageAI and MongoDB - Expand VoyageAI tool tests: metadata, all models, edge cases, error codes (60 tests) - Expand VoyageAI block tests: structure, subBlocks, conditions, params edge cases (44 tests) - Expand MongoDB utils tests: connection modes, URI building, all validators (56 tests) - Add live integration tests: embeddings (7 models/scenarios), rerank (5 scenarios), e2e workflow - Integration tests use undici to bypass global fetch mock - Tests skip gracefully when VOYAGEAI_API_KEY env var is not set --- apps/sim/app/api/tools/mongodb/utils.test.ts | 499 ++++++++++++++---- apps/sim/blocks/blocks/voyageai.test.ts | 403 +++++++++++--- .../voyageai/voyageai.integration.test.ts | 459 ++++++++++++++++ apps/sim/tools/voyageai/voyageai.test.ts | 376 ++++++++++++- 4 files changed, 1550 insertions(+), 187 deletions(-) create mode 100644 apps/sim/tools/voyageai/voyageai.integration.test.ts diff --git a/apps/sim/app/api/tools/mongodb/utils.test.ts b/apps/sim/app/api/tools/mongodb/utils.test.ts index feb6b9d1e82..045712a5891 100644 --- a/apps/sim/app/api/tools/mongodb/utils.test.ts +++ b/apps/sim/app/api/tools/mongodb/utils.test.ts @@ -3,15 +3,17 @@ */ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockConnect, mockMongoClient, mockValidateDatabaseHost } = vi.hoisted(() => { +const { mockConnect, mockDb, mockMongoClient, mockValidateDatabaseHost } = vi.hoisted(() => { + const mockDb = vi.fn() const mockConnect = vi.fn().mockResolvedValue(undefined) + const mockClose = vi.fn() const mockMongoClient = vi.fn().mockImplementation(() => ({ connect: mockConnect, - db: vi.fn(), - close: vi.fn(), + db: mockDb, + close: mockClose, })) const mockValidateDatabaseHost = vi.fn().mockResolvedValue({ isValid: true }) - return { mockConnect, mockMongoClient, mockValidateDatabaseHost } + return { mockConnect, mockDb, mockMongoClient, mockValidateDatabaseHost } }) vi.mock('mongodb', () => ({ @@ -36,139 +38,359 @@ describe('MongoDB Utils', () => { }) describe('createMongoDBConnection', () => { - it('should use connectionString directly when provided', async () => { - await createMongoDBConnection({ - connectionString: 'mongodb+srv://user:pass@cluster.mongodb.net/mydb', - host: '', - port: 27017, - database: 'mydb', + describe('connection string mode', () => { + it('should use connectionString directly when provided', async () => { + await createMongoDBConnection({ + connectionString: 'mongodb+srv://user:pass@cluster.mongodb.net/mydb', + host: '', + port: 27017, + database: 'mydb', + }) + + expect(mockMongoClient).toHaveBeenCalledWith( + 'mongodb+srv://user:pass@cluster.mongodb.net/mydb', + expect.objectContaining({ + connectTimeoutMS: 10000, + socketTimeoutMS: 10000, + maxPoolSize: 1, + }) + ) + expect(mockConnect).toHaveBeenCalled() }) - expect(mockMongoClient).toHaveBeenCalledWith( - 'mongodb+srv://user:pass@cluster.mongodb.net/mydb', - expect.objectContaining({ - connectTimeoutMS: 10000, - socketTimeoutMS: 10000, - maxPoolSize: 1, + it('should skip host validation when connectionString is provided', async () => { + await createMongoDBConnection({ + connectionString: 'mongodb+srv://test@cluster.net/db', + host: '', + port: 0, + database: 'db', }) - ) - expect(mockValidateDatabaseHost).not.toHaveBeenCalled() - expect(mockConnect).toHaveBeenCalled() - }) - it('should build URI from host/port when no connectionString', async () => { - await createMongoDBConnection({ - host: 'localhost', - port: 27017, - database: 'testdb', - username: 'user', - password: 'pass', + expect(mockValidateDatabaseHost).not.toHaveBeenCalled() }) - expect(mockValidateDatabaseHost).toHaveBeenCalledWith('localhost', 'host') - expect(mockMongoClient).toHaveBeenCalledWith( - expect.stringContaining('mongodb://user:pass@localhost:27017/testdb'), - expect.any(Object) - ) - }) + it('should apply connection options with connectionString', async () => { + await createMongoDBConnection({ + connectionString: 'mongodb+srv://test@cluster.net/db', + host: '', + port: 0, + database: 'db', + }) - it('should skip host validation when connectionString is provided', async () => { - await createMongoDBConnection({ - connectionString: 'mongodb+srv://test@cluster.net/db', - host: '', - port: 0, - database: 'db', + expect(mockMongoClient).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + connectTimeoutMS: 10000, + socketTimeoutMS: 10000, + maxPoolSize: 1, + }) + ) }) - expect(mockValidateDatabaseHost).not.toHaveBeenCalled() - }) + it('should ignore host/port/username/password when connectionString is provided', async () => { + await createMongoDBConnection({ + connectionString: 'mongodb+srv://real@cluster.net/db', + host: 'ignored-host', + port: 9999, + database: 'db', + username: 'ignored-user', + password: 'ignored-pass', + }) - it('should apply connection options with connectionString', async () => { - await createMongoDBConnection({ - connectionString: 'mongodb+srv://test@cluster.net/db', - host: '', - port: 0, - database: 'db', + expect(mockMongoClient).toHaveBeenCalledWith( + 'mongodb+srv://real@cluster.net/db', + expect.any(Object) + ) }) - expect(mockMongoClient).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - connectTimeoutMS: 10000, - socketTimeoutMS: 10000, - maxPoolSize: 1, + it('should handle connectionString with special characters', async () => { + const connStr = 'mongodb+srv://user:p%40ss%26word@cluster.mongodb.net/db?retryWrites=true' + await createMongoDBConnection({ + connectionString: connStr, + host: '', + port: 0, + database: 'db', }) - ) - }) - it('should include authSource in URI when provided', async () => { - await createMongoDBConnection({ - host: 'localhost', - port: 27017, - database: 'testdb', - authSource: 'admin', + expect(mockMongoClient).toHaveBeenCalledWith(connStr, expect.any(Object)) }) - expect(mockMongoClient).toHaveBeenCalledWith( - expect.stringContaining('authSource=admin'), - expect.any(Object) - ) - }) + it('should handle standard mongodb:// connectionString', async () => { + const connStr = 'mongodb://user:pass@host1:27017,host2:27017/mydb?replicaSet=rs0' + await createMongoDBConnection({ + connectionString: connStr, + host: '', + port: 0, + database: 'mydb', + }) - it('should include ssl in URI when required', async () => { - await createMongoDBConnection({ - host: 'localhost', - port: 27017, - database: 'testdb', - ssl: 'required', + expect(mockMongoClient).toHaveBeenCalledWith(connStr, expect.any(Object)) }) - expect(mockMongoClient).toHaveBeenCalledWith( - expect.stringContaining('ssl=true'), - expect.any(Object) - ) + it('should call connect() after creating client', async () => { + await createMongoDBConnection({ + connectionString: 'mongodb+srv://test@cluster.net/db', + host: '', + port: 0, + database: 'db', + }) + + expect(mockMongoClient).toHaveBeenCalled() + expect(mockConnect).toHaveBeenCalledAfter(mockMongoClient) + }) }) - it('should throw when host validation fails and no connectionString', async () => { - mockValidateDatabaseHost.mockResolvedValue({ - isValid: false, - error: 'Invalid host', + describe('host/port mode', () => { + it('should build URI from host/port with credentials', async () => { + await createMongoDBConnection({ + host: 'localhost', + port: 27017, + database: 'testdb', + username: 'user', + password: 'pass', + }) + + expect(mockValidateDatabaseHost).toHaveBeenCalledWith('localhost', 'host') + expect(mockMongoClient).toHaveBeenCalledWith( + expect.stringContaining('mongodb://user:pass@localhost:27017/testdb'), + expect.any(Object) + ) + }) + + it('should build URI without credentials when not provided', async () => { + await createMongoDBConnection({ + host: 'localhost', + port: 27017, + database: 'testdb', + }) + + expect(mockMongoClient).toHaveBeenCalledWith( + 'mongodb://localhost:27017/testdb', + expect.any(Object) + ) + }) + + it('should encode special characters in username and password', async () => { + await createMongoDBConnection({ + host: 'localhost', + port: 27017, + database: 'testdb', + username: 'user@domain', + password: 'p@ss:word', + }) + + const calledUri = mockMongoClient.mock.calls[0][0] + expect(calledUri).toContain('user%40domain') + expect(calledUri).toContain('p%40ss%3Aword') + }) + + it('should include authSource in URI when provided', async () => { + await createMongoDBConnection({ + host: 'localhost', + port: 27017, + database: 'testdb', + authSource: 'admin', + }) + + expect(mockMongoClient).toHaveBeenCalledWith( + expect.stringContaining('authSource=admin'), + expect.any(Object) + ) + }) + + it('should include ssl=true when ssl is required', async () => { + await createMongoDBConnection({ + host: 'localhost', + port: 27017, + database: 'testdb', + ssl: 'required', + }) + + expect(mockMongoClient).toHaveBeenCalledWith( + expect.stringContaining('ssl=true'), + expect.any(Object) + ) + }) + + it('should not include ssl param when ssl is disabled', async () => { + await createMongoDBConnection({ + host: 'localhost', + port: 27017, + database: 'testdb', + ssl: 'disabled', + }) + + const calledUri = mockMongoClient.mock.calls[0][0] + expect(calledUri).not.toContain('ssl=') + }) + + it('should not include ssl param when ssl is preferred', async () => { + await createMongoDBConnection({ + host: 'localhost', + port: 27017, + database: 'testdb', + ssl: 'preferred', + }) + + const calledUri = mockMongoClient.mock.calls[0][0] + expect(calledUri).not.toContain('ssl=') + }) + + it('should include both authSource and ssl when both provided', async () => { + await createMongoDBConnection({ + host: 'localhost', + port: 27017, + database: 'testdb', + authSource: 'admin', + ssl: 'required', + }) + + const calledUri = mockMongoClient.mock.calls[0][0] + expect(calledUri).toContain('authSource=admin') + expect(calledUri).toContain('ssl=true') + }) + + it('should throw when host validation fails', async () => { + mockValidateDatabaseHost.mockResolvedValue({ + isValid: false, + error: 'Invalid host', + }) + + await expect( + createMongoDBConnection({ + host: 'bad-host', + port: 27017, + database: 'testdb', + }) + ).rejects.toThrow('Invalid host') + }) + + it('should use custom port number', async () => { + await createMongoDBConnection({ + host: 'localhost', + port: 27018, + database: 'testdb', + }) + + expect(mockMongoClient).toHaveBeenCalledWith( + expect.stringContaining('localhost:27018'), + expect.any(Object) + ) }) - await expect( - createMongoDBConnection({ - host: 'bad-host', + it('should call connect() on the client', async () => { + await createMongoDBConnection({ + host: 'localhost', port: 27017, database: 'testdb', }) - ).rejects.toThrow('Invalid host') + + expect(mockConnect).toHaveBeenCalled() + }) + + it('should propagate connect errors', async () => { + mockConnect.mockRejectedValueOnce(new Error('Connection failed')) + + await expect( + createMongoDBConnection({ + host: 'localhost', + port: 27017, + database: 'testdb', + }) + ).rejects.toThrow('Connection failed') + }) }) }) describe('validateFilter', () => { - it('should accept valid filters', () => { + it('should accept valid simple filter', () => { expect(validateFilter('{"status": "active"}')).toEqual({ isValid: true }) }) - it('should reject dangerous operators', () => { + it('should accept valid complex filter', () => { + expect( + validateFilter('{"$and": [{"age": {"$gte": 18}}, {"status": "active"}]}') + ).toEqual({ isValid: true }) + }) + + it('should accept empty object filter', () => { + expect(validateFilter('{}')).toEqual({ isValid: true }) + }) + + it('should accept filter with $in operator', () => { + expect(validateFilter('{"status": {"$in": ["active", "pending"]}}')).toEqual({ + isValid: true, + }) + }) + + it('should accept filter with $or operator', () => { + expect( + validateFilter('{"$or": [{"name": "Alice"}, {"name": "Bob"}]}') + ).toEqual({ isValid: true }) + }) + + it('should reject $where operator', () => { const result = validateFilter('{"$where": "this.a > 1"}') expect(result.isValid).toBe(false) expect(result.error).toContain('dangerous operators') }) + it('should reject $function operator', () => { + const result = validateFilter('{"$function": {"body": "function(){}", "args": [], "lang": "js"}}') + expect(result.isValid).toBe(false) + }) + + it('should reject $accumulator operator', () => { + const result = validateFilter('{"$accumulator": {"init": "function(){return 0}"}}') + expect(result.isValid).toBe(false) + }) + + it('should reject nested dangerous operators', () => { + const result = validateFilter('{"outer": {"$where": "this.a > 1"}}') + expect(result.isValid).toBe(false) + }) + + it('should reject $regex operator', () => { + const result = validateFilter('{"name": {"$regex": ".*"}}') + expect(result.isValid).toBe(false) + }) + + it('should reject $expr operator', () => { + const result = validateFilter('{"$expr": {"$gt": ["$a", "$b"]}}') + expect(result.isValid).toBe(false) + }) + it('should reject invalid JSON', () => { - const result = validateFilter('not json') + const result = validateFilter('not json at all') expect(result.isValid).toBe(false) expect(result.error).toContain('Invalid JSON') }) + + it('should reject malformed JSON', () => { + const result = validateFilter('{status: active}') + expect(result.isValid).toBe(false) + }) }) describe('validatePipeline', () => { - it('should accept valid pipelines', () => { + it('should accept valid $match pipeline', () => { expect(validatePipeline('[{"$match": {"status": "active"}}]')).toEqual({ isValid: true }) }) - it('should allow $vectorSearch stage', () => { + it('should accept multi-stage pipeline', () => { + const pipeline = JSON.stringify([ + { $match: { status: 'active' } }, + { $group: { _id: '$category', count: { $sum: 1 } } }, + { $sort: { count: -1 } }, + ]) + expect(validatePipeline(pipeline)).toEqual({ isValid: true }) + }) + + it('should accept empty pipeline array', () => { + expect(validatePipeline('[]')).toEqual({ isValid: true }) + }) + + it('should allow $vectorSearch stage (critical for Atlas)', () => { const pipeline = JSON.stringify([ { $vectorSearch: { @@ -183,29 +405,122 @@ describe('MongoDB Utils', () => { expect(validatePipeline(pipeline)).toEqual({ isValid: true }) }) - it('should reject dangerous pipeline operators', () => { + it('should allow $vectorSearch followed by $project', () => { + const pipeline = JSON.stringify([ + { + $vectorSearch: { + index: 'idx', + path: 'emb', + queryVector: [0.1], + numCandidates: 50, + limit: 5, + }, + }, + { $project: { title: 1, score: { $meta: 'vectorSearchScore' } } }, + ]) + expect(validatePipeline(pipeline)).toEqual({ isValid: true }) + }) + + it('should allow $lookup stage', () => { + const pipeline = JSON.stringify([ + { $lookup: { from: 'orders', localField: '_id', foreignField: 'userId', as: 'orders' } }, + ]) + expect(validatePipeline(pipeline)).toEqual({ isValid: true }) + }) + + it('should allow $geoNear stage', () => { + const pipeline = JSON.stringify([ + { $geoNear: { near: { type: 'Point', coordinates: [0, 0] }, distanceField: 'dist' } }, + ]) + expect(validatePipeline(pipeline)).toEqual({ isValid: true }) + }) + + it('should reject $merge operator', () => { const result = validatePipeline('[{"$merge": {"into": "other_collection"}}]') expect(result.isValid).toBe(false) }) + it('should reject $out operator', () => { + const result = validatePipeline('[{"$out": "output_collection"}]') + expect(result.isValid).toBe(false) + }) + + it('should reject $function operator in pipeline', () => { + const result = validatePipeline( + '[{"$addFields": {"result": {"$function": {"body": "function(){}", "args": [], "lang": "js"}}}}]' + ) + expect(result.isValid).toBe(false) + }) + + it('should reject $currentOp operator', () => { + const result = validatePipeline('[{"$currentOp": {}}]') + expect(result.isValid).toBe(false) + }) + + it('should reject $listSessions operator', () => { + const result = validatePipeline('[{"$listSessions": {}}]') + expect(result.isValid).toBe(false) + }) + it('should reject non-array pipelines', () => { const result = validatePipeline('{"$match": {}}') expect(result.isValid).toBe(false) expect(result.error).toContain('must be an array') }) + + it('should reject invalid JSON', () => { + const result = validatePipeline('not json') + expect(result.isValid).toBe(false) + expect(result.error).toContain('Invalid JSON') + }) + + it('should reject nested dangerous operators in pipeline stages', () => { + const result = validatePipeline( + '[{"$match": {"nested": {"$where": "dangerous"}}}]' + ) + expect(result.isValid).toBe(false) + }) }) describe('sanitizeCollectionName', () => { - it('should accept valid collection names', () => { + it('should accept alphabetic collection names', () => { expect(sanitizeCollectionName('users')).toBe('users') + expect(sanitizeCollectionName('Products')).toBe('Products') + }) + + it('should accept names with underscores', () => { expect(sanitizeCollectionName('my_collection')).toBe('my_collection') expect(sanitizeCollectionName('_private')).toBe('_private') }) - it('should reject invalid collection names', () => { + it('should accept names with numbers (not leading)', () => { + expect(sanitizeCollectionName('users2')).toBe('users2') + expect(sanitizeCollectionName('collection_v3')).toBe('collection_v3') + }) + + it('should reject names with hyphens', () => { expect(() => sanitizeCollectionName('invalid-name')).toThrow('Invalid collection name') + }) + + it('should reject names starting with numbers', () => { expect(() => sanitizeCollectionName('123start')).toThrow('Invalid collection name') + }) + + it('should reject names with spaces', () => { expect(() => sanitizeCollectionName('has space')).toThrow('Invalid collection name') }) + + it('should reject names with dots', () => { + expect(() => sanitizeCollectionName('system.users')).toThrow('Invalid collection name') + }) + + it('should reject empty string', () => { + expect(() => sanitizeCollectionName('')).toThrow('Invalid collection name') + }) + + it('should reject names with special characters', () => { + expect(() => sanitizeCollectionName('col$ection')).toThrow('Invalid collection name') + expect(() => sanitizeCollectionName('col@ection')).toThrow('Invalid collection name') + }) }) }) diff --git a/apps/sim/blocks/blocks/voyageai.test.ts b/apps/sim/blocks/blocks/voyageai.test.ts index 67e91299b40..53c16b4a5ad 100644 --- a/apps/sim/blocks/blocks/voyageai.test.ts +++ b/apps/sim/blocks/blocks/voyageai.test.ts @@ -3,6 +3,7 @@ */ import { beforeEach, describe, expect, it, vi } from 'vitest' import { VoyageAIBlock } from '@/blocks/blocks/voyageai' +import { AuthMode, IntegrationType } from '@/blocks/types' describe('VoyageAIBlock', () => { beforeEach(() => { @@ -10,15 +11,72 @@ describe('VoyageAIBlock', () => { }) describe('block properties', () => { - it('should have required properties', () => { + it('should have correct type and name', () => { expect(VoyageAIBlock.type).toBe('voyageai') expect(VoyageAIBlock.name).toBe('Voyage AI') + }) + + it('should be in the tools category', () => { expect(VoyageAIBlock.category).toBe('tools') + }) + + it('should have AI integration type', () => { + expect(VoyageAIBlock.integrationType).toBe(IntegrationType.AI) + }) + + it('should have correct tags', () => { + expect(VoyageAIBlock.tags).toEqual(['llm', 'vector-search']) + }) + + it('should use API key auth mode', () => { + expect(VoyageAIBlock.authMode).toBe(AuthMode.ApiKey) + }) + + it('should have an icon defined', () => { expect(VoyageAIBlock.icon).toBeDefined() + expect(typeof VoyageAIBlock.icon).toBe('function') + }) + + it('should have a description and long description', () => { + expect(VoyageAIBlock.description).toBeTruthy() + expect(VoyageAIBlock.longDescription).toBeTruthy() + }) + + it('should have a background color', () => { + expect(VoyageAIBlock.bgColor).toBe('#1A1A2E') + }) + + it('should list both tool IDs in access', () => { expect(VoyageAIBlock.tools.access).toEqual(['voyageai_embeddings', 'voyageai_rerank']) }) - it('should have subBlocks with correct operation conditions', () => { + it('should have tools.config.tool and tools.config.params functions', () => { + expect(VoyageAIBlock.tools.config).toBeDefined() + expect(typeof VoyageAIBlock.tools.config!.tool).toBe('function') + expect(typeof VoyageAIBlock.tools.config!.params).toBe('function') + }) + }) + + describe('subBlocks structure', () => { + it('should have an operation dropdown as first subBlock', () => { + const opBlock = VoyageAIBlock.subBlocks[0] + expect(opBlock.id).toBe('operation') + expect(opBlock.type).toBe('dropdown') + }) + + it('should have embeddings and rerank operations', () => { + const opBlock = VoyageAIBlock.subBlocks[0] as any + const optionIds = opBlock.options.map((o: any) => o.id) + expect(optionIds).toContain('embeddings') + expect(optionIds).toContain('rerank') + }) + + it('should default to embeddings operation', () => { + const opBlock = VoyageAIBlock.subBlocks[0] as any + expect(opBlock.value()).toBe('embeddings') + }) + + it('should have embeddings-specific subBlocks with correct conditions', () => { const embeddingsBlocks = VoyageAIBlock.subBlocks.filter( (sb) => sb.condition && @@ -26,6 +84,13 @@ describe('VoyageAIBlock', () => { 'value' in sb.condition && sb.condition.value === 'embeddings' ) + const ids = embeddingsBlocks.map((sb) => sb.id) + expect(ids).toContain('input') + expect(ids).toContain('embeddingModel') + expect(ids).toContain('inputType') + }) + + it('should have rerank-specific subBlocks with correct conditions', () => { const rerankBlocks = VoyageAIBlock.subBlocks.filter( (sb) => sb.condition && @@ -33,17 +98,98 @@ describe('VoyageAIBlock', () => { 'value' in sb.condition && sb.condition.value === 'rerank' ) - expect(embeddingsBlocks.length).toBeGreaterThan(0) - expect(rerankBlocks.length).toBeGreaterThan(0) + const ids = rerankBlocks.map((sb) => sb.id) + expect(ids).toContain('query') + expect(ids).toContain('documents') + expect(ids).toContain('rerankModel') + expect(ids).toContain('topK') + }) + + it('should have apiKey subBlock without condition (always visible)', () => { + const apiKeyBlock = VoyageAIBlock.subBlocks.find((sb) => sb.id === 'apiKey') + expect(apiKeyBlock).toBeDefined() + expect(apiKeyBlock!.condition).toBeUndefined() + expect(apiKeyBlock!.required).toBe(true) + expect((apiKeyBlock as any).password).toBe(true) + }) + + it('should have input as required', () => { + const inputBlock = VoyageAIBlock.subBlocks.find((sb) => sb.id === 'input') + expect(inputBlock).toBeDefined() + expect(inputBlock!.required).toBe(true) + }) + + it('should have query as required for rerank', () => { + const queryBlock = VoyageAIBlock.subBlocks.find((sb) => sb.id === 'query') + expect(queryBlock).toBeDefined() + expect(queryBlock!.required).toBe(true) + }) + + it('should have documents as required for rerank', () => { + const docsBlock = VoyageAIBlock.subBlocks.find((sb) => sb.id === 'documents') + expect(docsBlock).toBeDefined() + expect(docsBlock!.required).toBe(true) + expect(docsBlock!.type).toBe('code') + }) + + it('should have inputType in advanced mode', () => { + const inputTypeBlock = VoyageAIBlock.subBlocks.find((sb) => sb.id === 'inputType') + expect(inputTypeBlock).toBeDefined() + expect(inputTypeBlock!.mode).toBe('advanced') + }) + + it('should have topK in advanced mode', () => { + const topKBlock = VoyageAIBlock.subBlocks.find((sb) => sb.id === 'topK') + expect(topKBlock).toBeDefined() + expect(topKBlock!.mode).toBe('advanced') + }) + + it('should have all 6 embedding models in the dropdown', () => { + const modelBlock = VoyageAIBlock.subBlocks.find((sb) => sb.id === 'embeddingModel') as any + expect(modelBlock).toBeDefined() + const modelIds = modelBlock.options.map((o: any) => o.id) + expect(modelIds).toContain('voyage-3-large') + expect(modelIds).toContain('voyage-3') + expect(modelIds).toContain('voyage-3-lite') + expect(modelIds).toContain('voyage-code-3') + expect(modelIds).toContain('voyage-finance-2') + expect(modelIds).toContain('voyage-law-2') + }) + + it('should have both rerank models in the dropdown', () => { + const modelBlock = VoyageAIBlock.subBlocks.find((sb) => sb.id === 'rerankModel') as any + expect(modelBlock).toBeDefined() + const modelIds = modelBlock.options.map((o: any) => o.id) + expect(modelIds).toContain('rerank-2') + expect(modelIds).toContain('rerank-2-lite') }) }) - describe('tools.config.tool', () => { - const toolFunction = VoyageAIBlock.tools.config?.tool + describe('inputs and outputs', () => { + it('should define all input fields', () => { + const inputKeys = Object.keys(VoyageAIBlock.inputs) + expect(inputKeys).toContain('operation') + expect(inputKeys).toContain('input') + expect(inputKeys).toContain('embeddingModel') + expect(inputKeys).toContain('inputType') + expect(inputKeys).toContain('query') + expect(inputKeys).toContain('documents') + expect(inputKeys).toContain('rerankModel') + expect(inputKeys).toContain('topK') + expect(inputKeys).toContain('apiKey') + }) - if (!toolFunction) { - throw new Error('VoyageAIBlock.tools.config.tool is missing') - } + it('should define output fields', () => { + const outputKeys = Object.keys(VoyageAIBlock.outputs) + expect(outputKeys).toContain('embeddings') + expect(outputKeys).toContain('results') + expect(outputKeys).toContain('model') + expect(outputKeys).toContain('usage') + }) + }) + + describe('tools.config.tool', () => { + const toolFunction = VoyageAIBlock.tools.config!.tool! it('should return voyageai_embeddings for embeddings operation', () => { expect(toolFunction({ operation: 'embeddings' })).toBe('voyageai_embeddings') @@ -56,89 +202,196 @@ describe('VoyageAIBlock', () => { it('should throw for invalid operation', () => { expect(() => toolFunction({ operation: 'invalid' })).toThrow('Invalid Voyage AI operation') }) + + it('should throw for empty operation', () => { + expect(() => toolFunction({ operation: '' })).toThrow() + }) + + it('should throw for undefined operation', () => { + expect(() => toolFunction({})).toThrow() + }) }) describe('tools.config.params', () => { - const paramsFunction = VoyageAIBlock.tools.config?.params - - if (!paramsFunction) { - throw new Error('VoyageAIBlock.tools.config.params is missing') - } - - it('should pass correct fields for embeddings operation', () => { - const result = paramsFunction({ - operation: 'embeddings', - apiKey: 'va-key', - input: 'hello world', - embeddingModel: 'voyage-3-large', - inputType: 'query', + const paramsFunction = VoyageAIBlock.tools.config!.params! + + describe('embeddings operation', () => { + it('should pass correct fields with all options', () => { + const result = paramsFunction({ + operation: 'embeddings', + apiKey: 'va-key', + input: 'hello world', + embeddingModel: 'voyage-3-large', + inputType: 'query', + }) + expect(result).toEqual({ + apiKey: 'va-key', + input: 'hello world', + model: 'voyage-3-large', + inputType: 'query', + }) }) - expect(result).toEqual({ - apiKey: 'va-key', - input: 'hello world', - model: 'voyage-3-large', - inputType: 'query', + + it('should omit inputType when not provided', () => { + const result = paramsFunction({ + operation: 'embeddings', + apiKey: 'va-key', + input: 'hello world', + embeddingModel: 'voyage-3', + }) + expect(result.inputType).toBeUndefined() + expect('inputType' in result).toBe(false) }) - }) - it('should omit inputType when not provided', () => { - const result = paramsFunction({ - operation: 'embeddings', - apiKey: 'va-key', - input: 'hello world', - embeddingModel: 'voyage-3', + it('should omit inputType when empty string', () => { + const result = paramsFunction({ + operation: 'embeddings', + apiKey: 'va-key', + input: 'hello', + embeddingModel: 'voyage-3', + inputType: '', + }) + expect(result.inputType).toBeUndefined() }) - expect(result.inputType).toBeUndefined() - }) - it('should parse JSON string documents for rerank', () => { - const result = paramsFunction({ - operation: 'rerank', - apiKey: 'va-key', - query: 'search query', - documents: '["doc1", "doc2"]', - rerankModel: 'rerank-2', + it('should map embeddingModel to model param', () => { + const result = paramsFunction({ + operation: 'embeddings', + apiKey: 'va-key', + input: 'hello', + embeddingModel: 'voyage-code-3', + }) + expect(result.model).toBe('voyage-code-3') + expect(result.embeddingModel).toBeUndefined() }) - expect(result).toEqual({ - apiKey: 'va-key', - query: 'search query', - documents: ['doc1', 'doc2'], - model: 'rerank-2', + + it('should not include rerank-specific fields', () => { + const result = paramsFunction({ + operation: 'embeddings', + apiKey: 'va-key', + input: 'hello', + embeddingModel: 'voyage-3', + query: 'should not appear', + documents: '["should not appear"]', + }) + expect(result.query).toBeUndefined() + expect(result.documents).toBeUndefined() }) }) - it('should handle array documents for rerank', () => { - const result = paramsFunction({ - operation: 'rerank', - apiKey: 'va-key', - query: 'search query', - documents: ['doc1', 'doc2'], - rerankModel: 'rerank-2', + describe('rerank operation', () => { + it('should parse JSON string documents', () => { + const result = paramsFunction({ + operation: 'rerank', + apiKey: 'va-key', + query: 'search query', + documents: '["doc1", "doc2"]', + rerankModel: 'rerank-2', + }) + expect(result).toEqual({ + apiKey: 'va-key', + query: 'search query', + documents: ['doc1', 'doc2'], + model: 'rerank-2', + }) }) - expect(result.documents).toEqual(['doc1', 'doc2']) - }) - it('should convert topK string to number', () => { - const result = paramsFunction({ - operation: 'rerank', - apiKey: 'va-key', - query: 'search query', - documents: ['doc1'], - rerankModel: 'rerank-2', - topK: '5', + it('should handle array documents directly', () => { + const result = paramsFunction({ + operation: 'rerank', + apiKey: 'va-key', + query: 'search query', + documents: ['doc1', 'doc2'], + rerankModel: 'rerank-2', + }) + expect(result.documents).toEqual(['doc1', 'doc2']) + }) + + it('should convert topK string to number', () => { + const result = paramsFunction({ + operation: 'rerank', + apiKey: 'va-key', + query: 'q', + documents: ['doc1'], + rerankModel: 'rerank-2', + topK: '5', + }) + expect(result.topK).toBe(5) + expect(typeof result.topK).toBe('number') + }) + + it('should handle topK as number directly', () => { + const result = paramsFunction({ + operation: 'rerank', + apiKey: 'va-key', + query: 'q', + documents: ['doc1'], + rerankModel: 'rerank-2', + topK: 10, + }) + expect(result.topK).toBe(10) + }) + + it('should omit topK when not provided', () => { + const result = paramsFunction({ + operation: 'rerank', + apiKey: 'va-key', + query: 'q', + documents: ['doc1'], + rerankModel: 'rerank-2', + }) + expect(result.topK).toBeUndefined() + }) + + it('should omit topK when empty string', () => { + const result = paramsFunction({ + operation: 'rerank', + apiKey: 'va-key', + query: 'q', + documents: ['doc1'], + rerankModel: 'rerank-2', + topK: '', + }) + expect(result.topK).toBeUndefined() + }) + + it('should map rerankModel to model param', () => { + const result = paramsFunction({ + operation: 'rerank', + apiKey: 'va-key', + query: 'q', + documents: ['doc1'], + rerankModel: 'rerank-2-lite', + }) + expect(result.model).toBe('rerank-2-lite') + expect(result.rerankModel).toBeUndefined() + }) + + it('should throw on invalid JSON documents string', () => { + expect(() => + paramsFunction({ + operation: 'rerank', + apiKey: 'va-key', + query: 'q', + documents: 'not valid json', + rerankModel: 'rerank-2', + }) + ).toThrow() }) - expect(result.topK).toBe(5) - }) - it('should omit topK when not provided', () => { - const result = paramsFunction({ - operation: 'rerank', - apiKey: 'va-key', - query: 'search query', - documents: ['doc1'], - rerankModel: 'rerank-2', + it('should not include embedding-specific fields', () => { + const result = paramsFunction({ + operation: 'rerank', + apiKey: 'va-key', + query: 'q', + documents: ['doc'], + rerankModel: 'rerank-2', + input: 'should not appear', + embeddingModel: 'should not appear', + }) + expect(result.input).toBeUndefined() + expect(result.embeddingModel).toBeUndefined() }) - expect(result.topK).toBeUndefined() }) }) }) diff --git a/apps/sim/tools/voyageai/voyageai.integration.test.ts b/apps/sim/tools/voyageai/voyageai.integration.test.ts new file mode 100644 index 00000000000..f65f05b6e70 --- /dev/null +++ b/apps/sim/tools/voyageai/voyageai.integration.test.ts @@ -0,0 +1,459 @@ +/** + * @vitest-environment node + * + * Integration tests for VoyageAI tools. + * These tests call the real VoyageAI API and require a valid API key. + * Set VOYAGEAI_API_KEY env var or they will be skipped. + */ +import { describe, expect, it } from 'vitest' +import { embeddingsTool } from '@/tools/voyageai/embeddings' +import { rerankTool } from '@/tools/voyageai/rerank' + +const API_KEY = process.env.VOYAGEAI_API_KEY +const describeIntegration = API_KEY ? describe : describe.skip + +/** + * Use undici's fetch directly to bypass the global fetch mock set up in vitest.setup.ts. + */ +async function liveFetch(url: string, init: RequestInit): Promise { + // vi.mocked(fetch) is the mock — call the real underlying impl + const { request } = await import('undici') + const resp = await request(url, { + method: init.method as any, + headers: init.headers as Record, + body: init.body as string, + }) + const bodyText = await resp.body.text() + return new Response(bodyText, { + status: resp.statusCode, + headers: resp.headers as Record, + }) +} + +describeIntegration('VoyageAI Integration Tests (live API)', () => { + describe('Embeddings API', () => { + it('should generate embeddings for a single text with voyage-3', async () => { + const body = embeddingsTool.request.body!({ + apiKey: API_KEY!, + input: 'Hello world, this is a test.', + }) + const headers = embeddingsTool.request.headers({ apiKey: API_KEY!, input: '' }) + const url = + typeof embeddingsTool.request.url === 'function' + ? embeddingsTool.request.url({ apiKey: API_KEY!, input: '' }) + : embeddingsTool.request.url + + const response = await liveFetch(url, { + method: 'POST', + headers, + body: JSON.stringify(body), + }) + + expect(response.ok).toBe(true) + + const result = await embeddingsTool.transformResponse!(response) + expect(result.success).toBe(true) + expect(result.output.embeddings).toHaveLength(1) + expect(result.output.embeddings[0].length).toBeGreaterThan(100) + expect(result.output.model).toBe('voyage-3') + expect(result.output.usage.total_tokens).toBeGreaterThan(0) + }, 15000) + + it('should generate embeddings for multiple texts', async () => { + const body = embeddingsTool.request.body!({ + apiKey: API_KEY!, + input: ['First document about AI', 'Second document about cooking', 'Third about sports'], + }) + const headers = embeddingsTool.request.headers({ apiKey: API_KEY!, input: '' }) + const url = + typeof embeddingsTool.request.url === 'function' + ? embeddingsTool.request.url({ apiKey: API_KEY!, input: '' }) + : embeddingsTool.request.url + + const response = await liveFetch(url, { + method: 'POST', + headers, + body: JSON.stringify(body), + }) + + expect(response.ok).toBe(true) + + const result = await embeddingsTool.transformResponse!(response) + expect(result.success).toBe(true) + expect(result.output.embeddings).toHaveLength(3) + expect(result.output.embeddings[0].length).toBe(result.output.embeddings[1].length) + }, 15000) + + it('should generate 1024-dimensional embeddings with voyage-3-large', async () => { + const body = embeddingsTool.request.body!({ + apiKey: API_KEY!, + input: 'Test embedding dimensions', + model: 'voyage-3-large', + }) + const headers = embeddingsTool.request.headers({ apiKey: API_KEY!, input: '' }) + const url = + typeof embeddingsTool.request.url === 'function' + ? embeddingsTool.request.url({ apiKey: API_KEY!, input: '' }) + : embeddingsTool.request.url + + const response = await liveFetch(url, { + method: 'POST', + headers, + body: JSON.stringify(body), + }) + + expect(response.ok).toBe(true) + + const result = await embeddingsTool.transformResponse!(response) + expect(result.output.embeddings[0]).toHaveLength(1024) + expect(result.output.model).toBe('voyage-3-large') + }, 15000) + + it('should generate 512-dimensional embeddings with voyage-3-lite', async () => { + const body = embeddingsTool.request.body!({ + apiKey: API_KEY!, + input: 'Test lite model', + model: 'voyage-3-lite', + }) + const headers = embeddingsTool.request.headers({ apiKey: API_KEY!, input: '' }) + const url = + typeof embeddingsTool.request.url === 'function' + ? embeddingsTool.request.url({ apiKey: API_KEY!, input: '' }) + : embeddingsTool.request.url + + const response = await liveFetch(url, { + method: 'POST', + headers, + body: JSON.stringify(body), + }) + + expect(response.ok).toBe(true) + + const result = await embeddingsTool.transformResponse!(response) + expect(result.output.embeddings[0]).toHaveLength(512) + expect(result.output.model).toBe('voyage-3-lite') + }, 15000) + + it('should respect input_type parameter', async () => { + const body = embeddingsTool.request.body!({ + apiKey: API_KEY!, + input: 'search query text', + inputType: 'query', + }) + const headers = embeddingsTool.request.headers({ apiKey: API_KEY!, input: '' }) + const url = + typeof embeddingsTool.request.url === 'function' + ? embeddingsTool.request.url({ apiKey: API_KEY!, input: '' }) + : embeddingsTool.request.url + + const response = await liveFetch(url, { + method: 'POST', + headers, + body: JSON.stringify(body), + }) + + expect(response.ok).toBe(true) + + const result = await embeddingsTool.transformResponse!(response) + expect(result.success).toBe(true) + expect(result.output.embeddings).toHaveLength(1) + }, 15000) + + it('should produce different embeddings for different texts', async () => { + const body = embeddingsTool.request.body!({ + apiKey: API_KEY!, + input: ['The sun is bright', 'Quantum computing is complex'], + }) + const headers = embeddingsTool.request.headers({ apiKey: API_KEY!, input: '' }) + const url = + typeof embeddingsTool.request.url === 'function' + ? embeddingsTool.request.url({ apiKey: API_KEY!, input: '' }) + : embeddingsTool.request.url + + const response = await liveFetch(url, { + method: 'POST', + headers, + body: JSON.stringify(body), + }) + + const result = await embeddingsTool.transformResponse!(response) + const emb1 = result.output.embeddings[0] + const emb2 = result.output.embeddings[1] + + expect(emb1).not.toEqual(emb2) + + const dotProduct = emb1.reduce( + (sum: number, val: number, i: number) => sum + val * emb2[i], + 0 + ) + expect(dotProduct).toBeLessThan(1.0) + }, 15000) + + it('should reject invalid API key', async () => { + const headers = embeddingsTool.request.headers({ apiKey: 'invalid-key', input: '' }) + const body = embeddingsTool.request.body!({ + apiKey: 'invalid-key', + input: 'test', + }) + const url = + typeof embeddingsTool.request.url === 'function' + ? embeddingsTool.request.url({ apiKey: 'invalid-key', input: '' }) + : embeddingsTool.request.url + + const response = await liveFetch(url, { + method: 'POST', + headers, + body: JSON.stringify(body), + }) + + expect(response.ok).toBe(false) + expect(response.status).toBe(401) + }, 15000) + }) + + describe('Rerank API', () => { + it('should rerank documents by relevance', async () => { + const documents = [ + 'The weather is sunny today', + 'Artificial intelligence is transforming healthcare', + 'Machine learning algorithms can detect patterns', + ] + + const body = rerankTool.request.body!({ + apiKey: API_KEY!, + query: 'What is artificial intelligence?', + documents, + }) + const headers = rerankTool.request.headers({ apiKey: API_KEY!, query: '', documents: [] }) + const url = + typeof rerankTool.request.url === 'function' + ? rerankTool.request.url({ apiKey: API_KEY!, query: '', documents: [] }) + : rerankTool.request.url + + const response = await liveFetch(url, { + method: 'POST', + headers, + body: JSON.stringify(body), + }) + + expect(response.ok).toBe(true) + + const result = await rerankTool.transformResponse!(response, { + apiKey: API_KEY!, + query: 'What is artificial intelligence?', + documents, + }) + + expect(result.success).toBe(true) + expect(result.output.results).toHaveLength(3) + expect(result.output.model).toBe('rerank-2') + expect(result.output.usage.total_tokens).toBeGreaterThan(0) + + for (const r of result.output.results) { + expect(r.relevance_score).toBeGreaterThanOrEqual(0) + expect(r.relevance_score).toBeLessThanOrEqual(1) + expect(r.index).toBeGreaterThanOrEqual(0) + expect(r.index).toBeLessThan(3) + expect(r.document).toBeTruthy() + } + + expect(result.output.results[0].relevance_score).toBeGreaterThanOrEqual( + result.output.results[1].relevance_score + ) + }, 15000) + + it('should respect top_k parameter', async () => { + const documents = ['doc A', 'doc B', 'doc C', 'doc D', 'doc E'] + + const body = rerankTool.request.body!({ + apiKey: API_KEY!, + query: 'test query', + documents, + topK: 2, + }) + const headers = rerankTool.request.headers({ apiKey: API_KEY!, query: '', documents: [] }) + const url = + typeof rerankTool.request.url === 'function' + ? rerankTool.request.url({ apiKey: API_KEY!, query: '', documents: [] }) + : rerankTool.request.url + + const response = await liveFetch(url, { + method: 'POST', + headers, + body: JSON.stringify(body), + }) + + expect(response.ok).toBe(true) + + const result = await rerankTool.transformResponse!(response, { + apiKey: API_KEY!, + query: 'test query', + documents, + topK: 2, + }) + + expect(result.output.results).toHaveLength(2) + }, 15000) + + it('should work with rerank-2-lite model', async () => { + const body = rerankTool.request.body!({ + apiKey: API_KEY!, + query: 'Python programming', + documents: ['Python is a language', 'Java is also a language'], + model: 'rerank-2-lite', + }) + const headers = rerankTool.request.headers({ apiKey: API_KEY!, query: '', documents: [] }) + const url = + typeof rerankTool.request.url === 'function' + ? rerankTool.request.url({ apiKey: API_KEY!, query: '', documents: [] }) + : rerankTool.request.url + + const response = await liveFetch(url, { + method: 'POST', + headers, + body: JSON.stringify(body), + }) + + expect(response.ok).toBe(true) + + const result = await rerankTool.transformResponse!(response, { + apiKey: API_KEY!, + query: 'Python programming', + documents: ['Python is a language', 'Java is also a language'], + model: 'rerank-2-lite', + }) + + expect(result.output.model).toBe('rerank-2-lite') + expect(result.output.results).toHaveLength(2) + }, 15000) + + it('should correctly map document text back from indices', async () => { + const documents = ['Alpha document', 'Beta document', 'Gamma document'] + + const body = rerankTool.request.body!({ + apiKey: API_KEY!, + query: 'gamma', + documents, + }) + const headers = rerankTool.request.headers({ apiKey: API_KEY!, query: '', documents: [] }) + const url = + typeof rerankTool.request.url === 'function' + ? rerankTool.request.url({ apiKey: API_KEY!, query: '', documents: [] }) + : rerankTool.request.url + + const response = await liveFetch(url, { + method: 'POST', + headers, + body: JSON.stringify(body), + }) + + const result = await rerankTool.transformResponse!(response, { + apiKey: API_KEY!, + query: 'gamma', + documents, + }) + + for (const r of result.output.results) { + expect(r.document).toBe(documents[r.index]) + } + }, 15000) + + it('should reject invalid API key', async () => { + const headers = rerankTool.request.headers({ apiKey: 'invalid-key', query: '', documents: [] }) + const body = rerankTool.request.body!({ + apiKey: 'invalid-key', + query: 'test', + documents: ['doc'], + }) + const url = + typeof rerankTool.request.url === 'function' + ? rerankTool.request.url({ apiKey: 'invalid-key', query: '', documents: [] }) + : rerankTool.request.url + + const response = await liveFetch(url, { + method: 'POST', + headers, + body: JSON.stringify(body), + }) + + expect(response.ok).toBe(false) + expect(response.status).toBe(401) + }, 15000) + }) + + describe('End-to-end workflow: Embed then Rerank', () => { + it('should embed documents and then rerank them', async () => { + const documents = [ + 'Neural networks are inspired by biological neurons', + 'The recipe calls for two cups of flour', + 'Deep learning has revolutionized natural language processing', + 'Football is the most popular sport worldwide', + ] + + const embedBody = embeddingsTool.request.body!({ + apiKey: API_KEY!, + input: documents, + inputType: 'document', + }) + const embedHeaders = embeddingsTool.request.headers({ apiKey: API_KEY!, input: '' }) + const embedUrl = + typeof embeddingsTool.request.url === 'function' + ? embeddingsTool.request.url({ apiKey: API_KEY!, input: '' }) + : embeddingsTool.request.url + + const embedResponse = await liveFetch(embedUrl, { + method: 'POST', + headers: embedHeaders, + body: JSON.stringify(embedBody), + }) + + const embedResult = await embeddingsTool.transformResponse!(embedResponse) + expect(embedResult.success).toBe(true) + expect(embedResult.output.embeddings).toHaveLength(4) + + const rerankBody = rerankTool.request.body!({ + apiKey: API_KEY!, + query: 'What are neural networks used for?', + documents, + }) + const rerankHeaders = rerankTool.request.headers({ apiKey: API_KEY!, query: '', documents: [] }) + const rerankUrl = + typeof rerankTool.request.url === 'function' + ? rerankTool.request.url({ apiKey: API_KEY!, query: '', documents: [] }) + : rerankTool.request.url + + const rerankResponse = await liveFetch(rerankUrl, { + method: 'POST', + headers: rerankHeaders, + body: JSON.stringify(rerankBody), + }) + + const rerankResult = await rerankTool.transformResponse!(rerankResponse, { + apiKey: API_KEY!, + query: 'What are neural networks used for?', + documents, + }) + + expect(rerankResult.success).toBe(true) + expect(rerankResult.output.results).toHaveLength(4) + + // Verify results are sorted by relevance (descending) + for (let i = 0; i < rerankResult.output.results.length - 1; i++) { + expect(rerankResult.output.results[i].relevance_score).toBeGreaterThanOrEqual( + rerankResult.output.results[i + 1].relevance_score + ) + } + + // Verify all documents are mapped back correctly + for (const r of rerankResult.output.results) { + expect(r.document).toBe(documents[r.index]) + } + + // The AI-related docs should score higher than the unrelated ones + const aiDocIndices = [0, 2] // "Neural networks..." and "Deep learning..." + const topTwoIndices = rerankResult.output.results.slice(0, 2).map((r: any) => r.index) + const aiDocsInTop2 = topTwoIndices.filter((i: number) => aiDocIndices.includes(i)) + expect(aiDocsInTop2.length).toBeGreaterThanOrEqual(1) + }, 30000) + }) +}) diff --git a/apps/sim/tools/voyageai/voyageai.test.ts b/apps/sim/tools/voyageai/voyageai.test.ts index eed9bd67e96..21bb1de9bea 100644 --- a/apps/sim/tools/voyageai/voyageai.test.ts +++ b/apps/sim/tools/voyageai/voyageai.test.ts @@ -18,12 +18,52 @@ describe('Voyage AI Embeddings Tool', () => { vi.resetAllMocks() }) + describe('Tool metadata', () => { + it('should have correct id and name', () => { + expect(embeddingsTool.id).toBe('voyageai_embeddings') + expect(embeddingsTool.name).toBe('Voyage AI Embeddings') + expect(embeddingsTool.version).toBe('1.0') + }) + + it('should have all required params defined', () => { + expect(embeddingsTool.params.input).toBeDefined() + expect(embeddingsTool.params.input.required).toBe(true) + expect(embeddingsTool.params.apiKey).toBeDefined() + expect(embeddingsTool.params.apiKey.required).toBe(true) + expect(embeddingsTool.params.model).toBeDefined() + expect(embeddingsTool.params.model.required).toBe(false) + expect(embeddingsTool.params.model.default).toBe('voyage-3') + expect(embeddingsTool.params.inputType).toBeDefined() + expect(embeddingsTool.params.inputType.required).toBe(false) + }) + + it('should have apiKey visibility as user-only', () => { + expect(embeddingsTool.params.apiKey.visibility).toBe('user-only') + }) + + it('should have output schema defined', () => { + expect(embeddingsTool.outputs).toBeDefined() + expect(embeddingsTool.outputs!.success).toBeDefined() + expect(embeddingsTool.outputs!.output).toBeDefined() + }) + + it('should use POST method', () => { + expect(embeddingsTool.request.method).toBe('POST') + }) + }) + describe('URL Construction', () => { it('should return the VoyageAI embeddings endpoint', () => { expect(tester.getRequestUrl({ apiKey: 'test-key', input: 'hello' })).toBe( 'https://api.voyageai.com/v1/embeddings' ) }) + + it('should return the same URL regardless of params', () => { + expect(tester.getRequestUrl({ apiKey: 'key', input: 'a', model: 'voyage-3-large' })).toBe( + 'https://api.voyageai.com/v1/embeddings' + ) + }) }) describe('Headers Construction', () => { @@ -32,12 +72,23 @@ describe('Voyage AI Embeddings Tool', () => { expect(headers.Authorization).toBe('Bearer va-test-key') expect(headers['Content-Type']).toBe('application/json') }) + + it('should use the exact apiKey provided', () => { + const headers = tester.getRequestHeaders({ apiKey: 'pa-abc123xyz', input: 'hello' }) + expect(headers.Authorization).toBe('Bearer pa-abc123xyz') + }) + + it('should only have Authorization and Content-Type headers', () => { + const headers = tester.getRequestHeaders({ apiKey: 'key', input: 'hello' }) + expect(Object.keys(headers)).toEqual(['Authorization', 'Content-Type']) + }) }) describe('Body Construction', () => { it('should wrap single string input into array', () => { const body = tester.getRequestBody({ apiKey: 'key', input: 'hello world' }) expect(body.input).toEqual(['hello world']) + expect(Array.isArray(body.input)).toBe(true) }) it('should pass array input directly', () => { @@ -45,29 +96,81 @@ describe('Voyage AI Embeddings Tool', () => { expect(body.input).toEqual(['text1', 'text2']) }) - it('should use default model when not specified', () => { + it('should handle single-element array input', () => { + const body = tester.getRequestBody({ apiKey: 'key', input: ['only one'] }) + expect(body.input).toEqual(['only one']) + }) + + it('should handle empty string input', () => { + const body = tester.getRequestBody({ apiKey: 'key', input: '' }) + expect(body.input).toEqual(['']) + }) + + it('should handle large array of inputs', () => { + const inputs = Array.from({ length: 100 }, (_, i) => `text ${i}`) + const body = tester.getRequestBody({ apiKey: 'key', input: inputs }) + expect(body.input).toHaveLength(100) + expect(body.input[99]).toBe('text 99') + }) + + it('should use default model voyage-3 when not specified', () => { const body = tester.getRequestBody({ apiKey: 'key', input: 'hello' }) expect(body.model).toBe('voyage-3') }) - it('should use specified model', () => { + it('should use specified model voyage-3-large', () => { const body = tester.getRequestBody({ apiKey: 'key', input: 'hello', model: 'voyage-3-large' }) expect(body.model).toBe('voyage-3-large') }) - it('should include input_type when provided', () => { + it('should use specified model voyage-3-lite', () => { + const body = tester.getRequestBody({ apiKey: 'key', input: 'hello', model: 'voyage-3-lite' }) + expect(body.model).toBe('voyage-3-lite') + }) + + it('should use specified model voyage-code-3', () => { + const body = tester.getRequestBody({ apiKey: 'key', input: 'hello', model: 'voyage-code-3' }) + expect(body.model).toBe('voyage-code-3') + }) + + it('should use specified model voyage-finance-2', () => { + const body = tester.getRequestBody({ + apiKey: 'key', + input: 'hello', + model: 'voyage-finance-2', + }) + expect(body.model).toBe('voyage-finance-2') + }) + + it('should use specified model voyage-law-2', () => { + const body = tester.getRequestBody({ apiKey: 'key', input: 'hello', model: 'voyage-law-2' }) + expect(body.model).toBe('voyage-law-2') + }) + + it('should include input_type query when provided', () => { const body = tester.getRequestBody({ apiKey: 'key', input: 'hello', inputType: 'query' }) expect(body.input_type).toBe('query') }) + it('should include input_type document when provided', () => { + const body = tester.getRequestBody({ apiKey: 'key', input: 'hello', inputType: 'document' }) + expect(body.input_type).toBe('document') + }) + it('should omit input_type when not provided', () => { const body = tester.getRequestBody({ apiKey: 'key', input: 'hello' }) expect(body.input_type).toBeUndefined() + expect('input_type' in body).toBe(false) + }) + + it('should not include apiKey in body', () => { + const body = tester.getRequestBody({ apiKey: 'key', input: 'hello' }) + expect(body.apiKey).toBeUndefined() }) }) describe('Response Transformation', () => { - it('should extract embeddings, model, and usage', async () => { + it('should extract embeddings, model, and usage for multiple inputs', async () => { tester.setup({ data: [ { embedding: [0.1, 0.2, 0.3], index: 0 }, @@ -86,21 +189,84 @@ describe('Voyage AI Embeddings Tool', () => { expect(result.output.model).toBe('voyage-3') expect(result.output.usage.total_tokens).toBe(10) }) + + it('should handle single embedding result', async () => { + tester.setup({ + data: [{ embedding: [0.1, 0.2, 0.3, 0.4, 0.5], index: 0 }], + model: 'voyage-3-lite', + usage: { total_tokens: 3 }, + }) + + const result = await tester.execute({ apiKey: 'key', input: 'single text' }) + expect(result.success).toBe(true) + expect(result.output.embeddings).toHaveLength(1) + expect(result.output.embeddings[0]).toEqual([0.1, 0.2, 0.3, 0.4, 0.5]) + expect(result.output.model).toBe('voyage-3-lite') + }) + + it('should handle high-dimensional embeddings (1024d)', async () => { + const embedding = Array.from({ length: 1024 }, () => Math.random()) + tester.setup({ + data: [{ embedding, index: 0 }], + model: 'voyage-3-large', + usage: { total_tokens: 5 }, + }) + + const result = await tester.execute({ apiKey: 'key', input: 'test' }) + expect(result.success).toBe(true) + expect(result.output.embeddings[0]).toHaveLength(1024) + }) + + it('should correctly pass through token count of 0', async () => { + tester.setup({ + data: [{ embedding: [0.1], index: 0 }], + model: 'voyage-3', + usage: { total_tokens: 0 }, + }) + + const result = await tester.execute({ apiKey: 'key', input: '' }) + expect(result.output.usage.total_tokens).toBe(0) + }) }) describe('Error Handling', () => { - it('should handle error responses', async () => { + it('should handle 401 unauthorized error', async () => { tester.setup({ error: 'Invalid API key' }, { ok: false, status: 401 }) const result = await tester.execute({ apiKey: 'bad-key', input: 'hello' }) expect(result.success).toBe(false) }) + it('should handle 429 rate limit error', async () => { + tester.setup({ error: 'Rate limited' }, { ok: false, status: 429 }) + const result = await tester.execute({ apiKey: 'key', input: 'hello' }) + expect(result.success).toBe(false) + }) + + it('should handle 500 server error', async () => { + tester.setup({ error: 'Internal error' }, { ok: false, status: 500 }) + const result = await tester.execute({ apiKey: 'key', input: 'hello' }) + expect(result.success).toBe(false) + }) + it('should handle network errors', async () => { tester.setupError('Network error') const result = await tester.execute({ apiKey: 'key', input: 'hello' }) expect(result.success).toBe(false) expect(result.error).toContain('Network error') }) + + it('should handle connection refused', async () => { + tester.setupError('ECONNREFUSED') + const result = await tester.execute({ apiKey: 'key', input: 'hello' }) + expect(result.success).toBe(false) + expect(result.error).toContain('ECONNREFUSED') + }) + + it('should handle timeout errors', async () => { + tester.setupError('timeout') + const result = await tester.execute({ apiKey: 'key', input: 'hello' }) + expect(result.success).toBe(false) + }) }) }) @@ -116,6 +282,35 @@ describe('Voyage AI Rerank Tool', () => { vi.resetAllMocks() }) + describe('Tool metadata', () => { + it('should have correct id and name', () => { + expect(rerankTool.id).toBe('voyageai_rerank') + expect(rerankTool.name).toBe('Voyage AI Rerank') + expect(rerankTool.version).toBe('1.0') + }) + + it('should have all required params defined', () => { + expect(rerankTool.params.query).toBeDefined() + expect(rerankTool.params.query.required).toBe(true) + expect(rerankTool.params.documents).toBeDefined() + expect(rerankTool.params.documents.required).toBe(true) + expect(rerankTool.params.apiKey).toBeDefined() + expect(rerankTool.params.apiKey.required).toBe(true) + expect(rerankTool.params.model).toBeDefined() + expect(rerankTool.params.model.required).toBe(false) + expect(rerankTool.params.model.default).toBe('rerank-2') + expect(rerankTool.params.topK).toBeDefined() + expect(rerankTool.params.topK.required).toBe(false) + }) + + it('should have output schema with results, model, usage', () => { + expect(rerankTool.outputs).toBeDefined() + expect(rerankTool.outputs!.output.properties!.results).toBeDefined() + expect(rerankTool.outputs!.output.properties!.model).toBeDefined() + expect(rerankTool.outputs!.output.properties!.usage).toBeDefined() + }) + }) + describe('URL Construction', () => { it('should return the VoyageAI rerank endpoint', () => { expect( @@ -137,7 +332,7 @@ describe('Voyage AI Rerank Tool', () => { }) describe('Body Construction', () => { - it('should send query, documents, and model', () => { + it('should send query, documents, and default model', () => { const body = tester.getRequestBody({ apiKey: 'key', query: 'what is AI?', @@ -148,13 +343,34 @@ describe('Voyage AI Rerank Tool', () => { expect(body.model).toBe('rerank-2') }) - it('should parse JSON string documents', () => { + it('should parse JSON string documents into array', () => { const body = tester.getRequestBody({ apiKey: 'key', query: 'test', documents: '["doc1", "doc2"]', }) expect(body.documents).toEqual(['doc1', 'doc2']) + expect(Array.isArray(body.documents)).toBe(true) + }) + + it('should handle direct array documents', () => { + const docs = ['first doc', 'second doc', 'third doc'] + const body = tester.getRequestBody({ + apiKey: 'key', + query: 'test', + documents: docs, + }) + expect(body.documents).toEqual(docs) + }) + + it('should handle large number of documents', () => { + const docs = Array.from({ length: 50 }, (_, i) => `document number ${i}`) + const body = tester.getRequestBody({ + apiKey: 'key', + query: 'test', + documents: docs, + }) + expect(body.documents).toHaveLength(50) }) it('should include top_k when provided', () => { @@ -167,6 +383,16 @@ describe('Voyage AI Rerank Tool', () => { expect(body.top_k).toBe(5) }) + it('should handle top_k of 1', () => { + const body = tester.getRequestBody({ + apiKey: 'key', + query: 'test', + documents: ['doc1', 'doc2'], + topK: 1, + }) + expect(body.top_k).toBe(1) + }) + it('should omit top_k when not provided', () => { const body = tester.getRequestBody({ apiKey: 'key', @@ -176,7 +402,17 @@ describe('Voyage AI Rerank Tool', () => { expect(body.top_k).toBeUndefined() }) - it('should use specified model', () => { + it('should omit top_k when 0 (falsy)', () => { + const body = tester.getRequestBody({ + apiKey: 'key', + query: 'test', + documents: ['doc1'], + topK: 0, + }) + expect(body.top_k).toBeUndefined() + }) + + it('should use specified model rerank-2-lite', () => { const body = tester.getRequestBody({ apiKey: 'key', query: 'test', @@ -185,6 +421,15 @@ describe('Voyage AI Rerank Tool', () => { }) expect(body.model).toBe('rerank-2-lite') }) + + it('should not include apiKey in body', () => { + const body = tester.getRequestBody({ + apiKey: 'key', + query: 'test', + documents: ['doc1'], + }) + expect(body.apiKey).toBeUndefined() + }) }) describe('Response Transformation', () => { @@ -205,35 +450,126 @@ describe('Voyage AI Rerank Tool', () => { }) expect(result.success).toBe(true) - expect(result.output.results).toEqual([ - { index: 1, relevance_score: 0.95, document: 'AI is artificial intelligence' }, - { index: 0, relevance_score: 0.72, document: 'Machine learning basics' }, - ]) + expect(result.output.results).toHaveLength(2) + expect(result.output.results[0]).toEqual({ + index: 1, + relevance_score: 0.95, + document: 'AI is artificial intelligence', + }) + expect(result.output.results[1]).toEqual({ + index: 0, + relevance_score: 0.72, + document: 'Machine learning basics', + }) expect(result.output.model).toBe('rerank-2') expect(result.output.usage.total_tokens).toBe(25) }) - }) - describe('Error Handling', () => { - it('should handle error responses', async () => { - tester.setup({ error: 'Rate limited' }, { ok: false, status: 429 }) + it('should handle single result', async () => { + tester.setup({ + data: [{ index: 0, relevance_score: 0.88 }], + model: 'rerank-2-lite', + usage: { total_tokens: 5 }, + }) + const result = await tester.execute({ apiKey: 'key', query: 'test', - documents: ['doc1'], + documents: ['only doc'], }) - expect(result.success).toBe(false) + + expect(result.success).toBe(true) + expect(result.output.results).toHaveLength(1) + expect(result.output.results[0].document).toBe('only doc') + expect(result.output.results[0].relevance_score).toBe(0.88) }) - it('should handle network errors', async () => { - tester.setupError('Connection refused') + it('should handle three documents reranked', async () => { + tester.setup({ + data: [ + { index: 2, relevance_score: 0.99 }, + { index: 0, relevance_score: 0.75 }, + { index: 1, relevance_score: 0.30 }, + ], + model: 'rerank-2', + usage: { total_tokens: 40 }, + }) + + const result = await tester.execute({ + apiKey: 'key', + query: 'query', + documents: ['doc A', 'doc B', 'doc C'], + }) + + expect(result.output.results[0].document).toBe('doc C') + expect(result.output.results[1].document).toBe('doc A') + expect(result.output.results[2].document).toBe('doc B') + }) + + it('should handle out-of-range index gracefully with empty string', async () => { + tester.setup({ + data: [{ index: 99, relevance_score: 0.5 }], + model: 'rerank-2', + usage: { total_tokens: 5 }, + }) + const result = await tester.execute({ apiKey: 'key', query: 'test', documents: ['doc1'], }) + + expect(result.output.results[0].document).toBe('') + }) + + it('should resolve documents from JSON string params', async () => { + tester.setup({ + data: [{ index: 0, relevance_score: 0.9 }], + model: 'rerank-2', + usage: { total_tokens: 5 }, + }) + + const result = await tester.execute({ + apiKey: 'key', + query: 'test', + documents: '["parsed doc"]', + }) + + expect(result.output.results[0].document).toBe('parsed doc') + }) + }) + + describe('Error Handling', () => { + it('should handle 401 error', async () => { + tester.setup({ error: 'Unauthorized' }, { ok: false, status: 401 }) + const result = await tester.execute({ apiKey: 'bad', query: 'test', documents: ['doc'] }) + expect(result.success).toBe(false) + }) + + it('should handle 429 rate limit error', async () => { + tester.setup({ error: 'Rate limited' }, { ok: false, status: 429 }) + const result = await tester.execute({ apiKey: 'key', query: 'test', documents: ['doc1'] }) + expect(result.success).toBe(false) + }) + + it('should handle 400 bad request', async () => { + tester.setup({ error: 'Bad request' }, { ok: false, status: 400 }) + const result = await tester.execute({ apiKey: 'key', query: 'test', documents: ['doc1'] }) + expect(result.success).toBe(false) + }) + + it('should handle network errors', async () => { + tester.setupError('Connection refused') + const result = await tester.execute({ apiKey: 'key', query: 'test', documents: ['doc1'] }) expect(result.success).toBe(false) expect(result.error).toContain('Connection refused') }) + + it('should handle DNS resolution failure', async () => { + tester.setupError('ENOTFOUND api.voyageai.com') + const result = await tester.execute({ apiKey: 'key', query: 'test', documents: ['doc1'] }) + expect(result.success).toBe(false) + expect(result.error).toContain('ENOTFOUND') + }) }) }) From 0e6dac0758e87ae047716f7da1e01c6d82d6df5c Mon Sep 17 00:00:00 2001 From: fzowl Date: Mon, 23 Mar 2026 19:18:25 +0100 Subject: [PATCH 3/8] chore: add .playwright-mcp to gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 61566eeeafd..fdc1cfa5afe 100644 --- a/.gitignore +++ b/.gitignore @@ -80,3 +80,4 @@ i18n.cache ## Claude Code .claude/launch.json .claude/worktrees/ +.playwright-mcp/ From 3c02549e1d351a8e3dba88f43d010e8db4d450cd Mon Sep 17 00:00:00 2001 From: fzowl Date: Mon, 23 Mar 2026 19:53:09 +0100 Subject: [PATCH 4/8] feat: update VoyageAI to latest models (v4, 3.5, rerank-2.5) - Add voyage-4-large, voyage-4, voyage-4-lite embedding models - Add voyage-3.5, voyage-3.5-lite embedding models - Add rerank-2.5, rerank-2.5-lite reranking models - Default embeddings model: voyage-3.5 - Default rerank model: rerank-2.5 - All models verified working with live API --- apps/sim/blocks/blocks/voyageai.test.ts | 15 ++++++++++----- apps/sim/blocks/blocks/voyageai.ts | 13 +++++++++---- apps/sim/tools/voyageai/embeddings.ts | 4 ++-- apps/sim/tools/voyageai/rerank.ts | 4 ++-- .../tools/voyageai/voyageai.integration.test.ts | 4 ++-- apps/sim/tools/voyageai/voyageai.test.ts | 8 ++++---- 6 files changed, 29 insertions(+), 19 deletions(-) diff --git a/apps/sim/blocks/blocks/voyageai.test.ts b/apps/sim/blocks/blocks/voyageai.test.ts index 53c16b4a5ad..89c549eb5ea 100644 --- a/apps/sim/blocks/blocks/voyageai.test.ts +++ b/apps/sim/blocks/blocks/voyageai.test.ts @@ -73,7 +73,7 @@ describe('VoyageAIBlock', () => { it('should default to embeddings operation', () => { const opBlock = VoyageAIBlock.subBlocks[0] as any - expect(opBlock.value()).toBe('embeddings') + expect(opBlock.value!()).toBe('embeddings') }) it('should have embeddings-specific subBlocks with correct conditions', () => { @@ -144,22 +144,27 @@ describe('VoyageAIBlock', () => { expect(topKBlock!.mode).toBe('advanced') }) - it('should have all 6 embedding models in the dropdown', () => { + it('should have all embedding models in the dropdown', () => { const modelBlock = VoyageAIBlock.subBlocks.find((sb) => sb.id === 'embeddingModel') as any expect(modelBlock).toBeDefined() const modelIds = modelBlock.options.map((o: any) => o.id) + expect(modelIds).toContain('voyage-4-large') + expect(modelIds).toContain('voyage-4') + expect(modelIds).toContain('voyage-4-lite') + expect(modelIds).toContain('voyage-3.5') + expect(modelIds).toContain('voyage-3.5-lite') expect(modelIds).toContain('voyage-3-large') - expect(modelIds).toContain('voyage-3') - expect(modelIds).toContain('voyage-3-lite') expect(modelIds).toContain('voyage-code-3') expect(modelIds).toContain('voyage-finance-2') expect(modelIds).toContain('voyage-law-2') }) - it('should have both rerank models in the dropdown', () => { + it('should have all rerank models in the dropdown', () => { const modelBlock = VoyageAIBlock.subBlocks.find((sb) => sb.id === 'rerankModel') as any expect(modelBlock).toBeDefined() const modelIds = modelBlock.options.map((o: any) => o.id) + expect(modelIds).toContain('rerank-2.5') + expect(modelIds).toContain('rerank-2.5-lite') expect(modelIds).toContain('rerank-2') expect(modelIds).toContain('rerank-2-lite') }) diff --git a/apps/sim/blocks/blocks/voyageai.ts b/apps/sim/blocks/blocks/voyageai.ts index b043d381706..b45428db747 100644 --- a/apps/sim/blocks/blocks/voyageai.ts +++ b/apps/sim/blocks/blocks/voyageai.ts @@ -38,15 +38,18 @@ export const VoyageAIBlock: BlockConfig = { title: 'Model', type: 'dropdown', options: [ + { label: 'voyage-4-large', id: 'voyage-4-large' }, + { label: 'voyage-4', id: 'voyage-4' }, + { label: 'voyage-4-lite', id: 'voyage-4-lite' }, + { label: 'voyage-3.5', id: 'voyage-3.5' }, + { label: 'voyage-3.5-lite', id: 'voyage-3.5-lite' }, { label: 'voyage-3-large', id: 'voyage-3-large' }, - { label: 'voyage-3', id: 'voyage-3' }, - { label: 'voyage-3-lite', id: 'voyage-3-lite' }, { label: 'voyage-code-3', id: 'voyage-code-3' }, { label: 'voyage-finance-2', id: 'voyage-finance-2' }, { label: 'voyage-law-2', id: 'voyage-law-2' }, ], condition: { field: 'operation', value: 'embeddings' }, - value: () => 'voyage-3', + value: () => 'voyage-3.5', }, { id: 'inputType', @@ -81,11 +84,13 @@ export const VoyageAIBlock: BlockConfig = { title: 'Model', type: 'dropdown', options: [ + { label: 'rerank-2.5', id: 'rerank-2.5' }, + { label: 'rerank-2.5-lite', id: 'rerank-2.5-lite' }, { label: 'rerank-2', id: 'rerank-2' }, { label: 'rerank-2-lite', id: 'rerank-2-lite' }, ], condition: { field: 'operation', value: 'rerank' }, - value: () => 'rerank-2', + value: () => 'rerank-2.5', }, { id: 'topK', diff --git a/apps/sim/tools/voyageai/embeddings.ts b/apps/sim/tools/voyageai/embeddings.ts index b87fe0134d9..c8cd4b899cd 100644 --- a/apps/sim/tools/voyageai/embeddings.ts +++ b/apps/sim/tools/voyageai/embeddings.ts @@ -19,7 +19,7 @@ export const embeddingsTool: ToolConfig { const body: Record = { input: Array.isArray(params.input) ? params.input : [params.input], - model: params.model || 'voyage-3', + model: params.model || 'voyage-3.5', } if (params.inputType) { body.input_type = params.inputType diff --git a/apps/sim/tools/voyageai/rerank.ts b/apps/sim/tools/voyageai/rerank.ts index 3ad130c1ab9..fb993274587 100644 --- a/apps/sim/tools/voyageai/rerank.ts +++ b/apps/sim/tools/voyageai/rerank.ts @@ -25,7 +25,7 @@ export const rerankTool: ToolConfig = { query: params.query, documents, - model: params.model || 'rerank-2', + model: params.model || 'rerank-2.5', } if (params.topK) { body.top_k = params.topK diff --git a/apps/sim/tools/voyageai/voyageai.integration.test.ts b/apps/sim/tools/voyageai/voyageai.integration.test.ts index f65f05b6e70..7289c2add66 100644 --- a/apps/sim/tools/voyageai/voyageai.integration.test.ts +++ b/apps/sim/tools/voyageai/voyageai.integration.test.ts @@ -55,7 +55,7 @@ describeIntegration('VoyageAI Integration Tests (live API)', () => { expect(result.success).toBe(true) expect(result.output.embeddings).toHaveLength(1) expect(result.output.embeddings[0].length).toBeGreaterThan(100) - expect(result.output.model).toBe('voyage-3') + expect(result.output.model).toBe('voyage-3.5') expect(result.output.usage.total_tokens).toBeGreaterThan(0) }, 15000) @@ -246,7 +246,7 @@ describeIntegration('VoyageAI Integration Tests (live API)', () => { expect(result.success).toBe(true) expect(result.output.results).toHaveLength(3) - expect(result.output.model).toBe('rerank-2') + expect(result.output.model).toBe('rerank-2.5') expect(result.output.usage.total_tokens).toBeGreaterThan(0) for (const r of result.output.results) { diff --git a/apps/sim/tools/voyageai/voyageai.test.ts b/apps/sim/tools/voyageai/voyageai.test.ts index 21bb1de9bea..0d3463f01cf 100644 --- a/apps/sim/tools/voyageai/voyageai.test.ts +++ b/apps/sim/tools/voyageai/voyageai.test.ts @@ -32,7 +32,7 @@ describe('Voyage AI Embeddings Tool', () => { expect(embeddingsTool.params.apiKey.required).toBe(true) expect(embeddingsTool.params.model).toBeDefined() expect(embeddingsTool.params.model.required).toBe(false) - expect(embeddingsTool.params.model.default).toBe('voyage-3') + expect(embeddingsTool.params.model.default).toBe('voyage-3.5') expect(embeddingsTool.params.inputType).toBeDefined() expect(embeddingsTool.params.inputType.required).toBe(false) }) @@ -115,7 +115,7 @@ describe('Voyage AI Embeddings Tool', () => { it('should use default model voyage-3 when not specified', () => { const body = tester.getRequestBody({ apiKey: 'key', input: 'hello' }) - expect(body.model).toBe('voyage-3') + expect(body.model).toBe('voyage-3.5') }) it('should use specified model voyage-3-large', () => { @@ -298,7 +298,7 @@ describe('Voyage AI Rerank Tool', () => { expect(rerankTool.params.apiKey.required).toBe(true) expect(rerankTool.params.model).toBeDefined() expect(rerankTool.params.model.required).toBe(false) - expect(rerankTool.params.model.default).toBe('rerank-2') + expect(rerankTool.params.model.default).toBe('rerank-2.5') expect(rerankTool.params.topK).toBeDefined() expect(rerankTool.params.topK.required).toBe(false) }) @@ -340,7 +340,7 @@ describe('Voyage AI Rerank Tool', () => { }) expect(body.query).toBe('what is AI?') expect(body.documents).toEqual(['AI is...', 'Machine learning is...']) - expect(body.model).toBe('rerank-2') + expect(body.model).toBe('rerank-2.5') }) it('should parse JSON string documents into array', () => { From 363df7bcc924306265e2863b0f71714db6b51b27 Mon Sep 17 00:00:00 2001 From: fzowl Date: Thu, 26 Mar 2026 21:28:50 +0100 Subject: [PATCH 5/8] feat: add multimodal embeddings (text + image + video) to VoyageAI integration - New tool: voyageai_multimodal_embeddings using voyage-multimodal-3.5 model - New API route: /api/tools/voyageai/multimodal-embeddings for server-side file handling - Supports text, image files/URLs, video files/URLs in a single embedding - Uses file-upload subBlocks with basic/advanced mode for images and video - Internal proxy pattern: downloads UserFiles via downloadFileFromStorage, converts to base64 - URL validation via validateUrlWithDNS for SSRF protection - 14 new unit tests (tool metadata, body, response transform) - 5 new integration tests (text-only, image URL, text+image, dimensions, auth) - 8 new block tests (multimodal operation, params, subBlocks) --- .../voyageai/multimodal-embeddings/route.ts | 211 ++++++++++++++++++ apps/sim/blocks/blocks/voyageai.test.ts | 109 ++++++++- apps/sim/blocks/blocks/voyageai.ts | 129 ++++++++++- apps/sim/tools/registry.ts | 7 +- apps/sim/tools/voyageai/index.ts | 2 + .../tools/voyageai/multimodal-embeddings.ts | 120 ++++++++++ apps/sim/tools/voyageai/types.ts | 24 ++ .../voyageai/voyageai.integration.test.ts | 114 ++++++++++ apps/sim/tools/voyageai/voyageai.test.ts | 136 +++++++++++ 9 files changed, 846 insertions(+), 6 deletions(-) create mode 100644 apps/sim/app/api/tools/voyageai/multimodal-embeddings/route.ts create mode 100644 apps/sim/tools/voyageai/multimodal-embeddings.ts diff --git a/apps/sim/app/api/tools/voyageai/multimodal-embeddings/route.ts b/apps/sim/app/api/tools/voyageai/multimodal-embeddings/route.ts new file mode 100644 index 00000000000..18f0107a4d2 --- /dev/null +++ b/apps/sim/app/api/tools/voyageai/multimodal-embeddings/route.ts @@ -0,0 +1,211 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { validateUrlWithDNS } from '@/lib/core/security/input-validation.server' +import { generateRequestId } from '@/lib/core/utils/request' +import { RawFileInputArraySchema, RawFileInputSchema } from '@/lib/uploads/utils/file-schemas' +import { processSingleFileToUserFile } from '@/lib/uploads/utils/file-utils' +import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('VoyageAIMultimodalAPI') + +const MultimodalEmbeddingsSchema = z.object({ + apiKey: z.string().min(1, 'API key is required'), + input: z.string().optional().nullable(), + imageFiles: z.union([RawFileInputSchema, RawFileInputArraySchema]).optional().nullable(), + imageUrls: z.string().optional().nullable(), + videoFile: RawFileInputSchema.optional().nullable(), + videoUrl: z.string().optional().nullable(), + model: z.string().optional().default('voyage-multimodal-3.5'), + inputType: z.enum(['query', 'document']).optional().nullable(), +}) + +export async function POST(request: NextRequest) { + const requestId = generateRequestId() + + try { + const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success) { + logger.warn(`[${requestId}] Unauthorized multimodal embeddings attempt`) + return NextResponse.json( + { success: false, error: authResult.error || 'Authentication required' }, + { status: 401 } + ) + } + + const body = await request.json() + const params = MultimodalEmbeddingsSchema.parse(body) + + const content: Array> = [] + + // Add text content + if (params.input?.trim()) { + content.push({ type: 'text', text: params.input }) + } + + // Process image files → base64 + if (params.imageFiles) { + const files = Array.isArray(params.imageFiles) ? params.imageFiles : [params.imageFiles] + for (const rawFile of files) { + try { + const userFile = processSingleFileToUserFile(rawFile, requestId, logger) + let base64 = userFile.base64 + if (!base64) { + const buffer = await downloadFileFromStorage(userFile, requestId, logger) + base64 = buffer.toString('base64') + logger.info(`[${requestId}] Converted image to base64 (${buffer.length} bytes)`) + } + const mimeType = userFile.type || 'image/jpeg' + content.push({ + type: 'image_base64', + image_base64: `data:${mimeType};base64,${base64}`, + }) + } catch (error) { + logger.error(`[${requestId}] Failed to process image file:`, error) + return NextResponse.json( + { success: false, error: `Failed to process image file: ${error instanceof Error ? error.message : 'Unknown error'}` }, + { status: 400 } + ) + } + } + } + + // Process image URLs + if (params.imageUrls?.trim()) { + let urls: string[] + try { + urls = JSON.parse(params.imageUrls) + } catch { + urls = params.imageUrls + .split(/[,\n]/) + .map((u) => u.trim()) + .filter(Boolean) + } + + for (const url of urls) { + const validation = await validateUrlWithDNS(url, 'imageUrl') + if (!validation.isValid) { + return NextResponse.json( + { success: false, error: `Invalid image URL: ${validation.error}` }, + { status: 400 } + ) + } + content.push({ type: 'image_url', image_url: url }) + } + } + + // Process video file → base64 + if (params.videoFile) { + try { + const userFile = processSingleFileToUserFile(params.videoFile, requestId, logger) + let base64 = userFile.base64 + if (!base64) { + const buffer = await downloadFileFromStorage(userFile, requestId, logger) + base64 = buffer.toString('base64') + logger.info(`[${requestId}] Converted video to base64 (${buffer.length} bytes)`) + } + const mimeType = userFile.type || 'video/mp4' + content.push({ + type: 'video_base64', + video_base64: `data:${mimeType};base64,${base64}`, + }) + } catch (error) { + logger.error(`[${requestId}] Failed to process video file:`, error) + return NextResponse.json( + { success: false, error: `Failed to process video file: ${error instanceof Error ? error.message : 'Unknown error'}` }, + { status: 400 } + ) + } + } + + // Process video URL + if (params.videoUrl?.trim()) { + const validation = await validateUrlWithDNS(params.videoUrl, 'videoUrl') + if (!validation.isValid) { + return NextResponse.json( + { success: false, error: `Invalid video URL: ${validation.error}` }, + { status: 400 } + ) + } + content.push({ type: 'video_url', video_url: params.videoUrl }) + } + + if (content.length === 0) { + return NextResponse.json( + { success: false, error: 'At least one input (text, image, or video) is required' }, + { status: 400 } + ) + } + + logger.info(`[${requestId}] Calling VoyageAI multimodal embeddings`, { + contentTypes: content.map((c) => c.type), + model: params.model, + }) + + // Build VoyageAI request + const voyageBody: Record = { + inputs: [{ content }], + model: params.model, + } + if (params.inputType) { + voyageBody.input_type = params.inputType + } + + const voyageResponse = await fetch('https://api.voyageai.com/v1/multimodalembeddings', { + method: 'POST', + headers: { + Authorization: `Bearer ${params.apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(voyageBody), + }) + + if (!voyageResponse.ok) { + const errorText = await voyageResponse.text() + logger.error(`[${requestId}] VoyageAI API error: ${voyageResponse.status}`, { errorText }) + return NextResponse.json( + { success: false, error: `VoyageAI API error: ${voyageResponse.status} - ${errorText}` }, + { status: voyageResponse.status } + ) + } + + const data = await voyageResponse.json() + + logger.info(`[${requestId}] Multimodal embeddings generated successfully`, { + embeddingsCount: data.data?.length, + totalTokens: data.usage?.total_tokens, + }) + + return NextResponse.json({ + success: true, + output: { + embeddings: data.data.map((item: { embedding: number[] }) => item.embedding), + model: data.model, + usage: { + text_tokens: data.usage?.text_tokens, + image_pixels: data.usage?.image_pixels, + video_pixels: data.usage?.video_pixels, + total_tokens: data.usage?.total_tokens, + }, + }, + }) + } catch (error) { + if (error instanceof z.ZodError) { + logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) + return NextResponse.json( + { success: false, error: 'Invalid request data', details: error.errors }, + { status: 400 } + ) + } + + const errorMessage = error instanceof Error ? error.message : 'Unknown error' + logger.error(`[${requestId}] Multimodal embeddings failed:`, error) + return NextResponse.json( + { success: false, error: `Multimodal embeddings failed: ${errorMessage}` }, + { status: 500 } + ) + } +} diff --git a/apps/sim/blocks/blocks/voyageai.test.ts b/apps/sim/blocks/blocks/voyageai.test.ts index 89c549eb5ea..bf4fa98ffc3 100644 --- a/apps/sim/blocks/blocks/voyageai.test.ts +++ b/apps/sim/blocks/blocks/voyageai.test.ts @@ -46,8 +46,12 @@ describe('VoyageAIBlock', () => { expect(VoyageAIBlock.bgColor).toBe('#1A1A2E') }) - it('should list both tool IDs in access', () => { - expect(VoyageAIBlock.tools.access).toEqual(['voyageai_embeddings', 'voyageai_rerank']) + it('should list all tool IDs in access', () => { + expect(VoyageAIBlock.tools.access).toEqual([ + 'voyageai_embeddings', + 'voyageai_multimodal_embeddings', + 'voyageai_rerank', + ]) }) it('should have tools.config.tool and tools.config.params functions', () => { @@ -159,6 +163,31 @@ describe('VoyageAIBlock', () => { expect(modelIds).toContain('voyage-law-2') }) + it('should have multimodal-specific subBlocks with correct conditions', () => { + const mmBlocks = VoyageAIBlock.subBlocks.filter( + (sb) => + sb.condition && + typeof sb.condition === 'object' && + 'value' in sb.condition && + sb.condition.value === 'multimodal_embeddings' + ) + const ids = mmBlocks.map((sb) => sb.id) + expect(ids).toContain('multimodalInput') + expect(ids).toContain('imageFiles') + expect(ids).toContain('imageFilesRef') + expect(ids).toContain('videoFile') + expect(ids).toContain('videoFileRef') + expect(ids).toContain('multimodalModel') + }) + + it('should have multimodal models in the dropdown', () => { + const modelBlock = VoyageAIBlock.subBlocks.find((sb) => sb.id === 'multimodalModel') as any + expect(modelBlock).toBeDefined() + const modelIds = modelBlock.options.map((o: any) => o.id) + expect(modelIds).toContain('voyage-multimodal-3.5') + expect(modelIds).toContain('voyage-multimodal-3') + }) + it('should have all rerank models in the dropdown', () => { const modelBlock = VoyageAIBlock.subBlocks.find((sb) => sb.id === 'rerankModel') as any expect(modelBlock).toBeDefined() @@ -200,6 +229,12 @@ describe('VoyageAIBlock', () => { expect(toolFunction({ operation: 'embeddings' })).toBe('voyageai_embeddings') }) + it('should return voyageai_multimodal_embeddings for multimodal_embeddings operation', () => { + expect(toolFunction({ operation: 'multimodal_embeddings' })).toBe( + 'voyageai_multimodal_embeddings' + ) + }) + it('should return voyageai_rerank for rerank operation', () => { expect(toolFunction({ operation: 'rerank' })).toBe('voyageai_rerank') }) @@ -398,5 +433,75 @@ describe('VoyageAIBlock', () => { expect(result.embeddingModel).toBeUndefined() }) }) + + describe('multimodal_embeddings operation', () => { + it('should pass text input and model', () => { + const result = paramsFunction({ + operation: 'multimodal_embeddings', + apiKey: 'va-key', + multimodalInput: 'describe this image', + multimodalModel: 'voyage-multimodal-3.5', + }) + expect(result.apiKey).toBe('va-key') + expect(result.input).toBe('describe this image') + expect(result.model).toBe('voyage-multimodal-3.5') + }) + + it('should pass image URLs', () => { + const result = paramsFunction({ + operation: 'multimodal_embeddings', + apiKey: 'va-key', + imageUrls: 'https://example.com/img.jpg', + multimodalModel: 'voyage-multimodal-3.5', + }) + expect(result.imageUrls).toBe('https://example.com/img.jpg') + }) + + it('should pass video URL', () => { + const result = paramsFunction({ + operation: 'multimodal_embeddings', + apiKey: 'va-key', + videoUrl: 'https://example.com/video.mp4', + multimodalModel: 'voyage-multimodal-3.5', + }) + expect(result.videoUrl).toBe('https://example.com/video.mp4') + }) + + it('should pass inputType for multimodal', () => { + const result = paramsFunction({ + operation: 'multimodal_embeddings', + apiKey: 'va-key', + multimodalInput: 'test', + multimodalModel: 'voyage-multimodal-3.5', + multimodalInputType: 'query', + }) + expect(result.inputType).toBe('query') + }) + + it('should omit empty optional fields', () => { + const result = paramsFunction({ + operation: 'multimodal_embeddings', + apiKey: 'va-key', + multimodalModel: 'voyage-multimodal-3.5', + }) + expect(result.input).toBeUndefined() + expect(result.imageFiles).toBeUndefined() + expect(result.imageUrls).toBeUndefined() + expect(result.videoFile).toBeUndefined() + expect(result.videoUrl).toBeUndefined() + }) + + it('should not include text embedding or rerank fields', () => { + const result = paramsFunction({ + operation: 'multimodal_embeddings', + apiKey: 'va-key', + multimodalModel: 'voyage-multimodal-3.5', + embeddingModel: 'should not appear', + query: 'should not appear', + }) + expect(result.embeddingModel).toBeUndefined() + expect(result.query).toBeUndefined() + }) + }) }) }) diff --git a/apps/sim/blocks/blocks/voyageai.ts b/apps/sim/blocks/blocks/voyageai.ts index b45428db747..b9c86ba7aaa 100644 --- a/apps/sim/blocks/blocks/voyageai.ts +++ b/apps/sim/blocks/blocks/voyageai.ts @@ -1,13 +1,14 @@ import { VoyageAIIcon } from '@/components/icons' import type { BlockConfig } from '@/blocks/types' import { AuthMode, IntegrationType } from '@/blocks/types' +import { normalizeFileInput } from '@/blocks/utils' export const VoyageAIBlock: BlockConfig = { type: 'voyageai', name: 'Voyage AI', description: 'Generate embeddings and rerank with Voyage AI', longDescription: - 'Integrate Voyage AI into the workflow. Generate embeddings from text or rerank documents by relevance.', + 'Integrate Voyage AI into the workflow. Generate text or multimodal embeddings, or rerank documents by relevance.', category: 'tools', authMode: AuthMode.ApiKey, integrationType: IntegrationType.AI, @@ -21,10 +22,12 @@ export const VoyageAIBlock: BlockConfig = { type: 'dropdown', options: [ { label: 'Generate Embeddings', id: 'embeddings' }, + { label: 'Multimodal Embeddings', id: 'multimodal_embeddings' }, { label: 'Rerank', id: 'rerank' }, ], value: () => 'embeddings', }, + // === Text Embeddings fields === { id: 'input', title: 'Input Text', @@ -63,6 +66,94 @@ export const VoyageAIBlock: BlockConfig = { value: () => 'document', mode: 'advanced', }, + // === Multimodal Embeddings fields === + { + id: 'multimodalInput', + title: 'Text Input', + type: 'long-input', + placeholder: 'Enter text to include in multimodal embedding (optional)', + condition: { field: 'operation', value: 'multimodal_embeddings' }, + }, + { + id: 'imageFiles', + title: 'Image Files', + type: 'file-upload', + canonicalParamId: 'imageFiles', + placeholder: 'Upload image files', + condition: { field: 'operation', value: 'multimodal_embeddings' }, + mode: 'basic', + multiple: true, + acceptedTypes: '.jpg,.jpeg,.png,.gif,.webp', + }, + { + id: 'imageFilesRef', + title: 'Image Files', + type: 'short-input', + canonicalParamId: 'imageFiles', + placeholder: 'Reference image files from previous blocks', + condition: { field: 'operation', value: 'multimodal_embeddings' }, + mode: 'advanced', + }, + { + id: 'imageUrls', + title: 'Image URLs', + type: 'long-input', + placeholder: 'Enter image URLs (one per line or comma-separated)', + condition: { field: 'operation', value: 'multimodal_embeddings' }, + mode: 'advanced', + }, + { + id: 'videoFile', + title: 'Video File', + type: 'file-upload', + canonicalParamId: 'videoFile', + placeholder: 'Upload a video file (MP4, max 20MB)', + condition: { field: 'operation', value: 'multimodal_embeddings' }, + mode: 'basic', + multiple: false, + acceptedTypes: '.mp4', + }, + { + id: 'videoFileRef', + title: 'Video File', + type: 'short-input', + canonicalParamId: 'videoFile', + placeholder: 'Reference a video file from previous blocks', + condition: { field: 'operation', value: 'multimodal_embeddings' }, + mode: 'advanced', + }, + { + id: 'videoUrl', + title: 'Video URL', + type: 'short-input', + placeholder: 'Enter a video URL', + condition: { field: 'operation', value: 'multimodal_embeddings' }, + mode: 'advanced', + }, + { + id: 'multimodalModel', + title: 'Model', + type: 'dropdown', + options: [ + { label: 'voyage-multimodal-3.5', id: 'voyage-multimodal-3.5' }, + { label: 'voyage-multimodal-3', id: 'voyage-multimodal-3' }, + ], + condition: { field: 'operation', value: 'multimodal_embeddings' }, + value: () => 'voyage-multimodal-3.5', + }, + { + id: 'multimodalInputType', + title: 'Input Type', + type: 'dropdown', + options: [ + { label: 'Document', id: 'document' }, + { label: 'Query', id: 'query' }, + ], + condition: { field: 'operation', value: 'multimodal_embeddings' }, + value: () => 'document', + mode: 'advanced', + }, + // === Rerank fields === { id: 'query', title: 'Query', @@ -100,6 +191,7 @@ export const VoyageAIBlock: BlockConfig = { condition: { field: 'operation', value: 'rerank' }, mode: 'advanced', }, + // === Common fields === { id: 'apiKey', title: 'API Key', @@ -110,12 +202,14 @@ export const VoyageAIBlock: BlockConfig = { }, ], tools: { - access: ['voyageai_embeddings', 'voyageai_rerank'], + access: ['voyageai_embeddings', 'voyageai_multimodal_embeddings', 'voyageai_rerank'], config: { tool: (params) => { switch (params.operation) { case 'embeddings': return 'voyageai_embeddings' + case 'multimodal_embeddings': + return 'voyageai_multimodal_embeddings' case 'rerank': return 'voyageai_rerank' default: @@ -130,6 +224,28 @@ export const VoyageAIBlock: BlockConfig = { if (params.inputType) { result.inputType = params.inputType } + } else if (params.operation === 'multimodal_embeddings') { + if (params.multimodalInput) { + result.input = params.multimodalInput + } + const imageFiles = normalizeFileInput(params.imageFiles) + if (imageFiles) { + result.imageFiles = imageFiles + } + if (params.imageUrls) { + result.imageUrls = params.imageUrls + } + const videoFile = normalizeFileInput(params.videoFile, { single: true }) + if (videoFile) { + result.videoFile = videoFile + } + if (params.videoUrl) { + result.videoUrl = params.videoUrl + } + result.model = params.multimodalModel + if (params.multimodalInputType) { + result.inputType = params.multimodalInputType + } } else { result.query = params.query result.documents = @@ -148,6 +264,13 @@ export const VoyageAIBlock: BlockConfig = { input: { type: 'string', description: 'Text to embed' }, embeddingModel: { type: 'string', description: 'Embedding model' }, inputType: { type: 'string', description: 'Input type (query or document)' }, + multimodalInput: { type: 'string', description: 'Text for multimodal embedding' }, + imageFiles: { type: 'json', description: 'Image files (UserFile objects)' }, + imageUrls: { type: 'string', description: 'Image URLs' }, + videoFile: { type: 'json', description: 'Video file (UserFile object)' }, + videoUrl: { type: 'string', description: 'Video URL' }, + multimodalModel: { type: 'string', description: 'Multimodal embedding model' }, + multimodalInputType: { type: 'string', description: 'Input type for multimodal' }, query: { type: 'string', description: 'Rerank query' }, documents: { type: 'json', description: 'Documents to rerank' }, rerankModel: { type: 'string', description: 'Rerank model' }, @@ -158,6 +281,6 @@ export const VoyageAIBlock: BlockConfig = { embeddings: { type: 'json', description: 'Generated embedding vectors' }, results: { type: 'json', description: 'Reranked results with scores' }, model: { type: 'string', description: 'Model used' }, - usage: { type: 'json', description: 'Token usage' }, + usage: { type: 'json', description: 'Token/pixel usage' }, }, } diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index 76ad601e7db..e98517c8fb2 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -2291,7 +2291,11 @@ import { veoVideoTool, } from '@/tools/video' import { visionTool, visionToolV2 } from '@/tools/vision' -import { voyageaiEmbeddingsTool, voyageaiRerankTool } from '@/tools/voyageai' +import { + voyageaiEmbeddingsTool, + voyageaiMultimodalEmbeddingsTool, + voyageaiRerankTool, +} from '@/tools/voyageai' import { wealthboxReadContactTool, wealthboxReadNoteTool, @@ -2543,6 +2547,7 @@ export const tools: Record = { vision_tool: visionTool, vision_tool_v2: visionToolV2, voyageai_embeddings: voyageaiEmbeddingsTool, + voyageai_multimodal_embeddings: voyageaiMultimodalEmbeddingsTool, voyageai_rerank: voyageaiRerankTool, file_parser: fileParseTool, file_parser_v2: fileParserV2Tool, diff --git a/apps/sim/tools/voyageai/index.ts b/apps/sim/tools/voyageai/index.ts index efac23a6418..6734369a335 100644 --- a/apps/sim/tools/voyageai/index.ts +++ b/apps/sim/tools/voyageai/index.ts @@ -1,7 +1,9 @@ import { embeddingsTool } from '@/tools/voyageai/embeddings' +import { multimodalEmbeddingsTool } from '@/tools/voyageai/multimodal-embeddings' import { rerankTool } from '@/tools/voyageai/rerank' export const voyageaiEmbeddingsTool = embeddingsTool +export const voyageaiMultimodalEmbeddingsTool = multimodalEmbeddingsTool export const voyageaiRerankTool = rerankTool export * from './types' diff --git a/apps/sim/tools/voyageai/multimodal-embeddings.ts b/apps/sim/tools/voyageai/multimodal-embeddings.ts new file mode 100644 index 00000000000..95af4c5ed48 --- /dev/null +++ b/apps/sim/tools/voyageai/multimodal-embeddings.ts @@ -0,0 +1,120 @@ +import type { + VoyageAIMultimodalEmbeddingsParams, + VoyageAIMultimodalEmbeddingsResponse, +} from '@/tools/voyageai/types' +import type { ToolConfig } from '@/tools/types' + +export const multimodalEmbeddingsTool: ToolConfig< + VoyageAIMultimodalEmbeddingsParams, + VoyageAIMultimodalEmbeddingsResponse +> = { + id: 'voyageai_multimodal_embeddings', + name: 'Voyage AI Multimodal Embeddings', + description: + 'Generate embeddings from text, images, and videos using Voyage AI multimodal models', + version: '1.0', + + params: { + input: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Text to include in the multimodal input', + }, + imageFiles: { + type: 'json', + required: false, + visibility: 'user-only', + description: 'Image files (UserFile objects) to embed', + }, + imageUrls: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Image URLs (comma-separated or JSON array)', + }, + videoFile: { + type: 'json', + required: false, + visibility: 'user-only', + description: 'Video file (UserFile object) to embed', + }, + videoUrl: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Video URL', + }, + model: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Multimodal embedding model to use', + default: 'voyage-multimodal-3.5', + }, + inputType: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Input type: "query" or "document"', + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Voyage AI API key', + }, + }, + + request: { + url: '/api/tools/voyageai/multimodal-embeddings', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + input: params.input, + imageFiles: params.imageFiles, + imageUrls: params.imageUrls, + videoFile: params.videoFile, + videoUrl: params.videoUrl, + model: params.model || 'voyage-multimodal-3.5', + inputType: params.inputType, + apiKey: params.apiKey, + }), + }, + + transformResponse: async (response) => { + const data = await response.json() + return { + success: true, + output: { + embeddings: data.output.embeddings, + model: data.output.model, + usage: data.output.usage, + }, + } + }, + + outputs: { + success: { type: 'boolean', description: 'Operation success status' }, + output: { + type: 'object', + description: 'Multimodal embeddings results', + properties: { + embeddings: { type: 'array', description: 'Array of embedding vectors' }, + model: { type: 'string', description: 'Model used for generating embeddings' }, + usage: { + type: 'object', + description: 'Usage information', + properties: { + text_tokens: { type: 'number', description: 'Text tokens used' }, + image_pixels: { type: 'number', description: 'Image pixels processed' }, + video_pixels: { type: 'number', description: 'Video pixels processed' }, + total_tokens: { type: 'number', description: 'Total tokens used' }, + }, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/voyageai/types.ts b/apps/sim/tools/voyageai/types.ts index 593875bf3c5..54e6f715916 100644 --- a/apps/sim/tools/voyageai/types.ts +++ b/apps/sim/tools/voyageai/types.ts @@ -27,6 +27,30 @@ export interface VoyageAIEmbeddingsResponse extends ToolResponse { } } +export interface VoyageAIMultimodalEmbeddingsParams { + apiKey: string + input?: string + imageFiles?: unknown + imageUrls?: string + videoFile?: unknown + videoUrl?: string + model?: string + inputType?: 'query' | 'document' +} + +export interface VoyageAIMultimodalEmbeddingsResponse extends ToolResponse { + output: { + embeddings: number[][] + model: string + usage: { + text_tokens?: number + image_pixels?: number + video_pixels?: number + total_tokens: number + } + } +} + export interface VoyageAIRerankResponse extends ToolResponse { output: { results: Array<{ diff --git a/apps/sim/tools/voyageai/voyageai.integration.test.ts b/apps/sim/tools/voyageai/voyageai.integration.test.ts index 7289c2add66..987dadc7161 100644 --- a/apps/sim/tools/voyageai/voyageai.integration.test.ts +++ b/apps/sim/tools/voyageai/voyageai.integration.test.ts @@ -456,4 +456,118 @@ describeIntegration('VoyageAI Integration Tests (live API)', () => { expect(aiDocsInTop2.length).toBeGreaterThanOrEqual(1) }, 30000) }) + + describe('Multimodal Embeddings API', () => { + const MULTIMODAL_URL = 'https://api.voyageai.com/v1/multimodalembeddings' + const MULTIMODAL_HEADERS = { + Authorization: `Bearer ${API_KEY}`, + 'Content-Type': 'application/json', + } + + it('should generate multimodal embedding with text-only input', async () => { + const response = await liveFetch(MULTIMODAL_URL, { + method: 'POST', + headers: MULTIMODAL_HEADERS, + body: JSON.stringify({ + inputs: [{ content: [{ type: 'text', text: 'Hello world' }] }], + model: 'voyage-multimodal-3.5', + }), + }) + + expect(response.ok).toBe(true) + const data = await response.json() + expect(data.data).toHaveLength(1) + expect(data.data[0].embedding.length).toBeGreaterThan(100) + expect(data.model).toBe('voyage-multimodal-3.5') + expect(data.usage.total_tokens).toBeGreaterThan(0) + }, 15000) + + it('should generate multimodal embedding with image URL', async () => { + const response = await liveFetch(MULTIMODAL_URL, { + method: 'POST', + headers: MULTIMODAL_HEADERS, + body: JSON.stringify({ + inputs: [ + { + content: [ + { + type: 'image_url', + image_url: + 'https://raw.githubusercontent.com/voyage-ai/voyage-multimodal-3/refs/heads/main/images/banana.jpg', + }, + ], + }, + ], + model: 'voyage-multimodal-3.5', + }), + }) + + expect(response.ok).toBe(true) + const data = await response.json() + expect(data.data).toHaveLength(1) + expect(data.data[0].embedding.length).toBeGreaterThan(100) + expect(data.usage.image_pixels).toBeGreaterThan(0) + expect(data.usage.total_tokens).toBeGreaterThan(0) + }, 30000) + + it('should generate multimodal embedding with text + image combined', async () => { + const response = await liveFetch(MULTIMODAL_URL, { + method: 'POST', + headers: MULTIMODAL_HEADERS, + body: JSON.stringify({ + inputs: [ + { + content: [ + { type: 'text', text: 'A yellow banana' }, + { + type: 'image_url', + image_url: + 'https://raw.githubusercontent.com/voyage-ai/voyage-multimodal-3/refs/heads/main/images/banana.jpg', + }, + ], + }, + ], + model: 'voyage-multimodal-3.5', + }), + }) + + expect(response.ok).toBe(true) + const data = await response.json() + expect(data.data).toHaveLength(1) + expect(data.usage.text_tokens).toBeGreaterThan(0) + expect(data.usage.image_pixels).toBeGreaterThan(0) + expect(data.usage.total_tokens).toBeGreaterThan(0) + }, 30000) + + it('should produce 1024-dimensional embeddings', async () => { + const response = await liveFetch(MULTIMODAL_URL, { + method: 'POST', + headers: MULTIMODAL_HEADERS, + body: JSON.stringify({ + inputs: [{ content: [{ type: 'text', text: 'dimension check' }] }], + model: 'voyage-multimodal-3.5', + }), + }) + + const data = await response.json() + expect(data.data[0].embedding).toHaveLength(1024) + }, 15000) + + it('should reject invalid API key', async () => { + const response = await liveFetch(MULTIMODAL_URL, { + method: 'POST', + headers: { + Authorization: 'Bearer invalid-key', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + inputs: [{ content: [{ type: 'text', text: 'test' }] }], + model: 'voyage-multimodal-3.5', + }), + }) + + expect(response.ok).toBe(false) + expect(response.status).toBe(401) + }, 15000) + }) }) diff --git a/apps/sim/tools/voyageai/voyageai.test.ts b/apps/sim/tools/voyageai/voyageai.test.ts index 0d3463f01cf..f254a43b963 100644 --- a/apps/sim/tools/voyageai/voyageai.test.ts +++ b/apps/sim/tools/voyageai/voyageai.test.ts @@ -4,6 +4,7 @@ import { ToolTester } from '@sim/testing/builders' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { embeddingsTool } from '@/tools/voyageai/embeddings' +import { multimodalEmbeddingsTool } from '@/tools/voyageai/multimodal-embeddings' import { rerankTool } from '@/tools/voyageai/rerank' describe('Voyage AI Embeddings Tool', () => { @@ -573,3 +574,138 @@ describe('Voyage AI Rerank Tool', () => { }) }) }) + +describe('Voyage AI Multimodal Embeddings Tool', () => { + describe('Tool metadata', () => { + it('should have correct id and name', () => { + expect(multimodalEmbeddingsTool.id).toBe('voyageai_multimodal_embeddings') + expect(multimodalEmbeddingsTool.name).toBe('Voyage AI Multimodal Embeddings') + expect(multimodalEmbeddingsTool.version).toBe('1.0') + }) + + it('should have apiKey as required and input as optional', () => { + expect(multimodalEmbeddingsTool.params.apiKey.required).toBe(true) + expect(multimodalEmbeddingsTool.params.input.required).toBe(false) + expect(multimodalEmbeddingsTool.params.imageFiles.required).toBe(false) + expect(multimodalEmbeddingsTool.params.videoFile.required).toBe(false) + }) + + it('should default to voyage-multimodal-3.5 model', () => { + expect(multimodalEmbeddingsTool.params.model.default).toBe('voyage-multimodal-3.5') + }) + + it('should use internal proxy URL', () => { + expect(multimodalEmbeddingsTool.request.url).toBe( + '/api/tools/voyageai/multimodal-embeddings' + ) + }) + + it('should use POST method', () => { + expect(multimodalEmbeddingsTool.request.method).toBe('POST') + }) + + it('should have output schema with embeddings, model, usage', () => { + expect(multimodalEmbeddingsTool.outputs).toBeDefined() + expect(multimodalEmbeddingsTool.outputs!.output.properties!.embeddings).toBeDefined() + expect(multimodalEmbeddingsTool.outputs!.output.properties!.model).toBeDefined() + expect(multimodalEmbeddingsTool.outputs!.output.properties!.usage).toBeDefined() + }) + }) + + describe('Body Construction', () => { + it('should pass all params to internal route', () => { + const body = multimodalEmbeddingsTool.request.body!({ + apiKey: 'key', + input: 'hello', + imageUrls: 'https://example.com/img.jpg', + model: 'voyage-multimodal-3.5', + }) + expect(body.apiKey).toBe('key') + expect(body.input).toBe('hello') + expect(body.imageUrls).toBe('https://example.com/img.jpg') + expect(body.model).toBe('voyage-multimodal-3.5') + }) + + it('should use default model when not specified', () => { + const body = multimodalEmbeddingsTool.request.body!({ + apiKey: 'key', + input: 'hello', + }) + expect(body.model).toBe('voyage-multimodal-3.5') + }) + + it('should pass image files through', () => { + const files = [{ id: '1', name: 'img.jpg', size: 100, type: 'image/jpeg' }] + const body = multimodalEmbeddingsTool.request.body!({ + apiKey: 'key', + imageFiles: files, + }) + expect(body.imageFiles).toEqual(files) + }) + + it('should pass video file through', () => { + const file = { id: '1', name: 'vid.mp4', size: 1000, type: 'video/mp4' } + const body = multimodalEmbeddingsTool.request.body!({ + apiKey: 'key', + videoFile: file, + }) + expect(body.videoFile).toEqual(file) + }) + + it('should pass video URL through', () => { + const body = multimodalEmbeddingsTool.request.body!({ + apiKey: 'key', + videoUrl: 'https://example.com/video.mp4', + }) + expect(body.videoUrl).toBe('https://example.com/video.mp4') + }) + + it('should pass inputType through', () => { + const body = multimodalEmbeddingsTool.request.body!({ + apiKey: 'key', + input: 'test', + inputType: 'query', + }) + expect(body.inputType).toBe('query') + }) + }) + + describe('Response Transformation', () => { + it('should extract embeddings from internal route response', async () => { + const mockResponse = { + ok: true, + json: async () => ({ + output: { + embeddings: [[0.1, 0.2, 0.3]], + model: 'voyage-multimodal-3.5', + usage: { text_tokens: 5, image_pixels: 200000, total_tokens: 362 }, + }, + }), + } as Response + + const result = await multimodalEmbeddingsTool.transformResponse!(mockResponse) + expect(result.success).toBe(true) + expect(result.output.embeddings).toEqual([[0.1, 0.2, 0.3]]) + expect(result.output.model).toBe('voyage-multimodal-3.5') + expect(result.output.usage.text_tokens).toBe(5) + expect(result.output.usage.image_pixels).toBe(200000) + expect(result.output.usage.total_tokens).toBe(362) + }) + + it('should handle response with video_pixels', async () => { + const mockResponse = { + ok: true, + json: async () => ({ + output: { + embeddings: [[0.4, 0.5]], + model: 'voyage-multimodal-3.5', + usage: { text_tokens: 0, video_pixels: 5000000, total_tokens: 4464 }, + }, + }), + } as Response + + const result = await multimodalEmbeddingsTool.transformResponse!(mockResponse) + expect(result.output.usage.video_pixels).toBe(5000000) + }) + }) +}) From 40153de1f16a453434810adbc8cdb029b0db0afa Mon Sep 17 00:00:00 2001 From: fzowl Date: Thu, 9 Apr 2026 07:43:52 +0200 Subject: [PATCH 6/8] style: fix code review issues in VoyageAI integration Remove non-TSDoc separator comments, fix relative import in barrel export, fix any types, and apply biome formatting fixes. --- .../voyageai/multimodal-embeddings/route.ts | 16 ++++++++-------- apps/sim/blocks/blocks/voyageai.ts | 4 ---- apps/sim/tools/voyageai/embeddings.ts | 5 +++-- apps/sim/tools/voyageai/index.ts | 2 +- .../tools/voyageai/multimodal-embeddings.ts | 2 +- apps/sim/tools/voyageai/rerank.ts | 2 +- .../voyageai/voyageai.integration.test.ts | 19 ++++++++++++++----- apps/sim/tools/voyageai/voyageai.test.ts | 12 +++++------- 8 files changed, 33 insertions(+), 29 deletions(-) diff --git a/apps/sim/app/api/tools/voyageai/multimodal-embeddings/route.ts b/apps/sim/app/api/tools/voyageai/multimodal-embeddings/route.ts index 18f0107a4d2..2374fd4ae37 100644 --- a/apps/sim/app/api/tools/voyageai/multimodal-embeddings/route.ts +++ b/apps/sim/app/api/tools/voyageai/multimodal-embeddings/route.ts @@ -41,12 +41,10 @@ export async function POST(request: NextRequest) { const content: Array> = [] - // Add text content if (params.input?.trim()) { content.push({ type: 'text', text: params.input }) } - // Process image files → base64 if (params.imageFiles) { const files = Array.isArray(params.imageFiles) ? params.imageFiles : [params.imageFiles] for (const rawFile of files) { @@ -66,14 +64,16 @@ export async function POST(request: NextRequest) { } catch (error) { logger.error(`[${requestId}] Failed to process image file:`, error) return NextResponse.json( - { success: false, error: `Failed to process image file: ${error instanceof Error ? error.message : 'Unknown error'}` }, + { + success: false, + error: `Failed to process image file: ${error instanceof Error ? error.message : 'Unknown error'}`, + }, { status: 400 } ) } } } - // Process image URLs if (params.imageUrls?.trim()) { let urls: string[] try { @@ -97,7 +97,6 @@ export async function POST(request: NextRequest) { } } - // Process video file → base64 if (params.videoFile) { try { const userFile = processSingleFileToUserFile(params.videoFile, requestId, logger) @@ -115,13 +114,15 @@ export async function POST(request: NextRequest) { } catch (error) { logger.error(`[${requestId}] Failed to process video file:`, error) return NextResponse.json( - { success: false, error: `Failed to process video file: ${error instanceof Error ? error.message : 'Unknown error'}` }, + { + success: false, + error: `Failed to process video file: ${error instanceof Error ? error.message : 'Unknown error'}`, + }, { status: 400 } ) } } - // Process video URL if (params.videoUrl?.trim()) { const validation = await validateUrlWithDNS(params.videoUrl, 'videoUrl') if (!validation.isValid) { @@ -145,7 +146,6 @@ export async function POST(request: NextRequest) { model: params.model, }) - // Build VoyageAI request const voyageBody: Record = { inputs: [{ content }], model: params.model, diff --git a/apps/sim/blocks/blocks/voyageai.ts b/apps/sim/blocks/blocks/voyageai.ts index b9c86ba7aaa..4bccb298eb1 100644 --- a/apps/sim/blocks/blocks/voyageai.ts +++ b/apps/sim/blocks/blocks/voyageai.ts @@ -27,7 +27,6 @@ export const VoyageAIBlock: BlockConfig = { ], value: () => 'embeddings', }, - // === Text Embeddings fields === { id: 'input', title: 'Input Text', @@ -66,7 +65,6 @@ export const VoyageAIBlock: BlockConfig = { value: () => 'document', mode: 'advanced', }, - // === Multimodal Embeddings fields === { id: 'multimodalInput', title: 'Text Input', @@ -153,7 +151,6 @@ export const VoyageAIBlock: BlockConfig = { value: () => 'document', mode: 'advanced', }, - // === Rerank fields === { id: 'query', title: 'Query', @@ -191,7 +188,6 @@ export const VoyageAIBlock: BlockConfig = { condition: { field: 'operation', value: 'rerank' }, mode: 'advanced', }, - // === Common fields === { id: 'apiKey', title: 'API Key', diff --git a/apps/sim/tools/voyageai/embeddings.ts b/apps/sim/tools/voyageai/embeddings.ts index c8cd4b899cd..9be5677accc 100644 --- a/apps/sim/tools/voyageai/embeddings.ts +++ b/apps/sim/tools/voyageai/embeddings.ts @@ -1,5 +1,5 @@ -import type { VoyageAIEmbeddingsParams, VoyageAIEmbeddingsResponse } from '@/tools/voyageai/types' import type { ToolConfig } from '@/tools/types' +import type { VoyageAIEmbeddingsParams, VoyageAIEmbeddingsResponse } from '@/tools/voyageai/types' export const embeddingsTool: ToolConfig = { id: 'voyageai_embeddings', @@ -25,7 +25,8 @@ export const embeddingsTool: ToolConfig = { id: 'voyageai_rerank', diff --git a/apps/sim/tools/voyageai/voyageai.integration.test.ts b/apps/sim/tools/voyageai/voyageai.integration.test.ts index 987dadc7161..30d05597865 100644 --- a/apps/sim/tools/voyageai/voyageai.integration.test.ts +++ b/apps/sim/tools/voyageai/voyageai.integration.test.ts @@ -16,10 +16,9 @@ const describeIntegration = API_KEY ? describe : describe.skip * Use undici's fetch directly to bypass the global fetch mock set up in vitest.setup.ts. */ async function liveFetch(url: string, init: RequestInit): Promise { - // vi.mocked(fetch) is the mock — call the real underlying impl const { request } = await import('undici') const resp = await request(url, { - method: init.method as any, + method: init.method as 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH', headers: init.headers as Record, body: init.body as string, }) @@ -359,7 +358,11 @@ describeIntegration('VoyageAI Integration Tests (live API)', () => { }, 15000) it('should reject invalid API key', async () => { - const headers = rerankTool.request.headers({ apiKey: 'invalid-key', query: '', documents: [] }) + const headers = rerankTool.request.headers({ + apiKey: 'invalid-key', + query: '', + documents: [], + }) const body = rerankTool.request.body!({ apiKey: 'invalid-key', query: 'test', @@ -416,7 +419,11 @@ describeIntegration('VoyageAI Integration Tests (live API)', () => { query: 'What are neural networks used for?', documents, }) - const rerankHeaders = rerankTool.request.headers({ apiKey: API_KEY!, query: '', documents: [] }) + const rerankHeaders = rerankTool.request.headers({ + apiKey: API_KEY!, + query: '', + documents: [], + }) const rerankUrl = typeof rerankTool.request.url === 'function' ? rerankTool.request.url({ apiKey: API_KEY!, query: '', documents: [] }) @@ -451,7 +458,9 @@ describeIntegration('VoyageAI Integration Tests (live API)', () => { // The AI-related docs should score higher than the unrelated ones const aiDocIndices = [0, 2] // "Neural networks..." and "Deep learning..." - const topTwoIndices = rerankResult.output.results.slice(0, 2).map((r: any) => r.index) + const topTwoIndices = rerankResult.output.results + .slice(0, 2) + .map((r: { index: number }) => r.index) const aiDocsInTop2 = topTwoIndices.filter((i: number) => aiDocIndices.includes(i)) expect(aiDocsInTop2.length).toBeGreaterThanOrEqual(1) }, 30000) diff --git a/apps/sim/tools/voyageai/voyageai.test.ts b/apps/sim/tools/voyageai/voyageai.test.ts index f254a43b963..8e3de5a27c4 100644 --- a/apps/sim/tools/voyageai/voyageai.test.ts +++ b/apps/sim/tools/voyageai/voyageai.test.ts @@ -314,9 +314,9 @@ describe('Voyage AI Rerank Tool', () => { describe('URL Construction', () => { it('should return the VoyageAI rerank endpoint', () => { - expect( - tester.getRequestUrl({ apiKey: 'key', query: 'test', documents: ['doc1'] }) - ).toBe('https://api.voyageai.com/v1/rerank') + expect(tester.getRequestUrl({ apiKey: 'key', query: 'test', documents: ['doc1'] })).toBe( + 'https://api.voyageai.com/v1/rerank' + ) }) }) @@ -490,7 +490,7 @@ describe('Voyage AI Rerank Tool', () => { data: [ { index: 2, relevance_score: 0.99 }, { index: 0, relevance_score: 0.75 }, - { index: 1, relevance_score: 0.30 }, + { index: 1, relevance_score: 0.3 }, ], model: 'rerank-2', usage: { total_tokens: 40 }, @@ -595,9 +595,7 @@ describe('Voyage AI Multimodal Embeddings Tool', () => { }) it('should use internal proxy URL', () => { - expect(multimodalEmbeddingsTool.request.url).toBe( - '/api/tools/voyageai/multimodal-embeddings' - ) + expect(multimodalEmbeddingsTool.request.url).toBe('/api/tools/voyageai/multimodal-embeddings') }) it('should use POST method', () => { From 7a6ee14fb229330a851646c333e70e7750ce9df2 Mon Sep 17 00:00:00 2001 From: fzowl Date: Thu, 9 Apr 2026 08:15:52 +0200 Subject: [PATCH 7/8] revert: drop all MongoDB connection string changes Reverts MongoDB Atlas connection string support due to validation issues in the Zod schemas. VoyageAI integration remains intact. --- .../sim/app/api/tools/mongodb/delete/route.ts | 10 +- .../app/api/tools/mongodb/execute/route.ts | 10 +- .../sim/app/api/tools/mongodb/insert/route.ts | 10 +- .../app/api/tools/mongodb/introspect/route.ts | 6 +- apps/sim/app/api/tools/mongodb/query/route.ts | 10 +- .../sim/app/api/tools/mongodb/update/route.ts | 10 +- apps/sim/app/api/tools/mongodb/utils.test.ts | 526 ------------------ apps/sim/app/api/tools/mongodb/utils.ts | 10 - apps/sim/blocks/blocks/mongodb.ts | 41 +- apps/sim/tools/mongodb/delete.ts | 6 - apps/sim/tools/mongodb/execute.ts | 6 - apps/sim/tools/mongodb/insert.ts | 6 - apps/sim/tools/mongodb/introspect.ts | 6 - apps/sim/tools/mongodb/query.ts | 6 - apps/sim/tools/mongodb/types.ts | 2 - apps/sim/tools/mongodb/update.ts | 6 - 16 files changed, 28 insertions(+), 643 deletions(-) delete mode 100644 apps/sim/app/api/tools/mongodb/utils.test.ts diff --git a/apps/sim/app/api/tools/mongodb/delete/route.ts b/apps/sim/app/api/tools/mongodb/delete/route.ts index f36f34bd3eb..95dcf328cd0 100644 --- a/apps/sim/app/api/tools/mongodb/delete/route.ts +++ b/apps/sim/app/api/tools/mongodb/delete/route.ts @@ -8,12 +8,11 @@ import { createMongoDBConnection, sanitizeCollectionName, validateFilter } from const logger = createLogger('MongoDBDeleteAPI') const DeleteSchema = z.object({ - connectionString: z.string().optional(), - host: z.string().default(''), - port: z.coerce.number().int().nonnegative().default(27017), + host: z.string().min(1, 'Host is required'), + port: z.coerce.number().int().positive('Port must be a positive integer'), database: z.string().min(1, 'Database name is required'), - username: z.string().default(''), - password: z.string().default(''), + username: z.string().min(1, 'Username is required'), + password: z.string().min(1, 'Password is required'), authSource: z.string().optional(), ssl: z.enum(['disabled', 'required', 'preferred']).default('preferred'), collection: z.string().min(1, 'Collection name is required'), @@ -76,7 +75,6 @@ export async function POST(request: NextRequest) { } client = await createMongoDBConnection({ - connectionString: params.connectionString, host: params.host, port: params.port, database: params.database, diff --git a/apps/sim/app/api/tools/mongodb/execute/route.ts b/apps/sim/app/api/tools/mongodb/execute/route.ts index 7ba10f53882..666d4a45069 100644 --- a/apps/sim/app/api/tools/mongodb/execute/route.ts +++ b/apps/sim/app/api/tools/mongodb/execute/route.ts @@ -8,12 +8,11 @@ import { createMongoDBConnection, sanitizeCollectionName, validatePipeline } fro const logger = createLogger('MongoDBExecuteAPI') const ExecuteSchema = z.object({ - connectionString: z.string().optional(), - host: z.string().default(''), - port: z.coerce.number().int().nonnegative().default(27017), + host: z.string().min(1, 'Host is required'), + port: z.coerce.number().int().positive('Port must be a positive integer'), database: z.string().min(1, 'Database name is required'), - username: z.string().default(''), - password: z.string().default(''), + username: z.string().min(1, 'Username is required'), + password: z.string().min(1, 'Password is required'), authSource: z.string().optional(), ssl: z.enum(['disabled', 'required', 'preferred']).default('preferred'), collection: z.string().min(1, 'Collection name is required'), @@ -62,7 +61,6 @@ export async function POST(request: NextRequest) { const pipelineDoc = JSON.parse(params.pipeline) client = await createMongoDBConnection({ - connectionString: params.connectionString, host: params.host, port: params.port, database: params.database, diff --git a/apps/sim/app/api/tools/mongodb/insert/route.ts b/apps/sim/app/api/tools/mongodb/insert/route.ts index 141ebfe8f66..f7feafd615a 100644 --- a/apps/sim/app/api/tools/mongodb/insert/route.ts +++ b/apps/sim/app/api/tools/mongodb/insert/route.ts @@ -8,12 +8,11 @@ import { createMongoDBConnection, sanitizeCollectionName } from '../utils' const logger = createLogger('MongoDBInsertAPI') const InsertSchema = z.object({ - connectionString: z.string().optional(), - host: z.string().default(''), - port: z.coerce.number().int().nonnegative().default(27017), + host: z.string().min(1, 'Host is required'), + port: z.coerce.number().int().positive('Port must be a positive integer'), database: z.string().min(1, 'Database name is required'), - username: z.string().default(''), - password: z.string().default(''), + username: z.string().min(1, 'Username is required'), + password: z.string().min(1, 'Password is required'), authSource: z.string().optional(), ssl: z.enum(['disabled', 'required', 'preferred']).default('preferred'), collection: z.string().min(1, 'Collection name is required'), @@ -55,7 +54,6 @@ export async function POST(request: NextRequest) { const sanitizedCollection = sanitizeCollectionName(params.collection) client = await createMongoDBConnection({ - connectionString: params.connectionString, host: params.host, port: params.port, database: params.database, diff --git a/apps/sim/app/api/tools/mongodb/introspect/route.ts b/apps/sim/app/api/tools/mongodb/introspect/route.ts index 0740f7c0e54..67f281553e3 100644 --- a/apps/sim/app/api/tools/mongodb/introspect/route.ts +++ b/apps/sim/app/api/tools/mongodb/introspect/route.ts @@ -8,9 +8,8 @@ import { createMongoDBConnection, executeIntrospect } from '../utils' const logger = createLogger('MongoDBIntrospectAPI') const IntrospectSchema = z.object({ - connectionString: z.string().optional(), - host: z.string().default(''), - port: z.coerce.number().int().nonnegative().default(27017), + host: z.string().min(1, 'Host is required'), + port: z.coerce.number().int().positive('Port must be a positive integer'), database: z.string().optional(), username: z.string().optional(), password: z.string().optional(), @@ -37,7 +36,6 @@ export async function POST(request: NextRequest) { ) client = await createMongoDBConnection({ - connectionString: params.connectionString, host: params.host, port: params.port, database: params.database || 'admin', diff --git a/apps/sim/app/api/tools/mongodb/query/route.ts b/apps/sim/app/api/tools/mongodb/query/route.ts index d1fe00a9312..06533e3a8f4 100644 --- a/apps/sim/app/api/tools/mongodb/query/route.ts +++ b/apps/sim/app/api/tools/mongodb/query/route.ts @@ -8,12 +8,11 @@ import { createMongoDBConnection, sanitizeCollectionName, validateFilter } from const logger = createLogger('MongoDBQueryAPI') const QuerySchema = z.object({ - connectionString: z.string().optional(), - host: z.string().default(''), - port: z.coerce.number().int().nonnegative().default(27017), + host: z.string().min(1, 'Host is required'), + port: z.coerce.number().int().positive('Port must be a positive integer'), database: z.string().min(1, 'Database name is required'), - username: z.string().default(''), - password: z.string().default(''), + username: z.string().min(1, 'Username is required'), + password: z.string().min(1, 'Password is required'), authSource: z.string().optional(), ssl: z.enum(['disabled', 'required', 'preferred']).default('preferred'), collection: z.string().min(1, 'Collection name is required'), @@ -91,7 +90,6 @@ export async function POST(request: NextRequest) { } client = await createMongoDBConnection({ - connectionString: params.connectionString, host: params.host, port: params.port, database: params.database, diff --git a/apps/sim/app/api/tools/mongodb/update/route.ts b/apps/sim/app/api/tools/mongodb/update/route.ts index e73e3b76e53..e6c0f867f7e 100644 --- a/apps/sim/app/api/tools/mongodb/update/route.ts +++ b/apps/sim/app/api/tools/mongodb/update/route.ts @@ -8,12 +8,11 @@ import { createMongoDBConnection, sanitizeCollectionName, validateFilter } from const logger = createLogger('MongoDBUpdateAPI') const UpdateSchema = z.object({ - connectionString: z.string().optional(), - host: z.string().default(''), - port: z.coerce.number().int().nonnegative().default(27017), + host: z.string().min(1, 'Host is required'), + port: z.coerce.number().int().positive('Port must be a positive integer'), database: z.string().min(1, 'Database name is required'), - username: z.string().default(''), - password: z.string().default(''), + username: z.string().min(1, 'Username is required'), + password: z.string().min(1, 'Password is required'), authSource: z.string().optional(), ssl: z.enum(['disabled', 'required', 'preferred']).default('preferred'), collection: z.string().min(1, 'Collection name is required'), @@ -100,7 +99,6 @@ export async function POST(request: NextRequest) { } client = await createMongoDBConnection({ - connectionString: params.connectionString, host: params.host, port: params.port, database: params.database, diff --git a/apps/sim/app/api/tools/mongodb/utils.test.ts b/apps/sim/app/api/tools/mongodb/utils.test.ts deleted file mode 100644 index 045712a5891..00000000000 --- a/apps/sim/app/api/tools/mongodb/utils.test.ts +++ /dev/null @@ -1,526 +0,0 @@ -/** - * @vitest-environment node - */ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { mockConnect, mockDb, mockMongoClient, mockValidateDatabaseHost } = vi.hoisted(() => { - const mockDb = vi.fn() - const mockConnect = vi.fn().mockResolvedValue(undefined) - const mockClose = vi.fn() - const mockMongoClient = vi.fn().mockImplementation(() => ({ - connect: mockConnect, - db: mockDb, - close: mockClose, - })) - const mockValidateDatabaseHost = vi.fn().mockResolvedValue({ isValid: true }) - return { mockConnect, mockDb, mockMongoClient, mockValidateDatabaseHost } -}) - -vi.mock('mongodb', () => ({ - MongoClient: mockMongoClient, -})) - -vi.mock('@/lib/core/security/input-validation.server', () => ({ - validateDatabaseHost: mockValidateDatabaseHost, -})) - -import { - createMongoDBConnection, - sanitizeCollectionName, - validateFilter, - validatePipeline, -} from '@/app/api/tools/mongodb/utils' - -describe('MongoDB Utils', () => { - beforeEach(() => { - vi.clearAllMocks() - mockValidateDatabaseHost.mockResolvedValue({ isValid: true }) - }) - - describe('createMongoDBConnection', () => { - describe('connection string mode', () => { - it('should use connectionString directly when provided', async () => { - await createMongoDBConnection({ - connectionString: 'mongodb+srv://user:pass@cluster.mongodb.net/mydb', - host: '', - port: 27017, - database: 'mydb', - }) - - expect(mockMongoClient).toHaveBeenCalledWith( - 'mongodb+srv://user:pass@cluster.mongodb.net/mydb', - expect.objectContaining({ - connectTimeoutMS: 10000, - socketTimeoutMS: 10000, - maxPoolSize: 1, - }) - ) - expect(mockConnect).toHaveBeenCalled() - }) - - it('should skip host validation when connectionString is provided', async () => { - await createMongoDBConnection({ - connectionString: 'mongodb+srv://test@cluster.net/db', - host: '', - port: 0, - database: 'db', - }) - - expect(mockValidateDatabaseHost).not.toHaveBeenCalled() - }) - - it('should apply connection options with connectionString', async () => { - await createMongoDBConnection({ - connectionString: 'mongodb+srv://test@cluster.net/db', - host: '', - port: 0, - database: 'db', - }) - - expect(mockMongoClient).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - connectTimeoutMS: 10000, - socketTimeoutMS: 10000, - maxPoolSize: 1, - }) - ) - }) - - it('should ignore host/port/username/password when connectionString is provided', async () => { - await createMongoDBConnection({ - connectionString: 'mongodb+srv://real@cluster.net/db', - host: 'ignored-host', - port: 9999, - database: 'db', - username: 'ignored-user', - password: 'ignored-pass', - }) - - expect(mockMongoClient).toHaveBeenCalledWith( - 'mongodb+srv://real@cluster.net/db', - expect.any(Object) - ) - }) - - it('should handle connectionString with special characters', async () => { - const connStr = 'mongodb+srv://user:p%40ss%26word@cluster.mongodb.net/db?retryWrites=true' - await createMongoDBConnection({ - connectionString: connStr, - host: '', - port: 0, - database: 'db', - }) - - expect(mockMongoClient).toHaveBeenCalledWith(connStr, expect.any(Object)) - }) - - it('should handle standard mongodb:// connectionString', async () => { - const connStr = 'mongodb://user:pass@host1:27017,host2:27017/mydb?replicaSet=rs0' - await createMongoDBConnection({ - connectionString: connStr, - host: '', - port: 0, - database: 'mydb', - }) - - expect(mockMongoClient).toHaveBeenCalledWith(connStr, expect.any(Object)) - }) - - it('should call connect() after creating client', async () => { - await createMongoDBConnection({ - connectionString: 'mongodb+srv://test@cluster.net/db', - host: '', - port: 0, - database: 'db', - }) - - expect(mockMongoClient).toHaveBeenCalled() - expect(mockConnect).toHaveBeenCalledAfter(mockMongoClient) - }) - }) - - describe('host/port mode', () => { - it('should build URI from host/port with credentials', async () => { - await createMongoDBConnection({ - host: 'localhost', - port: 27017, - database: 'testdb', - username: 'user', - password: 'pass', - }) - - expect(mockValidateDatabaseHost).toHaveBeenCalledWith('localhost', 'host') - expect(mockMongoClient).toHaveBeenCalledWith( - expect.stringContaining('mongodb://user:pass@localhost:27017/testdb'), - expect.any(Object) - ) - }) - - it('should build URI without credentials when not provided', async () => { - await createMongoDBConnection({ - host: 'localhost', - port: 27017, - database: 'testdb', - }) - - expect(mockMongoClient).toHaveBeenCalledWith( - 'mongodb://localhost:27017/testdb', - expect.any(Object) - ) - }) - - it('should encode special characters in username and password', async () => { - await createMongoDBConnection({ - host: 'localhost', - port: 27017, - database: 'testdb', - username: 'user@domain', - password: 'p@ss:word', - }) - - const calledUri = mockMongoClient.mock.calls[0][0] - expect(calledUri).toContain('user%40domain') - expect(calledUri).toContain('p%40ss%3Aword') - }) - - it('should include authSource in URI when provided', async () => { - await createMongoDBConnection({ - host: 'localhost', - port: 27017, - database: 'testdb', - authSource: 'admin', - }) - - expect(mockMongoClient).toHaveBeenCalledWith( - expect.stringContaining('authSource=admin'), - expect.any(Object) - ) - }) - - it('should include ssl=true when ssl is required', async () => { - await createMongoDBConnection({ - host: 'localhost', - port: 27017, - database: 'testdb', - ssl: 'required', - }) - - expect(mockMongoClient).toHaveBeenCalledWith( - expect.stringContaining('ssl=true'), - expect.any(Object) - ) - }) - - it('should not include ssl param when ssl is disabled', async () => { - await createMongoDBConnection({ - host: 'localhost', - port: 27017, - database: 'testdb', - ssl: 'disabled', - }) - - const calledUri = mockMongoClient.mock.calls[0][0] - expect(calledUri).not.toContain('ssl=') - }) - - it('should not include ssl param when ssl is preferred', async () => { - await createMongoDBConnection({ - host: 'localhost', - port: 27017, - database: 'testdb', - ssl: 'preferred', - }) - - const calledUri = mockMongoClient.mock.calls[0][0] - expect(calledUri).not.toContain('ssl=') - }) - - it('should include both authSource and ssl when both provided', async () => { - await createMongoDBConnection({ - host: 'localhost', - port: 27017, - database: 'testdb', - authSource: 'admin', - ssl: 'required', - }) - - const calledUri = mockMongoClient.mock.calls[0][0] - expect(calledUri).toContain('authSource=admin') - expect(calledUri).toContain('ssl=true') - }) - - it('should throw when host validation fails', async () => { - mockValidateDatabaseHost.mockResolvedValue({ - isValid: false, - error: 'Invalid host', - }) - - await expect( - createMongoDBConnection({ - host: 'bad-host', - port: 27017, - database: 'testdb', - }) - ).rejects.toThrow('Invalid host') - }) - - it('should use custom port number', async () => { - await createMongoDBConnection({ - host: 'localhost', - port: 27018, - database: 'testdb', - }) - - expect(mockMongoClient).toHaveBeenCalledWith( - expect.stringContaining('localhost:27018'), - expect.any(Object) - ) - }) - - it('should call connect() on the client', async () => { - await createMongoDBConnection({ - host: 'localhost', - port: 27017, - database: 'testdb', - }) - - expect(mockConnect).toHaveBeenCalled() - }) - - it('should propagate connect errors', async () => { - mockConnect.mockRejectedValueOnce(new Error('Connection failed')) - - await expect( - createMongoDBConnection({ - host: 'localhost', - port: 27017, - database: 'testdb', - }) - ).rejects.toThrow('Connection failed') - }) - }) - }) - - describe('validateFilter', () => { - it('should accept valid simple filter', () => { - expect(validateFilter('{"status": "active"}')).toEqual({ isValid: true }) - }) - - it('should accept valid complex filter', () => { - expect( - validateFilter('{"$and": [{"age": {"$gte": 18}}, {"status": "active"}]}') - ).toEqual({ isValid: true }) - }) - - it('should accept empty object filter', () => { - expect(validateFilter('{}')).toEqual({ isValid: true }) - }) - - it('should accept filter with $in operator', () => { - expect(validateFilter('{"status": {"$in": ["active", "pending"]}}')).toEqual({ - isValid: true, - }) - }) - - it('should accept filter with $or operator', () => { - expect( - validateFilter('{"$or": [{"name": "Alice"}, {"name": "Bob"}]}') - ).toEqual({ isValid: true }) - }) - - it('should reject $where operator', () => { - const result = validateFilter('{"$where": "this.a > 1"}') - expect(result.isValid).toBe(false) - expect(result.error).toContain('dangerous operators') - }) - - it('should reject $function operator', () => { - const result = validateFilter('{"$function": {"body": "function(){}", "args": [], "lang": "js"}}') - expect(result.isValid).toBe(false) - }) - - it('should reject $accumulator operator', () => { - const result = validateFilter('{"$accumulator": {"init": "function(){return 0}"}}') - expect(result.isValid).toBe(false) - }) - - it('should reject nested dangerous operators', () => { - const result = validateFilter('{"outer": {"$where": "this.a > 1"}}') - expect(result.isValid).toBe(false) - }) - - it('should reject $regex operator', () => { - const result = validateFilter('{"name": {"$regex": ".*"}}') - expect(result.isValid).toBe(false) - }) - - it('should reject $expr operator', () => { - const result = validateFilter('{"$expr": {"$gt": ["$a", "$b"]}}') - expect(result.isValid).toBe(false) - }) - - it('should reject invalid JSON', () => { - const result = validateFilter('not json at all') - expect(result.isValid).toBe(false) - expect(result.error).toContain('Invalid JSON') - }) - - it('should reject malformed JSON', () => { - const result = validateFilter('{status: active}') - expect(result.isValid).toBe(false) - }) - }) - - describe('validatePipeline', () => { - it('should accept valid $match pipeline', () => { - expect(validatePipeline('[{"$match": {"status": "active"}}]')).toEqual({ isValid: true }) - }) - - it('should accept multi-stage pipeline', () => { - const pipeline = JSON.stringify([ - { $match: { status: 'active' } }, - { $group: { _id: '$category', count: { $sum: 1 } } }, - { $sort: { count: -1 } }, - ]) - expect(validatePipeline(pipeline)).toEqual({ isValid: true }) - }) - - it('should accept empty pipeline array', () => { - expect(validatePipeline('[]')).toEqual({ isValid: true }) - }) - - it('should allow $vectorSearch stage (critical for Atlas)', () => { - const pipeline = JSON.stringify([ - { - $vectorSearch: { - index: 'vector_index', - path: 'embedding', - queryVector: [0.1, 0.2, 0.3], - numCandidates: 100, - limit: 10, - }, - }, - ]) - expect(validatePipeline(pipeline)).toEqual({ isValid: true }) - }) - - it('should allow $vectorSearch followed by $project', () => { - const pipeline = JSON.stringify([ - { - $vectorSearch: { - index: 'idx', - path: 'emb', - queryVector: [0.1], - numCandidates: 50, - limit: 5, - }, - }, - { $project: { title: 1, score: { $meta: 'vectorSearchScore' } } }, - ]) - expect(validatePipeline(pipeline)).toEqual({ isValid: true }) - }) - - it('should allow $lookup stage', () => { - const pipeline = JSON.stringify([ - { $lookup: { from: 'orders', localField: '_id', foreignField: 'userId', as: 'orders' } }, - ]) - expect(validatePipeline(pipeline)).toEqual({ isValid: true }) - }) - - it('should allow $geoNear stage', () => { - const pipeline = JSON.stringify([ - { $geoNear: { near: { type: 'Point', coordinates: [0, 0] }, distanceField: 'dist' } }, - ]) - expect(validatePipeline(pipeline)).toEqual({ isValid: true }) - }) - - it('should reject $merge operator', () => { - const result = validatePipeline('[{"$merge": {"into": "other_collection"}}]') - expect(result.isValid).toBe(false) - }) - - it('should reject $out operator', () => { - const result = validatePipeline('[{"$out": "output_collection"}]') - expect(result.isValid).toBe(false) - }) - - it('should reject $function operator in pipeline', () => { - const result = validatePipeline( - '[{"$addFields": {"result": {"$function": {"body": "function(){}", "args": [], "lang": "js"}}}}]' - ) - expect(result.isValid).toBe(false) - }) - - it('should reject $currentOp operator', () => { - const result = validatePipeline('[{"$currentOp": {}}]') - expect(result.isValid).toBe(false) - }) - - it('should reject $listSessions operator', () => { - const result = validatePipeline('[{"$listSessions": {}}]') - expect(result.isValid).toBe(false) - }) - - it('should reject non-array pipelines', () => { - const result = validatePipeline('{"$match": {}}') - expect(result.isValid).toBe(false) - expect(result.error).toContain('must be an array') - }) - - it('should reject invalid JSON', () => { - const result = validatePipeline('not json') - expect(result.isValid).toBe(false) - expect(result.error).toContain('Invalid JSON') - }) - - it('should reject nested dangerous operators in pipeline stages', () => { - const result = validatePipeline( - '[{"$match": {"nested": {"$where": "dangerous"}}}]' - ) - expect(result.isValid).toBe(false) - }) - }) - - describe('sanitizeCollectionName', () => { - it('should accept alphabetic collection names', () => { - expect(sanitizeCollectionName('users')).toBe('users') - expect(sanitizeCollectionName('Products')).toBe('Products') - }) - - it('should accept names with underscores', () => { - expect(sanitizeCollectionName('my_collection')).toBe('my_collection') - expect(sanitizeCollectionName('_private')).toBe('_private') - }) - - it('should accept names with numbers (not leading)', () => { - expect(sanitizeCollectionName('users2')).toBe('users2') - expect(sanitizeCollectionName('collection_v3')).toBe('collection_v3') - }) - - it('should reject names with hyphens', () => { - expect(() => sanitizeCollectionName('invalid-name')).toThrow('Invalid collection name') - }) - - it('should reject names starting with numbers', () => { - expect(() => sanitizeCollectionName('123start')).toThrow('Invalid collection name') - }) - - it('should reject names with spaces', () => { - expect(() => sanitizeCollectionName('has space')).toThrow('Invalid collection name') - }) - - it('should reject names with dots', () => { - expect(() => sanitizeCollectionName('system.users')).toThrow('Invalid collection name') - }) - - it('should reject empty string', () => { - expect(() => sanitizeCollectionName('')).toThrow('Invalid collection name') - }) - - it('should reject names with special characters', () => { - expect(() => sanitizeCollectionName('col$ection')).toThrow('Invalid collection name') - expect(() => sanitizeCollectionName('col@ection')).toThrow('Invalid collection name') - }) - }) -}) diff --git a/apps/sim/app/api/tools/mongodb/utils.ts b/apps/sim/app/api/tools/mongodb/utils.ts index 5d45a173399..33e6af90ae7 100644 --- a/apps/sim/app/api/tools/mongodb/utils.ts +++ b/apps/sim/app/api/tools/mongodb/utils.ts @@ -3,16 +3,6 @@ import { validateDatabaseHost } from '@/lib/core/security/input-validation.serve import type { MongoDBCollectionInfo, MongoDBConnectionConfig } from '@/tools/mongodb/types' export async function createMongoDBConnection(config: MongoDBConnectionConfig) { - if (config.connectionString) { - const client = new MongoClient(config.connectionString, { - connectTimeoutMS: 10000, - socketTimeoutMS: 10000, - maxPoolSize: 1, - }) - await client.connect() - return client - } - const hostValidation = await validateDatabaseHost(config.host, 'host') if (!hostValidation.isValid) { throw new Error(hostValidation.error) diff --git a/apps/sim/blocks/blocks/mongodb.ts b/apps/sim/blocks/blocks/mongodb.ts index b0bf99f8166..cf1398f295e 100644 --- a/apps/sim/blocks/blocks/mongodb.ts +++ b/apps/sim/blocks/blocks/mongodb.ts @@ -30,32 +30,12 @@ export const MongoDBBlock: BlockConfig 'query', }, - { - id: 'connectionMode', - title: 'Connection Mode', - type: 'dropdown', - options: [ - { label: 'Host & Port', id: 'host_port' }, - { label: 'Connection String (Atlas)', id: 'connection_string' }, - ], - value: () => 'host_port', - }, - { - id: 'connectionString', - title: 'Connection String', - type: 'short-input', - placeholder: 'mongodb+srv://user:password@cluster.mongodb.net/mydb', - password: true, - condition: { field: 'connectionMode', value: 'connection_string' }, - required: { field: 'connectionMode', value: 'connection_string' }, - }, { id: 'host', title: 'Host', type: 'short-input', placeholder: 'localhost or your.mongodb.host', - condition: { field: 'connectionMode', value: 'connection_string', not: true }, - required: { field: 'connectionMode', value: 'connection_string', not: true }, + required: true, }, { id: 'port', @@ -63,8 +43,7 @@ export const MongoDBBlock: BlockConfig '27017', - condition: { field: 'connectionMode', value: 'connection_string', not: true }, - required: { field: 'connectionMode', value: 'connection_string', not: true }, + required: true, }, { id: 'database', @@ -78,8 +57,7 @@ export const MongoDBBlock: BlockConfig { - const { operation, documents, connectionMode, ...rest } = params + const { operation, documents, ...rest } = params let parsedDocuments if (documents && typeof documents === 'string' && documents.trim()) { @@ -876,7 +853,7 @@ Return ONLY the MongoDB query filter as valid JSON - no explanations, no markdow parsedDocuments = documents } - const connectionConfig: Record = { + const connectionConfig = { host: rest.host, port: typeof rest.port === 'string' ? Number.parseInt(rest.port, 10) : rest.port || 27017, database: rest.database, @@ -886,10 +863,6 @@ Return ONLY the MongoDB query filter as valid JSON - no explanations, no markdow ssl: rest.ssl || 'preferred', } - if (rest.connectionString) { - connectionConfig.connectionString = rest.connectionString - } - const result: any = { ...connectionConfig } if (rest.collection) result.collection = rest.collection @@ -927,8 +900,6 @@ Return ONLY the MongoDB query filter as valid JSON - no explanations, no markdow }, inputs: { operation: { type: 'string', description: 'Database operation to perform' }, - connectionMode: { type: 'string', description: 'Connection mode (host_port or connection_string)' }, - connectionString: { type: 'string', description: 'Full MongoDB connection string' }, host: { type: 'string', description: 'MongoDB host' }, port: { type: 'string', description: 'MongoDB port' }, database: { type: 'string', description: 'Database name' }, diff --git a/apps/sim/tools/mongodb/delete.ts b/apps/sim/tools/mongodb/delete.ts index 44457ecfb5b..afb26f3f135 100644 --- a/apps/sim/tools/mongodb/delete.ts +++ b/apps/sim/tools/mongodb/delete.ts @@ -8,12 +8,6 @@ export const deleteTool: ToolConfig = { version: '1.0', params: { - connectionString: { - type: 'string', - required: false, - visibility: 'user-only', - description: 'Full MongoDB connection string (e.g., mongodb+srv://user:pass@cluster.mongodb.net/db)', - }, host: { type: 'string', required: true, diff --git a/apps/sim/tools/mongodb/execute.ts b/apps/sim/tools/mongodb/execute.ts index 8c4d8f77162..12e5d42f3fb 100644 --- a/apps/sim/tools/mongodb/execute.ts +++ b/apps/sim/tools/mongodb/execute.ts @@ -8,12 +8,6 @@ export const executeTool: ToolConfig = { version: '1.0', params: { - connectionString: { - type: 'string', - required: false, - visibility: 'user-only', - description: 'Full MongoDB connection string (e.g., mongodb+srv://user:pass@cluster.mongodb.net/db)', - }, host: { type: 'string', required: true, diff --git a/apps/sim/tools/mongodb/insert.ts b/apps/sim/tools/mongodb/insert.ts index 52ea1c12039..b129f653986 100644 --- a/apps/sim/tools/mongodb/insert.ts +++ b/apps/sim/tools/mongodb/insert.ts @@ -8,12 +8,6 @@ export const insertTool: ToolConfig = { version: '1.0', params: { - connectionString: { - type: 'string', - required: false, - visibility: 'user-only', - description: 'Full MongoDB connection string (e.g., mongodb+srv://user:pass@cluster.mongodb.net/db)', - }, host: { type: 'string', required: true, diff --git a/apps/sim/tools/mongodb/introspect.ts b/apps/sim/tools/mongodb/introspect.ts index ad6fd0bfc47..045a11107c7 100644 --- a/apps/sim/tools/mongodb/introspect.ts +++ b/apps/sim/tools/mongodb/introspect.ts @@ -8,12 +8,6 @@ export const introspectTool: ToolConfig = { version: '1.0', params: { - connectionString: { - type: 'string', - required: false, - visibility: 'user-only', - description: 'Full MongoDB connection string (e.g., mongodb+srv://user:pass@cluster.mongodb.net/db)', - }, host: { type: 'string', required: true, diff --git a/apps/sim/tools/mongodb/types.ts b/apps/sim/tools/mongodb/types.ts index 44389e9acc1..2465458736c 100644 --- a/apps/sim/tools/mongodb/types.ts +++ b/apps/sim/tools/mongodb/types.ts @@ -8,7 +8,6 @@ export interface MongoDBConnectionConfig { password?: string authSource?: string ssl?: 'disabled' | 'required' | 'preferred' - connectionString?: string } export interface MongoDBQueryParams extends MongoDBConnectionConfig { @@ -50,7 +49,6 @@ export interface MongoDBIntrospectParams { password?: string authSource?: string ssl?: 'disabled' | 'required' | 'preferred' - connectionString?: string } export interface MongoDBCollectionInfo { diff --git a/apps/sim/tools/mongodb/update.ts b/apps/sim/tools/mongodb/update.ts index a8a32934302..d1812e30999 100644 --- a/apps/sim/tools/mongodb/update.ts +++ b/apps/sim/tools/mongodb/update.ts @@ -8,12 +8,6 @@ export const updateTool: ToolConfig = { version: '1.0', params: { - connectionString: { - type: 'string', - required: false, - visibility: 'user-only', - description: 'Full MongoDB connection string (e.g., mongodb+srv://user:pass@cluster.mongodb.net/db)', - }, host: { type: 'string', required: true, From 80e00997d5c55b03d4714311c8b75d3776a95a4a Mon Sep 17 00:00:00 2001 From: fzowl Date: Fri, 10 Apr 2026 16:34:55 +0200 Subject: [PATCH 8/8] fix: resolve PR review comments - Rename imageFiles/videoFile subblock IDs to avoid canonicalParamId collision - Add response.ok guard in embeddings and rerank transformResponse - Remove unused truncation param from types - Fix test pattern: use beforeEach/clearAllMocks instead of afterEach/resetAllMocks - Add Array.isArray guard for JSON.parse of imageUrls --- .../tools/voyageai/multimodal-embeddings/route.ts | 3 ++- apps/sim/blocks/blocks/voyageai.test.ts | 4 ++-- apps/sim/blocks/blocks/voyageai.ts | 4 ++-- apps/sim/tools/voyageai/embeddings.ts | 3 +++ apps/sim/tools/voyageai/rerank.ts | 3 +++ apps/sim/tools/voyageai/types.ts | 2 -- apps/sim/tools/voyageai/voyageai.test.ts | 14 +++----------- 7 files changed, 15 insertions(+), 18 deletions(-) diff --git a/apps/sim/app/api/tools/voyageai/multimodal-embeddings/route.ts b/apps/sim/app/api/tools/voyageai/multimodal-embeddings/route.ts index 2374fd4ae37..41420cf346e 100644 --- a/apps/sim/app/api/tools/voyageai/multimodal-embeddings/route.ts +++ b/apps/sim/app/api/tools/voyageai/multimodal-embeddings/route.ts @@ -77,7 +77,8 @@ export async function POST(request: NextRequest) { if (params.imageUrls?.trim()) { let urls: string[] try { - urls = JSON.parse(params.imageUrls) + const parsed = JSON.parse(params.imageUrls) + urls = Array.isArray(parsed) ? parsed : [parsed] } catch { urls = params.imageUrls .split(/[,\n]/) diff --git a/apps/sim/blocks/blocks/voyageai.test.ts b/apps/sim/blocks/blocks/voyageai.test.ts index bf4fa98ffc3..608c1ad0f4e 100644 --- a/apps/sim/blocks/blocks/voyageai.test.ts +++ b/apps/sim/blocks/blocks/voyageai.test.ts @@ -173,9 +173,9 @@ describe('VoyageAIBlock', () => { ) const ids = mmBlocks.map((sb) => sb.id) expect(ids).toContain('multimodalInput') - expect(ids).toContain('imageFiles') + expect(ids).toContain('imageFilesUpload') expect(ids).toContain('imageFilesRef') - expect(ids).toContain('videoFile') + expect(ids).toContain('videoFileUpload') expect(ids).toContain('videoFileRef') expect(ids).toContain('multimodalModel') }) diff --git a/apps/sim/blocks/blocks/voyageai.ts b/apps/sim/blocks/blocks/voyageai.ts index 4bccb298eb1..8efc146e579 100644 --- a/apps/sim/blocks/blocks/voyageai.ts +++ b/apps/sim/blocks/blocks/voyageai.ts @@ -73,7 +73,7 @@ export const VoyageAIBlock: BlockConfig = { condition: { field: 'operation', value: 'multimodal_embeddings' }, }, { - id: 'imageFiles', + id: 'imageFilesUpload', title: 'Image Files', type: 'file-upload', canonicalParamId: 'imageFiles', @@ -101,7 +101,7 @@ export const VoyageAIBlock: BlockConfig = { mode: 'advanced', }, { - id: 'videoFile', + id: 'videoFileUpload', title: 'Video File', type: 'file-upload', canonicalParamId: 'videoFile', diff --git a/apps/sim/tools/voyageai/embeddings.ts b/apps/sim/tools/voyageai/embeddings.ts index 9be5677accc..731ac0f1163 100644 --- a/apps/sim/tools/voyageai/embeddings.ts +++ b/apps/sim/tools/voyageai/embeddings.ts @@ -57,6 +57,9 @@ export const embeddingsTool: ToolConfig { const data = await response.json() + if (!response.ok) { + throw new Error(data.detail ?? data.message ?? `VoyageAI API error: ${response.status}`) + } return { success: true, output: { diff --git a/apps/sim/tools/voyageai/rerank.ts b/apps/sim/tools/voyageai/rerank.ts index f25257b95cf..7ce97ad10a7 100644 --- a/apps/sim/tools/voyageai/rerank.ts +++ b/apps/sim/tools/voyageai/rerank.ts @@ -66,6 +66,9 @@ export const rerankTool: ToolConfig { const data = await response.json() + if (!response.ok) { + throw new Error(data.detail ?? data.message ?? `VoyageAI API error: ${response.status}`) + } const originalDocuments: string[] = params ? typeof params.documents === 'string' ? JSON.parse(params.documents) diff --git a/apps/sim/tools/voyageai/types.ts b/apps/sim/tools/voyageai/types.ts index 54e6f715916..c71bd8c70d3 100644 --- a/apps/sim/tools/voyageai/types.ts +++ b/apps/sim/tools/voyageai/types.ts @@ -5,7 +5,6 @@ export interface VoyageAIEmbeddingsParams { input: string | string[] model?: string inputType?: 'query' | 'document' - truncation?: boolean } export interface VoyageAIRerankParams { @@ -14,7 +13,6 @@ export interface VoyageAIRerankParams { documents: string | string[] model?: string topK?: number - truncation?: boolean } export interface VoyageAIEmbeddingsResponse extends ToolResponse { diff --git a/apps/sim/tools/voyageai/voyageai.test.ts b/apps/sim/tools/voyageai/voyageai.test.ts index 8e3de5a27c4..1aaa0db7af5 100644 --- a/apps/sim/tools/voyageai/voyageai.test.ts +++ b/apps/sim/tools/voyageai/voyageai.test.ts @@ -2,7 +2,7 @@ * @vitest-environment node */ import { ToolTester } from '@sim/testing/builders' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { embeddingsTool } from '@/tools/voyageai/embeddings' import { multimodalEmbeddingsTool } from '@/tools/voyageai/multimodal-embeddings' import { rerankTool } from '@/tools/voyageai/rerank' @@ -12,11 +12,7 @@ describe('Voyage AI Embeddings Tool', () => { beforeEach(() => { tester = new ToolTester(embeddingsTool as any) - }) - - afterEach(() => { - tester.cleanup() - vi.resetAllMocks() + vi.clearAllMocks() }) describe('Tool metadata', () => { @@ -276,11 +272,7 @@ describe('Voyage AI Rerank Tool', () => { beforeEach(() => { tester = new ToolTester(rerankTool as any) - }) - - afterEach(() => { - tester.cleanup() - vi.resetAllMocks() + vi.clearAllMocks() }) describe('Tool metadata', () => {