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
---
title: Configuration
description: Every Config field in rtb-chat — type, default, which providers honour it, and the error you get when it is wrong.
tags: [reference, configuration]
---

# Configuration

`Config` is the only input to `AiClient::new`. It is a plain struct with public
fields and a `Default` impl, so the usual construction is to set what you need and
spread the rest:

```rust
use rtb_chat::{Config, Provider};
use secrecy::SecretString;

let config = Config {
    provider: Provider::Anthropic,
    model: "claude-opus-4-7".into(),
    api_key: SecretString::from(std::env::var("ANTHROPIC_API_KEY")?),
    ..Config::default()
};
```

## Config fields at a glance

| Field | Type | Default | Honoured on |
|---|---|---|---|
| `provider` | `Provider` | `Provider::Anthropic` | both backends |
| `model` | `String` | `"claude-opus-4-7"` | both backends |
| `base_url` | `Option<Url>` | `None` | Anthropic-direct only |
| `api_key` | `SecretString` | empty (invalid) | both backends |
| `timeout` | `Duration` | 60 s | Anthropic-direct only |
| `allow_insecure_base_url` | `bool` | `false` | both backends |

`Config` derives `Debug` and `Clone`. It derives neither `Serialize` nor
`Deserialize`, so it cannot be read from a config file directly — the tool layer
builds it in code, typically after resolving the key through
`rtb_credentials`.

## provider — which backend and which wire protocol

`Provider` is `#[non_exhaustive]`, so match on it with a `_` arm. The six variants
and what each one selects are in [Providers](02-providers.md).

`provider` alone does **not** determine where a genai-backed request goes. On the
genai backend the model name picks the adapter. See
[The model name picks the adapter](02-providers.md#the-model-name-picks-the-adapter).

## model — required, and never inferred

A provider-specific model identifier. `Config::default()` sets
`"claude-opus-4-7"`.

An empty `model` is rejected by `AiClient::new`:

```text
invalid AI client config: model must not be empty
```

The value is passed through unchanged — `rtb-chat` does not validate it against a
list of known models, and does not map friendly names to versioned ones. A model
the provider does not recognise surfaces as a provider error on the first call,
not at construction.

## base_url — overriding the endpoint

`None` means "use the vendor's default endpoint".

On the **Anthropic-direct** backend the override is honoured. The request URL is
built by trimming any trailing `/` and appending `/v1/messages`, so
`https://gateway.internal/anthropic/` becomes
`https://gateway.internal/anthropic/v1/messages`. With `base_url: None` the
default is `https://api.anthropic.com`.

On the **genai** backend `base_url` is validated and then never used. A custom
endpoint set on `Provider::OpenAi`, `Provider::OpenAiCompatible`,
`Provider::Gemini` or `Provider::Ollama` has no effect on where the request goes.
This is a defect, not a design choice — see
[What rtb-chat does not do](../explanation/03-limitations.md#a-custom-base_url-is-ignored-on-every-genai-backed-provider).

When `base_url` is `Some`, `AiClient::new` runs it through
[`validate_base_url`](#validate_base_url-what-gets-rejected).

## api_key — always required, even for Ollama

Held as a `secrecy::SecretString`: `Debug` prints `[REDACTED]` and the memory is
zeroed on drop.

`AiClient::new` rejects an empty key before anything else:

```text
invalid AI client config: api_key must not be empty
```

That check runs for every provider, including `Provider::Ollama`, which needs no
credential at all. A local Ollama client still has to be given some non-empty
placeholder string. It is never sent anywhere: the genai Ollama adapter defines no
API-key environment variable, and `rtb-chat` sets none for it.

Where the key goes afterwards depends on the backend:

- **Anthropic-direct** — sent as the `x-api-key` request header.
- **genai** — written into the **process environment** by `AiClient::new`, as
  `OPENAI_API_KEY` (for `OpenAi` and `OpenAiCompatible`) or `GEMINI_API_KEY` (for
  `Gemini`). 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
  before you build more than one client.

## timeout — per request, Anthropic-direct only

Applied to the `reqwest::Client` that the Anthropic-direct backend builds, as a
whole-request timeout. Default 60 s.

On the genai backend the field is ignored: genai builds its own HTTP client, and
`rtb-chat` passes it no timeout. Setting `timeout` on an OpenAI, Gemini, Ollama or
OpenAI-compatible client changes nothing.

A timeout expiring surfaces as `AiError::Transport`.

## allow_insecure_base_url — the test escape hatch

`false` in production, and there is no way to set it from a config file — `Config`
has no `Deserialize` impl, so nothing outside your own Rust code can turn it on.

Setting it to `true` does two things:

1. `validate_base_url` accepts an `http://` URL.
2. The Anthropic-direct `reqwest` client is built without `https_only`, so it will
   actually follow through to a plaintext host.

It does not restrict you to loopback. `allow_insecure_base_url: true` with
`base_url: Some("http://some-public-host/")` is accepted, and the API key crosses
the wire in plaintext. Confine it to tests. [Test without a
provider](../how-to/05-test-without-a-provider.md) shows the intended use.

## validate_base_url — what gets rejected

`validate_base_url(&Url, allow_insecure: bool) -> Result<(), AiError>` is public,
so you can run the same check at config-parse time and reject a bad endpoint
before you get as far as building a client.

`AiClient::new` calls it only when `base_url` is `Some`. Every rejection is
`AiError::InvalidConfig`.

| Input | Result |
|---|---|
| `https://api.anthropic.com` | accepted |
| `http://127.0.0.1:8080` with `allow_insecure = false` | rejected — scheme not permitted |
| `http://127.0.0.1:8080` with `allow_insecure = true` | accepted |
| `ftp://host/` or any non-`http(s)` scheme | rejected, even with `allow_insecure = true` |
| `https://user:pw@host/` | rejected — userinfo |
| `https://example.com`, `https://api.example.com` | rejected — placeholder host |
| `https://example.org`, `https://x.example.org` | rejected — placeholder host |
| `https://example.net` | **accepted** — only `example.com` and `example.org` are blocked |

Host matching is case-insensitive and covers subdomains of the two blocked
domains. Nothing else in the URL is inspected: paths, ports and query strings pass
through untouched.

Why these three rules and not others is covered in
[Why the endpoint is validated](../explanation/02-endpoint-validation.md).

## What AiClient::new logs

One `tracing` event at INFO on success, carrying the provider and the endpoint
**host only**:

```text
rtb-chat: AiClient ready (anthropic-direct)  provider=Anthropic host=api.anthropic.com
```

The path, the query string and the API key are never logged. With `base_url` unset
the host is the vendor default for the provider; note that `Provider::Ollama`
reports `localhost` and `Provider::OpenAiCompatible` reports the literal string
`openai-compatible`, because there is no real endpoint to name yet.

## Errors from AiClient::new

Construction fails only on configuration, never on the network — nothing is sent
until you call `chat`, `chat_stream` or `chat_structured`.

| Condition | Error |
|---|---|
| `api_key` empty | `AiError::InvalidConfig("api_key must not be empty")` |
| `model` empty | `AiError::InvalidConfig("model must not be empty")` |
| `base_url` fails validation | `AiError::InvalidConfig(...)` from `validate_base_url` |
| `reqwest::Client` fails to build | `AiError::InvalidConfig(...)` with the reqwest message, redacted |

The checks run in that order, so an empty key masks a bad URL. Fix them one at a
time.