test: add exa web provider adapter
This commit is contained in:
110
.pi/agent/extensions/web-search/src/providers/exa.test.ts
Normal file
110
.pi/agent/extensions/web-search/src/providers/exa.test.ts
Normal 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",
|
||||
},
|
||||
]);
|
||||
});
|
||||
124
.pi/agent/extensions/web-search/src/providers/exa.ts
Normal file
124
.pi/agent/extensions/web-search/src/providers/exa.ts
Normal 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,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
57
.pi/agent/extensions/web-search/src/providers/types.ts
Normal file
57
.pi/agent/extensions/web-search/src/providers/types.ts
Normal 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>;
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
|
||||
Reference in New Issue
Block a user