rtb-chat¶
A single chat client across providers. genai
covers the mainstream (OpenAI, Gemini, Ollama, OpenAI-compatible); the Anthropic
backend additionally drops down to direct reqwest calls against the Anthropic
Messages API for features genai does not surface — prompt caching, extended
thinking, citations.
Formerly
rtb-ai. Renamed at extraction for GTB-family name convergence — the Go counterpart lives at chat.go.phpboyscout.uk. Only the crate identity changed; the API is unchanged.
Part of the phpboyscout Rust toolkit; extracted from — and battle-tested by — rust-tool-base.
Start here¶
| If you want to… | Go to |
|---|---|
| Get something working from scratch | Your first chat call |
| Solve one task — streaming, structured output, caching, testing | How-to guides |
| Look up a field, a default or an error | Reference |
| Understand why it is built this way | Explanation |
| Know what it will not do | What rtb-chat does not do |
Public API¶
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, 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 — one-shot, streaming, and structured. |
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. Behaviour, defaults and failure modes: Reference.
Structured output¶
chat_structured::<T>() takes any T: serde::Deserialize +
schemars::JsonSchema. The generated JSON Schema is sent with the
request and the response is validated with jsonschema before
deserialising — a malformed model response surfaces as an
AiError, never a partial T.
Recipe and failure modes: Get structured output.
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 — never the
path or query. The reasoning is in Why the endpoint is
validated.
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 and are
resolved through the precedence chain documented in
configure credentials.
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.
Further reading¶
The blog carries a curated route through this subject: Rust, and what survived the port collects everything written about it, ordered so you can start at the beginning rather than newest-first.
Ask phpbotscout

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.