diff --git a/backend/src/pro/exa.test.ts b/backend/src/pro/exa.test.ts index 72de5288f..0ff386013 100644 --- a/backend/src/pro/exa.test.ts +++ b/backend/src/pro/exa.test.ts @@ -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) => { @@ -22,7 +23,6 @@ const createTestExaPlugin = (mockExaClient: any) => { const response = await store.exaClient.search(body.query, { numResults: body.max_results, - useAutoprompt: true, type: 'fast', }) @@ -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 }, }) @@ -137,7 +138,6 @@ describe('Pro - Exa Plugin', () => { }) expect(mockSearch).toHaveBeenCalledWith('test search', { numResults: 10, - useAutoprompt: true, type: 'fast', }) }) @@ -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: [] }) @@ -174,7 +188,6 @@ describe('Pro - Exa Plugin', () => { expect(mockSearch).toHaveBeenCalledWith('test search', { numResults: 10, - useAutoprompt: true, type: 'fast', }) }) @@ -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 }, }) @@ -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 }, }) @@ -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 }, }) @@ -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 }, }) @@ -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 }, }) @@ -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') + }) + }) }) diff --git a/backend/src/pro/exa.ts b/backend/src/pro/exa.ts index ff8b8bb7e..9404b3415 100644 --- a/backend/src/pro/exa.ts +++ b/backend/src/pro/exa.ts @@ -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 @@ -13,7 +37,7 @@ const getExaClient = memoize(() => { return null } - return new Exa(apiKey) + return createExaClient(apiKey) }) /** @@ -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', }) @@ -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) {