Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 37 additions & 4 deletions backend/src/pro/exa.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { beforeEach, describe, expect, it, mock } from 'bun:test'
import { Elysia, t } from 'elysia'
import { createExaClient } from './exa'

// Create a test version of the plugin with mocked Exa client
const createTestExaPlugin = (mockExaClient: any) => {
Expand All @@ -22,7 +23,6 @@ const createTestExaPlugin = (mockExaClient: any) => {

const response = await store.exaClient.search(body.query, {
numResults: body.max_results,
useAutoprompt: true,
type: 'fast',
})

Expand Down Expand Up @@ -53,6 +53,7 @@ const createTestExaPlugin = (mockExaClient: any) => {

const response = await store.exaClient.getContents([body.url], {
livecrawlTimeout: 5_000,
maxAgeHours: 24,
extras: { imageLinks: 1 },
text: { maxCharacters },
})
Expand Down Expand Up @@ -137,7 +138,6 @@ describe('Pro - Exa Plugin', () => {
})
expect(mockSearch).toHaveBeenCalledWith('test search', {
numResults: 10,
useAutoprompt: true,
type: 'fast',
})
})
Expand All @@ -156,11 +156,25 @@ describe('Pro - Exa Plugin', () => {
expect(response.status).toBe(200)
expect(mockSearch).toHaveBeenCalledWith('test search', {
numResults: 5,
useAutoprompt: true,
type: 'fast',
})
})

it('should not send useAutoprompt (undocumented in current API reference)', async () => {
mockSearch.mockResolvedValueOnce({ results: [] })

await app.handle(
new Request('http://localhost/search', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: 'test search' }),
}),
)

const [, options] = mockSearch.mock.calls[0]
expect(options).not.toHaveProperty('useAutoprompt')
})

it('should use default max_results when not provided', async () => {
mockSearch.mockResolvedValueOnce({ results: [] })

Expand All @@ -174,7 +188,6 @@ describe('Pro - Exa Plugin', () => {

expect(mockSearch).toHaveBeenCalledWith('test search', {
numResults: 10,
useAutoprompt: true,
type: 'fast',
})
})
Expand Down Expand Up @@ -315,6 +328,7 @@ describe('Pro - Exa Plugin', () => {
})
expect(mockGetContents).toHaveBeenCalledWith(['https://example.com'], {
livecrawlTimeout: 5_000,
maxAgeHours: 24,
extras: { imageLinks: 1 },
text: { maxCharacters: 16_000 },
})
Expand Down Expand Up @@ -422,6 +436,7 @@ describe('Pro - Exa Plugin', () => {
expect(response.status).toBe(200)
expect(mockGetContents).toHaveBeenCalledWith([url], {
livecrawlTimeout: 5_000,
maxAgeHours: 24,
extras: { imageLinks: 1 },
text: { maxCharacters: 16_000 },
})
Expand Down Expand Up @@ -521,6 +536,7 @@ describe('Pro - Exa Plugin', () => {
expect(response.status).toBe(200)
expect(mockGetContents).toHaveBeenCalledWith(['https://example.com'], {
livecrawlTimeout: 5_000,
maxAgeHours: 24,
extras: { imageLinks: 1 },
text: { maxCharacters: 32_000 },
})
Expand All @@ -547,6 +563,7 @@ describe('Pro - Exa Plugin', () => {
expect(response.status).toBe(200)
expect(mockGetContents).toHaveBeenCalledWith(['https://example.com'], {
livecrawlTimeout: 5_000,
maxAgeHours: 24,
extras: { imageLinks: 1 },
text: { maxCharacters: 64_000 },
})
Expand All @@ -573,6 +590,7 @@ describe('Pro - Exa Plugin', () => {
expect(response.status).toBe(200)
expect(mockGetContents).toHaveBeenCalledWith(['https://example.com'], {
livecrawlTimeout: 5_000,
maxAgeHours: 24,
extras: { imageLinks: 1 },
text: { maxCharacters: 1_000 },
})
Expand Down Expand Up @@ -628,4 +646,19 @@ describe('Pro - Exa Plugin', () => {
expect(data.data.text).toContain('[Content truncated. Call fetch_content with max_length=64000 for more.]')
})
})

describe('createExaClient', () => {
it('should attach x-exa-integration header for API attribution', () => {
const client = createExaClient('test-api-key')
const headers = (client as unknown as { headers: Headers }).headers
expect(headers.get('x-exa-integration')).toBe('thunderbolt')
})

it('should preserve the x-api-key header alongside the integration header', () => {
const client = createExaClient('test-api-key')
const headers = (client as unknown as { headers: Headers }).headers
expect(headers.get('x-api-key')).toBe('test-api-key')
expect(headers.get('x-exa-integration')).toBe('thunderbolt')
})
})
})
35 changes: 30 additions & 5 deletions backend/src/pro/exa.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,33 @@ import { getSettings } from '@/config/settings'
import { memoize } from '@/lib/memoize'
import { safeErrorHandler } from '@/middleware/error-handling'
import { Elysia, t } from 'elysia'
import { Exa } from 'exa-js'
import { Exa, type ContentsOptions } from 'exa-js'
import type { FetchContentResponse, SearchResponse } from './types'

/**
* exa-js v1.10.2 does not yet type `maxAgeHours`, but the `/contents` API accepts it
* as the successor to the deprecated `livecrawl` enum. The SDK spreads unknown options
* into the request body, so this field reaches the API unchanged.
*/
type ContentsOptionsWithMaxAge = ContentsOptions & { maxAgeHours?: number }

/**
* Default freshness window for fetched content. Pages cached within the last 24 hours
* are served as-is; older pages trigger a live crawl bounded by `livecrawlTimeout`.
*/
const DEFAULT_MAX_AGE_HOURS = 24

/**
* Builds an Exa client with the `x-exa-integration` header set so the Exa team can
* attribute API usage to the thunderbolt repo. `headers` is private in the SDK but
* is a runtime `Headers` instance, so the cast is safe.
*/
export const createExaClient = (apiKey: string): Exa => {
const client = new Exa(apiKey)
;(client as unknown as { headers: Headers }).headers.set('x-exa-integration', 'thunderbolt')
return client
}

const getExaClient = memoize(() => {
const settings = getSettings()
const apiKey = settings.exaApiKey
Expand All @@ -13,7 +37,7 @@ const getExaClient = memoize(() => {
return null
}

return new Exa(apiKey)
return createExaClient(apiKey)
})

/**
Expand All @@ -31,7 +55,6 @@ export const exaPlugin = new Elysia({ name: 'exa' })

const response = await store.exaClient.search(body.query, {
numResults: body.max_results,
useAutoprompt: true,
type: 'fast',
})

Expand Down Expand Up @@ -61,11 +84,13 @@ export const exaPlugin = new Elysia({ name: 'exa' })
const requestedMax = body.max_length ?? defaultMaxChars
const maxCharacters = Math.min(Math.max(requestedMax, minChars), hardCap)

const response = await store.exaClient.getContents([body.url], {
const contentsOptions: ContentsOptionsWithMaxAge = {
livecrawlTimeout: 5_000,
maxAgeHours: DEFAULT_MAX_AGE_HOURS,
extras: { imageLinks: 1 },
text: { maxCharacters },
})
}
const response = await store.exaClient.getContents([body.url], contentsOptions)

const result = response.results[0]
if (!result) {
Expand Down