Skip to content

Rust SDK

Install the official HiNow crate and use chat, streaming, web search, agents, and semantic search in Rust.

Updated on Aug 09, 2026

The official Rust SDK for the HiNow API. It is asynchronous, runs on reqwest and tokio, and all response types are custom structs — the compiler holds you accountable before the API does.

The API speaks the OpenAI protocol, and the SDK follows the same format. If you have already integrated with OpenAI, the shape of the calls is what you know.

Installation

terminalbash
cargo add hinow-ai tokio --features tokio/full

The crate is hinow-ai, the use statement is hinow_ai

Cargo accepts hyphens in package names, but Rust does not accept hyphens in identifiers — so the hinow-ai crate becomes the hinow_ai module in your code.

Store the key in HINOW_API_KEY and use Hinow::from_env(). Hinow::new("hi_…") also works, but avoid leaving the value in your code.

terminalbash
export HINOW_API_KEY="hi_sua_chave_aqui"

The first call

main.rsrust
use hinow_ai::{ChatCompletionRequest, Hinow, Message};

#[tokio::main]
async fn main() -> Result<(), hinow_ai::Error> {
    // from_env reads HINOW_API_KEY.
    let client = Hinow::from_env()?;

    let answer = client
        .chat()
        .completions()
        .create(
            ChatCompletionRequest::new("hinow/himax")
                .add_message(Message::system("You answer in English."))
                .add_message(Message::user("What is an API? Answer in one paragraph.")),
        )
        .await?;

    // text() gives you the string; content holds the raw value.
    println!("{}", answer.choices[0].message.text());

    if let Some(uso) = &answer.usage {
        println!("\ninput: {} · output: {}", uso.prompt_tokens, uso.completion_tokens);
    }

    Ok(())
}

The hinow/ prefix is part of the model name

Passing himax instead of hinow/himax returns 404 model_not_found, and the message does not make it clear that the prefix is missing. This applies to all: hinow/himax, hinow/hinova, hinow/higenesis.

Read the response via text()

content is a serde_json::Value because a message can carry text or a list of parts. text() handles both cases and returns a String.

Showing the response as it arrives

create_stream returns a ChatStream, and next().await? delivers one chunk at a time. In a chat interface, this changes the perception of speed more than switching models does.

streaming.rsrust
use hinow_ai::{ChatCompletionRequest, Hinow, Message};
use std::io::Write;

#[tokio::main]
async fn main() -> Result<(), hinow_ai::Error> {
    let client = Hinow::from_env()?;

    let mut stream = client
        .chat()
        .completions()
        .create_stream(
            ChatCompletionRequest::new("hinow/hinova")
                .add_message(Message::user("Write three lines about the sea at dawn.")),
        )
        .await?;

    while let Some(chunk) = stream.next().await? {
        // Note the delta: each chunk carries the new fragment, not the whole answer.
        if let Some(escolha) = chunk.choices.first() {
            print!("{}", escolha.delta.content.as_deref().unwrap_or(""));
            let _ = std::io::stdout().flush();
        }
    }

    println!();
    Ok(())
}

It is delta, not message

In the complete response, the text comes in message.text(). In streaming, each chunk brings only the new snippet, in delta.content — putting it all together is up to you.

Responds instantly, without a job to track, at $0.005 per call. The nine types are in search_type, and an invalid type is rejected before the request leaves.

busca_web.rsrust
use hinow_ai::{Hinow, SearchRequest};

#[tokio::main]
async fn main() -> Result<(), hinow_ai::Error> {
    let client = Hinow::from_env()?;

    // Answers on the spot: there is no job to follow.
    let search = client
        .tools()
        .search(
            SearchRequest::new("best beaches in northeast Brazil")
                .country("br")
                .lang("pt-br"),
        )
        .await?;

    for item in &search.results {
        println!("{}. {}", item.position, item.title.clone().unwrap_or_default());
        println!("   {}", item.url.clone().unwrap_or_default());
    }

    println!("\n{} results · US$ {}", search.results.len(), search.cost);
    Ok(())
}
TypeWhat is populated in each result
search_type::SEARCH, SCHOLAR, PATENTSposition, title, url, snippet
search_type::NEWSthe above plus source, date, image_url
search_type::IMAGESlink, image_url, thumbnail_url, width, height
search_type::VIDEOSchannel, duration, date, thumbnail_url
search_type::PLACESaddress, category, phone, website, rating, coordinates
search_type::SHOPPINGprice, delivery, rating, source
search_type::AUTOCOMPLETEsuggestions; results remains empty

Website contacts

Scans one or more sites for emails, phone numbers, and social profiles, and each finding comes with the exact page where it appeared. Since scanning takes time, this runs as a job.

contatos_site.rsrust
use hinow_ai::{Hinow, WebsiteContactsRequest};

#[tokio::main]
async fn main() -> Result<(), hinow_ai::Error> {
    let client = Hinow::from_env()?;

    // The crawl takes a while, so it runs as a job. This method waits for you.
    let job = client
        .tools()
        .website_contacts_and_wait(
            WebsiteContactsRequest::new(vec!["https://www.teclia.com".to_string()])
                .max_depth(2)
                .max_links_per_page(10),
        )
        .await?;

    let cache = if job.cached == Some(true) { " (from cache)" } else { "" };
    println!("status: {} · US$ {}{}\n", job.status, job.cost.unwrap_or(0.0), cache);

    // Each item carries the type, the value and the exact page it was found on.
    if let Some(resultado) = &job.result {
        for item in &resultado.items {
            match item.item_type.as_str() {
                "email" | "phone" => {
                    println!("{:<9}{}", item.item_type, item.value);
                    println!("{:<9}em {}", "", item.source_url);
                }
                _ => println!("{:<9}{}", item.platform.clone().unwrap_or_default(),
                              item.url.clone().unwrap_or_default()),
            }
        }
    }

    Ok(())
}

Running the same crawl twice is not charged again

The job returns with cached set to true when the result came from the cache. If you want to control the cycle yourself, use website_contacts and track it with tools().jobs().retrieve(&job.job_id) — note that the field is job_id, not id.

Validate what comes back before using it

The scan returns what it found in the page HTML, as it is. Truncated or repeated addresses appear — treat the list as raw material and validate before saving to your database.

Agents

Assistants, threads, and runs in the same format as the OpenAI API. You declare the functions, and the model decides when to call them: the run pauses, you execute, and return the result.

agente.rsrust
use hinow_ai::{AssistantRequest, Hinow, Tool, ToolOutput};
use serde_json::json;

// Your real function: a match here, just for the example.
fn get_order(code: &str) -> serde_json::Value {
    match code {
        "A-1001" => json!({ "status": "delivered", "delivered": "2026-08-02" }),
        "A-1002" => json!({ "status": "in transit", "estimated": "2026-08-12" }),
        _ => json!({ "error": "order not found" }),
    }
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let client = Hinow::from_env()?;

    // 1. The assistant: model, instructions and the functions it may call.
    let assistant = client
        .beta()
        .assistants()
        .create(
            AssistantRequest::new("hinow/himax")
                .name("Support")
                .instructions(
                    "You answer questions about orders. \
                     Check the tool before stating any status.",
                )
                .tools(vec![Tool::function(
                    "get_order",
                    "Look up an order by its code.",
                    json!({
                        "type": "object",
                        "properties": {
                            "code": { "type": "string", "description": "Order code, e.g. A-1001" }
                        },
                        "required": ["code"]
                    }),
                )]),
        )
        .await?;

    // 2. The conversation and the question.
    let thread = client.beta().threads().create().await?;
    client
        .beta()
        .threads()
        .messages()
        .create(&thread.id, "Has order A-1002 arrived?", "user")
        .await?;

    // 3. Run it and wait.
    let mut run = client
        .beta()
        .threads()
        .runs()
        .create_and_poll(&thread.id, &assistant.id)
        .await?;

    // 4. While the model asks for functions, run them and hand the result back.
    while run.status == "requires_action" {
        let calls = run
            .required_action
            .as_ref()
            .and_then(|acao| acao.submit_tool_outputs.as_ref())
            .map(|s| s.tool_calls.clone())
            .unwrap_or_default();

        let mut outputs = Vec::new();
        for call in calls {
            let args: serde_json::Value = serde_json::from_str(&call.function.arguments)?;
            let code = args["code"].as_str().unwrap_or_default();
            println!("-> the model called {}({})", call.function.name, code);

            outputs.push(ToolOutput::new(
                &call.id,
                &get_order(code).to_string(),
            ));
        }

        client
            .beta()
            .threads()
            .runs()
            .submit_tool_outputs(&thread.id, &run.id, outputs)
            .await?;

        run = client.beta().threads().runs().poll(&thread.id, &run.id).await?;
    }

    // 5. The final answer is the last message on the thread.
    let messages = client
        .beta()
        .threads()
        .messages()
        .list(&thread.id, Some(1), Some("desc"))
        .await?;
    println!("\n{}", messages.data[0].text());

    client.beta().assistants().delete(&assistant.id).await?;
    Ok(())
}

requires_action is not an error

It is the run handing control back to you to execute a function. This is why poll returns in this state instead of continuing to spin: read the required_action, call submit_tool_outputs, and resume polling.

Upload files, group them into a vector store, and search by meaning. Indexing is asynchronous — searching before it finishes returns zero results, without error, which is exactly what poll is for.

conhecimento.rsrust
use hinow_ai::{Hinow, RagSearchRequest, VectorStoreRequest};

#[tokio::main]
async fn main() -> Result<(), hinow_ai::Error> {
    let client = Hinow::from_env()?;

    // 1. Upload the document.
    let file = client
        .files()
        .create_from_path("returns-policy.txt", "assistants")
        .await?;
    println!("file: {} ({} bytes)", file.id, file.bytes);

    // 2. Create the store and attach the file to it.
    let base = client
        .vector_stores()
        .create(VectorStoreRequest::new("Support base"))
        .await?;
    client.vector_stores().files().create(&base.id, &file.id).await?;
    println!("store: {}", base.id);

    // 3. Indexing is asynchronous. Searching before it finishes returns nothing
    //    at all, with no error, so poll waits for it.
    let state = client.vector_stores().files().poll(&base.id, &file.id).await?;
    println!("indexing: {}\n", state.status);

    // 4. Search by meaning, not by exact word.
    let hits = client
        .rag()
        .search(
            RagSearchRequest::new("How many days do I have to return an item?")
                .rag_id(&base.id)
                .top_k(3),
        )
        .await?;

    for achado in &hits.results {
        let excerpt: String = achado.text.replace('\n', " ").chars().take(110).collect();
        println!("{:.2}  {}", achado.score, achado.source.clone().unwrap_or_default());
        println!("      {}…", excerpt);
    }

    Ok(())
}

The filter for vector stores is called rag_id

The API ignores vector_store_id in this endpoint: the search simply scans all documents in the account instead of the vector store you intended. This is the kind of detail that makes it seem like the search is returning garbage.

Errors

Error is an enum, so match covers every failure type and the compiler warns you if you miss a case. e.status() returns the HTTP code when the failure came from the API.

erros.rsrust
use hinow_ai::{ChatCompletionRequest, Error, Hinow, Message};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let client = Hinow::from_env()?;

    let resultado = client
        .chat()
        .completions()
        .create(
            ChatCompletionRequest::new("himax") // the hinow/ prefix is missing
                .add_message(Message::user("Olá")),
        )
        .await;

    match resultado {
        Ok(answer) => println!("{}", answer.choices[0].message.text()),
        Err(Error::Authentication { .. }) => {
            println!("Invalid or expired key. Check HINOW_API_KEY.")
        }
        Err(Error::NotFound { message, .. }) => println!("Not found: {}", message),
        Err(Error::InvalidRequest { message, .. }) => println!("Invalid request: {}", message),
        Err(Error::RateLimit { .. }) => {
            println!("Rate limited. The SDK already retried; wait a moment.")
        }
        // Nothing reached the API, so nothing was charged.
        Err(Error::Connection(e)) => println!("Sem answer da API: {}", e),
        // Safety net: any other failure.
        Err(e) => println!("Error {:?}: {}", e.status(), e),
    }

    Ok(())
}
VariantWhen it occurs
Error::Authentication401 — missing, invalid, or revoked key
Error::Permission403 — the key lacks access to this resource
Error::NotFound404 — non-existent model, file, or assistant
Error::InvalidRequest400 or 422 — missing a field or a value is out of range
Error::RateLimit429 — limit reached or balance exhausted
Error::Server5xx — the failure is on the API side
Error::Connectionthe request did not arrive; nothing was charged
Error::Timeouta job or run did not complete within the given time

Balance and catalog

modelos.rsrust
use hinow_ai::Hinow;

#[tokio::main]
async fn main() -> Result<(), hinow_ai::Error> {
    let client = Hinow::from_env()?;

    // Account credit, in US dollars.
    let balance = client.get_balance().await?;
    println!("balance: US$ {:.2}\n", balance.balance);

    // One specific model. The id is namespaced: hinow/himax, not himax.
    let model = client.models().retrieve("hinow/himax").await?;
    println!("{} ({})", model.name.clone().unwrap_or_default(), model.id);
    println!("categories: {}", model.category.join(", "));

    if let Some(cost) = &model.cost {
        println!(
            "price per million tokens: input US$ {} · output US$ {}",
            cost.input.unwrap_or(0.0),
            cost.output.unwrap_or(0.0)
        );
    }

    Ok(())
}

Everything the client exposes

MethodPurpose
chat()Chat, streaming, function calling, JSON mode
embeddings()Vectors for semantic search
images() · audio() · video()Generation
models()Catalog, pricing, and capabilities of each model
tools()Web search and site contacts
files()Document upload
vector_stores()Searchable knowledge bases
rag()Semantic search on your documents
beta()Server-executed agents
get_balance()Account balance

Configuration

cliente.rsrust
use hinow_ai::Hinow;
use std::time::Duration;

let client = Hinow::builder(&std::env::var("HINOW_API_KEY")?)
    .base_url("https://api.hinow.ai")  // ou HINOW_BASE_URL
    .timeout(Duration::from_secs(120))
    .max_retries(2)                    // repete 429 e 5xx
    .build()?;

Coming from version 1.x

Up to version 1.0.1, ChatCompletionRequest wrapped temperature, max_tokens, and top_p in a HashMap<String, String> called parameters, converting all numbers to strings. The API accepts this format and ignores it, so requesting max_tokens(10) returned the entire response. Now they are typed fields sent at the root level.

Other changes affecting existing code:

  • get_balance returns Balance directly, without the {success, data} wrapper. BalanceResponse became an alias, so the old name continues to compile.
  • Error gained a variant per failure type; Error::Api still exists for cases that don't fit.
  • ModelInfo now includes name, category, and price, which the API already sent but the SDK discarded.
  • content_as_string() continues to work, and text() is the short name that also concatenates content across parts.

Choosing between HiMax, HiNova, and HiGenesis

What each model does well, its cost, and how to write the prompt for each.

Was this page helpful?