MoonBit client for the Exa search API
flowchart LR
C["@exa.Client<br/>search · contents · answer"] -->|HttpRequest| T["&@exa.Transport"]
T -->|HttpResponse| C
T --> A["@exa_http.AsyncHttpTransport<br/>moonbitlang/async/http"]
T --> M["@exa.MockTransport<br/>canned responses, no network"]
A --> E["api.exa.ai"]moon add marianoguerra/exaimport {
"marianoguerra/exa",
"marianoguerra/exa/async_http" @exa_http,
"moonbitlang/async",
}///|
async fn main {
let client = @exa_http.client_from_env()
let response = client.search(
"papers on retrieval-augmented generation",
num_results=5,
category=Publication,
contents=@exa.ContentsOptions::new(highlights=On),
)
for result in response.results {
println(result.title.unwrap_or("(untitled)"))
println(" \{result.url}")
}
}EXA_API_KEY=... moon run cmd/main --target native -- "your query"///|
test "content options serialise the way the API expects" {
let compact : Json = @exa.ContentsOptions::new(text=On, highlights=On).to_json()
inspect(
compact.stringify(),
content=(
#|{"text":true,"highlights":true}
),
)
let detailed : Json = @exa.ContentsOptions::new(
text=With(@exa.TextOptions::new(max_characters=2000, verbosity=Full)),
summary=With(@exa.SummaryOptions::new(query="what do they sell")),
max_age_hours=0,
).to_json()
inspect(
detailed.stringify(),
content=(
#|{"text":{"maxCharacters":2000,"verbosity":"full"},"summary":{"query":"what do they sell"},"maxAgeHours":0}
),
)
}///|
let response = client.search(query) catch {
@exa.Api(..) as error if error.is_rate_limited() => ... // back off and retry
@exa.Api(status~, tag~, message~, request_id~) => ...
@exa.Decode(message) => ...
}///|
fn run_async(work : async () -> Unit noraise) -> Unit = "%async.run"
///|
fn[T] block_on(work : async () -> T) -> T raise {
let outcome : Array[Result[T, Error]] = []
run_async(async fn() noraise {
outcome.push(Ok(work()) catch { error => Err(error) })
})
match outcome {
[Ok(value)] => value
[Err(error)] => raise error
_ => abort("async work did not complete synchronously")
}
}
///|
test "a search against a canned response" {
let transport = @exa.MockTransport::json(
(
#|{
#| "requestId": "req-1",
#| "results": [{"url": "https://exa.ai", "title": "Exa"}],
#| "costDollars": {"total": 0.005}
#|}
),
)
let client = @exa.Client::new(transport, "test-key")
let response = block_on(async fn() {
client.search("what is exa", num_results=1)
})
// what came back
assert_eq(response.results[0].title, Some("Exa"))
assert_eq(response.cost_dollars.unwrap().total, 0.005)
// and what went out
inspect(
transport.last_body().unwrap().stringify(),
content=(
#|{"query":"what is exa","numResults":1}
),
)
}
///|
test "an api error surfaces as ExaError::Api" {
let transport = @exa.MockTransport::json(
(
#|{"requestId":"req-2","error":"Invalid API key","tag":"INVALID_API_KEY"}
),
status=401,
)
let client = @exa.Client::new(transport, "wrong-key")
let failed = try {
let _ = block_on(async fn() { client.search("q") })
false
} catch {
@exa.Api(..) as error => error.is_unauthorized()
_ => false
}
assert_true(failed)
}///|
fn blank(url : String) -> @exa.SearchResult {
{
url,
id: url,
title: None,
published_date: None,
author: None,
image: None,
favicon: None,
text: None,
summary: None,
highlights: [],
highlight_scores: [],
subpages: [],
extras: None,
raw: Json::null(),
}
}
///|
test "response values can be built by hand" {
let untitled = blank("https://example.com")
assert_eq(untitled.title, None)
let titled = { ..blank("https://exa.ai"), title: Some("Exa"), }
assert_eq(titled.title, Some("Exa"))
assert_eq(titled.id, "https://exa.ai")
}pub(all) enum AnswerModel {
Exa
ExaPro
ExaResearch
ExaFast
}impl ToJson for AnswerModelpub(all) struct AnswerResponse {
request_id : String?
answer : Json
answer_text : String?
citations : Array[SearchResult]
cost_dollars : CostDollars?
raw : Json
} derive(Eq, Debug)pub(all) enum Category {
Company
People
Publication
News
PersonalSite
FinancialReport
}async fn Client::answer(self : Client, query : String, model? : AnswerModel, text? : Bool, system_prompt? : String, user_location? : String, output_schema? : Json) -> AnswerResponseasync fn Client::contents(self : Client, urls : Array[String], text? : Text, highlights? : Highlights, summary? : Summary, livecrawl_timeout? : Int, max_age_hours? : Int, subpages? : Int, subpage_target? : SubpageTarget, extras? : ExtrasOptions) -> ContentsResponse#deprecated("Use `Client::search` with a query describing the source page")
async fn Client::find_similar(self : Client, url : String, num_results? : Int, category? : Category, include_domains? : Array[String], exclude_domains? : Array[String], start_published_date? : String, end_published_date? : String, start_crawl_date? : String, end_crawl_date? : String, exclude_source_domain? : Bool, contents? : ContentsOptions) -> SearchResponseasync fn Client::search(self : Client, query : String, search_type? : SearchType, num_results? : Int, category? : Category, user_location? : String, include_domains? : Array[String], exclude_domains? : Array[String], start_published_date? : String, end_published_date? : String, start_crawl_date? : String, end_crawl_date? : String, moderation? : Bool, additional_queries? : Array[String], system_prompt? : String, output_schema? : Json, contents? : ContentsOptions) -> SearchResponsepub struct ContentsOptions {
text : Text?
highlights : Highlights?
summary : Summary?
livecrawl_timeout : Int?
max_age_hours : Int?
subpages : Int?
subpage_target : SubpageTarget?
extras : ExtrasOptions?
}impl ToJson for ContentsOptionsfn ContentsOptions::new(text? : Text, highlights? : Highlights, summary? : Summary, livecrawl_timeout? : Int, max_age_hours? : Int, subpages? : Int, subpage_target? : SubpageTarget, extras? : ExtrasOptions) -> ContentsOptionspub(all) struct ContentsResponse {
request_id : String?
results : Array[SearchResult]
statuses : Array[ContentStatus]
cost_dollars : CostDollars?
raw : Json
} derive(Eq, Debug)pub struct ExtrasOptions {
links : Int?
image_links : Int?
}impl ToJson for ExtrasOptionsimpl ToJson for Highlightspub struct HighlightsOptions {
query : String?
dynamic : Bool?
max_characters : Int?
}impl ToJson for HighlightsOptionsfn HighlightsOptions::new(query? : String, dynamic? : Bool, max_characters? : Int) -> HighlightsOptionsimpl Transport for MockTransportpub(all) struct SearchResponse {
request_id : String?
results : Array[SearchResult]
output : SynthesisOutput?
cost_dollars : CostDollars?
search_time : Double?
raw : Json
} derive(Eq, Debug)pub(all) struct SearchResult {
url : String
id : String
title : String?
published_date : String?
author : String?
image : String?
favicon : String?
text : String?
summary : String?
highlights : Array[String]
highlight_scores : Array[Double]
subpages : Array[SearchResult]
extras : Extras?
raw : Json
} derive(Eq, Debug)pub(all) enum SearchType {
Auto
Fast
Instant
DeepLite
Deep
DeepReasoning
}impl ToJson for SearchTypepub(all) enum Section {
Header
Navigation
Banner
Body
Sidebar
Footer
Metadata
}impl ToJson for TextOptionsfn TextOptions::new(max_characters? : Int, include_html_tags? : Bool, verbosity? : Verbosity, include_sections? : Array[Section], exclude_sections? : Array[Section]) -> TextOptionspub(all) enum Verbosity {
Compact
Standard
Full
}