feat: add exa web search source (#124)

This commit is contained in:
2026-06-13 00:46:53 +01:00
committed by GitHub
parent 877b955493
commit ef7301ab18
15 changed files with 906 additions and 38 deletions

View File

@@ -0,0 +1,14 @@
{
"name": "@freya/source-web-search",
"version": "0.0.0",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
"scripts": {
"test": "bun test ."
},
"dependencies": {
"@freya/core": "workspace:*",
"arktype": "^2.1.0"
}
}

View File

@@ -0,0 +1,97 @@
import { describe, expect, test } from "bun:test"
import { ExaSearchClient } from "./exa-client.ts"
describe("ExaSearchClient", () => {
test("maps request and response", async () => {
const originalFetch = globalThis.fetch
let requestUrl = ""
let requestHeaders: Headers
let requestBody: unknown
globalThis.fetch = (async (
input: Parameters<typeof fetch>[0],
init?: Parameters<typeof fetch>[1],
) => {
requestUrl = String(input)
requestHeaders = new Headers(init?.headers)
requestBody = JSON.parse(String(init?.body))
return new Response(
JSON.stringify({
requestId: "exa-request-1",
results: [
{
id: "result-1",
url: "https://example.com",
title: "Example",
publishedDate: "2026-01-01T00:00:00.000Z",
author: "Author",
image: "https://example.com/image.png",
favicon: "https://example.com/favicon.ico",
highlights: ["A useful passage"],
highlightScores: [0.7],
summary: "Summary",
},
],
}),
{ status: 200 },
)
}) as unknown as typeof fetch
try {
const client = new ExaSearchClient("api-key", "https://api.example.test")
const result = await client.search({
query: "test query",
numResults: 3,
includeDomains: ["example.com"],
highlights: false,
})
expect(requestUrl).toBe("https://api.example.test/search")
expect(requestHeaders!.get("x-api-key")).toBe("api-key")
expect(requestBody).toEqual({
query: "test query",
numResults: 3,
includeDomains: ["example.com"],
contents: { highlights: false },
})
expect(result).toEqual({
query: "test query",
requestId: "exa-request-1",
results: [
{
id: "result-1",
url: "https://example.com",
title: "Example",
publishedDate: "2026-01-01T00:00:00.000Z",
author: "Author",
image: "https://example.com/image.png",
favicon: "https://example.com/favicon.ico",
text: null,
highlights: ["A useful passage"],
highlightScores: [0.7],
summary: "Summary",
},
],
})
} finally {
globalThis.fetch = originalFetch
}
})
test("throws on non-ok response", async () => {
const originalFetch = globalThis.fetch
globalThis.fetch = (async () =>
new Response("nope", { status: 401, statusText: "Unauthorized" })) as unknown as typeof fetch
try {
const client = new ExaSearchClient("bad-key")
await expect(client.search({ query: "test" })).rejects.toThrow(
"Exa API error: 401 Unauthorized",
)
} finally {
globalThis.fetch = originalFetch
}
})
})

View File

@@ -0,0 +1,124 @@
import { type } from "arktype"
import type {
WebSearchClient,
WebSearchRequest,
WebSearchResponse,
WebSearchResult,
} from "./types.ts"
const EXA_API_BASE = "https://api.exa.ai"
const DEFAULT_NUM_RESULTS = 10
const ExaSearchResult = type({
id: "string",
url: "string",
"title?": "string | null",
"publishedDate?": "string | null",
"author?": "string | null",
"image?": "string | null",
"favicon?": "string | null",
"text?": "string | null",
"highlights?": "string[]",
"highlightScores?": "number[]",
"summary?": "string | null",
})
const ExaSearchResponse = type({
results: ExaSearchResult.array(),
"requestId?": "string",
})
interface ExaSearchBody {
query: string
numResults?: number
includeDomains?: string[]
excludeDomains?: string[]
startCrawlDate?: string
endCrawlDate?: string
startPublishedDate?: string
endPublishedDate?: string
type?: WebSearchRequest["type"]
category?: string
userLocation?: string
moderation?: boolean
contents: {
highlights: boolean
}
}
export class ExaSearchClient implements WebSearchClient {
private readonly apiKey: string
private readonly baseUrl: string
constructor(apiKey: string, baseUrl = EXA_API_BASE) {
this.apiKey = apiKey
this.baseUrl = baseUrl
}
async search(request: WebSearchRequest): Promise<WebSearchResponse> {
const response = await fetch(new URL("/search", this.baseUrl), {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": this.apiKey,
},
body: JSON.stringify(toExaSearchBody(request)),
})
if (!response.ok) {
throw new Error(`Exa API error: ${response.status} ${response.statusText}`)
}
const data = await response.json()
const parsed = ExaSearchResponse(data)
if (parsed instanceof type.errors) {
throw new Error(`Invalid Exa API response: ${parsed.summary}`)
}
return {
query: request.query,
requestId: parsed.requestId ?? null,
results: parsed.results.map(toWebSearchResult),
}
}
}
function toExaSearchBody(request: WebSearchRequest): ExaSearchBody {
const body: ExaSearchBody = {
query: request.query,
numResults: request.numResults ?? DEFAULT_NUM_RESULTS,
contents: {
highlights: request.highlights ?? true,
},
}
if (request.includeDomains) body.includeDomains = request.includeDomains
if (request.excludeDomains) body.excludeDomains = request.excludeDomains
if (request.startCrawlDate) body.startCrawlDate = request.startCrawlDate
if (request.endCrawlDate) body.endCrawlDate = request.endCrawlDate
if (request.startPublishedDate) body.startPublishedDate = request.startPublishedDate
if (request.endPublishedDate) body.endPublishedDate = request.endPublishedDate
if (request.type) body.type = request.type
if (request.category) body.category = request.category
if (request.userLocation) body.userLocation = request.userLocation
if (request.moderation !== undefined) body.moderation = request.moderation
return body
}
function toWebSearchResult(result: typeof ExaSearchResult.infer): WebSearchResult {
return {
id: result.id,
url: result.url,
title: result.title ?? null,
publishedDate: result.publishedDate ?? null,
author: result.author ?? null,
image: result.image ?? null,
favicon: result.favicon ?? null,
text: result.text ?? null,
highlights: result.highlights ?? [],
highlightScores: result.highlightScores ?? [],
summary: result.summary ?? null,
}
}

View File

@@ -0,0 +1,11 @@
export { ExaSearchClient } from "./exa-client.ts"
export { WebSearchSource } from "./web-search-source.ts"
export {
WebSearchAction,
WebSearchType,
type WebSearchClient,
type WebSearchRequest,
type WebSearchResponse,
type WebSearchResult,
type WebSearchSourceOptions,
} from "./types.ts"

View File

@@ -0,0 +1,61 @@
export const WebSearchAction = {
Search: "search",
} as const
export type WebSearchAction = (typeof WebSearchAction)[keyof typeof WebSearchAction]
export const WebSearchType = {
Instant: "instant",
Fast: "fast",
Auto: "auto",
DeepLite: "deep-lite",
Deep: "deep",
DeepReasoning: "deep-reasoning",
} as const
export type WebSearchType = (typeof WebSearchType)[keyof typeof WebSearchType]
export interface WebSearchRequest {
query: string
numResults?: number
includeDomains?: string[]
excludeDomains?: string[]
startCrawlDate?: string
endCrawlDate?: string
startPublishedDate?: string
endPublishedDate?: string
type?: WebSearchType
category?: string
userLocation?: string
moderation?: boolean
highlights?: boolean
}
export interface WebSearchResult extends Record<string, unknown> {
id: string
url: string
title: string | null
publishedDate: string | null
author: string | null
image: string | null
favicon: string | null
text: string | null
highlights: string[]
highlightScores: number[]
summary: string | null
}
export interface WebSearchResponse extends Record<string, unknown> {
query: string
requestId: string | null
results: WebSearchResult[]
}
export interface WebSearchClient {
search(request: WebSearchRequest): Promise<WebSearchResponse>
}
export interface WebSearchSourceOptions {
apiKey?: string
client?: WebSearchClient
}

View File

@@ -0,0 +1,123 @@
import { Context } from "@freya/core"
import { describe, expect, test } from "bun:test"
import type { WebSearchClient, WebSearchRequest, WebSearchResponse } from "./types.ts"
import { WebSearchAction } from "./types.ts"
import { WebSearchSource } from "./web-search-source.ts"
class RecordingSearchClient implements WebSearchClient {
requests: WebSearchRequest[] = []
async search(request: WebSearchRequest): Promise<WebSearchResponse> {
this.requests.push(request)
return {
query: request.query,
requestId: "request-1",
results: [
{
id: "https://example.com/a",
url: "https://example.com/a",
title: "Example result",
publishedDate: "2026-01-01T00:00:00.000Z",
author: "Example Author",
image: null,
favicon: "https://example.com/favicon.ico",
text: null,
highlights: ["Relevant excerpt"],
highlightScores: [0.8],
summary: null,
},
],
}
}
}
describe("WebSearchSource", () => {
test("has correct id", () => {
const source = new WebSearchSource({ client: new RecordingSearchClient() })
expect(source.id).toBe("freya.web-search")
})
test("does not provide context or feed items", async () => {
const source = new WebSearchSource({ client: new RecordingSearchClient() })
expect("fetchItems" in source).toBe(false)
expect(await source.fetchContext(new Context())).toBeNull()
})
test("lists search action", async () => {
const source = new WebSearchSource({ client: new RecordingSearchClient() })
const actions = await source.listActions()
expect(actions[WebSearchAction.Search]).toBeDefined()
expect(actions[WebSearchAction.Search]!.id).toBe(WebSearchAction.Search)
expect(actions[WebSearchAction.Search]!.input).toBeDefined()
})
test("executes search action with normalized params", async () => {
const client = new RecordingSearchClient()
const source = new WebSearchSource({ client })
const result = await source.executeAction(WebSearchAction.Search, {
query: " latest personal assistant research ",
includeDomains: ["exa.ai"],
type: "fast",
userLocation: "gb",
moderation: true,
})
expect(result.requestId).toBe("request-1")
expect(result.results).toHaveLength(1)
expect(client.requests).toEqual([
{
query: "latest personal assistant research",
numResults: 10,
includeDomains: ["exa.ai"],
type: "fast",
userLocation: "GB",
moderation: true,
},
])
})
test("allows per-call numResults override", async () => {
const client = new RecordingSearchClient()
const source = new WebSearchSource({ client })
await source.executeAction(WebSearchAction.Search, {
query: "freya",
numResults: 2,
})
expect(client.requests[0]!.numResults).toBe(2)
})
test("throws for invalid action", async () => {
const source = new WebSearchSource({ client: new RecordingSearchClient() })
await expect(source.executeAction("missing", {})).rejects.toThrow("Unknown action")
})
test("throws for invalid search params", async () => {
const source = new WebSearchSource({ client: new RecordingSearchClient() })
await expect(
source.executeAction(WebSearchAction.Search, {
query: "",
}),
).rejects.toThrow("query must not be empty")
await expect(
source.executeAction(WebSearchAction.Search, {
query: "x",
numResults: 101,
}),
).rejects.toThrow("numResults must be an integer")
})
test("throws if neither client nor apiKey is provided", () => {
expect(() => new WebSearchSource({})).toThrow("Either client or apiKey must be provided")
})
})

View File

@@ -0,0 +1,121 @@
import type { ActionDefinition, Context, ContextEntry, FeedSource } from "@freya/core"
import { UnknownActionError } from "@freya/core"
import { type } from "arktype"
import type {
WebSearchClient,
WebSearchRequest,
WebSearchResponse,
WebSearchSourceOptions,
} from "./types.ts"
import { ExaSearchClient } from "./exa-client.ts"
import { WebSearchAction, WebSearchType } from "./types.ts"
const DEFAULT_NUM_RESULTS = 10
const MIN_NUM_RESULTS = 1
const MAX_NUM_RESULTS = 100
const SearchInput = type({
"+": "reject",
query: "string",
"numResults?": "number",
"includeDomains?": "string[]",
"excludeDomains?": "string[]",
"startCrawlDate?": "string.date.iso",
"endCrawlDate?": "string.date.iso",
"startPublishedDate?": "string.date.iso",
"endPublishedDate?": "string.date.iso",
"type?": "'instant' | 'fast' | 'auto' | 'deep-lite' | 'deep' | 'deep-reasoning'",
"category?": "string",
"userLocation?": "string",
"moderation?": "boolean",
"highlights?": "boolean",
})
/**
* Action-only FeedSource for web search through Exa.
*
* It intentionally does not produce feed items. Consumers call the `search`
* action and receive structured web results.
*/
export class WebSearchSource implements FeedSource {
readonly id = "freya.web-search"
private readonly client: WebSearchClient
constructor(options: WebSearchSourceOptions) {
if (!options.client && !options.apiKey) {
throw new Error("Either client or apiKey must be provided")
}
this.client = options.client ?? new ExaSearchClient(options.apiKey!)
}
async listActions(): Promise<Record<string, ActionDefinition>> {
return {
[WebSearchAction.Search]: {
id: WebSearchAction.Search,
description: "Search the web and return structured results",
input: SearchInput,
},
}
}
async executeAction(actionId: string, params: unknown): Promise<WebSearchResponse> {
switch (actionId) {
case WebSearchAction.Search:
return this.client.search(this.parseSearchInput(params))
default:
throw new UnknownActionError(actionId)
}
}
async fetchContext(_context: Context): Promise<readonly ContextEntry[] | null> {
return null
}
private parseSearchInput(params: unknown): WebSearchRequest {
const parsed = SearchInput(params)
if (parsed instanceof type.errors) {
throw new Error(parsed.summary)
}
const query = parsed.query.trim()
if (!query) {
throw new Error("query must not be empty")
}
const numResults = parsed.numResults ?? DEFAULT_NUM_RESULTS
if (
!Number.isInteger(numResults) ||
numResults < MIN_NUM_RESULTS ||
numResults > MAX_NUM_RESULTS
) {
throw new Error(`numResults must be an integer from ${MIN_NUM_RESULTS} to ${MAX_NUM_RESULTS}`)
}
if (parsed.userLocation && !/^[A-Za-z]{2}$/.test(parsed.userLocation)) {
throw new Error("userLocation must be a two-letter ISO country code")
}
const request: WebSearchRequest = {
query,
numResults,
}
if (parsed.includeDomains) request.includeDomains = parsed.includeDomains
if (parsed.excludeDomains) request.excludeDomains = parsed.excludeDomains
if (parsed.startCrawlDate) request.startCrawlDate = parsed.startCrawlDate
if (parsed.endCrawlDate) request.endCrawlDate = parsed.endCrawlDate
if (parsed.startPublishedDate) request.startPublishedDate = parsed.startPublishedDate
if (parsed.endPublishedDate) request.endPublishedDate = parsed.endPublishedDate
if (parsed.type) request.type = parsed.type as WebSearchType
if (parsed.category) request.category = parsed.category
if (parsed.userLocation) request.userLocation = parsed.userLocation.toUpperCase()
if (parsed.moderation !== undefined) request.moderation = parsed.moderation
if (parsed.highlights !== undefined) request.highlights = parsed.highlights
return request
}
}