---
title: rtb-chat
description: Unified multi-provider AI chat client, using genai for OpenAI, Gemini and Ollama and calling the Anthropic Messages API directly for Claude.
---
# `rtb-chat`
A single chat client across providers. [`genai`](https://crates.io/crates/genai)
covers the mainstream (OpenAI, Gemini, Ollama, OpenAI-compatible). The two
Anthropic providers bypass it and make direct `reqwest` calls against the Anthropic
Messages API, for the features `genai` does not surface: prompt caching, extended
thinking and citations.
> **Formerly `rtb-ai`.** Renamed at extraction for GTB-family name
> convergence; the Go counterpart lives at
> [chat.go.phpboyscout.uk](https://chat.go.phpboyscout.uk). Only the
> crate identity changed; the API is unchanged.
Part of the [phpboyscout Rust toolkit](https://rust.phpboyscout.uk), the
small, framework-free crates extracted from
[rust-tool-base](https://gitlab.com/phpboyscout/rust-tool-base).
## Start here
| Get something working from scratch | [Your first chat call](tutorials/01-your-first-chat-call.md) |
| Solve one task: streaming, structured output, caching, testing | [How-to guides](how-to/index.md) |
| Look up a field, a default or an error | [Reference](reference/index.md) |
| Understand why it is built this way | [Explanation](explanation/index.md) |
| Know what it will **not** do | [What rtb-chat does not do](explanation/03-limitations.md) |
## Public API
```rust
use rtb_chat::{AiClient, ChatRequest, Config, Message, Provider};
use secrecy::SecretString;
let client = AiClient::new(Config {
provider: Provider::Anthropic,
model: "claude-opus-4-7".into(),
api_key: SecretString::from(std::env::var("ANTHROPIC_API_KEY")?),
..Config::default()
})?;
let reply = client.chat(ChatRequest {
messages: vec![Message::user("Summarise this changelog in one line.")],
max_tokens: Some(512),
cache_control: true,
..ChatRequest::default()
}).await?;
```
`Config` and `ChatRequest` are plain structs with public fields and a
`Default` impl: set what you need and spread the rest. `Provider` is
`#[non_exhaustive]`. `api_key` has no usable default: an empty key is rejected by
`AiClient::new`.
| `AiClient` | `new` / `chat` / `chat_stream` / `chat_structured`. |
| `Config`, `Provider` | Provider selection, model, endpoint, auth. |
| `Message`, `Role`, `ContentBlock`, `Citation`, `Usage` | Request/response message model. |
| `ChatRequest`, `ChatResponse`, `ChatStream`, `ChatStreamEvent` | Call shapes for one-shot, streaming and structured calls. |
| `ThinkingMode` | Extended-thinking control on the Anthropic backend. |
| `AiError` | `thiserror` + `miette::Diagnostic` error enum. |
| `validate_base_url` | Endpoint shape check: scheme, userinfo, placeholder hosts (see below). |
Generated signatures: [docs.rs/rtb-chat](https://docs.rs/rtb-chat). Behaviour,
defaults and failure modes: [Reference](reference/index.md).
## Structured output
`chat_structured::<T>()` takes any `T: serde::de::DeserializeOwned +
schemars::JsonSchema`. The generated JSON Schema is sent with the
request and the response is validated with `jsonschema` *before*
deserialising, so a malformed model response surfaces as an
`AiError`, never a partial `T`.
Recipe and failure modes: [Get structured
output](how-to/03-get-structured-output.md).
## Endpoint safety
Every `Config::base_url` passes `validate_base_url`: non-HTTPS
schemes, URLs carrying userinfo (`user:pass@host`), and placeholder
hosts (`example.com`, `example.org` and their subdomains) are rejected. Tests
against a mock server set `Config::allow_insecure_base_url` to accept `http://`.
`Config` implements neither `Serialize` nor `Deserialize`, so nothing outside your
own Rust code (no config file, no environment variable) can turn that
enforcement off.
Each successful `AiClient::new` logs the endpoint hostname at INFO, and never the
path or query. The reasoning is in [Why the endpoint is
validated](explanation/02-endpoint-validation.md).
## Model defaults
`Config::default()` selects `Provider::Anthropic` with the model
`claude-opus-4-7` and a 60-second timeout. New AI code across the estate defaults
to **Claude 4.7** models with prompt caching at every stable point (system prompt,
tools, static context). Migration mapping: Opus 4.6 → Opus 4.7, Sonnet 4.5 →
Sonnet 4.6, Haiku 4.5 (current).
## Secrets
API keys cross the boundary as `secrecy::SecretString`. `rtb-chat` does not
resolve them itself: the tool layer does, typically through `rtb_credentials` and
the precedence chain documented in
[configure credentials](https://gitlab.com/phpboyscout/rust-tool-base/-/blob/main/docs/how-to/configure-credentials.md).
Never log the exposed form.
On the genai-backed providers the key is written into the **process environment**
so genai can find it. That has consequences worth reading before you build a
second client. See [The genai backend puts your key in the process
environment](explanation/03-limitations.md#the-genai-backend-puts-your-key-in-the-process-environment).
## Further reading
The blog carries a curated route through this subject: **[Rust, and what survived the port](https://phpboyscout.uk/topics/rust/)** collects
everything written about it, ordered so you can start at the beginning rather
than newest-first.
!!! tip "Ask phpbotscout"
{ width="84" align=left style="border-radius:10px;margin-right:1rem" }
He answers questions about the projects over on the Discord, citing the docs
where they already cover it, and offering to raise an issue where they don't.
Bring a bug, an idea, or a questionable engineering decision.
[Join the Discord](https://discord.gg/mQzGbmGyzZ){ .md-button .md-button--primary }