rtb-chat 0.7.3

Unified multi-provider AI chat client for Claude, OpenAI, Gemini, and OpenAI-compatible endpoints. Part of the phpboyscout Rust toolkit.
Documentation
# AGENTS.md

This file provides guidance to AI coding agents (Claude Code, agy, codex, etc.) when working with code in this repository.

## What this is

`rtb-chat` is a chat client for six providers behind one `AiClient`: you give it a request,
it returns a reply. It is one of the small, framework-free crates extracted from
[rust-tool-base](https://gitlab.com/phpboyscout/rust-tool-base), and that decomposition is
still in progress, so the split between monorepo and extracted crates moves. It was `rtb-ai`
until the Wave-4 extraction, so the diagnostic codes still read `rtb::ai::*` and `rtb-docs`'s
feature is still `ai` — grep for both names, or you will miss things.

What it deliberately is not: no tool use, no function calling, no agent loop, no
conversation state, no retry or backoff. The `messages` vector you pass is the whole
history and keeping it is the caller's job. `docs/explanation/03-limitations.md` is the
maintained list of what is a deliberate omission and what is a defect — read it before
concluding something is broken, and keep it current in the same change.

The authoritative contract is the wiki page `specs/0016-rtb-ai-v0.1` in rust-tool-base,
where the `T#` criteria in `tests/unit.rs` are defined. Both `src/lib.rs:16` and
`tests/unit.rs:4` cite it instead as `docs/development/specs/2026-05-01-rtb-ai-v0.1.md`,
a path that exists in no repo.

## The two-backend split is what newcomers get wrong

`Provider` has six variants and they do not share a code path. `Provider::is_anthropic`
(`src/config.rs:39`) is the switch: `Anthropic` and `AnthropicLocal` go down this
crate's own `reqwest`-on-Messages implementation, while `OpenAi`, `OpenAiCompatible`,
`Gemini` and `Ollama` go through `genai`.

Only the first path is really ours, and the asymmetry is invisible in the type signatures.
`Config::base_url`, `Config::timeout`, `ChatRequest::temperature` and `ChatRequest::max_tokens`
are each validated and then never used on the genai side; the call succeeds at the provider's
defaults with no error and no warning. `AiError::Auth` and `AiError::RateLimited` come only
from our own status mapping, so a genai failure of any kind arrives as `AiError::Provider`.
Do not add a field to `Config` or `ChatRequest` and assume both paths honour it — wire it on
both, or document which one it lands on.

The genai path also puts the API key in the process environment, because that is how genai
resolves credentials: `AiClient::new` calls `std::env::set_var` (`src/client.rs:287-288`). The
key outlives the client, a second client for the same provider silently repoints the first, and
constructing clients concurrently is undefined behaviour. Anthropic-direct sends a header.

That one call is the crate's only `unsafe` block, and it is why `src/lib.rs:25` declares
`#![deny(unsafe_code)]` rather than `#![forbid(unsafe_code)]`, matched by `unsafe_code = "deny"`
at `Cargo.toml:21`. rust-tool-base's manifest calls `forbid` the production guarantee; that is
not what this crate declares, and tightening it to `forbid` breaks the build.

## What redaction actually covers

`rtb-chat` and `rtb-telemetry` are the only two crates depending on `rtb-redact`. The
`rtb` umbrella does not depend on it directly, so redaction reaches an application
through one of those two or not at all.

"It redacts secrets" is comforting and false, so do not write it. Redaction is applied
at exactly one seam: the `String` payloads of `AiError`, via `error::redact`
(`src/error.rs:64`). **Prompts, message content and model replies never pass through
it** — a `ChatResponse` or `ChatStream` item carries whatever the model returned, so
logging one logs it raw. `AiError::RateLimited` is the one variant whose `String` field
is not redacted, safe only because `host` is a `Url::host_str()` and `validate_base_url`
rejects embedded userinfo (`src/config.rs:110`); the claim at `src/error.rs:10-11` that
every payload is redacted is wider than the code.

What it catches is narrower still. `rtb-redact` is seven regexes, not a parser, and the
rule matching by name keys on `=`. **JSON is not a form it recognises**: measured at both
0.6.1 and 0.6.3, `{"password":"hunter2"}` and `{"client_secret":"..."}` come back
untouched where `client_secret=...` is redacted. A secret in a JSON body is caught only
when its *value* is independently recognisable — an `sk-...` prefix, a JWT, a `Bearer `
scheme, a PEM block, or a 40-plus-character whitespace-bounded run. An error body of
unexpected JSON shape reaches `redact` verbatim, capped at 500 characters
(`src/anthropic.rs:288-295`), which is where this bites.

`Cargo.toml:38` asks for `rtb-redact = "0.6.0"`, a caret requirement any `0.6.x` satisfies, and
`Cargo.lock` resolves `0.6.1` against a published `0.6.3`. That lock governs this repo's CI only
— a dependent application resolves its own — so a redact fix reaches these tests once it moves.

## Where this sits in the toolkit

Three crates depend on this one, and inter-repo dependencies resolve by published crates.io
version rather than by path, so a change here reaches none of them until it is released:

- `rtb-cli-bin` — required (`crates/rtb-cli-bin/Cargo.toml:87` in rust-tool-base).
- `rtb` — optional behind the `chat` feature, which is **not** in `default`
  (`crates/rtb/Cargo.toml:29,59`); it is reached through `full`.
- `rtb-docs` — optional behind its own `ai` feature, and it lives in the four-crate
  workspace in `rust/cli` rather than a repo of its own, so a repo-level search misses it.

This crate carries its own `docs/`, the Rust toolkit's pattern, unlike the Go toolkit
where a family's docs live with its core. Releases run through `release-plz`.

## The quality gate

`just ci` runs `fmt-check`, `lint`, `docs`, `test` and `audit`. Run it before raising a merge
request, so CI confirms rather than discovers. Coverage is gated at a measured floor of 65%, not
the house 90%, because `src/client.rs` sits near 35% (`.gitlab-ci.yml:40-45`) — tracked work.

Only `feat` and `fix` cut a release, so a change that repoints or removes a public interface
is `feat`, not `refactor`, or it lands and never ships. Never cut a release yourself.

## Which skills apply here

> **The estate has no Rust-specific skills yet.** The table below is the
> language-agnostic set only, and is worth revisiting once that gap is filled.

| When | Skill |
|---|---|
| Starting a feature, a component, or any non-trivial change | `spec-driven-development` |
| Working in a repo other than the one you were invoked in | `cross-repo-worktree` |
| Writing or restructuring this crate's documentation | `diataxis-docs` |
| Writing anything others will read and check | `checkable-claims` |
| Concluding anything from a checkout, a lockfile or a green tick | `verify-dont-trust` |
| Deciding a change is finished | `definition-of-done` |
| Writing a commit message or a merge request description | `conventional-commits`, `pre-1-0-release-safety` |
| Committing, branching, merging, or opening a merge request | `forge-publish-workflow` |
| A fix belonging in `rtb-redact` or rust-tool-base rather than here | `raise-a-forge-issue` |
| Releasing several crates in this toolkit together | `release-train` |

> Skills are a Claude Code mechanism, from the [phpboyscout marketplace]https://gitlab.com/phpboyscout/claude-code-plugins.
> An agent without them should treat a named skill as a topic to ask about.