rtb-chat 0.7.4

Unified multi-provider AI chat client for Claude, OpenAI, Gemini, and OpenAI-compatible endpoints. Part of the phpboyscout Rust toolkit.
Documentation
---
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

| If you want to… | Go to |
|---|---|
| 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`.

| Item | Purpose |
|---|---|
| `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"

    ![phpbotscout](https://phpboyscout.uk/images/projects/logo-phpbotscout.png){ 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 }