Skip to content

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:

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.

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:

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 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 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:

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.