---
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
| `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`.
| `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`.
| `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.