exa

    MoonBit client for the Exa search API

    exa
    search
    api
    client
    http
    Download zip
    Version
    0.1.3
    License
    Apache-2.0
    Last updated
    19 hours ago
    Downloads
    10

    Dependencies

    #marianoguerra/exa

    A MoonBit client for the Exa search API: /search, /contents, /answer and /findSimilar.

    The library does not speak HTTP itself. It turns calls into request values and hands them to a Transport, which means the core package builds on every backend and your tests never touch the network. A transport backed by moonbitlang/async/http ships alongside it.

    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"]

    #Install

    moon add marianoguerra/exa

    Add what you need to your moon.pkg:

    import { "marianoguerra/exa", "marianoguerra/exa/async_http" @exa_http, "moonbitlang/async", }

    #Quick start

    Every call is async, so it runs inside an event loop. client_from_env reads your key from EXA_API_KEY.

    ///|
    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}")
    }
    }

    cmd/main in this repository is a working version of that:

    EXA_API_KEY=... moon run cmd/main --target native -- "your query"

    #Calls

    Client::search takes a natural-language query — Exa's index is embedding-based, so a long description beats keywords — plus optional filters, all as labelled arguments. Pass contents to get page text back with the results instead of making a second call.

    Client::contents fetches text, highlights or summaries for URLs you already have. A URL that fails to crawl does not fail the call; it turns up in statuses with an error tag.

    Client::answer asks a question and returns prose plus the citations behind it, or a structured object when you pass an output_schema.

    Client::find_similar is there for existing code. Exa deprecated the endpoint — use search with a query describing the source page.

    Options that the API models as boolean | object are modelled as two cases, so text=On sends true and text=With(..) sends the settings object:

    ///|
    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}
    ),
    )
    }

    Arguments you leave out are left out of the request body entirely.

    #Errors

    A non-2xx response raises ExaError::Api carrying Exa's status, error tag, message and request id. A 2xx body that will not decode raises ExaError::Decode. Anything that stopped the request from getting there — DNS, TLS, timeouts — propagates from the transport unchanged.

    ///|
    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) => ...
    }

    is_unauthorized, is_payment_required and is_rate_limited save you from hard-coding status numbers.

    #Testing without a network

    MockTransport answers from a script and records what it was asked to send, so you can assert on both the request your code built and how it handled the response. Because calls are async, drive them with the %async.run intrinsic:

    ///|
    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)
    }

    Every response type also keeps the JSON it decoded from in a raw field, so a field Exa adds tomorrow is reachable today.

    The response types can be built by hand, so code that renders them can be tested without going through canned JSON. new takes the one required field and leaves the rest optional:

    ///|
    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)
    }

    The shapes are also public, so a struct literal works where you want every field spelled out — MoonBit needs all of them, and .. spreads from an existing value:

    ///|
    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)
    }

    Because the shapes are public, adding a field to a response type breaks such literals — new fields land in a minor version, not a patch. Calls to new are unaffected, since a new field becomes a new optional argument.

    #Backends

    The core package builds everywhere. marianoguerra/exa/async_http follows moonbitlang/async/http: native, JS and wasm, but not wasm-gc. On wasm-gc, or in a browser, implement @exa.Transport over whatever HTTP you have.

    #Not covered yet

    Server-sent event streaming (stream: true on /search and /answer), and the Websets, agent run, monitor, batch and team-management endpoints.

    #License

    Apache-2.0

    Transport

    pub(open) trait Transport {
    async fn send(Self, HttpRequest) -> HttpResponse
    }

    Anything that can perform an HTTP request.

    ExaError

    pub(all) suberror ExaError {
    Api(status~ : Int, tag~ : String, message~ : String, request_id~ : String?)
    Decode(String)
    } derive(ToJson,
    Debug
    )

    Everything this client can fail with.

    Failures raised by the underlying Transport (connection refused, TLS errors, timeouts) propagate unchanged — wrapping them would only hide the transport's own error type from the caller.

    ExaError::is_payment_required

    fn ExaError::is_payment_required(self : ExaError) -> Bool

    The team is out of credits or exceeded a spending budget (HTTP 402).

    ExaError::is_rate_limited

    fn ExaError::is_rate_limited(self : ExaError) -> Bool

    A rate limit was exceeded (HTTP 429). Worth retrying after a backoff.

    ExaError::is_unauthorized

    fn ExaError::is_unauthorized(self : ExaError) -> Bool

    The API key was missing, malformed or rejected (HTTP 401).

    ExaError::request_id

    fn ExaError::request_id(self : ExaError) -> String?

    Exa's request id, when the failure came from the API. Quote it in support requests.

    MockTransportExhausted

    pub(all) suberror MockTransportExhausted {
    MockTransportExhausted(String)
    } derive(
    Debug
    )

    The mock ran out of canned responses.

    AnswerModel

    pub(all) enum AnswerModel {
    Exa
    ExaPro
    ExaResearch
    ExaFast
    }

    Which model answers the question. Exa is the default; ExaFast trades depth for latency, ExaPro and ExaResearch go the other way.

    AnswerResponse

    pub(all) struct AnswerResponse {
    request_id : String?
    answer : Json
    answer_text : String?
    citations : Array[SearchResult]
    cost_dollars : CostDollars?
    raw : Json
    } derive(Eq,
    Debug
    )

    The result of an /answer call.

    AnswerResponse::new

    fn AnswerResponse::new(answer? : Json, citations? : Array[SearchResult], request_id? : String, cost_dollars? : CostDollars, raw? : Json) -> AnswerResponse

    Build a response directly, for testing code that consumes one.

    answer_text is filled in from answer when it is a plain string, the same way decoding does it.

    Category

    pub(all) enum Category {
    Company
    People
    Publication
    News
    PersonalSite
    FinancialReport
    }

    Restrict a search to one kind of page.
    impl ToJson for Category

    Citation

    pub(all) struct Citation {
    url : String?
    title : String?
    } derive(Eq,
    Debug
    )

    A source backing part of a structured output.

    Citation::new

    fn Citation::new(url? : String, title? : String) -> Citation

    Build one directly, for tests and for code that synthesises results.

    Client

    pub struct Client {
    transport : &Transport
    api_key : String
    base_url : String
    }

    The Exa API client.

    Construct one with a Transport and an API key, then call search, contents or answer on it. A Client holds no mutable state, so a single one can be shared across concurrent tasks (whether that is safe in practice is up to the transport).

    Client::answer

    async fn Client::answer(self : Client, query : String, model? : AnswerModel, text? : Bool, system_prompt? : String, user_location? : String, output_schema? : Json) -> AnswerResponse

    Ask a question and get an answer with citations.

    Exa runs the search and the synthesis; set text to also get each cited page's full text back, and output_schema to get a structured answer instead of prose.

    Client::contents

    async 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

    Fetch the contents of URLs you already have.

    Ask for at least one of text, highlights or summary — with none of them set, Exa has nothing to return. URLs that fail to crawl do not fail the call; they show up in statuses instead.

    Client::find_similar

    #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) -> SearchResponse

    Find pages similar to one you already have.

    Exa marks this endpoint deprecated: prefer Client::search with a query describing the source page. It is kept here because existing code calls it.

    Client::new

    fn Client::new(transport : &Transport, api_key : String, base_url? : String) -> Client

    Build a client. base_url only needs setting to point at a proxy or a test server; it may carry a path prefix and a trailing slash is ignored.

    Client::search

    async 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) -> SearchResponse

    Search the web.

    query is a natural-language description of what you are looking for — Exa's index is embedding-based, so long descriptive queries work better than keywords. Pass contents to get page text, highlights or summaries back with the results instead of making a second /contents call.

    Note that search_type maps to the API's type field, which is a reserved word in MoonBit.

    Raises ExaError::Api if Exa rejects the request, and whatever the Transport raises if the request never got there.

    ContentStatus

    pub(all) struct ContentStatus {
    id : String
    status : String
    error_tag : String?
    http_status_code : Int?
    } derive(Eq,
    Debug
    )

    Why one URL in a /contents request did not come back with content.

    ContentStatus::is_success

    fn ContentStatus::is_success(self : ContentStatus) -> Bool

    True when this URL was crawled successfully.

    ContentStatus::new

    fn ContentStatus::new(id : String, status? : String, error_tag? : String, http_status_code? : Int) -> ContentStatus

    Build a status directly, for testing code that branches on one.

    ContentsOptions

    pub struct ContentsOptions {
    text : Text?
    highlights : Highlights?
    summary : Summary?
    livecrawl_timeout : Int?
    max_age_hours : Int?
    subpages : Int?
    subpage_target : SubpageTarget?
    extras : ExtrasOptions?
    }

    What to retrieve for each result.

    ContentsOptions::new

    fn ContentsOptions::new(text? : Text, highlights? : Highlights, summary? : Summary, livecrawl_timeout? : Int, max_age_hours? : Int, subpages? : Int, subpage_target? : SubpageTarget, extras? : ExtrasOptions) -> ContentsOptions

    ContentsResponse

    pub(all) struct ContentsResponse {
    request_id : String?
    results : Array[SearchResult]
    statuses : Array[ContentStatus]
    cost_dollars : CostDollars?
    raw : Json
    } derive(Eq,
    Debug
    )

    The result of a /contents call.

    ContentsResponse::new

    fn ContentsResponse::new(results? : Array[SearchResult], statuses? : Array[ContentStatus], request_id? : String, cost_dollars? : CostDollars, raw? : Json) -> ContentsResponse

    Build a response directly, for testing code that consumes one.

    CostDollars

    pub(all) struct CostDollars {
    total : Double
    raw : Json
    } derive(Eq,
    Debug
    )

    What the request cost. raw holds Exa's full per-component breakdown (search.neural, contents.text, and so on).

    CostDollars::new

    fn CostDollars::new(total : Double, raw? : Json) -> CostDollars

    Build one directly, for tests and for code that synthesises results.

    Extras

    pub(all) struct Extras {
    links : Array[String]
    image_links : Array[String]
    } derive(Eq,
    Debug
    )

    Extra links scraped out of a page, when ExtrasOptions asked for them.

    Extras::new

    fn Extras::new(links? : Array[String], image_links? : Array[String]) -> Extras

    Build one directly, for tests and for code that synthesises results.

    ExtrasOptions

    pub struct ExtrasOptions {
    links : Int?
    image_links : Int?
    }

    Extra links to scrape out of each page.

    ExtrasOptions::new

    fn ExtrasOptions::new(links? : Int, image_links? : Int) -> ExtrasOptions

    Grounding

    pub(all) struct Grounding {
    field : String?
    confidence : String?
    citations : Array[Citation]
    } derive(Eq,
    Debug
    )

    Which sources back one field of a structured output, and how confident Exa is in it (low, medium or high).

    Grounding::new

    fn Grounding::new(field? : String, confidence? : String, citations? : Array[Citation]) -> Grounding

    Build one directly, for tests and for code that synthesises results.

    Highlights

    pub(all) enum Highlights {
    On
    With(HighlightsOptions)
    }

    Ask for highlights: On for the API's defaults, With(..) to configure.

    See HighlightsOptions for what a returned highlight actually looks like — it is raw page text, not a tidy one-line excerpt.

    HighlightsOptions

    pub struct HighlightsOptions {
    query : String?
    dynamic : Bool?
    max_characters : Int?
    }

    Settings for query-relevant excerpts.

    A highlight is a passage lifted out of the page, not a formatted snippet: it carries the page's own newlines, can run long, and may pull in navigation text such as a skip-to-content link. Code that renders one into a list or a heading generally wants to collapse whitespace and cap the length with max_characters.

    HighlightsOptions::new

    fn HighlightsOptions::new(query? : String, dynamic? : Bool, max_characters? : Int) -> HighlightsOptions

    HttpRequest

    pub(all) struct HttpRequest {
    meth : String
    url : String
    headers : Array[(String, String)]
    body : String
    } derive(Eq,
    Debug
    )

    A single HTTP request, fully rendered and ready to send.

    HttpResponse

    pub(all) struct HttpResponse {
    status : Int
    headers : Array[(String, String)]
    body : String
    } derive(Eq,
    Debug
    )

    The response to an HttpRequest.

    HttpResponse::header

    fn HttpResponse::header(self : HttpResponse, name : String) -> String?

    Look up a response header, case-insensitively.

    MockTransport

    pub struct MockTransport {
    queue : Array[HttpResponse]
    seen : Array[HttpRequest]
    position : Int
    }

    A Transport that returns pre-canned responses and records what it was asked to send.

    MockTransport::json

    fn MockTransport::json(body : String, status? : Int) -> MockTransport

    A mock that answers a single request with body as a JSON payload.

    MockTransport::last_body

    fn MockTransport::last_body(self : MockTransport) -> Json?

    The body of the most recent request, re-parsed as JSON. Handy for asserting on what got serialised.

    MockTransport::last_request

    fn MockTransport::last_request(self : MockTransport) -> HttpRequest?

    The most recent request, or None if nothing was sent yet.

    MockTransport::new

    A mock that answers with responses in order.

    MockTransport::requests

    fn MockTransport::requests(self : MockTransport) -> Array[HttpRequest]

    Every request the mock has been asked to send, in order.

    SearchResponse

    pub(all) struct SearchResponse {
    request_id : String?
    results : Array[SearchResult]
    output : SynthesisOutput?
    cost_dollars : CostDollars?
    search_time : Double?
    raw : Json
    } derive(Eq,
    Debug
    )

    The result of a /search call.

    SearchResponse::new

    fn SearchResponse::new(results? : Array[SearchResult], request_id? : String, output? : SynthesisOutput, cost_dollars? : CostDollars, search_time? : Double, raw? : Json) -> SearchResponse

    Build a response directly, for testing code that consumes one.

    SearchResult

    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
    )

    One search result, and its contents when they were requested.

    The same shape is returned by /search, /contents, /findSimilar and as /answer's citations, so fields not requested are simply absent.

    SearchResult::new

    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) -> SearchResult

    Build a result directly, without going through a response body.

    Everything but the URL is optional, so a test for "a result with no title" is one line. id defaults to the URL, which is what Exa returns today, and raw to null — a hand-built result was not decoded from anything.

    SearchType

    pub(all) enum SearchType {
    Auto
    Fast
    Instant
    DeepLite
    Deep
    DeepReasoning
    }

    Which search strategy Exa should use. Auto lets Exa pick.

    Section

    pub(all) enum Section {
    Header
    Navigation
    Banner
    Body
    Sidebar
    Footer
    Metadata
    }

    A structural region of a page, for include_sections / exclude_sections.
    impl ToJson for Section

    SubpageTarget

    pub(all) enum SubpageTarget {
    Keyword(String)
    Keywords(Array[String])
    }

    Keywords steering which subpages get crawled.

    Summary

    pub(all) enum Summary {
    On
    With(SummaryOptions)
    }

    Ask for a summary: On for the API's defaults, With(..) to configure.
    impl ToJson for Summary

    SummaryOptions

    pub struct SummaryOptions {
    query : String?
    schema : Json?
    }

    Settings for the LLM-generated summary.

    SummaryOptions::new

    fn SummaryOptions::new(query? : String, schema? : Json) -> SummaryOptions

    SynthesisOutput

    pub(all) struct SynthesisOutput {
    content : Json
    content_text : String?
    grounding : Array[Grounding]
    raw : Json
    } derive(Eq,
    Debug
    )

    The synthesised answer returned when a request supplied an output_schema.

    SynthesisOutput::new

    fn SynthesisOutput::new(content? : Json, grounding? : Array[Grounding], raw? : Json) -> SynthesisOutput

    Build one directly, for tests and for code that synthesises results.

    content_text is filled in from content when it is a plain string, the same way decoding does it.

    Text

    pub(all) enum Text {
    On
    With(TextOptions)
    }

    Ask for page text: On for the API's defaults, With(..) to configure it.
    impl ToJson for Text

    TextOptions

    pub struct TextOptions {
    max_characters : Int?
    include_html_tags : Bool?
    verbosity : Verbosity?
    include_sections : Array[Section]?
    exclude_sections : Array[Section]?
    }

    Settings for full page text.

    TextOptions::new

    fn TextOptions::new(max_characters? : Int, include_html_tags? : Bool, verbosity? : Verbosity, include_sections? : Array[Section], exclude_sections? : Array[Section]) -> TextOptions

    Verbosity

    pub(all) enum Verbosity {
    Compact
    Standard
    Full
    }

    How much of a page's text to return.
    impl ToJson for Verbosity

    default_base_url

    let default_base_url : String

    The default Exa API endpoint.