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/ 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..2374fd4ae37 --- /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> = [] + + if (params.input?.trim()) { + content.push({ type: 'text', text: params.input }) + } + + 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 } + ) + } + } + } + + 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 }) + } + } + + 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 } + ) + } + } + + 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, + }) + + 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 new file mode 100644 index 00000000000..bf4fa98ffc3 --- /dev/null +++ b/apps/sim/blocks/blocks/voyageai.test.ts @@ -0,0 +1,507 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { VoyageAIBlock } from '@/blocks/blocks/voyageai' +import { AuthMode, IntegrationType } from '@/blocks/types' + +describe('VoyageAIBlock', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('block 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 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', () => { + 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 && + typeof sb.condition === 'object' && + '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 && + typeof sb.condition === 'object' && + 'value' in sb.condition && + sb.condition.value === 'rerank' + ) + 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 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-code-3') + expect(modelIds).toContain('voyage-finance-2') + 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() + 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') + }) + }) + + 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') + }) + + 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') + }) + + 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') + }) + + 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! + + 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', + }) + }) + + 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 empty string', () => { + const result = paramsFunction({ + operation: 'embeddings', + apiKey: 'va-key', + input: 'hello', + embeddingModel: 'voyage-3', + inputType: '', + }) + expect(result.inputType).toBeUndefined() + }) + + 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() + }) + + 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() + }) + }) + + 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', + }) + }) + + 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() + }) + + 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() + }) + }) + + 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 new file mode 100644 index 00000000000..4bccb298eb1 --- /dev/null +++ b/apps/sim/blocks/blocks/voyageai.ts @@ -0,0 +1,282 @@ +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 text or multimodal embeddings, 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: 'Multimodal Embeddings', id: 'multimodal_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-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-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.5', + }, + { + 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: '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', + }, + { + 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.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.5', + }, + { + 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_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: + 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 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 = + 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)' }, + 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' }, + 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/pixel 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 ( = { gamma_list_folders: gammaListFoldersTool, vision_tool: visionTool, vision_tool_v2: visionToolV2, + voyageai_embeddings: voyageaiEmbeddingsTool, + voyageai_multimodal_embeddings: voyageaiMultimodalEmbeddingsTool, + 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..9be5677accc --- /dev/null +++ b/apps/sim/tools/voyageai/embeddings.ts @@ -0,0 +1,90 @@ +import type { ToolConfig } from '@/tools/types' +import type { VoyageAIEmbeddingsParams, VoyageAIEmbeddingsResponse } from '@/tools/voyageai/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.5', + }, + 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.5', + } + 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..e4a3a4c923c --- /dev/null +++ b/apps/sim/tools/voyageai/index.ts @@ -0,0 +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 '@/tools/voyageai/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..be355f6397e --- /dev/null +++ b/apps/sim/tools/voyageai/multimodal-embeddings.ts @@ -0,0 +1,120 @@ +import type { ToolConfig } from '@/tools/types' +import type { + VoyageAIMultimodalEmbeddingsParams, + VoyageAIMultimodalEmbeddingsResponse, +} from '@/tools/voyageai/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/rerank.ts b/apps/sim/tools/voyageai/rerank.ts new file mode 100644 index 00000000000..f25257b95cf --- /dev/null +++ b/apps/sim/tools/voyageai/rerank.ts @@ -0,0 +1,120 @@ +import type { ToolConfig } from '@/tools/types' +import type { VoyageAIRerankParams, VoyageAIRerankResponse } from '@/tools/voyageai/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.5', + }, + 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.5', + } + 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..54e6f715916 --- /dev/null +++ b/apps/sim/tools/voyageai/types.ts @@ -0,0 +1,66 @@ +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 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<{ + index: number + relevance_score: number + document: string + }> + model: string + usage: { + total_tokens: number + } + } +} 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..30d05597865 --- /dev/null +++ b/apps/sim/tools/voyageai/voyageai.integration.test.ts @@ -0,0 +1,582 @@ +/** + * @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 { + const { request } = await import('undici') + const resp = await request(url, { + method: init.method as 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH', + 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.5') + 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.5') + 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: { index: number }) => r.index) + const aiDocsInTop2 = topTwoIndices.filter((i: number) => aiDocIndices.includes(i)) + 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 new file mode 100644 index 00000000000..8e3de5a27c4 --- /dev/null +++ b/apps/sim/tools/voyageai/voyageai.test.ts @@ -0,0 +1,709 @@ +/** + * @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 { multimodalEmbeddingsTool } from '@/tools/voyageai/multimodal-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('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.5') + 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', () => { + 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') + }) + + 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', () => { + const body = tester.getRequestBody({ apiKey: 'key', input: ['text1', 'text2'] }) + expect(body.input).toEqual(['text1', 'text2']) + }) + + 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.5') + }) + + 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 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 for multiple inputs', 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) + }) + + 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 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) + }) + }) +}) + +describe('Voyage AI Rerank Tool', () => { + let tester: ToolTester + + beforeEach(() => { + tester = new ToolTester(rerankTool as any) + }) + + afterEach(() => { + tester.cleanup() + 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.5') + 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(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 default 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.5') + }) + + 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', () => { + const body = tester.getRequestBody({ + apiKey: 'key', + query: 'test', + documents: ['doc1'], + topK: 5, + }) + 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', + query: 'test', + documents: ['doc1'], + }) + expect(body.top_k).toBeUndefined() + }) + + 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', + documents: ['doc1'], + model: 'rerank-2-lite', + }) + 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', () => { + 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).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) + }) + + 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: ['only doc'], + }) + + 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 three documents reranked', async () => { + tester.setup({ + data: [ + { index: 2, relevance_score: 0.99 }, + { index: 0, relevance_score: 0.75 }, + { index: 1, relevance_score: 0.3 }, + ], + 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') + }) + }) +}) + +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) + }) + }) +})