navree
Documentation

AI SDK

One Ai service for Anthropic, OpenAI, Google Gemini, Mistral, Ollama and AWS Bedrock: text, typed structured output, streaming over SSE or real time, failover between models, token budgets and AiFake for tests.

On this page

Introduction

Featureai navree-ai puts language models behind one service. You name your models in configuration (smart, fast, embed), inject Arc<Ai>, and ask for what you need:

Rust

The provider's HTTP API, authentication, retries, failover to another model, token limits, usage metrics and error handling are the SDK's job. Your code never imports a vendor client, and moving a feature from one provider to another is a one-line configuration change.

You wantUsePage
Text: summaries, drafts, rewritesai.text(prompt)Generating text
A typed value: extraction, classificationai.structured::<T>(prompt)Structured output
Words on screen as they are generatedai.stream(prompt)Streaming
A model that calls your code to finish a taskAgent and #[tool]Agents and tools
Answers grounded in your own dataEmbeddings, VectorStoreEmbeddings and RAG
AI clients that call your appMcpModuleMCP servers

The examples on this page add AI features to the reference CRM, on top of its own services: a summary of a deal's activity, a follow-up email drafted for the rep, and both streamed to the browser.

Installation

Turn on the ai feature of the navree facade. It is opt-in: full doesn't include it.

Cargo.toml

The feature brings navree-ai and every provider, all pure Rust. Providers speak HTTP through navree-http-client (rustls, timeouts and its SSRF guard), so there is nothing else to install. The guard refuses private and internal addresses except the hosts you configure yourself: every ai.providers.*.url, ai.vectors.url, and 127.0.0.1 when a model uses Ollama at its default address. Add AiModule to your app root:

src/app.rs

AiModule is global: every module injects Arc<Ai>, Arc<Embeddings>, Arc<VectorStore> and Arc<Conversations> without importing it. It needs only ConfigModule. DatabaseModule is needed for conversation memory and the pgvector store, and CacheModule makes budgets and the embeddings cache shared between processes; without it both live in the process's memory. Agents also need AuthModule (the user they act for) and AccessModule (that user's abilities).

The module also contributes the ai migration set (ai_conversations and ai_messages), the ai:prune command, and the mapping of an AiError returned from a handler to its status.

Configuration

Configuration names your providers (accounts with a vendor) and your models (a provider plus a model id, under a name your code uses). Code asks for fast; configuration decides that fast is Claude Sonnet on Anthropic today. A provider named after its driver (anthropic, openai, gemini, mistral, ollama, bedrock) needs no table of its own when the defaults and the environment suit you.

config/app.toml

Keys are secrets, so they belong in .env or the environment, never in config/app.toml:

.env

A provider table needs no other key when the defaults suit you, so the two lines above are the whole provider configuration for this example. Every value maps to an environment variable the usual way (APP_AI__DEFAULT=fast, APP_AI__MODELS__FAST__MODEL=claude-sonnet-5-5), and fallbacks also accepts a comma-separated string (APP_AI__MODELS__SMART__FALLBACKS=fast,backup).

The boot fails, naming the key, when a model names an unknown provider, a fallback names an unknown model, the fallbacks form a cycle, or default names no model. A missing API key doesn't fail the boot: requests to that provider fail with AiError::Config, and the log warns once at startup. Config::redacted() and the logs mask every api_key, secret_access_key and key.

Top-level keys

KeyDefaultMeaning
ai.defaultthe first model by nameThe model a request uses when it names none.
ai.timeout_secs60The limit of one attempt. For streams, the longest gap between two events.
ai.max_retries2Retries of one model on a 429, a 5xx, a timeout or a connection failure, before its fallbacks.
ai.retry_base_ms500The delay before the first retry; each later one doubles, up to 30 seconds.
ai.structured_retries2How often structured output that doesn't match its type is sent back for repair.
ai.budget.daily_tokens_per_user0 (off)Tokens one user may spend per UTC day. See budgets.
ai.log_contentfalseLog prompts and outputs at debug. Leave it off in production.
ai.fakefalseAnswer every request from an AiFake. For tests.

The ai.embeddings and ai.vectors sections are described in Embeddings and RAG, and ai.memory in Agents and tools.

Providers

The table's key is the provider's name. Set the keys you need; the rest have defaults.

ProviderNameKeysText, tools, streamingEmbeddings
Anthropicanthropicapi_key, urlYes, with images and PDFsNo
OpenAIopenaiapi_key, url, organizationYes, with imagesYes
Google Geminigeminiapi_key, urlYes, with images and PDFsYes
Mistralmistralapi_key, urlYesYes
Ollamaollamaurl (default http://127.0.0.1:11434)Yes, for models that support toolsYes
AWS Bedrockbedrockregion, access_key_id, secret_access_key, session_token, profileYes, through the Converse API, with images and PDFsAmazon Titan and Cohere models
  • url replaces the vendor's API address, for a proxy or a gateway. With the openai driver it points at any server that speaks the OpenAI chat completions API.
  • Ollama reads images too. Mistral takes text only.
  • Bedrock requests are signed with AWS Signature Version 4, computed by Navree without the AWS SDK. Without region, the driver reads AWS_REGION, then AWS_DEFAULT_REGION. Without access_key_id, it reads AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY and AWS_SESSION_TOKEN, then the profile profile (else AWS_PROFILE, else default) of the shared credentials file (AWS_SHARED_CREDENTIALS_FILE, else ~/.aws/credentials), then the role of the platform: the container credentials endpoint of an ECS task role or EKS Pod Identity (AWS_CONTAINER_CREDENTIALS_RELATIVE_URI or AWS_CONTAINER_CREDENTIALS_FULL_URI, with AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE or AWS_CONTAINER_AUTHORIZATION_TOKEN), else the EC2 instance profile through the instance metadata service (IMDSv2; AWS_EC2_METADATA_DISABLED=true turns it off). Role credentials are cached and refreshed five minutes before they expire. Web identity tokens (EKS IAM roles for service accounts) aren't read.
  • Ollama needs no key and sends nothing off the machine. Run it next to your app and name a model you have pulled (ollama pull llama3.3).
  • Anthropic requests use API version 2026-10-21, which has no sampling parameters, so the anthropic driver never sends a temperature.

To use two accounts of one vendor, or an OpenAI-compatible server next to OpenAI itself, give the second table a name of its own and say which driver it uses:

config/app.toml

Models

KeyDefaultMeaning
providerrequiredA provider name from ai.providers, or a built-in one.
modelrequiredThe vendor's model id, sent as it is.
max_tokens4096The output limit of a request that sets none.
temperaturethe vendor'sSampling temperature, from 0.0. Not sent to Anthropic.
fallbacks[]Models tried in order when this one is unavailable.
timeout_secsai.timeout_secsThis model's attempt limit.
dimensionsthe model'sFor embedding models: the vector size.

Name models after their job, not their vendor: smart, fast, vision, embed. Upgrading to a new model is then one line in config/app.toml, and every feature that uses fast moves together.

Configuring in code

AiModule::for_root takes the options directly, without reading configuration; invalid options fail the boot the same way:

Rust

Generating text

ai.text(prompt) builds a request. Awaiting it sends the request and returns a TextResponse; a failure is an AiError, which ? turns into an AppError in handlers.

The CRM summarizes a deal's activity for whoever opens the deal. The service reads the timeline through ActivityService, with the reader's own access, so the summary never mentions an activity the reader couldn't open:

crates/crm-deals/src/insights.rs

The route authorizes the deal like any other read, then asks the service:

crates/crm-deals/src/controller.rs
HTTP
JSON

Request options

Every request builder (text, structured, stream) takes the same options:

MethodDefaultWhat it does
.system(text)noneThe system prompt: who the model is and the rules it follows.
.model(name)ai.defaultA model name from ai.models.
.max_tokens(n)the model's max_tokensThe output limit of this request.
.temperature(t)the model'sLower is more predictable.
.stop([..])noneStop sequences.
.attach(attachment)noneAn image or a document; see below.
.user(id)noneThe user the request is for (a Uuid or an Option<Uuid>): counts against their budget and labels the log line.
.timeout(duration)the model'sThe attempt limit of this request.

Conversations

A prompt is a string or a list of messages. Pass the earlier turns to continue a conversation yourself:

Rust

Agents store conversations for you, per user, in your database.

The response

FieldType
textStringThe generated text. TextResponse also implements Display.
modelStringThe model name that answered, after any failover (fast, backup).
providerStringIts provider.
usageUsageinput_tokens, output_tokens, cached_input_tokens.
finishFinishStop, MaxTokens (cut off by the limit) or Refused (the model declined). Agents also see ToolCalls.

Check finish when a cut-off answer would mislead: Finish::MaxTokens means the text ends mid-thought.

Images and documents

Attach an image or a PDF to any request. The CRM reads the terms out of a contract a rep uploads:

Rust

Attachment::image(bytes, "image/png") and Attachment::pdf(bytes) build one from bytes; Attachment::from_disk reads a file from storage and takes its type from the content (its magic bytes). Attachments go first in the last user message. Failover skips models whose provider can't read the attachment's type, and when no model in the chain can, the request fails with AiError::Unsupported before anything is sent.

Structured output

Free text is for people. When code consumes the answer, ask for a type. ai.structured::<T>(prompt) returns a T:

crates/crm-deals/src/insights.rs

How it works:

  1. The JSON Schema comes from utoipa's ToSchema, the same derive your OpenAPI document uses, with the types it refers to under $defs. Doc comments become field descriptions, and the model reads them, so write them for the model: units, formats, what to leave out. An app without its own utoipa dependency imports the derive as use navree::ai::utoipa::{self, ToSchema};.
  2. Navree asks the provider for JSON that matches the schema with the provider's own structured-output mode (Anthropic, OpenAI, Gemini, Mistral and Ollama have one). Bedrock gets the schema in the system prompt instead. The answer is read as JSON (code fences and surrounding prose are tolerated) and deserialized into T.
  3. With .validated() (available when T derives garde::Validate), the value is also checked against its rules, the same rules as a validated request.
  4. When the answer doesn't deserialize or breaks a rule, the errors go back to the model with a request to correct it, up to ai.structured_retries times. After that the request fails with AiError::InvalidOutput.

T needs Deserialize and ToSchema. A refusal fails a structured request with AiError::Refused. Enums work as they do in your API, so classification is a type; a T that isn't an object (an enum, a list) is asked for as {"value": ...} and unwrapped, since providers want an object at the top:

Rust

Streaming

A summary that takes eight seconds to generate feels slow; the same summary appearing word by word from the first half second doesn't. ai.stream(prompt) takes the same options as ai.text and returns a TextStream as soon as the provider starts answering. It is a Stream of StreamEvents:

EventWhen
StreamEvent::Text(String)A piece of the answer.
StreamEvent::Done(Completion)Last: the model, provider, usage and finish of the whole answer.

Agents stream three more kinds, ToolCall, ToolResult and Approval; see streaming agents. StreamEvent is #[non_exhaustive], so a match on it ends with _ => ....

To the browser over SSE

A TextStream is a response. Return it from a handler and the browser receives server-sent events:

crates/crm-deals/src/controller.rs
Response body (text/event-stream)
  • Errors before the first event (a bad model name, every provider down) are ordinary error responses, because ai.stream(..).await? fails before the handler returns.
  • An error after the first event arrives as event: error with Navree's error body, {"statusCode": 503, "message": "The AI provider is unavailable", "error": "Service Unavailable", "code": "service_unavailable"}, and ends the stream.
  • The response sets Cache-Control: no-cache and X-Accel-Buffering: no (so nginx passes events through at once) and sends a keep-alive comment every 15 seconds.
  • Once the handler has returned the stream, http.timeout_secs no longer applies; ai.timeout_secs (or the model's timeout_secs, or the request's .timeout(..)) limits the gap between events instead, and a stall ends the stream with an error event.
  • When the client disconnects, Navree stops reading from the provider, which ends the generation and its cost.
  • The tokens count against the user's budget and the metrics when the done event is produced.

In the browser, EventSource connects to the route. It sends cookies but no Authorization header, so it suits the backend-for-frontend setup, where the BFF holds the tokens. With a bearer token, read the stream with fetch:

web/lib/stream.ts

Saving what was streamed

on_finish runs once the last event is sent, with the whole answer as a TextResponse. Use it to store what the user saw:

Rust

It runs on its own task once the stream has ended after its done event. It doesn't run when the client disconnects first, because the generation stopped, nor after an error. Errors it returns are logged at warn, since the response has already been sent.

Over real time

A stream tied to one request ends with the request. For work that should outlive it, or reach every tab the user has open, generate in a queued job and send the pieces over real-time broadcasting to the user's private channel:

crates/crm-deals/src/jobs/draft.rs

The handler dispatches the job and answers 202 Accepted with the draft_id; the page listens on the user's channel:

TypeScript

A worker doesn't hold the WebSocket connections, so it hands its broadcasts to the serving process through realtime.relay_url (see broadcasting from workers). chunked(window) merges the text that arrives within each window into one event, which keeps the broadcast rate steady whatever the model's speed.

Failover and retries

Providers have bad minutes. A request moves through its model's attempts, then its fallbacks, before it gives up:

  1. The model is tried, and retried up to ai.max_retries times after a 429, a 5xx, a timeout or a connection failure. Retries back off exponentially from ai.retry_base_ms (500 ms) and wait at least the provider's retry-after; a wait longer than the model's attempt limit ends that model's retries at once.
  2. When the retries are used up, the first model in fallbacks gets the request, with its own retries, then the next.
  3. When every model has failed, the request fails with AiError::Unavailable (or RateLimited when the last failure was a 429).

Failover happens only when trying again elsewhere can help. A request the provider rejects as invalid (a prompt longer than the context window, an unreadable image) fails at once with AiError::Request; a rejected API key fails with AiError::Config and is logged at error; a refusal is an answer, not a failure. A stream fails over only before its first event.

The response says who answered (response.model, response.provider), and the request's log line names every model it tried, so a dashboard of outcome="failover" shows a provider having a bad day before your users notice.

Token limits and budgets

Three limits keep spending predictable:

  • Per request: .max_tokens(n), or the model's max_tokens (default 4096), caps the output.
  • Per user: with ai.budget.daily_tokens_per_user set, requests marked .user(id) count their input and output tokens against that user's allowance for the UTC day (by the app's dyn Clock). A request that starts when the allowance is spent fails with AiError::BudgetExceeded, a 429 whose Retry-After points at midnight UTC; a request that starts under it finishes, so a day can end slightly over. Counts live in the app's cache, under ai:budget:<user>:<date>; use a shared cache store when several processes serve the app. ai.tokens_used_today(user_id) reads the count, for a usage meter.
  • Per agent run: agents stop after max_steps model calls; see agent options.

Usage is measured on every request, through the metrics crate, so the /metrics endpoint of ObserveModule gets:

MetricLabels
ai_requests_totalmodel, provider, outcome (ok, failover, error)
ai_tokens_totalmodel, provider, type (input, output, cached_input)
ai_request_duration_secondsmodel, provider

Each request also writes one log line at target navree::ai with the model, provider, user_id, token counts, duration_ms, outcome, the models it tried and the request id. Prompts and outputs are never logged unless ai.log_content is on, and then at debug.

Errors

Every method returns Result<_, AiError>. In handlers ? converts it into an AppError with Navree's error shape:

AiErrorWhenStatus
Unavailable5xx, timeouts and connection failures outlasted the retries and fallbacks503, "The AI provider is unavailable"
RateLimitedThe provider's rate limit outlasted the retries and fallbacks429, with Retry-After
BudgetExceededThe user's daily tokens are spent429, with Retry-After
InvalidOutputStructured output still didn't match its type after the repairs502
RefusedThe model declined, for structured output (text requests return Finish::Refused instead)502
RequestThe provider rejected the request as invalid500, logged with the provider's message
UnsupportedNo model in the chain supports an attachment or a feature500
ConfigAn unknown model, a provider without credentials, or a rejected key500, logged at error
NotFoundAn agent conversation that isn't the user's, or an unknown tool call to approve404
UnauthenticatedAn agent run without a signed-in user401
NoFakeReplyAiFake has no reply for a request500
InternalAnything else: a database error in conversation memory, a vector store failure500

error.status() gives the status and error.to_app_error() the response. AiError::unavailable(provider), rate_limited(provider, retry_after), request(provider, message), config(message), not_found(message) and internal(message) build one, for fakes and tests.

503 Service Unavailable

The provider's own error text never reaches the client; it is in the log. When an AI feature is optional, handle the error and carry on:

Rust

Testing with AiFake

AiFake answers requests without a network or an API key and records every request, so tests are fast, free and the same every time.

Installing the fake

Set ai.fake and take the fake from the app. This works with any test builder, including the CRM's TestCrm:

apps/crm-api/tests/deal_summary.rs

In a TestApp you build yourself, you can also install a fake you created: .override_value(Arc::new(fake.ai())). Such an Ai isn't tied to the app, so it serves text, structured, stream, embeddings and reranking; agents need the app's own Ai, so test them with ai.fake. Outside an app, AiFake::new().ai() is a working Ai on its own, and fake.ai_with(options) takes options (max_retries, ai.memory, ...).

Scripting replies

The fake answers from a queue of replies, in order:

Rust
ReplyAnswers with
FakeReply::text(s)Text. Streams split it into words.
FakeReply::json(value)A structured value, checked against T and its rules like a real answer. Invalid JSON triggers the repair loop, so you can test it.
FakeReply::tool_call(name, args)A tool call, for agents.
FakeReply::refusal()A refusal: Finish::Refused for text, AiError::Refused for structured output.
FakeReply::error(e)An AiError, which goes through retries and failover like a real one.
.with_usage(input, output)Any of the above, reporting these token counts (otherwise about four characters per token).

ai.respond_when(predicate, reply) answers matching requests with reply every time, ahead of the queue, for tests where the order of calls doesn't matter. A request that nothing answers fails with AiError::NoFakeReply, naming the prompt, so a test never passes by accident.

A transient error (unavailable, rate_limited) is retried ai.max_retries times and then fails over, as in production, but without the waits; each retry takes the next reply. To see the error itself, set ai.max_retries to 0 or queue errors for every attempt.

Assertions

AssertionChecks
assert_prompted(predicate)Some recorded request matches.
assert_not_prompted(predicate)No recorded request matches.
assert_prompt_count(n)Exactly n requests were made.
assert_nothing_prompted()No request was made.
assert_tool_called(name), assert_tool_not_called(name)An agent did or didn't call the tool.

ai.requests() returns the recorded requests, each a FakeRequest with model() (the name from ai.models, any name works with the fake), system(), messages(), last_user_message(), tools() (the tool names offered), max_tokens(), user() and is_structured(). ai.tool_results(name) returns what an agent's tool returned to the model, and ai.embedded() the texts embedded so far. The fake's embeddings are deterministic too.

Reference

The Ai service

MethodReturns (awaited)
text(prompt)TextResponse
structured::<T>(prompt)T (.validated() adds its garde rules)
stream(prompt)TextStream: a Stream of StreamEvent, and a response
agent::<A>()An agent run builder; see Agents and tools
rerank(query, matches)The matches re-ordered; see reranking
fake()Option<AiFake>: the fake when ai.fake is on
tokens_used_today(user_id)u64: the user's tokens this UTC day
options()&AiOptions

Ai::new(options) builds one outside an app (budgets and the embeddings cache then live in its memory).

Types

Type
AiThe service. Inject Arc<Ai>.
AiOptions, ProviderConfig, ModelConfigThe ai section, for AiModule::for_root. ProviderConfig::anthropic(key), openai(key), openai_compatible(url), gemini(key), mistral(key), ollama(url), bedrock(region); ModelConfig::new(provider, model) with .fallbacks([..]), .max_tokens(n), .dimensions(n).
BudgetOptions, EmbeddingsOptions, VectorsOptions, MemoryOptionsThe ai.budget, ai.embeddings, ai.vectors and ai.memory tables.
TextRequest, StructuredRequest<T>, StreamRequestThe request builders text, structured and stream return; await them to send.
Message, Role, Part, PromptMessage::user, Message::assistant, for prompts with history. A prompt is anything that converts into Prompt: a string or a Vec<Message>.
Attachmentimage, pdf, from_disk.
TextResponse, Usage, FinishA text answer and its accounting.
TextStream, StreamEvent, CompletionStreams. TextStream has chunked(window) and on_finish(f).
AiErrorEvery failure; converts into AppError.
AiFake, FakeReply, FakeRequestThe test fake.
json_schema::<T>()The JSON Schema Navree sends for a ToSchema type.