--- 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. tags: [ai, openai, reqwest, vision, http, retry, tools] verified: - by: openwiki/0.7.2 at: 2026-10-21T21:49:12.291Z sources: - id: openwiki-source-1b05507d7085e8e99a9b64f1 resource: repo://src/ai.rs - id: openwiki-source-2a737474d86fc75cc9d9694f resource: repo://src/config.rs generated: { by: "claude-code", at: "2026-10-21T21:49:12.291Z" } --- # 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 workflow](../workflows/ai-autoreply.md); the conversation turns themselves are built elsewhere. ## Types - `Reply` (`src/ai.rs:51-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:60-80`): `System`, `User`, `Assistant`, serialised as `"system"`, `"user"`, `"assistant"`. - `ChatTurn { role, content }` (`src/ai.rs:82-87`): one message of the conversation. - `ImageInput { mime, bytes }` (`src/ai.rs:141-146`): an image to describe. - `AiClient` (`src/ai.rs:89-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:105-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:169-197`): - requires `openai_api_key`; otherwise fails with `openai_api_key mancante in config.toml`; - 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:22-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. 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:34-43`). ## Text completions and the `no_reply` tool `complete(&turns)` (`src/ai.rs:199-208`) 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:343-379`). 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:42-49`). The client never executes the tool: calling it simply means "send nothing". ## Image descriptions `has_vision()` (`src/ai.rs:210-216`) is true when a vision model is configured. `describe_image(&image)` (`src/ai.rs:218-236`) 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:408-427`): 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:381-392`); 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:394-406`). 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:238-259`), 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:317-332`) 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; - before retry number *n* it sleeps `retry_delay(n)` = 2 s × n (`src/ai.rs:334-341`), 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:261-280`) delegates to `request_reply` (`src/ai.rs:282-314`), 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:568-574`); 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:333-339`) after the attempt completes. `format_log_record` (`src/ai.rs:521-547`) 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:473-519`); 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:556-566`); write failures are ignored so logging never makes a reply fail and never writes over the TUI. ## Reply parsing `parse_reply` (`src/ai.rs:429-477`) looks at `choices[0]`: - if any of its `message.tool_calls` is a function call named `no_reply` (`calls_no_reply`), 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. ## Tests `src/ai.rs:576` 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). See [Unit tests](../testing/unit-tests.md).