test: add exa web provider adapter

This commit is contained in:
alex wiesner
2026-04-09 11:08:09 +01:00
parent 5e1315a20a
commit 30cfe7e8f1
4 changed files with 296 additions and 5 deletions

View File

@@ -0,0 +1,110 @@
import test from "node:test";
import assert from "node:assert/strict";
import { createExaProvider } from "./exa.ts";
const baseConfig = {
name: "exa-main",
type: "exa" as const,
apiKey: "exa-test-key",
options: {
defaultSearchLimit: 7,
defaultFetchTextMaxCharacters: 9000,
defaultFetchHighlightsMaxCharacters: 1200,
},
};
test("createExaProvider maps generic search requests to Exa search with contents disabled", async () => {
let captured: { query: string; options: Record<string, unknown> } | undefined;
const provider = createExaProvider(baseConfig, () => ({
async search(query, options) {
captured = { query, options };
return {
requestId: "req-search-1",
searchTime: 123,
results: [
{
id: "doc-1",
title: "Exa Docs",
url: "https://exa.ai/docs",
publishedDate: "2026-04-09",
author: "Exa",
score: 0.98,
},
],
};
},
async getContents() {
throw new Error("not used");
},
}));
const result = await provider.search({
query: "exa docs",
includeDomains: ["exa.ai"],
});
assert.deepEqual(captured, {
query: "exa docs",
options: {
contents: false,
numResults: 7,
includeDomains: ["exa.ai"],
excludeDomains: undefined,
startPublishedDate: undefined,
endPublishedDate: undefined,
category: undefined,
},
});
assert.equal(result.providerName, "exa-main");
assert.equal(result.results[0]?.url, "https://exa.ai/docs");
});
test("createExaProvider fetch defaults to text and preserves per-url failures", async () => {
const calls: Array<{ urls: string[]; options: Record<string, unknown> }> = [];
const provider = createExaProvider(baseConfig, () => ({
async search() {
throw new Error("not used");
},
async getContents(urls, options) {
const requestUrls = Array.isArray(urls) ? urls : [urls];
calls.push({ urls: requestUrls, options });
if (requestUrls[0] === "https://bad.example") {
throw new Error("429 rate limited");
}
return {
requestId: `req-${calls.length}`,
results: [
{
url: requestUrls[0],
title: "Fetched page",
text: "Fetched body",
},
],
};
},
}));
const result = await provider.fetch({
urls: ["https://good.example", "https://bad.example"],
});
assert.equal((calls[0]?.options.text as { maxCharacters: number }).maxCharacters, 9000);
assert.deepEqual(result.results, [
{
url: "https://good.example",
title: "Fetched page",
text: "Fetched body",
highlights: undefined,
summary: undefined,
},
{
url: "https://bad.example",
title: null,
error: "429 rate limited",
},
]);
});

View File

@@ -0,0 +1,124 @@
import Exa from "exa-js";
import type { ExaProviderConfig } from "../schema.ts";
import type {
NormalizedFetchRequest,
NormalizedFetchResponse,
NormalizedSearchRequest,
NormalizedSearchResponse,
WebProvider,
} from "./types.ts";
export interface ExaClientLike {
search(query: string, options?: Record<string, unknown>): Promise<any>;
getContents(urls: string[] | string, options?: Record<string, unknown>): Promise<any>;
}
export type ExaClientFactory = (apiKey: string) => ExaClientLike;
export function buildSearchOptions(config: ExaProviderConfig, request: NormalizedSearchRequest) {
return {
contents: false,
numResults: request.limit ?? config.options?.defaultSearchLimit ?? 5,
includeDomains: request.includeDomains,
excludeDomains: request.excludeDomains,
startPublishedDate: request.startPublishedDate,
endPublishedDate: request.endPublishedDate,
category: request.category,
};
}
export function buildFetchOptions(config: ExaProviderConfig, request: NormalizedFetchRequest) {
const text = request.text ?? (!request.highlights && !request.summary);
return {
...(text
? {
text: {
maxCharacters: request.textMaxCharacters ?? config.options?.defaultFetchTextMaxCharacters ?? 12000,
},
}
: {}),
...(request.highlights
? {
highlights: {
maxCharacters: config.options?.defaultFetchHighlightsMaxCharacters ?? 1000,
},
}
: {}),
...(request.summary ? { summary: true } : {}),
};
}
export function createExaProvider(
config: ExaProviderConfig,
createClient: ExaClientFactory = (apiKey) => new Exa(apiKey) as unknown as ExaClientLike,
): WebProvider {
const client = createClient(config.apiKey);
return {
name: config.name,
type: config.type,
async search(request: NormalizedSearchRequest): Promise<NormalizedSearchResponse> {
const response = await client.search(request.query, buildSearchOptions(config, request));
return {
providerName: config.name,
requestId: response.requestId,
searchTime: response.searchTime,
results: (response.results ?? []).map((item: any) => ({
id: item.id,
title: item.title ?? null,
url: item.url,
publishedDate: item.publishedDate,
author: item.author,
score: item.score,
})),
};
},
async fetch(request: NormalizedFetchRequest): Promise<NormalizedFetchResponse> {
const requestIds: string[] = [];
const options = buildFetchOptions(config, request);
const results = await Promise.all(
request.urls.map(async (url) => {
try {
const response = await client.getContents([url], options);
if (response.requestId) {
requestIds.push(response.requestId);
}
const item = response.results?.[0];
if (!item) {
return {
url,
title: null,
error: "No content returned",
};
}
return {
url: item.url ?? url,
title: item.title ?? null,
text: typeof item.text === "string" ? item.text : undefined,
highlights: Array.isArray(item.highlights) ? item.highlights : undefined,
summary: typeof item.summary === "string" ? item.summary : undefined,
};
} catch (error) {
return {
url,
title: null,
error: (error as Error).message,
};
}
}),
);
return {
providerName: config.name,
requestIds,
results,
};
},
};
}

View File

@@ -0,0 +1,57 @@
export interface NormalizedSearchRequest {
query: string;
limit?: number;
includeDomains?: string[];
excludeDomains?: string[];
startPublishedDate?: string;
endPublishedDate?: string;
category?: string;
provider?: string;
}
export interface NormalizedSearchResult {
id?: string;
title: string | null;
url: string;
publishedDate?: string;
author?: string;
score?: number;
}
export interface NormalizedSearchResponse {
providerName: string;
requestId?: string;
searchTime?: number;
results: NormalizedSearchResult[];
}
export interface NormalizedFetchRequest {
urls: string[];
text?: boolean;
highlights?: boolean;
summary?: boolean;
textMaxCharacters?: number;
provider?: string;
}
export interface NormalizedFetchResult {
url: string;
title: string | null;
text?: string;
highlights?: string[];
summary?: string;
error?: string;
}
export interface NormalizedFetchResponse {
providerName: string;
requestIds?: string[];
results: NormalizedFetchResult[];
}
export interface WebProvider {
name: string;
type: string;
search(request: NormalizedSearchRequest): Promise<NormalizedSearchResponse>;
fetch(request: NormalizedFetchRequest): Promise<NormalizedFetchResponse>;
}

View File

@@ -384,7 +384,7 @@ git -C /home/alex/dotfiles commit -m "test: add web search config loader"
- Create: `/home/alex/dotfiles/.pi/agent/extensions/web-search/src/providers/exa.ts`
- Test: `/home/alex/dotfiles/.pi/agent/extensions/web-search/src/providers/exa.test.ts`
- [ ] **Step 1: Write the failing Exa adapter tests**
- [x] **Step 1: Write the failing Exa adapter tests**
Create `/home/alex/dotfiles/.pi/agent/extensions/web-search/src/providers/exa.test.ts` with this content:
@@ -501,7 +501,7 @@ test("createExaProvider fetch defaults to text and preserves per-url failures",
});
```
- [ ] **Step 2: Run the Exa adapter tests to verify they fail**
- [x] **Step 2: Run the Exa adapter tests to verify they fail**
Run:
@@ -512,7 +512,7 @@ npx tsx --test src/providers/exa.test.ts
Expected: `FAIL` with an `ERR_MODULE_NOT_FOUND` error for `./exa.ts`.
- [ ] **Step 3: Write the provider interface and Exa adapter implementation**
- [x] **Step 3: Write the provider interface and Exa adapter implementation**
Create `/home/alex/dotfiles/.pi/agent/extensions/web-search/src/providers/types.ts` with this content:
@@ -705,7 +705,7 @@ export function createExaProvider(
}
```
- [ ] **Step 4: Run the Exa adapter tests to verify they pass**
- [x] **Step 4: Run the Exa adapter tests to verify they pass**
Run:
@@ -716,7 +716,7 @@ npx tsx --test src/providers/exa.test.ts
Expected: `PASS` for both tests.
- [ ] **Step 5: Commit the provider layer**
- [x] **Step 5: Commit the provider layer**
Run: