MoonBit client for the Exa search API
Dependencies
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)
}///|
test "response values can be built with new" {
let untitled = @exa.SearchResult::new("https://example.com")
assert_eq(untitled.title, None)
let titled = @exa.SearchResult::new("https://exa.ai", title="Exa")
assert_eq(titled.title, Some("Exa"))
assert_eq(titled.id, "https://exa.ai") // defaults to the url
let response = @exa.SearchResponse::new(
results=[titled, untitled],
cost_dollars=@exa.CostDollars::new(0.005),
)
assert_eq(response.results[1].title, None)
}///|
test "response values can be built as literals" {
let result : @exa.SearchResult = {
url: "https://exa.ai",
id: "https://exa.ai",
title: Some("Exa"),
published_date: None,
author: None,
image: None,
favicon: None,
text: None,
summary: None,
highlights: [],
highlight_scores: [],
subpages: [],
extras: None,
raw: Json::null(),
}
assert_eq(result, @exa.SearchResult::new("https://exa.ai", title="Exa"))
assert_eq({ ..result, title: None, }.title, None)
}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)fn AnswerResponse::new(answer? : Json, citations? : Array[SearchResult], request_id? : String, cost_dollars? : CostDollars, raw? : Json) -> AnswerResponsepub(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) -> SearchResponsefn ContentStatus::new(id : String, status? : String, error_tag? : String, http_status_code? : Int) -> ContentStatuspub 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)fn ContentsResponse::new(results? : Array[SearchResult], statuses? : Array[ContentStatus], request_id? : String, cost_dollars? : CostDollars, raw? : Json) -> ContentsResponsepub 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)fn SearchResponse::new(results? : Array[SearchResult], request_id? : String, output? : SynthesisOutput, cost_dollars? : CostDollars, search_time? : Double, raw? : Json) -> SearchResponsepub(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)fn SearchResult::new(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) -> SearchResultpub(all) enum SearchType {
Auto
Fast
Instant
DeepLite
Deep
DeepReasoning
}impl ToJson for SearchTypepub(all) enum Section {
Header
Navigation
Banner
Body
Sidebar
Footer
Metadata
}fn SynthesisOutput::new(content? : Json, grounding? : Array[Grounding], raw? : Json) -> SynthesisOutputimpl 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
}Install
Download zipMoonBit client for the Exa search API
Dependencies