--- 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, error handling and reply parsing. tags: [ai, openai, reqwest, vision, http] 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-19T19:53:56.480Z" } verified: - by: openwiki/0.7.2 at: 2026-10-19T19:53:56.480Z --- # 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 - `Role` (`src/ai.rs:38-58`): `System`, `User`, `Assistant`, serialised as `"system"`, `"user"`, `"assistant"`. - `ChatTurn { role, content }` (`src/ai.rs:60-65`): one message of the conversation. - `ImageInput { mime, bytes }` (`src/ai.rs:95-100`): an image for vision requests. - `AiClient` (`src/ai.rs:67-77`): `reqwest::Client`, endpoint, API key, model, optional vision model and optional log path. Both `AiClient` and `ImageInput` have hand-written `Debug` impls that omit the API key and the image bytes (`src/ai.rs:79-114`). ## Construction `AiClient::new(&Config)` (`src/ai.rs:123-146`): - 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`); - builds the endpoint as `openai_base_url` (trailing `/` trimmed) + `/chat/completions`; - copies `openai_model` and `openai_vision_model`; - sets the log path to `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](../operations/configuration-and-data.md) (default base URL `https://ollama.com/v1`, default model `deepseek-v4.1-flash`, `src/config.rs:23-25`). ## Text completions `complete(&turns)` (`src/ai.rs:155-157`) sends ```json { "model": "", "messages": [{ "role": "...", "content": "..." }] } ``` built by `build_request_body` / `build_messages` (`src/ai.rs:251-265`). ## Vision completions `has_vision()` (`src/ai.rs:163-165`) is true when a vision model is configured. `complete_with_image(&turns, &image)` (`src/ai.rs:175-188`): 1. fails with `modello vision non configurato` without a vision model; 2. validates the image (`src/ai.rs:311-323`): 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 body using the vision model where the **last user turn** becomes `[{"type":"text",...}, {"type":"image_url",...}]`; with no user turn, a user message holding only the image is appended (`src/ai.rs:274-290`). The image is embedded as a `data:;base64,<...>` URL (`src/ai.rs:297-304`). ## Sending and error handling `send_body` (`src/ai.rs:200-209`) delegates to `request_reply` (`src/ai.rs:217-243`), 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 : ` | | 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:448-450`); the API key is never included. ## Optional request log When `ai_log = true` in `config.toml` (default `false`), `send_body` appends one record per request to `ai_requests.log` in the data directory (`Config::ai_log_path`, `src/config.rs:283-285`) after the request completes (`src/ai.rs:200-209`). `format_log_record` (`src/ai.rs:406-421`) writes a header `===== | | modello: =====`, the pretty-printed request body and the outcome labelled `RISPOSTA` or `ERRORE`. - Image data URLs are replaced by `data:;base64,` (`redact_images` / `redact_data_url`, `src/ai.rs:356-397`); 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:436-442`); write failures are ignored so logging never makes a reply fail and never writes over the TUI. ## Reply parsing `parse_reply` (`src/ai.rs:331-348`) 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. ## Tests `src/ai.rs:452-739` 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, and the request log (image redaction, no key or base64 in records, logging only when enabled). See [Unit tests](../testing/unit-tests.md).