type: integration title: AI chat-completions client description: The OpenAI-compatible HTTP client in src/ai.rs — text completions offering the no_reply tool, image descriptions with the vision model (request text from VISION.md), image validation, timeouts, retries with linear backoff, error handling, the optional request log and reply parsing — plus the OpenAI-compatible audio transcription client in src/transcribe.rs. tags: [ai, openai, reqwest, vision, http, retry, tools, transcription, whisper] sources:
651d1fb6c9
resource: repo://Cargo.toml1b05507d70
resource: repo://src/ai.rs2a737474d8
resource: repo://src/config.rs
generated: { by: "openwiki/0.7.2", at: "2026-10-24T16:42:08.550Z" }
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. Voice notes go through a
separate OpenAI-compatible audio/transcriptions endpoint, implemented in
src/transcribe.rs (see below).
Reply (src/ai.rs:53-58): the successful outcome of a completion —
Text(String) to send, or NoReply when the model called the no_reply
tool.Role (src/ai.rs:62-81): System, User, Assistant, serialised as
"system", "user", "assistant".ChatTurn { role, content } (src/ai.rs:84-87): one message of the
conversation.ImageInput { mime, bytes } (src/ai.rs:143-146): an image to describe.AiClient (src/ai.rs:91-103): reqwest::Client, endpoint, API key,
model, optional vision model, the image description request, optional log
path and the retry count.StatusError { status, body } (src/ai.rs:107-122): 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:124-160).
AiClient::new(&Config) (src/ai.rs:170-202):
openai_api_key; otherwise fails with
openai_api_key mancante in config.toml;ring rustls crypto provider as the process-wide default
(rustls::crypto::ring::default_provider().install_default()), ignoring
the error that only means a provider is already installed: reqwest is
built with rustls-no-provider, so TLS needs a provider installed first
(Cargo.toml:13-16); ring is already compiled for whatsapp-rust, and
this avoids the large aws-lc-sys C build;REQUEST_TIMEOUT_SECONDS), applied to each attempt;openai_base_url (trailing / trimmed) +
/chat/completions;openai_model, openai_vision_model and ai_retries;VISION.md
(Config::image_description_prompt), falling back to the built-in
DEFAULT_IMAGE_DESCRIPTION_REQUEST (src/ai.rs:23), an Italian request
for an objective, concise description of subjects, actions, readable text
and context;Config::ai_log_path() only when ai_log is true.No network traffic happens until a request is made. Because the client is
rebuilt whenever the configuration is reloaded (see
Application state), the
provider installation runs again and is a no-op after the first time. 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:39-47).
no_reply toolcomplete(&turns) (src/ai.rs:211-213) sends
{ "model": "<model>",
"messages": [{ "role": "...", "content": "..." }],
"tools": [{ "type": "function",
"function": { "name": "no_reply", "description": "...",
"parameters": { "type": "object", "properties": {} } } }] }
built by build_request_body, build_tools and build_messages
(src/ai.rs:353-389). The only tool offered is the parameterless no_reply
function; its description is neutral ("does not send any message in the
chat: call it instead of writing a reply") and never says when to use it —
that decision belongs to the system prompt in SOUL.md
(src/ai.rs:44-49). The client never executes the tool: calling it simply
means "send nothing".
has_vision() (src/ai.rs:219-221) is true when a vision model is
configured. describe_image(&image) (src/ai.rs:230-241) asks the vision
model for a description of one image, independently of any
conversation:
modello vision non configurato without a vision model;validate_image, src/ai.rs:418-432): 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", <description request>},
{"type":"image_url", ...}] to the vision model, without tools
(build_description_body, src/ai.rs:391-402);NoReply outcome is turned into the
risposta del servizio AI vuota error.The image is embedded as a data:<mime>;base64,<...> URL
(build_image_part, src/ai.rs:404-416). Validation errors happen before any
request and are not retried. The caller stores the description in the
history store, so each image is described
once and later replies see only the text.
Both request kinds go through send_with_retry (src/ai.rs:250-264),
which performs up to 1 + ai_retries attempts with the same body:
is_retryable (src/ai.rs:329-342) says so: non-2xx statuses other than
429 fail immediately (401, 400, ...); 429, 5xx and any non-status failure
(transport errors, timeouts, body read errors, invalid JSON, missing or
empty reply) are retried;retry_delay(n) = 2 s × n
(src/ai.rs:344-351), 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.
send_body (src/ai.rs:276-285) delegates to request_reply
(src/ai.rs:293-319), 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:577-583); 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:405-409) after the attempt completes.
format_log_record (src/ai.rs:534-556) writes a header
===== <local timestamp> | <endpoint> | modello: <model> =====, the
pretty-printed request body and the outcome labelled RISPOSTA or ERRORE;
a NoReply outcome is logged as RISPOSTA with the text
(tool no_reply: nessun messaggio inviato).
data:<mime>;base64,<omessi N byte>
(redact_images / redact_data_url, src/ai.rs:484-524); text parts and
remote URLs are kept.append_to_file, src/ai.rs:565-575); write failures are ignored so
logging never makes a reply fail and never writes over the TUI.parse_reply (src/ai.rs:440-465) looks at choices[0]:
message.tool_calls is a function call named no_reply
(calls_no_reply, src/ai.rs:467-479), the result is Reply::NoReply,
even when the message also carries text;message.content, trims it and returns Reply::Text;
calls to other tool names are ignored;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/transcribe.rs implements the separate transcription of voice notes by
any OpenAI-compatible audio/transcriptions endpoint. The
AI autoreply downloads each kept audio and
calls transcribe, then appends the text to the stored message.
TranscriptionJob (src/transcribe.rs:59-71): endpoint URL, API key,
model, language and the MIME type of the audio bytes. It is built by the
app from transcribe_url, transcribe_key, transcribe_model and
transcribe_language (defaults: Groq audio/transcriptions,
whisper-large-v3-turbo, auto; see
Configuration and data,
src/config.rs:49-53).transcribe(job, bytes) (src/transcribe.rs:81-109) validates the audio
(validate_audio, src/transcribe.rs:270-281: empty →
audio vuota: impossibile trascrivere, larger than MAX_AUDIO_BYTES
= 25 MiB → audio troppo grande: N byte) before any request, then
POSTs a multipart/form-data body to job.url with bearer auth. It
performs a single request — no retries.multipart_body, src/transcribe.rs:201-231) always
contains the model field; the language field is sent only when it is
explicit — blank or auto (any case) makes the endpoint detect the
language by itself (request_fields, is_explicit_language,
src/transcribe.rs:177-193). The file part is named
audio.<extension>, where the extension comes from the MIME type
(audio_suffix, src/transcribe.rs:151-170: ogg, mp3, mp4, m4a,
aac, wav, otherwise bin). The MIME part header is sanitised to safe
single-line printable ASCII (header_mime, src/transcribe.rs:239-248),
so a hostile MIME cannot inject extra multipart headers. The boundary is
unique per request (process id plus an atomic counter,
src/transcribe.rs:284-287).OnceLock<reqwest::Client> with a 60 s
request timeout (client, build_client,
src/transcribe.rs:111-129); it relies on the rustls provider already
installed by AiClient::new, so it installs none itself.read_response (src/transcribe.rs:134-148) fails non-2xx replies with
servizio di trascrizione ha risposto <status>: <body> (body truncated to
300 characters, key never included); parse_transcription
(src/transcribe.rs:251-267) extracts the trimmed text field and fails
with risposta del servizio di trascrizione non valida: <body> or
...senza testo on malformed or empty replies. The audio bytes are never
logged or stored.src/ai.rs:589 onward covers the description body (request text plus
image, no tools), the VISION.md request and its default, missing vision
model, oversize/empty images (without leaking data), the exact size limit,
has_vision, Debug redaction, request body shape including tools, the
no_reply tool definition, reply parsing (trimmed text, no_reply with
or without text, other tool calls ignored, missing choices/content), the
missing-key error, endpoint construction, truncation, the request log
(image redaction, no key or base64 in records, no_reply 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).src/transcribe.rs:299 onward covers the multipart body (fields before
the file part, file headers and terminator, bytes appended unchanged),
the language rules (auto/blank/whitespace omitted, explicit sent once),
audio_suffix mappings (including the codecs parameter), MIME
sanitisation against header injection, boundary uniqueness, audio
validation (empty, oversize) and transcription parsing (trimmed, blank or
missing text).See Unit tests.