src / providers / ExaProvider.ts
src / providers / ExaProvider.ts
import { defaultConfigurationService } from "../config/ConfigurationService";
import { HTTPClient, JsonObject } from "../http/HTTPClient";
import { ProviderError } from "../models/errors";
import { SearchOptions } from "../types/search";
import { KeyManager } from "../utils/KeyManager";
import { BaseProvider } from "./BaseProvider";
import { InternalProviderResult } from "./types/InternalProviderResult";
export const EXA_SEARCH_URL = "https://api.exa.ai/search";
export interface ExaProviderOptions {
keys?: string[];
keyManager?: KeyManager;
httpClient?: HTTPClient;
cooldownHours?: number;
}
/**
* Exa search provider implementation.
* Mapped from Python `providers/exa.py` ExaProvider.
*/
export class ExaProvider extends BaseProvider {
public static readonly PROVIDER_NAME = "exa";
public static readonly DISPLAY_NAME = "Exa";
private httpClient: HTTPClient;
constructor(options: ExaProviderOptions = {}) {
super({
providerName: ExaProvider.PROVIDER_NAME,
displayName: ExaProvider.DISPLAY_NAME,
capabilities: ["web"],
keys: options.keys,
keyManager: options.keyManager,
cooldownHours: options.cooldownHours,
});
this._rotationStatusCodes = [401, 403, 429];
this.httpClient = options.httpClient ?? new HTTPClient();
}
/**
* Execute web search using Exa Search API endpoint with key rotation.
* Smallest Useful Payload Principle: requests lightweight highlights/summaries rather than full page text.
*/
public async search(query: string, options: SearchOptions = {}): Promise<InternalProviderResult[]> {
if (!query || !query.trim()) {
return [];
}
const numResults = options.numResults ?? 10;
const maxSnippetLen = defaultConfigurationService.getSearchMaxSnippetLength();
const { apiKey: _k, numResults: _n, provider: _p, ...extraKwargs } = options;
const doSearch = async (apiKey: string): Promise<InternalProviderResult[]> => {
const headers: Record<string, string> = {
"x-api-key": apiKey.trim(),
"Content-Type": "application/json",
accept: "application/json",
};
const payload: JsonObject = {
query: query.trim(),
numResults: numResults,
contents: {
highlights: {
numSentences: 3,
},
text: {
maxCharacters: maxSnippetLen,
},
},
...extraKwargs,
};
let response;
try {
response = await this.httpClient.post(EXA_SEARCH_URL, {
json: payload,
headers,
});
} catch (exc: unknown) {
const err = exc instanceof Error ? exc : new Error(String(exc));
throw new ProviderError(`Exa search HTTP request failed: ${err.message}`);
}
const data = this.parseJsonResponse<Record<string, unknown>>(response);
if (!("results" in data)) {
throw new ProviderError("Invalid response payload from Exa API: missing 'results' array");
}
const rawResults = data.results;
if (!Array.isArray(rawResults)) {
throw new ProviderError("Invalid response payload from Exa API: 'results' field must be a list");
}
this._lastRawResultsCount = rawResults.length;
const candidates = rawResults.map((item: unknown) =>
this.extractCandidate((item as Record<string, unknown>) || {})
);
return this.enforceResultLimit(candidates, numResults);
};
return this.executeWithKeyRotation(doSearch);
}
private extractCandidate(rawItem: Record<string, unknown>): InternalProviderResult {
let snippetCandidate = "";
let snippetSource = "empty";
if (Array.isArray(rawItem.highlights) && rawItem.highlights.length > 0) {
snippetCandidate = rawItem.highlights.join(" ");
snippetSource = "highlights";
} else if (typeof rawItem.summary === "string" && rawItem.summary.trim()) {
snippetCandidate = rawItem.summary;
snippetSource = "summary";
} else if (typeof rawItem.text === "string" && rawItem.text.trim()) {
snippetCandidate = rawItem.text;
snippetSource = "text";
} else if (typeof rawItem.snippet === "string" && rawItem.snippet.trim()) {
snippetCandidate = rawItem.snippet;
snippetSource = "native_snippet";
}
return {
title: String(rawItem.title || ""),
url: String(rawItem.url || ""),
snippetCandidate,
publishedDate: (rawItem.publishedDate as string) || undefined,
source: this.providerName,
score: typeof rawItem.score === "number" ? rawItem.score : undefined,
raw: rawItem,
debug: { snippetSource },
};
}
}
import { defaultConfigurationService } from "../config/ConfigurationService";
import { HTTPClient, JsonObject } from "../http/HTTPClient";
import { ProviderError } from "../models/errors";
import { SearchOptions } from "../types/search";
import { KeyManager } from "../utils/KeyManager";
import { BaseProvider } from "./BaseProvider";
import { InternalProviderResult } from "./types/InternalProviderResult";
export const EXA_SEARCH_URL = "https://api.exa.ai/search";
export interface ExaProviderOptions {
keys?: string[];
keyManager?: KeyManager;
httpClient?: HTTPClient;
cooldownHours?: number;
}
/**
* Exa search provider implementation.
* Mapped from Python `providers/exa.py` ExaProvider.
*/
export class ExaProvider extends BaseProvider {
public static readonly PROVIDER_NAME = "exa";
public static readonly DISPLAY_NAME = "Exa";
private httpClient: HTTPClient;
constructor(options: ExaProviderOptions = {}) {
super({
providerName: ExaProvider.PROVIDER_NAME,
displayName: ExaProvider.DISPLAY_NAME,
capabilities: ["web"],
keys: options.keys,
keyManager: options.keyManager,
cooldownHours: options.cooldownHours,
});
this._rotationStatusCodes = [401, 403, 429];
this.httpClient = options.httpClient ?? new HTTPClient();
}
/**
* Execute web search using Exa Search API endpoint with key rotation.
* Smallest Useful Payload Principle: requests lightweight highlights/summaries rather than full page text.
*/
public async search(query: string, options: SearchOptions = {}): Promise<InternalProviderResult[]> {
if (!query || !query.trim()) {
return [];
}
const numResults = options.numResults ?? 10;
const maxSnippetLen = defaultConfigurationService.getSearchMaxSnippetLength();
const { apiKey: _k, numResults: _n, provider: _p, ...extraKwargs } = options;
const doSearch = async (apiKey: string): Promise<InternalProviderResult[]> => {
const headers: Record<string, string> = {
"x-api-key": apiKey.trim(),
"Content-Type": "application/json",
accept: "application/json",
};
const payload: JsonObject = {
query: query.trim(),
numResults: numResults,
contents: {
highlights: {
numSentences: 3,
},
text: {
maxCharacters: maxSnippetLen,
},
},
...extraKwargs,
};
let response;
try {
response = await this.httpClient.post(EXA_SEARCH_URL, {
json: payload,
headers,
});
} catch (exc: unknown) {
const err = exc instanceof Error ? exc : new Error(String(exc));
throw new ProviderError(`Exa search HTTP request failed: ${err.message}`);
}
const data = this.parseJsonResponse<Record<string, unknown>>(response);
if (!("results" in data)) {
throw new ProviderError("Invalid response payload from Exa API: missing 'results' array");
}
const rawResults = data.results;
if (!Array.isArray(rawResults)) {
throw new ProviderError("Invalid response payload from Exa API: 'results' field must be a list");
}
this._lastRawResultsCount = rawResults.length;
const candidates = rawResults.map((item: unknown) =>
this.extractCandidate((item as Record<string, unknown>) || {})
);
return this.enforceResultLimit(candidates, numResults);
};
return this.executeWithKeyRotation(doSearch);
}
private extractCandidate(rawItem: Record<string, unknown>): InternalProviderResult {
let snippetCandidate = "";
let snippetSource = "empty";
if (Array.isArray(rawItem.highlights) && rawItem.highlights.length > 0) {
snippetCandidate = rawItem.highlights.join(" ");
snippetSource = "highlights";
} else if (typeof rawItem.summary === "string" && rawItem.summary.trim()) {
snippetCandidate = rawItem.summary;
snippetSource = "summary";
} else if (typeof rawItem.text === "string" && rawItem.text.trim()) {
snippetCandidate = rawItem.text;
snippetSource = "text";
} else if (typeof rawItem.snippet === "string" && rawItem.snippet.trim()) {
snippetCandidate = rawItem.snippet;
snippetSource = "native_snippet";
}
return {
title: String(rawItem.title || ""),
url: String(rawItem.url || ""),
snippetCandidate,
publishedDate: (rawItem.publishedDate as string) || undefined,
source: this.providerName,
score: typeof rawItem.score === "number" ? rawItem.score : undefined,
raw: rawItem,
debug: { snippetSource },
};
}
}