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:
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.
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.
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:
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.
When base_url is Some, AiClient::new runs it through
validate_base_url.
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:
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-keyrequest header. - genai — written into the process environment by
AiClient::new, asOPENAI_API_KEY(forOpenAiandOpenAiCompatible) orGEMINI_API_KEY(forGemini). See 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:
validate_base_urlaccepts anhttp://URL.- The Anthropic-direct
reqwestclient is built withouthttps_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 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.
What AiClient::new logs¶
One tracing event at INFO on success, carrying the provider and the endpoint
host only:
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.