type: integration title: AI chat-completions client description: The OpenAI-compatible HTTP client in src/ai.rs — request building, vision image input and validation, timeouts, retries with linear backoff, error handling, the optional request log and reply parsing. tags: [ai, openai, reqwest, vision, http, retry] sources:
1b05507d70
resource: repo://src/ai.rs2a737474d8
resource: repo://src/config.rs
generated: { by: "claude-code", at: "2026-10-20T20:27:06.515Z" }
verified:src/ai.rs implements AiClient, a small client for any
OpenAI-compatible POST <base_url>/chat/completions endpoint. It is used by
the AI autoreply workflow; the conversation
turns themselves are built elsewhere.
Role (src/ai.rs:42-58): System, User, Assistant, serialised as
"system", "user", "assistant".ChatTurn { role, content } (src/ai.rs:62-67): one message of the
conversation.ImageInput { mime, bytes } (src/ai.rs:119-124): an image for vision
requests.AiClient (src/ai.rs:69-81): reqwest::Client, endpoint, API key,
model, optional vision model, optional log path and the retry count.StatusError { status, body } (src/ai.rs:83-100): the typed error of a
non-2xx response, kept typed so the retry logic can inspect the status.Both AiClient and ImageInput have hand-written Debug impls that omit the
API key and the image bytes (src/ai.rs:102-138).
AiClient::new(&Config) (src/ai.rs:147-171):
openai_api_key; otherwise fails with
openai_api_key mancante in config.toml;REQUEST_TIMEOUT_SECONDS), applied to each attempt;openai_base_url (trailing / trimmed) +
/chat/completions;openai_model, openai_vision_model and ai_retries;Config::ai_log_path() only when ai_log is true.No network traffic happens until a completion is requested. Defaults and
config keys are described in
Configuration and data
(default base URL https://ollama.com/v1, default model
deepseek-v4.1-flash, default ai_retries 2, src/config.rs:34-45).
complete(&turns) (src/ai.rs:180-182) sends
{ "model": "<model>", "messages": [{ "role": "...", "content": "..." }] }
built by build_request_body / build_messages (src/ai.rs:325-346).
has_vision() (src/ai.rs:188-190) is true when a vision model is
configured. complete_with_image(&turns, &image) (src/ai.rs:200-213):
modello vision non configurato without a vision model;validate_image, src/ai.rs:385-399): empty →
immagine vuota, larger than MAX_IMAGE_BYTES (5 MiB) →
immagine troppo grande: N byte (errors mention the size only, never the
data);[{"type":"text",...}, {"type":"image_url",...}]; with no user turn, a
user message holding only the image is appended
(build_vision_request_body, src/ai.rs:348-369).The image is embedded as a data:<mime>;base64,<...> URL
(build_image_part, src/ai.rs:371-379). Validation errors happen before any
request and are not retried.
Both completion methods go through send_with_retry (src/ai.rs:222-236),
which performs up to 1 + ai_retries attempts with the same body:
is_retryable (src/ai.rs:301-309) accepts the error: HTTP 429 and 5xx
responses and every non-status failure (transport errors, timeouts, body
read errors, invalid JSON, missing or empty reply) are retried; any other
4xx response (e.g. 401, 400) fails immediately;retry_delay(n) = 2 s × n
(src/ai.rs:316-318), i.e. 2 s, 4 s, … (linear backoff);ai_retries = 0 disables retrying. Each attempt is a separate HTTP request
and, when enabled, a separate log record. With vision, the retries apply to
the vision request; the courtesy fallback in the
autoreply workflow starts only after they are
exhausted and has its own retries.
send_body (src/ai.rs:248-257) delegates to request_reply
(src/ai.rs:265-291), which posts JSON with bearer auth and returns
Italian anyhow errors:
| Failure | Message |
|---|---|
| Transport error | richiesta al servizio AI fallita |
| Body read error | lettura della risposta del servizio AI fallita |
| Non-2xx status | servizio AI ha risposto <status>: <body> (a StatusError) |
| Invalid JSON | risposta del servizio AI non valida: <body> |
Response bodies in errors are truncated to 300 characters on a character
boundary (truncate_chars, src/ai.rs:522-524); the API key is never
included.
When ai_log = true in config.toml (default false), send_body appends
one record per attempt to ai_requests.log in the data directory
(Config::ai_log_path, src/config.rs:318-320) after the attempt completes.
format_log_record (src/ai.rs:480-499) writes a header
===== <local timestamp> | <endpoint> | modello: <model> =====, the
pretty-printed request body and the outcome labelled RISPOSTA or ERRORE.
data:<mime>;base64,<omessi N byte>
(redact_images / redact_data_url, src/ai.rs:430-472); text parts and
remote URLs are kept.append_to_file, src/ai.rs:510-516); write failures are ignored so
logging never makes a reply fail and never writes over the TUI.parse_reply (src/ai.rs:405-422) takes choices[0].message.content,
trims it and fails with risposta del servizio AI senza choices when there
are no choices or risposta del servizio AI vuota when the content is
missing or blank.
src/ai.rs:526-850 covers vision body shapes, missing vision model,
oversize/empty images (without leaking data), the exact size limit,
has_vision, Debug redaction, request body shape, reply parsing edge
cases, the missing-key error, endpoint construction, truncation, the
request log (image redaction, no key or base64 in records, logging only when
enabled, append-only writes) and the retry policy (is_retryable refuses
4xx except 429 and accepts generic errors, the StatusError message is
unchanged, retry_delay grows linearly). See
Unit tests.