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 both backends
api_key SecretString empty (invalid) both backends
timeout Duration 60 s both backends
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.

Without a base_url, provider alone does not determine where a genai-backed request goes: 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 the override replaces genai's endpoint, and genai appends its own path to it: chat/completions for the OpenAI adapter, api/chat for Ollama. A trailing / is added if missing, so https://api.together.xyz/v1 becomes https://api.together.xyz/v1/chat/completions. Setting base_url also fixes the genai adapter to the one provider names, rather than the one genai would infer from the model name. See The model name picks the adapter.

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

A whole-request timeout on the HTTP client each backend builds: the reqwest client on the Anthropic-direct backend, and the one genai builds on the genai backend. Default 60 s.

A timeout expiring surfaces as AiError::Transport on the Anthropic-direct backend, and as AiError::Provider on genai, which reports every failure that way.

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.