diff --git a/apps/sim/executor/handlers/generic/generic-handler.test.ts b/apps/sim/executor/handlers/generic/generic-handler.test.ts index 9addd3b3a..6e211b8d0 100644 --- a/apps/sim/executor/handlers/generic/generic-handler.test.ts +++ b/apps/sim/executor/handlers/generic/generic-handler.test.ts @@ -148,165 +148,4 @@ describe('GenericBlockHandler', () => { ) }) - describe('Knowledge block cost tracking', () => { - beforeEach(() => { - // Set up knowledge block mock - mockBlock = { - ...mockBlock, - config: { tool: 'knowledge_search', params: {} }, - } - - mockTool = { - ...mockTool, - id: 'knowledge_search', - name: 'Knowledge Search', - } - - mockGetTool.mockImplementation((toolId) => { - if (toolId === 'knowledge_search') { - return mockTool - } - return undefined - }) - }) - - it.concurrent( - 'should pass through cost information from knowledge tools unchanged', - async () => { - const inputs = { query: 'test query' } - // Tool's transformResponse already restructures cost, so executeTool returns restructured data - const mockToolResponse = { - success: true, - output: { - results: [], - query: 'test query', - totalResults: 0, - cost: { - input: 0.00001042, - output: 0, - total: 0.00001042, - }, - tokens: { - input: 521, - output: 0, - total: 521, - }, - model: 'text-embedding-3-small', - }, - } - - mockExecuteTool.mockResolvedValue(mockToolResponse) - - const result = await handler.execute(mockContext, mockBlock, inputs) - - // Generic handler passes through output unchanged - expect(result).toEqual({ - results: [], - query: 'test query', - totalResults: 0, - cost: { - input: 0.00001042, - output: 0, - total: 0.00001042, - }, - tokens: { - input: 521, - output: 0, - total: 521, - }, - model: 'text-embedding-3-small', - }) - } - ) - - it.concurrent('should pass through knowledge_upload_chunk output unchanged', async () => { - // Update to upload_chunk tool - mockBlock.config.tool = 'knowledge_upload_chunk' - mockTool.id = 'knowledge_upload_chunk' - mockTool.name = 'Knowledge Upload Chunk' - - mockGetTool.mockImplementation((toolId) => { - if (toolId === 'knowledge_upload_chunk') { - return mockTool - } - return undefined - }) - - const inputs = { content: 'test content' } - // Tool's transformResponse already restructures cost - const mockToolResponse = { - success: true, - output: { - data: { - id: 'chunk-123', - content: 'test content', - chunkIndex: 0, - }, - message: 'Successfully uploaded chunk', - documentId: 'doc-123', - cost: { - input: 0.00000521, - output: 0, - total: 0.00000521, - }, - tokens: { - input: 260, - output: 0, - total: 260, - }, - model: 'text-embedding-3-small', - }, - } - - mockExecuteTool.mockResolvedValue(mockToolResponse) - - const result = await handler.execute(mockContext, mockBlock, inputs) - - // Generic handler passes through output unchanged - expect(result).toEqual({ - data: { - id: 'chunk-123', - content: 'test content', - chunkIndex: 0, - }, - message: 'Successfully uploaded chunk', - documentId: 'doc-123', - cost: { - input: 0.00000521, - output: 0, - total: 0.00000521, - }, - tokens: { - input: 260, - output: 0, - total: 260, - }, - model: 'text-embedding-3-small', - }) - }) - - it('should pass through output unchanged for knowledge tools without cost info', async () => { - const inputs = { query: 'test query' } - const mockToolResponse = { - success: true, - output: { - results: [], - query: 'test query', - totalResults: 0, - // No cost information - }, - } - - mockExecuteTool.mockResolvedValue(mockToolResponse) - - const result = await handler.execute(mockContext, mockBlock, inputs) - - // Should return original output unchanged - expect(result).toEqual({ - results: [], - query: 'test query', - totalResults: 0, - }) - }) - }) }) diff --git a/apps/sim/tools/exa/answer.ts b/apps/sim/tools/exa/answer.ts index 9b2a6f3f4..8e43e135f 100644 --- a/apps/sim/tools/exa/answer.ts +++ b/apps/sim/tools/exa/answer.ts @@ -38,8 +38,9 @@ export const answerTool: ToolConfig = { type: 'custom', getCost: (_params, output) => { // Use _costDollars from Exa API response (internal field, stripped from final output) - if (output._costDollars?.total) { - return { cost: output._costDollars.total, metadata: { costDollars: output._costDollars } } + const costDollars = output._costDollars as { total?: number } | undefined + if (costDollars?.total) { + return { cost: costDollars.total, metadata: { costDollars } } } // Fallback: $5/1000 requests logger.warn('Exa answer response missing costDollars, using fallback pricing') diff --git a/apps/sim/tools/exa/find_similar_links.ts b/apps/sim/tools/exa/find_similar_links.ts index 055d9016b..6e693789d 100644 --- a/apps/sim/tools/exa/find_similar_links.ts +++ b/apps/sim/tools/exa/find_similar_links.ts @@ -87,12 +87,14 @@ export const findSimilarLinksTool: ToolConfig< type: 'custom', getCost: (_params, output) => { // Use _costDollars from Exa API response (internal field, stripped from final output) - if (output._costDollars?.total) { - return { cost: output._costDollars.total, metadata: { costDollars: output._costDollars } } + const costDollars = output._costDollars as { total?: number } | undefined + if (costDollars?.total) { + return { cost: costDollars.total, metadata: { costDollars } } } // Fallback: $5/1000 (1-25 results) or $25/1000 (26-100 results) logger.warn('Exa find_similar_links response missing costDollars, using fallback pricing') - const resultCount = output.similarLinks?.length || 0 + const similarLinks = output.similarLinks as unknown[] | undefined + const resultCount = similarLinks?.length || 0 return resultCount <= 25 ? 0.005 : 0.025 }, }, diff --git a/apps/sim/tools/exa/get_contents.ts b/apps/sim/tools/exa/get_contents.ts index 3365eb8f6..449f7a595 100644 --- a/apps/sim/tools/exa/get_contents.ts +++ b/apps/sim/tools/exa/get_contents.ts @@ -72,12 +72,14 @@ export const getContentsTool: ToolConfig { // Use _costDollars from Exa API response (internal field, stripped from final output) - if (output._costDollars?.total) { - return { cost: output._costDollars.total, metadata: { costDollars: output._costDollars } } + const costDollars = output._costDollars as { total?: number } | undefined + if (costDollars?.total) { + return { cost: costDollars.total, metadata: { costDollars } } } // Fallback: $1/1000 pages logger.warn('Exa get_contents response missing costDollars, using fallback pricing') - return (output.results?.length || 0) * 0.001 + const results = output.results as unknown[] | undefined + return (results?.length || 0) * 0.001 }, }, }, diff --git a/apps/sim/tools/exa/research.ts b/apps/sim/tools/exa/research.ts index b3d4c9d2a..5270097b0 100644 --- a/apps/sim/tools/exa/research.ts +++ b/apps/sim/tools/exa/research.ts @@ -42,8 +42,9 @@ export const researchTool: ToolConfig = type: 'custom', getCost: (params, output) => { // Use _costDollars from Exa API response (internal field, stripped from final output) - if (output._costDollars?.total) { - return { cost: output._costDollars.total, metadata: { costDollars: output._costDollars } } + const costDollars = output._costDollars as { total?: number } | undefined + if (costDollars?.total) { + return { cost: costDollars.total, metadata: { costDollars } } } // Fallback to estimate if cost not available diff --git a/apps/sim/tools/exa/search.ts b/apps/sim/tools/exa/search.ts index c371fa3b9..f0cc7afd0 100644 --- a/apps/sim/tools/exa/search.ts +++ b/apps/sim/tools/exa/search.ts @@ -97,8 +97,9 @@ export const searchTool: ToolConfig = { type: 'custom', getCost: (params, output) => { // Use _costDollars from Exa API response (internal field, stripped from final output) - if (output._costDollars?.total) { - return { cost: output._costDollars.total, metadata: { costDollars: output._costDollars } } + const costDollars = output._costDollars as { total?: number } | undefined + if (costDollars?.total) { + return { cost: costDollars.total, metadata: { costDollars } } } // Fallback: estimate based on search type and result count @@ -107,7 +108,8 @@ export const searchTool: ToolConfig = { if (isDeepSearch) { return 0.015 } - const resultCount = output.results?.length || 0 + const results = output.results as unknown[] | undefined + const resultCount = results?.length || 0 return resultCount <= 25 ? 0.005 : 0.025 }, }, diff --git a/apps/sim/tools/knowledge/knowledge.test.ts b/apps/sim/tools/knowledge/knowledge.test.ts new file mode 100644 index 000000000..4fe553e3b --- /dev/null +++ b/apps/sim/tools/knowledge/knowledge.test.ts @@ -0,0 +1,208 @@ +/** + * @vitest-environment node + * + * Knowledge Tools Unit Tests + * + * Tests for knowledge_search and knowledge_upload_chunk tools, + * specifically the cost restructuring in transformResponse. + */ + +import { describe, expect, it } from 'vitest' +import { knowledgeSearchTool } from '@/tools/knowledge/search' +import { knowledgeUploadChunkTool } from '@/tools/knowledge/upload_chunk' + +/** + * Creates a mock Response object for testing transformResponse + */ +function createMockResponse(data: unknown): Response { + return { + json: async () => data, + ok: true, + status: 200, + } as Response +} + +describe('Knowledge Tools', () => { + describe('knowledgeSearchTool', () => { + describe('transformResponse', () => { + it('should restructure cost information for logging', async () => { + const apiResponse = { + data: { + results: [{ content: 'test result', similarity: 0.95 }], + query: 'test query', + totalResults: 1, + cost: { + input: 0.00001042, + output: 0, + total: 0.00001042, + tokens: { + prompt: 521, + completion: 0, + total: 521, + }, + model: 'text-embedding-3-small', + pricing: { + input: 0.02, + output: 0, + updatedAt: '2025-07-10', + }, + }, + }, + } + + const result = await knowledgeSearchTool.transformResponse!( + createMockResponse(apiResponse) + ) + + expect(result.success).toBe(true) + expect(result.output).toEqual({ + results: [{ content: 'test result', similarity: 0.95 }], + query: 'test query', + totalResults: 1, + cost: { + input: 0.00001042, + output: 0, + total: 0.00001042, + }, + tokens: { + prompt: 521, + completion: 0, + total: 521, + }, + model: 'text-embedding-3-small', + }) + }) + + it('should handle response without cost information', async () => { + const apiResponse = { + data: { + results: [], + query: 'test query', + totalResults: 0, + }, + } + + const result = await knowledgeSearchTool.transformResponse!( + createMockResponse(apiResponse) + ) + + expect(result.success).toBe(true) + expect(result.output).toEqual({ + results: [], + query: 'test query', + totalResults: 0, + }) + expect(result.output.cost).toBeUndefined() + expect(result.output.tokens).toBeUndefined() + expect(result.output.model).toBeUndefined() + }) + + it('should handle response with partial cost information', async () => { + const apiResponse = { + data: { + results: [], + query: 'test query', + totalResults: 0, + cost: { + input: 0.001, + output: 0, + total: 0.001, + // No tokens or model + }, + }, + } + + const result = await knowledgeSearchTool.transformResponse!( + createMockResponse(apiResponse) + ) + + expect(result.success).toBe(true) + expect(result.output.cost).toEqual({ + input: 0.001, + output: 0, + total: 0.001, + }) + expect(result.output.tokens).toBeUndefined() + expect(result.output.model).toBeUndefined() + }) + }) + }) + + describe('knowledgeUploadChunkTool', () => { + describe('transformResponse', () => { + it('should restructure cost information for logging', async () => { + const apiResponse = { + data: { + id: 'chunk-123', + chunkIndex: 0, + content: 'test content', + contentLength: 12, + tokenCount: 3, + enabled: true, + documentId: 'doc-456', + documentName: 'Test Document', + createdAt: '2025-01-01T00:00:00Z', + updatedAt: '2025-01-01T00:00:00Z', + cost: { + input: 0.00000521, + output: 0, + total: 0.00000521, + tokens: { + prompt: 260, + completion: 0, + total: 260, + }, + model: 'text-embedding-3-small', + pricing: { + input: 0.02, + output: 0, + updatedAt: '2025-07-10', + }, + }, + }, + } + + const result = await knowledgeUploadChunkTool.transformResponse!( + createMockResponse(apiResponse) + ) + + expect(result.success).toBe(true) + expect(result.output.cost).toEqual({ + input: 0.00000521, + output: 0, + total: 0.00000521, + }) + expect(result.output.tokens).toEqual({ + prompt: 260, + completion: 0, + total: 260, + }) + expect(result.output.model).toBe('text-embedding-3-small') + expect(result.output.data.chunkId).toBe('chunk-123') + expect(result.output.documentId).toBe('doc-456') + }) + + it('should handle response without cost information', async () => { + const apiResponse = { + data: { + id: 'chunk-123', + chunkIndex: 0, + content: 'test content', + documentId: 'doc-456', + documentName: 'Test Document', + }, + } + + const result = await knowledgeUploadChunkTool.transformResponse!( + createMockResponse(apiResponse) + ) + + expect(result.success).toBe(true) + expect(result.output.cost).toBeUndefined() + expect(result.output.tokens).toBeUndefined() + expect(result.output.model).toBeUndefined() + expect(result.output.data.chunkId).toBe('chunk-123') + }) + }) + }) +})