ai-client.md 8.6 KB


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-1b05507d70 resource: repo://src/ai.rs
  • id: openwiki-source-2a737474d8 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 <base_url>/chat/completions endpoint. It is used by the AI autoreply workflow; 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 (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

{ "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: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", <description request>}, {"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:<mime>;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, 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 <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: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 ===== <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).

  • Image data URLs are replaced by data:<mime>;base64,<omessi N byte> (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.