--- 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: - id: openwiki-source-651d1fb6c9e49916a916ab51 resource: repo://Cargo.toml - id: openwiki-source-1b05507d7085e8e99a9b64f1 resource: repo://src/ai.rs - id: openwiki-source-2a737474d86fc75cc9d9694f resource: repo://src/config.rs generated: { by: "openwiki/0.7.2", at: "2026-10-24T16:42:08.550Z" } verified: - by: openwiki/0.7.2 at: 2026-10-23T21:26:24.156Z --- # AI chat-completions client `src/ai.rs` implements `AiClient`, a small client for any OpenAI-compatible `POST /chat/completions` endpoint. It is used by the [AI autoreply](../workflows/ai-autoreply.md). Voice notes go through a separate OpenAI-compatible `audio/transcriptions` endpoint, implemented in `src/transcribe.rs` (see below). ## Types - `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`). ## Construction `AiClient::new(&Config)` (`src/ai.rs:170-202`): - requires `openai_api_key`; otherwise fails with `openai_api_key mancante in config.toml`; - installs the `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; - builds the HTTP client with a 60 s request timeout (`REQUEST_TIMEOUT_SECONDS`), applied to each attempt; - builds the endpoint as `openai_base_url` (trailing `/` trimmed) + `/chat/completions`; - copies `openai_model`, `openai_vision_model` and `ai_retries`; - takes the image description request from `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; - sets the log path to `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](../architecture/app-state.md#configuration-reload)), the provider installation runs again and is a no-op after the first time. Defaults and config keys are described in [Configuration and data](../operations/configuration-and-data.md) (default base URL `https://ollama.com/v1`, default model `deepseek-v4.1-flash`, default `ai_retries` 2, `src/config.rs:39-47`). ## Text completions and the `no_reply` tool `complete(&turns)` (`src/ai.rs:211-213`) sends ```json { "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". ## Image descriptions `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: 1. fails with `modello vision non configurato` without a vision model; 2. validates the image (`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); 3. sends a single user message `[{"type":"text", }, {"type":"image_url", ...}]` to the vision model, **without tools** (`build_description_body`, `src/ai.rs:391-402`); 4. returns the description text; a `NoReply` outcome is turned into the `risposta del servizio AI vuota` error. The image is embedded as a `data:;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](../concepts/history-store.md), so each image is described once and later replies see only the text. ## Retries 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: - after a failed attempt it retries only while attempts remain and `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; - before retry number *n* it sleeps `retry_delay(n)` = 2 s × n (`src/ai.rs:344-351`), i.e. 2 s, 4 s, … (linear backoff); - the returned error is the one of the last attempt. `ai_retries = 0` disables retrying. Each attempt is a separate HTTP request and, when enabled, a separate log record. ## Sending and error handling `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 : ` (a `StatusError`) | | Invalid JSON | `risposta del servizio AI non valida: ` | 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. ## Optional request log 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 `===== | | modello: =====`, 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)`. - Image data URLs are replaced by `data:;base64,` (`redact_images` / `redact_data_url`, `src/ai.rs:484-524`); text parts and remote URLs are kept. - The API key is never written (it is only in the bearer header, not in the body). - The file is opened in append mode and created if missing (`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. ## Reply parsing `parse_reply` (`src/ai.rs:440-465`) looks at `choices[0]`: - if any of its `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; - otherwise it takes `message.content`, trims it and returns `Reply::Text`; calls to other tool names are ignored; - it 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. ## Audio transcription `src/transcribe.rs` implements the separate transcription of voice notes by any OpenAI-compatible `audio/transcriptions` endpoint. The [AI autoreply](../workflows/ai-autoreply.md) 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](../operations/configuration-and-data.md), `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. - The multipart body (`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.`, 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`). - The HTTP client is a process-wide `OnceLock` 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 : ` (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: ` or `...senza testo` on malformed or empty replies. The audio bytes are never logged or stored. ## Tests - `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](../testing/unit-tests.md).