mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
feat(tools): add generic search tool (#2140)
This commit is contained in:
@@ -836,17 +836,18 @@ export function DiscordIcon(props: SVGProps<SVGSVGElement>) {
|
||||
|
||||
export function LinkedInIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg {...props} height='72' viewBox='0 0 72 72' width='72' xmlns='http://www.w3.org/2000/svg'>
|
||||
<g fill='none' fillRule='evenodd'>
|
||||
<path
|
||||
d='M8,72 L64,72 C68.418278,72 72,68.418278 72,64 L72,8 C72,3.581722 68.418278,-8.11624501e-16 64,0 L8,0 C3.581722,8.11624501e-16 -5.41083001e-16,3.581722 0,8 L0,64 C5.41083001e-16,68.418278 3.581722,72 8,72 Z'
|
||||
fill='#0072B1'
|
||||
/>
|
||||
<path
|
||||
d='M62,62 L51.315625,62 L51.315625,43.8021149 C51.315625,38.8127542 49.4197917,36.0245323 45.4707031,36.0245323 C41.1746094,36.0245323 38.9300781,38.9261103 38.9300781,43.8021149 L38.9300781,62 L28.6333333,62 L28.6333333,27.3333333 L38.9300781,27.3333333 L38.9300781,32.0029283 C38.9300781,32.0029283 42.0260417,26.2742151 49.3825521,26.2742151 C56.7356771,26.2742151 62,30.7644705 62,40.051212 L62,62 Z M16.349349,22.7940133 C12.8420573,22.7940133 10,19.9296567 10,16.3970067 C10,12.8643566 12.8420573,10 16.349349,10 C19.8566406,10 22.6970052,12.8643566 22.6970052,16.3970067 C22.6970052,19.9296567 19.8566406,22.7940133 16.349349,22.7940133 Z M11.0325521,62 L21.769401,62 L21.769401,27.3333333 L11.0325521,27.3333333 L11.0325521,62 Z'
|
||||
fill='#FFF'
|
||||
/>
|
||||
</g>
|
||||
<svg
|
||||
{...props}
|
||||
fill='currentColor'
|
||||
height='72'
|
||||
viewBox='0 0 72 72'
|
||||
width='72'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
>
|
||||
<path
|
||||
d='M62,62 L51.315625,62 L51.315625,43.8021149 C51.315625,38.8127542 49.4197917,36.0245323 45.4707031,36.0245323 C41.1746094,36.0245323 38.9300781,38.9261103 38.9300781,43.8021149 L38.9300781,62 L28.6333333,62 L28.6333333,27.3333333 L38.9300781,27.3333333 L38.9300781,32.0029283 C38.9300781,32.0029283 42.0260417,26.2742151 49.3825521,26.2742151 C56.7356771,26.2742151 62,30.7644705 62,40.051212 L62,62 Z M16.349349,22.7940133 C12.8420573,22.7940133 10,19.9296567 10,16.3970067 C10,12.8643566 12.8420573,10 16.349349,10 C19.8566406,10 22.6970052,12.8643566 22.6970052,16.3970067 C22.6970052,19.9296567 19.8566406,22.7940133 16.349349,22.7940133 Z M11.0325521,62 L21.769401,62 L21.769401,27.3333333 L11.0325521,27.3333333 L11.0325521,62 Z'
|
||||
fill='currentColor'
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -3833,3 +3834,32 @@ export function ApifyIcon(props: SVGProps<SVGSVGElement>) {
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
interface StatusDotIconProps extends SVGProps<SVGSVGElement> {
|
||||
status: 'operational' | 'degraded' | 'outage' | 'maintenance' | 'loading' | 'error'
|
||||
}
|
||||
|
||||
export function StatusDotIcon({ status, className, ...props }: StatusDotIconProps) {
|
||||
const colors = {
|
||||
operational: '#10B981',
|
||||
degraded: '#F59E0B',
|
||||
outage: '#EF4444',
|
||||
maintenance: '#3B82F6',
|
||||
loading: '#9CA3AF',
|
||||
error: '#9CA3AF',
|
||||
}
|
||||
|
||||
return (
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width={6}
|
||||
height={6}
|
||||
viewBox='0 0 6 6'
|
||||
fill='none'
|
||||
className={className}
|
||||
{...props}
|
||||
>
|
||||
<circle cx={3} cy={3} r={3} fill={colors[status]} />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -68,6 +68,7 @@ import {
|
||||
ResendIcon,
|
||||
S3Icon,
|
||||
SalesforceIcon,
|
||||
SearchIcon,
|
||||
SendgridIcon,
|
||||
SentryIcon,
|
||||
SerperIcon,
|
||||
@@ -128,6 +129,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
|
||||
serper: SerperIcon,
|
||||
sentry: SentryIcon,
|
||||
sendgrid: SendgridIcon,
|
||||
search: SearchIcon,
|
||||
salesforce: SalesforceIcon,
|
||||
s3: S3Icon,
|
||||
resend: ResendIcon,
|
||||
|
||||
@@ -63,6 +63,7 @@
|
||||
"resend",
|
||||
"s3",
|
||||
"salesforce",
|
||||
"search",
|
||||
"sendgrid",
|
||||
"sentry",
|
||||
"serper",
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
---
|
||||
title: Search
|
||||
description: Search the web ($0.01 per search)
|
||||
---
|
||||
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="search"
|
||||
color="#3B82F6"
|
||||
/>
|
||||
|
||||
{/* MANUAL-CONTENT-START:intro */}
|
||||
The **Search** tool lets you search the web from within your Sim workflows using state-of-the-art search engines. Use it to pull in the latest information, news, facts, and web content directly into your agents, automations, or conversations.
|
||||
|
||||
- **General web search**: Find up-to-date information from the internet to supplement your workflows.
|
||||
- **Automated queries**: Let agents or program logic submit search queries and handle the results automatically.
|
||||
- **Structured results**: Returns the most relevant web results, including title, link, snippet, and date for each result.
|
||||
|
||||
> **Note:** Each search costs **$0.01** per query.
|
||||
|
||||
This tool is ideal for any workflow where your agents need access to live web data or must reference current events, perform research, or fetch supplemental content.
|
||||
{/* MANUAL-CONTENT-END */}
|
||||
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Search the web using the Search tool. Each search costs $0.01 per query.
|
||||
|
||||
|
||||
|
||||
## Tools
|
||||
|
||||
### `search_tool`
|
||||
|
||||
Search the web. Returns the most relevant web results, including title, link, snippet, and date for each result.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `query` | string | Yes | The search query |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `results` | json | Search results |
|
||||
| `query` | string | The search query |
|
||||
| `totalResults` | number | Total number of results |
|
||||
| `source` | string | Search source \(exa\) |
|
||||
| `cost` | json | Cost information \($0.01\) |
|
||||
|
||||
|
||||
|
||||
## Notes
|
||||
|
||||
- Category: `tools`
|
||||
- Type: `search`
|
||||
@@ -0,0 +1,130 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { SEARCH_TOOL_COST } from '@/lib/billing/constants'
|
||||
import { env } from '@/lib/env'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { executeTool } from '@/tools'
|
||||
|
||||
const logger = createLogger('search')
|
||||
|
||||
const SearchRequestSchema = z.object({
|
||||
query: z.string().min(1),
|
||||
})
|
||||
|
||||
export const maxDuration = 60
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const requestId = crypto.randomUUID()
|
||||
|
||||
try {
|
||||
const { searchParams: urlParams } = new URL(request.url)
|
||||
const workflowId = urlParams.get('workflowId') || undefined
|
||||
|
||||
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
|
||||
|
||||
if (!authResult.success || !authResult.userId) {
|
||||
const errorMessage = workflowId ? 'Workflow not found' : authResult.error || 'Unauthorized'
|
||||
const statusCode = workflowId ? 404 : 401
|
||||
return NextResponse.json({ success: false, error: errorMessage }, { status: statusCode })
|
||||
}
|
||||
|
||||
const userId = authResult.userId
|
||||
|
||||
logger.info(`[${requestId}] Authenticated search request via ${authResult.authType}`, {
|
||||
userId,
|
||||
})
|
||||
|
||||
const body = await request.json()
|
||||
const validated = SearchRequestSchema.parse(body)
|
||||
|
||||
if (!env.EXA_API_KEY) {
|
||||
logger.error(`[${requestId}] EXA_API_KEY not configured`)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Search service not configured' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Executing search`, {
|
||||
userId,
|
||||
query: validated.query,
|
||||
})
|
||||
|
||||
const result = await executeTool('exa_search', {
|
||||
query: validated.query,
|
||||
type: 'auto',
|
||||
useAutoprompt: true,
|
||||
text: true,
|
||||
apiKey: env.EXA_API_KEY,
|
||||
})
|
||||
|
||||
if (!result.success) {
|
||||
logger.error(`[${requestId}] Search failed`, {
|
||||
userId,
|
||||
error: result.error,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: result.error || 'Search failed',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
const results = (result.output.results || []).map((r: any, index: number) => ({
|
||||
title: r.title || '',
|
||||
link: r.url || '',
|
||||
snippet: r.text || '',
|
||||
date: r.publishedDate || undefined,
|
||||
position: index + 1,
|
||||
}))
|
||||
|
||||
const cost = {
|
||||
input: 0,
|
||||
output: 0,
|
||||
total: SEARCH_TOOL_COST,
|
||||
tokens: {
|
||||
prompt: 0,
|
||||
completion: 0,
|
||||
total: 0,
|
||||
},
|
||||
model: 'search-exa',
|
||||
pricing: {
|
||||
input: 0,
|
||||
cachedInput: 0,
|
||||
output: 0,
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Search completed`, {
|
||||
userId,
|
||||
resultCount: results.length,
|
||||
cost: cost.total,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
results,
|
||||
query: validated.query,
|
||||
totalResults: results.length,
|
||||
source: 'exa',
|
||||
cost,
|
||||
})
|
||||
} catch (error: any) {
|
||||
logger.error(`[${requestId}] Search failed`, {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
})
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error.message || 'Search failed',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,45 @@ import {
|
||||
mockTriggerDevSdk,
|
||||
} from '@/app/api/__test-utils__/utils'
|
||||
|
||||
const {
|
||||
hasProcessedMessageMock,
|
||||
markMessageAsProcessedMock,
|
||||
closeRedisConnectionMock,
|
||||
acquireLockMock,
|
||||
generateRequestHashMock,
|
||||
validateSlackSignatureMock,
|
||||
handleWhatsAppVerificationMock,
|
||||
handleSlackChallengeMock,
|
||||
processWhatsAppDeduplicationMock,
|
||||
processGenericDeduplicationMock,
|
||||
fetchAndProcessAirtablePayloadsMock,
|
||||
processWebhookMock,
|
||||
executeMock,
|
||||
} = vi.hoisted(() => ({
|
||||
hasProcessedMessageMock: vi.fn().mockResolvedValue(false),
|
||||
markMessageAsProcessedMock: vi.fn().mockResolvedValue(true),
|
||||
closeRedisConnectionMock: vi.fn().mockResolvedValue(undefined),
|
||||
acquireLockMock: vi.fn().mockResolvedValue(true),
|
||||
generateRequestHashMock: vi.fn().mockResolvedValue('test-hash-123'),
|
||||
validateSlackSignatureMock: vi.fn().mockResolvedValue(true),
|
||||
handleWhatsAppVerificationMock: vi.fn().mockResolvedValue(null),
|
||||
handleSlackChallengeMock: vi.fn().mockReturnValue(null),
|
||||
processWhatsAppDeduplicationMock: vi.fn().mockResolvedValue(null),
|
||||
processGenericDeduplicationMock: vi.fn().mockResolvedValue(null),
|
||||
fetchAndProcessAirtablePayloadsMock: vi.fn().mockResolvedValue(undefined),
|
||||
processWebhookMock: vi.fn().mockResolvedValue(new Response('Webhook processed', { status: 200 })),
|
||||
executeMock: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
output: { response: 'Webhook execution success' },
|
||||
logs: [],
|
||||
metadata: {
|
||||
duration: 100,
|
||||
startTime: new Date().toISOString(),
|
||||
endTime: new Date().toISOString(),
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@trigger.dev/sdk', () => ({
|
||||
tasks: {
|
||||
trigger: vi.fn().mockResolvedValue({ id: 'mock-task-id' }),
|
||||
@@ -32,31 +71,6 @@ vi.mock('@/background/logs-webhook-delivery', () => ({
|
||||
logsWebhookDelivery: {},
|
||||
}))
|
||||
|
||||
const hasProcessedMessageMock = vi.fn().mockResolvedValue(false)
|
||||
const markMessageAsProcessedMock = vi.fn().mockResolvedValue(true)
|
||||
const closeRedisConnectionMock = vi.fn().mockResolvedValue(undefined)
|
||||
const acquireLockMock = vi.fn().mockResolvedValue(true)
|
||||
const generateRequestHashMock = vi.fn().mockResolvedValue('test-hash-123')
|
||||
const validateSlackSignatureMock = vi.fn().mockResolvedValue(true)
|
||||
const handleWhatsAppVerificationMock = vi.fn().mockResolvedValue(null)
|
||||
const handleSlackChallengeMock = vi.fn().mockReturnValue(null)
|
||||
const processWhatsAppDeduplicationMock = vi.fn().mockResolvedValue(null)
|
||||
const processGenericDeduplicationMock = vi.fn().mockResolvedValue(null)
|
||||
const fetchAndProcessAirtablePayloadsMock = vi.fn().mockResolvedValue(undefined)
|
||||
const processWebhookMock = vi
|
||||
.fn()
|
||||
.mockResolvedValue(new Response('Webhook processed', { status: 200 }))
|
||||
const executeMock = vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
output: { response: 'Webhook execution success' },
|
||||
logs: [],
|
||||
metadata: {
|
||||
duration: 100,
|
||||
startTime: new Date().toISOString(),
|
||||
endTime: new Date().toISOString(),
|
||||
},
|
||||
})
|
||||
|
||||
vi.mock('@/lib/redis', () => ({
|
||||
hasProcessedMessage: hasProcessedMessageMock,
|
||||
markMessageAsProcessed: markMessageAsProcessedMock,
|
||||
@@ -76,9 +90,6 @@ vi.mock('@/lib/webhooks/utils', () => ({
|
||||
|
||||
vi.mock('@/app/api/webhooks/utils', () => ({
|
||||
generateRequestHash: generateRequestHashMock,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/api/webhooks/utils', () => ({
|
||||
validateSlackSignature: validateSlackSignatureMock,
|
||||
}))
|
||||
|
||||
@@ -117,7 +128,47 @@ vi.mock('@/lib/logs/execution/logging-session', () => ({
|
||||
})),
|
||||
}))
|
||||
|
||||
process.env.DATABASE_URL = 'postgresql://test:test@localhost:5432/test'
|
||||
vi.mock('@/lib/workspaces/utils', async () => {
|
||||
const actual = await vi.importActual('@/lib/workspaces/utils')
|
||||
return {
|
||||
...(actual as Record<string, unknown>),
|
||||
getWorkspaceBilledAccountUserId: vi
|
||||
.fn()
|
||||
.mockImplementation(async (workspaceId: string | null | undefined) =>
|
||||
workspaceId ? 'test-user-id' : null
|
||||
),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/services/queue', () => ({
|
||||
RateLimiter: vi.fn().mockImplementation(() => ({
|
||||
checkRateLimit: vi.fn().mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 10,
|
||||
resetAt: new Date(),
|
||||
}),
|
||||
})),
|
||||
RateLimitError: class RateLimitError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public statusCode = 429
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'RateLimitError'
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workflows/db-helpers', () => ({
|
||||
loadWorkflowFromNormalizedTables: vi.fn().mockResolvedValue({
|
||||
blocks: {},
|
||||
edges: [],
|
||||
loops: {},
|
||||
parallels: {},
|
||||
isFromNormalizedTables: true,
|
||||
}),
|
||||
blockExistsInDeployment: vi.fn().mockResolvedValue(true),
|
||||
}))
|
||||
|
||||
vi.mock('drizzle-orm/postgres-js', () => ({
|
||||
drizzle: vi.fn().mockReturnValue({}),
|
||||
@@ -125,9 +176,12 @@ vi.mock('drizzle-orm/postgres-js', () => ({
|
||||
|
||||
vi.mock('postgres', () => vi.fn().mockReturnValue({}))
|
||||
|
||||
process.env.DATABASE_URL = 'postgresql://test:test@localhost:5432/test'
|
||||
|
||||
import { POST } from '@/app/api/webhooks/trigger/[path]/route'
|
||||
|
||||
describe('Webhook Trigger API Route', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
vi.clearAllMocks()
|
||||
|
||||
globalMockData.webhooks.length = 0
|
||||
@@ -143,48 +197,6 @@ describe('Webhook Trigger API Route', () => {
|
||||
workspaceId: 'test-workspace-id',
|
||||
})
|
||||
|
||||
vi.doMock('@/lib/workspaces/utils', async () => {
|
||||
const actual = await vi.importActual('@/lib/workspaces/utils')
|
||||
return {
|
||||
...(actual as Record<string, unknown>),
|
||||
getWorkspaceBilledAccountUserId: vi
|
||||
.fn()
|
||||
.mockImplementation(async (workspaceId: string | null | undefined) =>
|
||||
workspaceId ? 'test-user-id' : null
|
||||
),
|
||||
}
|
||||
})
|
||||
|
||||
vi.doMock('@/services/queue', () => ({
|
||||
RateLimiter: vi.fn().mockImplementation(() => ({
|
||||
checkRateLimit: vi.fn().mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 10,
|
||||
resetAt: new Date(),
|
||||
}),
|
||||
})),
|
||||
RateLimitError: class RateLimitError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public statusCode = 429
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'RateLimitError'
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
vi.doMock('@/lib/workflows/db-helpers', () => ({
|
||||
loadWorkflowFromNormalizedTables: vi.fn().mockResolvedValue({
|
||||
blocks: {},
|
||||
edges: [],
|
||||
loops: {},
|
||||
parallels: {},
|
||||
isFromNormalizedTables: true,
|
||||
}),
|
||||
blockExistsInDeployment: vi.fn().mockResolvedValue(true),
|
||||
}))
|
||||
|
||||
hasProcessedMessageMock.mockResolvedValue(false)
|
||||
markMessageAsProcessedMock.mockResolvedValue(true)
|
||||
acquireLockMock.mockResolvedValue(true)
|
||||
@@ -208,8 +220,6 @@ describe('Webhook Trigger API Route', () => {
|
||||
|
||||
const params = Promise.resolve({ path: 'non-existent-path' })
|
||||
|
||||
const { POST } = await import('@/app/api/webhooks/trigger/[path]/route')
|
||||
|
||||
const response = await POST(req, { params })
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
@@ -239,7 +249,6 @@ describe('Webhook Trigger API Route', () => {
|
||||
const req = createMockRequest('POST', { event: 'test', id: 'test-123' })
|
||||
const params = Promise.resolve({ path: 'test-path' })
|
||||
|
||||
const { POST } = await import('@/app/api/webhooks/trigger/[path]/route')
|
||||
const response = await POST(req, { params })
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
@@ -273,7 +282,6 @@ describe('Webhook Trigger API Route', () => {
|
||||
const req = createMockRequest('POST', { event: 'bearer.test' }, headers)
|
||||
const params = Promise.resolve({ path: 'test-path' })
|
||||
|
||||
const { POST } = await import('@/app/api/webhooks/trigger/[path]/route')
|
||||
const response = await POST(req, { params })
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
@@ -305,7 +313,6 @@ describe('Webhook Trigger API Route', () => {
|
||||
const req = createMockRequest('POST', { event: 'custom.header.test' }, headers)
|
||||
const params = Promise.resolve({ path: 'test-path' })
|
||||
|
||||
const { POST } = await import('@/app/api/webhooks/trigger/[path]/route')
|
||||
const response = await POST(req, { params })
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
@@ -347,7 +354,6 @@ describe('Webhook Trigger API Route', () => {
|
||||
const req = createMockRequest('POST', { event: 'case.test' }, headers)
|
||||
const params = Promise.resolve({ path: 'test-path' })
|
||||
|
||||
const { POST } = await import('@/app/api/webhooks/trigger/[path]/route')
|
||||
const response = await POST(req, { params })
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
@@ -389,7 +395,6 @@ describe('Webhook Trigger API Route', () => {
|
||||
const req = createMockRequest('POST', { event: 'custom.case.test' }, headers)
|
||||
const params = Promise.resolve({ path: 'test-path' })
|
||||
|
||||
const { POST } = await import('@/app/api/webhooks/trigger/[path]/route')
|
||||
const response = await POST(req, { params })
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
@@ -413,7 +418,6 @@ describe('Webhook Trigger API Route', () => {
|
||||
const req = createMockRequest('POST', { event: 'wrong.token.test' }, headers)
|
||||
const params = Promise.resolve({ path: 'test-path' })
|
||||
|
||||
const { POST } = await import('@/app/api/webhooks/trigger/[path]/route')
|
||||
const response = await POST(req, { params })
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
@@ -442,7 +446,6 @@ describe('Webhook Trigger API Route', () => {
|
||||
const req = createMockRequest('POST', { event: 'wrong.custom.test' }, headers)
|
||||
const params = Promise.resolve({ path: 'test-path' })
|
||||
|
||||
const { POST } = await import('@/app/api/webhooks/trigger/[path]/route')
|
||||
const response = await POST(req, { params })
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
@@ -463,7 +466,6 @@ describe('Webhook Trigger API Route', () => {
|
||||
const req = createMockRequest('POST', { event: 'no.auth.test' })
|
||||
const params = Promise.resolve({ path: 'test-path' })
|
||||
|
||||
const { POST } = await import('@/app/api/webhooks/trigger/[path]/route')
|
||||
const response = await POST(req, { params })
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
@@ -492,7 +494,6 @@ describe('Webhook Trigger API Route', () => {
|
||||
const req = createMockRequest('POST', { event: 'exclusivity.test' }, headers)
|
||||
const params = Promise.resolve({ path: 'test-path' })
|
||||
|
||||
const { POST } = await import('@/app/api/webhooks/trigger/[path]/route')
|
||||
const response = await POST(req, { params })
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
@@ -521,7 +522,6 @@ describe('Webhook Trigger API Route', () => {
|
||||
const req = createMockRequest('POST', { event: 'wrong.header.name.test' }, headers)
|
||||
const params = Promise.resolve({ path: 'test-path' })
|
||||
|
||||
const { POST } = await import('@/app/api/webhooks/trigger/[path]/route')
|
||||
const response = await POST(req, { params })
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
@@ -547,7 +547,6 @@ describe('Webhook Trigger API Route', () => {
|
||||
const req = createMockRequest('POST', { event: 'no.token.config.test' }, headers)
|
||||
const params = Promise.resolve({ path: 'test-path' })
|
||||
|
||||
const { POST } = await import('@/app/api/webhooks/trigger/[path]/route')
|
||||
const response = await POST(req, { params })
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { SearchIcon } from '@/components/icons'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
|
||||
export const SearchBlock: BlockConfig = {
|
||||
type: 'search',
|
||||
name: 'Search',
|
||||
description: 'Search the web ($0.01 per search)',
|
||||
longDescription: 'Search the web using the Search tool. Each search costs $0.01 per query.',
|
||||
bgColor: '#3B82F6',
|
||||
icon: SearchIcon,
|
||||
category: 'tools',
|
||||
docsLink: 'https://docs.sim.ai/tools/search',
|
||||
subBlocks: [
|
||||
{
|
||||
id: 'query',
|
||||
title: 'Search Query',
|
||||
type: 'long-input',
|
||||
placeholder: 'Enter your search query...',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
tools: {
|
||||
access: ['search_tool'],
|
||||
config: {
|
||||
tool: () => 'search_tool',
|
||||
},
|
||||
},
|
||||
inputs: {
|
||||
query: { type: 'string', description: 'Search query' },
|
||||
},
|
||||
outputs: {
|
||||
results: { type: 'json', description: 'Search results' },
|
||||
query: { type: 'string', description: 'The search query' },
|
||||
totalResults: { type: 'number', description: 'Total number of results' },
|
||||
source: { type: 'string', description: 'Search source (exa)' },
|
||||
cost: { type: 'json', description: 'Cost information ($0.01)' },
|
||||
},
|
||||
}
|
||||
+20
-22
@@ -77,12 +77,12 @@ import { RouterBlock } from '@/blocks/blocks/router'
|
||||
import { S3Block } from '@/blocks/blocks/s3'
|
||||
import { SalesforceBlock } from '@/blocks/blocks/salesforce'
|
||||
import { ScheduleBlock } from '@/blocks/blocks/schedule'
|
||||
import { SearchBlock } from '@/blocks/blocks/search'
|
||||
import { SendGridBlock } from '@/blocks/blocks/sendgrid'
|
||||
import { SentryBlock } from '@/blocks/blocks/sentry'
|
||||
import { SerperBlock } from '@/blocks/blocks/serper'
|
||||
import { SharepointBlock } from '@/blocks/blocks/sharepoint'
|
||||
import { SlackBlock } from '@/blocks/blocks/slack'
|
||||
import { SmtpBlock } from '@/blocks/blocks/smtp'
|
||||
import { StagehandBlock } from '@/blocks/blocks/stagehand'
|
||||
import { StagehandAgentBlock } from '@/blocks/blocks/stagehand_agent'
|
||||
import { StartTriggerBlock } from '@/blocks/blocks/start_trigger'
|
||||
@@ -95,7 +95,6 @@ import { TelegramBlock } from '@/blocks/blocks/telegram'
|
||||
import { ThinkingBlock } from '@/blocks/blocks/thinking'
|
||||
import { TranslateBlock } from '@/blocks/blocks/translate'
|
||||
import { TrelloBlock } from '@/blocks/blocks/trello'
|
||||
import { TtsBlock } from '@/blocks/blocks/tts'
|
||||
import { TwilioSMSBlock } from '@/blocks/blocks/twilio'
|
||||
import { TwilioVoiceBlock } from '@/blocks/blocks/twilio_voice'
|
||||
import { TypeformBlock } from '@/blocks/blocks/typeform'
|
||||
@@ -120,13 +119,15 @@ import type { BlockConfig } from '@/blocks/types'
|
||||
export const registry: Record<string, BlockConfig> = {
|
||||
agent: AgentBlock,
|
||||
airtable: AirtableBlock,
|
||||
api: ApiBlock,
|
||||
api_trigger: ApiTriggerBlock,
|
||||
apify: ApifyBlock,
|
||||
apollo: ApolloBlock,
|
||||
api: ApiBlock,
|
||||
arxiv: ArxivBlock,
|
||||
asana: AsanaBlock,
|
||||
browser_use: BrowserUseBlock,
|
||||
calendly: CalendlyBlock,
|
||||
chat_trigger: ChatTriggerBlock,
|
||||
clay: ClayBlock,
|
||||
condition: ConditionBlock,
|
||||
confluence: ConfluenceBlock,
|
||||
@@ -134,8 +135,8 @@ export const registry: Record<string, BlockConfig> = {
|
||||
elevenlabs: ElevenLabsBlock,
|
||||
evaluator: EvaluatorBlock,
|
||||
exa: ExaBlock,
|
||||
firecrawl: FirecrawlBlock,
|
||||
file: FileBlock,
|
||||
firecrawl: FirecrawlBlock,
|
||||
function: FunctionBlock,
|
||||
generic_webhook: GenericWebhookBlock,
|
||||
github: GitHubBlock,
|
||||
@@ -154,6 +155,8 @@ export const registry: Record<string, BlockConfig> = {
|
||||
hunter: HunterBlock,
|
||||
image_generator: ImageGeneratorBlock,
|
||||
incidentio: IncidentioBlock,
|
||||
input_trigger: InputTriggerBlock,
|
||||
intercom: IntercomBlock,
|
||||
jina: JinaBlock,
|
||||
jira: JiraBlock,
|
||||
knowledge: KnowledgeBlock,
|
||||
@@ -161,9 +164,11 @@ export const registry: Record<string, BlockConfig> = {
|
||||
linkedin: LinkedInBlock,
|
||||
linkup: LinkupBlock,
|
||||
mailchimp: MailchimpBlock,
|
||||
mailgun: MailgunBlock,
|
||||
manual_trigger: ManualTriggerBlock,
|
||||
mcp: McpBlock,
|
||||
mem0: Mem0Block,
|
||||
zep: ZepBlock,
|
||||
memory: MemoryBlock,
|
||||
microsoft_excel: MicrosoftExcelBlock,
|
||||
microsoft_planner: MicrosoftPlannerBlock,
|
||||
microsoft_teams: MicrosoftTeamsBlock,
|
||||
@@ -173,44 +178,35 @@ export const registry: Record<string, BlockConfig> = {
|
||||
neo4j: Neo4jBlock,
|
||||
note: NoteBlock,
|
||||
notion: NotionBlock,
|
||||
onedrive: OneDriveBlock,
|
||||
openai: OpenAIBlock,
|
||||
outlook: OutlookBlock,
|
||||
onedrive: OneDriveBlock,
|
||||
parallel_ai: ParallelBlock,
|
||||
perplexity: PerplexityBlock,
|
||||
posthog: PostHogBlock,
|
||||
pinecone: PineconeBlock,
|
||||
pipedrive: PipedriveBlock,
|
||||
postgresql: PostgreSQLBlock,
|
||||
posthog: PostHogBlock,
|
||||
pylon: PylonBlock,
|
||||
qdrant: QdrantBlock,
|
||||
resend: ResendBlock,
|
||||
sendgrid: SendGridBlock,
|
||||
mailgun: MailgunBlock,
|
||||
smtp: SmtpBlock,
|
||||
memory: MemoryBlock,
|
||||
reddit: RedditBlock,
|
||||
resend: ResendBlock,
|
||||
response: ResponseBlock,
|
||||
router: RouterBlock,
|
||||
schedule: ScheduleBlock,
|
||||
s3: S3Block,
|
||||
salesforce: SalesforceBlock,
|
||||
schedule: ScheduleBlock,
|
||||
search: SearchBlock,
|
||||
sendgrid: SendGridBlock,
|
||||
sentry: SentryBlock,
|
||||
intercom: IntercomBlock,
|
||||
zendesk: ZendeskBlock,
|
||||
serper: SerperBlock,
|
||||
sharepoint: SharepointBlock,
|
||||
slack: SlackBlock,
|
||||
stagehand: StagehandBlock,
|
||||
stagehand_agent: StagehandAgentBlock,
|
||||
slack: SlackBlock,
|
||||
starter: StarterBlock,
|
||||
stt: SttBlock,
|
||||
tts: TtsBlock,
|
||||
start_trigger: StartTriggerBlock,
|
||||
input_trigger: InputTriggerBlock,
|
||||
chat_trigger: ChatTriggerBlock,
|
||||
manual_trigger: ManualTriggerBlock,
|
||||
api_trigger: ApiTriggerBlock,
|
||||
stt: SttBlock,
|
||||
stripe: StripeBlock,
|
||||
supabase: SupabaseBlock,
|
||||
tavily: TavilyBlock,
|
||||
@@ -234,6 +230,8 @@ export const registry: Record<string, BlockConfig> = {
|
||||
workflow_input: WorkflowInputBlock,
|
||||
x: XBlock,
|
||||
youtube: YouTubeBlock,
|
||||
zep: ZepBlock,
|
||||
zendesk: ZendeskBlock,
|
||||
}
|
||||
|
||||
export const getBlock = (type: string): BlockConfig | undefined => registry[type]
|
||||
|
||||
@@ -318,48 +318,49 @@ describe('GenericBlockHandler', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it.concurrent('should not process cost info for non-knowledge tools', async () => {
|
||||
// Set up non-knowledge tool
|
||||
mockBlock.config.tool = 'some_other_tool'
|
||||
mockTool.id = 'some_other_tool'
|
||||
it.concurrent(
|
||||
'should process cost info for all tools (universal cost extraction)',
|
||||
async () => {
|
||||
mockBlock.config.tool = 'some_other_tool'
|
||||
mockTool.id = 'some_other_tool'
|
||||
|
||||
mockGetTool.mockImplementation((toolId) => {
|
||||
if (toolId === 'some_other_tool') {
|
||||
return mockTool
|
||||
mockGetTool.mockImplementation((toolId) => {
|
||||
if (toolId === 'some_other_tool') {
|
||||
return mockTool
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
|
||||
const inputs = { param: 'value' }
|
||||
const mockToolResponse = {
|
||||
success: true,
|
||||
output: {
|
||||
result: 'success',
|
||||
cost: {
|
||||
input: 0.001,
|
||||
output: 0.002,
|
||||
total: 0.003,
|
||||
tokens: { prompt: 100, completion: 50, total: 150 },
|
||||
model: 'some-model',
|
||||
},
|
||||
},
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
|
||||
const inputs = { param: 'value' }
|
||||
const mockToolResponse = {
|
||||
success: true,
|
||||
output: {
|
||||
mockExecuteTool.mockResolvedValue(mockToolResponse)
|
||||
|
||||
const result = await handler.execute(mockContext, mockBlock, inputs)
|
||||
|
||||
expect(result).toEqual({
|
||||
result: 'success',
|
||||
cost: {
|
||||
input: 0.001,
|
||||
output: 0.002,
|
||||
total: 0.003,
|
||||
tokens: { prompt: 100, completion: 50, total: 150 },
|
||||
model: 'some-model',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
mockExecuteTool.mockResolvedValue(mockToolResponse)
|
||||
|
||||
const result = await handler.execute(mockContext, mockBlock, inputs)
|
||||
|
||||
// Should return original output without cost transformation
|
||||
expect(result).toEqual({
|
||||
result: 'success',
|
||||
cost: {
|
||||
input: 0.001,
|
||||
output: 0.002,
|
||||
total: 0.003,
|
||||
tokens: { prompt: 100, completion: 50, total: 150 },
|
||||
model: 'some-model',
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -104,7 +104,7 @@ export class GenericBlockHandler implements BlockHandler {
|
||||
const output = result.output
|
||||
let cost = null
|
||||
|
||||
if (block.config.tool?.startsWith('knowledge_') && output?.cost) {
|
||||
if (output?.cost) {
|
||||
cost = output.cost
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,11 @@ export const DEFAULT_ENTERPRISE_TIER_COST_LIMIT = 200
|
||||
*/
|
||||
export const BASE_EXECUTION_CHARGE = 0.001
|
||||
|
||||
/**
|
||||
* Fixed cost for search tool invocation (in dollars)
|
||||
*/
|
||||
export const SEARCH_TOOL_COST = 0.01
|
||||
|
||||
/**
|
||||
* Default threshold (in dollars) for incremental overage billing
|
||||
* When unbilled overage reaches this amount, an invoice item is created
|
||||
|
||||
@@ -9,47 +9,51 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
|
||||
const mockDb = {
|
||||
select: vi.fn(),
|
||||
insert: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
transaction: vi.fn(),
|
||||
}
|
||||
const { mockDb, mockWorkflowBlocks, mockWorkflowEdges, mockWorkflowSubflows } = vi.hoisted(() => {
|
||||
const mockDb = {
|
||||
select: vi.fn(),
|
||||
insert: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
transaction: vi.fn(),
|
||||
}
|
||||
|
||||
const mockWorkflowBlocks = {
|
||||
workflowId: 'workflowId',
|
||||
id: 'id',
|
||||
type: 'type',
|
||||
name: 'name',
|
||||
positionX: 'positionX',
|
||||
positionY: 'positionY',
|
||||
enabled: 'enabled',
|
||||
horizontalHandles: 'horizontalHandles',
|
||||
height: 'height',
|
||||
subBlocks: 'subBlocks',
|
||||
outputs: 'outputs',
|
||||
data: 'data',
|
||||
parentId: 'parentId',
|
||||
extent: 'extent',
|
||||
}
|
||||
const mockWorkflowBlocks = {
|
||||
workflowId: 'workflowId',
|
||||
id: 'id',
|
||||
type: 'type',
|
||||
name: 'name',
|
||||
positionX: 'positionX',
|
||||
positionY: 'positionY',
|
||||
enabled: 'enabled',
|
||||
horizontalHandles: 'horizontalHandles',
|
||||
height: 'height',
|
||||
subBlocks: 'subBlocks',
|
||||
outputs: 'outputs',
|
||||
data: 'data',
|
||||
parentId: 'parentId',
|
||||
extent: 'extent',
|
||||
}
|
||||
|
||||
const mockWorkflowEdges = {
|
||||
workflowId: 'workflowId',
|
||||
id: 'id',
|
||||
sourceBlockId: 'sourceBlockId',
|
||||
targetBlockId: 'targetBlockId',
|
||||
sourceHandle: 'sourceHandle',
|
||||
targetHandle: 'targetHandle',
|
||||
}
|
||||
const mockWorkflowEdges = {
|
||||
workflowId: 'workflowId',
|
||||
id: 'id',
|
||||
sourceBlockId: 'sourceBlockId',
|
||||
targetBlockId: 'targetBlockId',
|
||||
sourceHandle: 'sourceHandle',
|
||||
targetHandle: 'targetHandle',
|
||||
}
|
||||
|
||||
const mockWorkflowSubflows = {
|
||||
workflowId: 'workflowId',
|
||||
id: 'id',
|
||||
type: 'type',
|
||||
config: 'config',
|
||||
}
|
||||
const mockWorkflowSubflows = {
|
||||
workflowId: 'workflowId',
|
||||
id: 'id',
|
||||
type: 'type',
|
||||
config: 'config',
|
||||
}
|
||||
|
||||
vi.doMock('@sim/db', () => ({
|
||||
return { mockDb, mockWorkflowBlocks, mockWorkflowEdges, mockWorkflowSubflows }
|
||||
})
|
||||
|
||||
vi.mock('@sim/db', () => ({
|
||||
db: mockDb,
|
||||
workflowBlocks: mockWorkflowBlocks,
|
||||
workflowEdges: mockWorkflowEdges,
|
||||
@@ -64,9 +68,11 @@ vi.doMock('@sim/db', () => ({
|
||||
createdBy: 'createdBy',
|
||||
deployedBy: 'deployedBy',
|
||||
},
|
||||
workflow: {},
|
||||
webhook: {},
|
||||
}))
|
||||
|
||||
vi.doMock('drizzle-orm', () => ({
|
||||
vi.mock('drizzle-orm', () => ({
|
||||
eq: vi.fn((field, value) => ({ field, value, type: 'eq' })),
|
||||
and: vi.fn((...conditions) => ({ type: 'and', conditions })),
|
||||
desc: vi.fn((field) => ({ field, type: 'desc' })),
|
||||
@@ -78,7 +84,7 @@ vi.doMock('drizzle-orm', () => ({
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.doMock('@/lib/logs/console/logger', () => ({
|
||||
vi.mock('@/lib/logs/console/logger', () => ({
|
||||
createLogger: vi.fn(() => ({
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
@@ -87,6 +93,8 @@ vi.doMock('@/lib/logs/console/logger', () => ({
|
||||
})),
|
||||
}))
|
||||
|
||||
import * as dbHelpers from '@/lib/workflows/db-helpers'
|
||||
|
||||
const mockWorkflowId = 'test-workflow-123'
|
||||
|
||||
const mockBlocksFromDb = [
|
||||
@@ -306,11 +314,8 @@ const mockWorkflowState: WorkflowState = {
|
||||
}
|
||||
|
||||
describe('Database Helpers', () => {
|
||||
let dbHelpers: typeof import('@/lib/workflows/db-helpers')
|
||||
|
||||
beforeEach(async () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
dbHelpers = await import('@/lib/workflows/db-helpers')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -341,6 +346,7 @@ describe('Database Helpers', () => {
|
||||
}))
|
||||
|
||||
const result = await dbHelpers.loadWorkflowFromNormalizedTables(mockWorkflowId)
|
||||
|
||||
expect(result).toBeDefined()
|
||||
expect(result?.isFromNormalizedTables).toBe(true)
|
||||
expect(result?.blocks).toBeDefined()
|
||||
|
||||
@@ -117,7 +117,13 @@ import {
|
||||
exaSearchTool,
|
||||
} from '@/tools/exa'
|
||||
import { fileParseTool } from '@/tools/file'
|
||||
import { crawlTool, extractTool, mapTool, scrapeTool, searchTool } from '@/tools/firecrawl'
|
||||
import {
|
||||
crawlTool,
|
||||
extractTool,
|
||||
searchTool as firecrawlSearchTool,
|
||||
mapTool,
|
||||
scrapeTool,
|
||||
} from '@/tools/firecrawl'
|
||||
import { functionExecuteTool } from '@/tools/function'
|
||||
import {
|
||||
githubAddAssigneesTool,
|
||||
@@ -768,6 +774,7 @@ import {
|
||||
salesforceUpdateOpportunityTool,
|
||||
salesforceUpdateTaskTool,
|
||||
} from '@/tools/salesforce'
|
||||
import { searchTool } from '@/tools/search'
|
||||
import {
|
||||
sendGridAddContactsToListTool,
|
||||
sendGridAddContactTool,
|
||||
@@ -1047,7 +1054,7 @@ export const tools: Record<string, ToolConfig> = {
|
||||
vision_tool: visionTool,
|
||||
file_parser: fileParseTool,
|
||||
firecrawl_scrape: scrapeTool,
|
||||
firecrawl_search: searchTool,
|
||||
firecrawl_search: firecrawlSearchTool,
|
||||
firecrawl_crawl: crawlTool,
|
||||
firecrawl_map: mapTool,
|
||||
firecrawl_extract: extractTool,
|
||||
@@ -1455,6 +1462,7 @@ export const tools: Record<string, ToolConfig> = {
|
||||
knowledge_search: knowledgeSearchTool,
|
||||
knowledge_upload_chunk: knowledgeUploadChunkTool,
|
||||
knowledge_create_document: knowledgeCreateDocumentTool,
|
||||
search_tool: searchTool,
|
||||
elevenlabs_tts: elevenLabsTtsTool,
|
||||
stt_whisper: whisperSttTool,
|
||||
stt_deepgram: deepgramSttTool,
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export { searchTool } from './tool'
|
||||
export type { SearchParams, SearchResponse } from './types'
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import type { SearchParams, SearchResponse } from './types'
|
||||
|
||||
export const searchTool: ToolConfig<SearchParams, SearchResponse> = {
|
||||
id: 'search_tool',
|
||||
name: 'Web Search',
|
||||
description:
|
||||
'Search the web. Returns the most relevant web results, including title, link, snippet, and date for each result.',
|
||||
version: '1.0.0',
|
||||
|
||||
params: {
|
||||
query: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'The search query',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: () => '/api/tools/search',
|
||||
method: 'POST',
|
||||
headers: () => ({
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: (params) => ({
|
||||
query: params.query,
|
||||
}),
|
||||
},
|
||||
|
||||
transformResponse: async (response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`Search failed: ${response.statusText}`)
|
||||
}
|
||||
const data = await response.json()
|
||||
return {
|
||||
success: true,
|
||||
output: data,
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { ToolResponse } from '@/tools/types'
|
||||
|
||||
export interface SearchParams {
|
||||
query: string
|
||||
}
|
||||
|
||||
export interface SearchResponse extends ToolResponse {
|
||||
output: {
|
||||
results: Array<{
|
||||
title: string
|
||||
link: string
|
||||
snippet: string
|
||||
date?: string
|
||||
position: number
|
||||
}>
|
||||
query: string
|
||||
totalResults: number
|
||||
source: 'exa'
|
||||
cost: {
|
||||
input: number
|
||||
output: number
|
||||
total: number
|
||||
tokens: {
|
||||
prompt: number
|
||||
completion: number
|
||||
total: number
|
||||
}
|
||||
model: string
|
||||
pricing?: {
|
||||
input: number
|
||||
cachedInput: number
|
||||
output: number
|
||||
updatedAt: string
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user