|
@@ -18,6 +18,9 @@ use crate::config::Config;
|
|
|
pub const MAX_IMAGE_BYTES: usize = 5 * 1024 * 1024;
|
|
pub const MAX_IMAGE_BYTES: usize = 5 * 1024 * 1024;
|
|
|
const MISSING_VISION_MODEL_MESSAGE: &str = "modello vision non configurato";
|
|
const MISSING_VISION_MODEL_MESSAGE: &str = "modello vision non configurato";
|
|
|
const EMPTY_IMAGE_MESSAGE: &str = "immagine vuota";
|
|
const EMPTY_IMAGE_MESSAGE: &str = "immagine vuota";
|
|
|
|
|
+const EMPTY_REPLY_MESSAGE: &str = "risposta del servizio AI vuota";
|
|
|
|
|
+/// Request sent with an image to obtain its description when `vision.md` is missing or blank.
|
|
|
|
|
+const DEFAULT_IMAGE_DESCRIPTION_REQUEST: &str = "Descrivi in modo oggettivo e conciso, in italiano, il contenuto di questa immagine: soggetti, azioni, testo leggibile e contesto. Rispondi solo con la descrizione.";
|
|
|
const TEXT_PART_TYPE: &str = "text";
|
|
const TEXT_PART_TYPE: &str = "text";
|
|
|
const IMAGE_PART_TYPE: &str = "image_url";
|
|
const IMAGE_PART_TYPE: &str = "image_url";
|
|
|
const REQUEST_TIMEOUT_SECONDS: u64 = 60;
|
|
const REQUEST_TIMEOUT_SECONDS: u64 = 60;
|
|
@@ -36,6 +39,23 @@ const DATA_URL_BASE64_MARKER: &str = ";base64,";
|
|
|
const DATA_URL_PREFIX: &str = "data:";
|
|
const DATA_URL_PREFIX: &str = "data:";
|
|
|
/// Base wait, in seconds, between attempts: the wait before retry N is N times this value.
|
|
/// Base wait, in seconds, between attempts: the wait before retry N is N times this value.
|
|
|
const RETRY_BASE_DELAY_SECONDS: u64 = 2;
|
|
const RETRY_BASE_DELAY_SECONDS: u64 = 2;
|
|
|
|
|
+/// Name of the tool the model calls when no message must be sent; when to call it is up to
|
|
|
|
|
+/// the system prompt (`SOUL.md`).
|
|
|
|
|
+const NO_REPLY_TOOL_NAME: &str = "no_reply";
|
|
|
|
|
+/// Neutral description of the no-reply tool: it says what the tool does, never when to use it.
|
|
|
|
|
+const NO_REPLY_TOOL_DESCRIPTION: &str =
|
|
|
|
|
+ "Non invia nessun messaggio nella chat: chiamalo al posto di scrivere una risposta.";
|
|
|
|
|
+/// Text logged in place of the reply when the model called the no-reply tool.
|
|
|
|
|
+const LOG_NO_REPLY_TEXT: &str = "(tool no_reply: nessun messaggio inviato)";
|
|
|
|
|
+
|
|
|
|
|
+/// Successful outcome of a completion.
|
|
|
|
|
+#[derive(Debug, Clone, PartialEq, Eq)]
|
|
|
|
|
+pub enum Reply {
|
|
|
|
|
+ /// Text to send to the chat.
|
|
|
|
|
+ Text(String),
|
|
|
|
|
+ /// The model called the `no_reply` tool: nothing must be sent.
|
|
|
|
|
+ NoReply,
|
|
|
|
|
+}
|
|
|
|
|
|
|
|
/// Author of a single conversation turn sent to the model.
|
|
/// Author of a single conversation turn sent to the model.
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
@@ -74,6 +94,8 @@ pub struct AiClient {
|
|
|
api_key: String,
|
|
api_key: String,
|
|
|
model: String,
|
|
model: String,
|
|
|
vision_model: Option<String>,
|
|
vision_model: Option<String>,
|
|
|
|
|
+ /// Request sent with each image to describe it: `vision.md`, else `DEFAULT_IMAGE_DESCRIPTION_REQUEST`.
|
|
|
|
|
+ description_request: String,
|
|
|
/// File receiving one record per request; `None` when `ai_log` is disabled.
|
|
/// File receiving one record per request; `None` when `ai_log` is disabled.
|
|
|
log_path: Option<PathBuf>,
|
|
log_path: Option<PathBuf>,
|
|
|
/// Number of retries after a failed attempt.
|
|
/// Number of retries after a failed attempt.
|
|
@@ -165,6 +187,10 @@ impl AiClient {
|
|
|
api_key,
|
|
api_key,
|
|
|
model: config.openai_model.clone(),
|
|
model: config.openai_model.clone(),
|
|
|
vision_model: config.openai_vision_model.clone(),
|
|
vision_model: config.openai_vision_model.clone(),
|
|
|
|
|
+ description_request: config
|
|
|
|
|
+ .image_description_prompt
|
|
|
|
|
+ .clone()
|
|
|
|
|
+ .unwrap_or_else(|| DEFAULT_IMAGE_DESCRIPTION_REQUEST.to_owned()),
|
|
|
log_path: config.ai_log.then(|| config.ai_log_path()),
|
|
log_path: config.ai_log.then(|| config.ai_log_path()),
|
|
|
retries: config.ai_retries,
|
|
retries: config.ai_retries,
|
|
|
})
|
|
})
|
|
@@ -173,43 +199,40 @@ impl AiClient {
|
|
|
/// Requests a completion for the given conversation.
|
|
/// Requests a completion for the given conversation.
|
|
|
///
|
|
///
|
|
|
/// Inputs: `turns`, the ordered conversation (system prompt included).
|
|
/// Inputs: `turns`, the ordered conversation (system prompt included).
|
|
|
- /// Output: the first choice's message content, trimmed; an error on
|
|
|
|
|
- /// transport failure, non-2xx status (status code and truncated body, never
|
|
|
|
|
- /// the key), malformed JSON or empty reply.
|
|
|
|
|
|
|
+ /// Output: the reply (see `parse_reply`); an error on transport failure,
|
|
|
|
|
+ /// non-2xx status (status code and truncated body, never the key), malformed
|
|
|
|
|
+ /// JSON or empty reply.
|
|
|
/// Side effects: performs up to `1 + retries` HTTPS POST requests.
|
|
/// Side effects: performs up to `1 + retries` HTTPS POST requests.
|
|
|
- pub async fn complete(&self, turns: &[ChatTurn]) -> anyhow::Result<String> {
|
|
|
|
|
|
|
+ pub async fn complete(&self, turns: &[ChatTurn]) -> anyhow::Result<Reply> {
|
|
|
self.send_with_retry(&build_request_body(&self.model, turns)).await
|
|
self.send_with_retry(&build_request_body(&self.model, turns)).await
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/// Tells whether a vision model is configured.
|
|
/// Tells whether a vision model is configured.
|
|
|
///
|
|
///
|
|
|
- /// Inputs: the client. Output: `true` if `complete_with_image` can be used.
|
|
|
|
|
|
|
+ /// Inputs: the client. Output: `true` if `describe_image` can be used.
|
|
|
/// Side effects: none.
|
|
/// Side effects: none.
|
|
|
pub fn has_vision(&self) -> bool {
|
|
pub fn has_vision(&self) -> bool {
|
|
|
self.vision_model.is_some()
|
|
self.vision_model.is_some()
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- /// Requests a completion for the conversation with an attached image.
|
|
|
|
|
|
|
+ /// Asks the vision model for a description of one image, stored and used in every context.
|
|
|
///
|
|
///
|
|
|
- /// Inputs: `turns`, the ordered conversation; `image`, attached to the last
|
|
|
|
|
- /// user turn (or to a new user turn if there is none).
|
|
|
|
|
- /// Output: the reply text; an error if no vision model is configured, the
|
|
|
|
|
- /// image is empty or larger than `MAX_IMAGE_BYTES`, or on any failure
|
|
|
|
|
- /// described in `complete`. Errors never contain the image data.
|
|
|
|
|
- /// Side effects: performs up to `1 + retries` HTTPS POST requests.
|
|
|
|
|
- pub async fn complete_with_image(
|
|
|
|
|
- &self,
|
|
|
|
|
- turns: &[ChatTurn],
|
|
|
|
|
- image: &ImageInput,
|
|
|
|
|
- ) -> anyhow::Result<String> {
|
|
|
|
|
|
|
+ /// Inputs: `image`, the image to describe.
|
|
|
|
|
+ /// Output: the description text; an error if no vision model is configured,
|
|
|
|
|
+ /// the image is invalid (see `validate_image`) or on any failure described in
|
|
|
|
|
+ /// `complete`. Errors never contain the image data.
|
|
|
|
|
+ /// Side effects: performs up to `1 + retries` HTTPS POST requests (no tools offered).
|
|
|
|
|
+ pub async fn describe_image(&self, image: &ImageInput) -> anyhow::Result<String> {
|
|
|
let model = self
|
|
let model = self
|
|
|
.vision_model
|
|
.vision_model
|
|
|
.as_deref()
|
|
.as_deref()
|
|
|
.ok_or_else(|| anyhow!(MISSING_VISION_MODEL_MESSAGE))?;
|
|
.ok_or_else(|| anyhow!(MISSING_VISION_MODEL_MESSAGE))?;
|
|
|
validate_image(image)?;
|
|
validate_image(image)?;
|
|
|
|
|
|
|
|
- self.send_with_retry(&build_vision_request_body(model, turns, image))
|
|
|
|
|
- .await
|
|
|
|
|
|
|
+ match self.send_with_retry(&build_description_body(model, &self.description_request, image)).await? {
|
|
|
|
|
+ Reply::Text(description) => Ok(description),
|
|
|
|
|
+ Reply::NoReply => bail!(EMPTY_REPLY_MESSAGE),
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/// Sends a prepared request body, retrying transient failures.
|
|
/// Sends a prepared request body, retrying transient failures.
|
|
@@ -219,7 +242,7 @@ impl AiClient {
|
|
|
/// last attempt (returned immediately when not retryable or retries are exhausted).
|
|
/// last attempt (returned immediately when not retryable or retries are exhausted).
|
|
|
/// Side effects: performs up to `1 + retries` HTTPS POST requests (each one
|
|
/// Side effects: performs up to `1 + retries` HTTPS POST requests (each one
|
|
|
/// logged when the AI log is enabled); sleeps `retry_delay` before each retry.
|
|
/// logged when the AI log is enabled); sleeps `retry_delay` before each retry.
|
|
|
- async fn send_with_retry(&self, request_body: &Value) -> anyhow::Result<String> {
|
|
|
|
|
|
|
+ async fn send_with_retry(&self, request_body: &Value) -> anyhow::Result<Reply> {
|
|
|
let mut attempt = 0;
|
|
let mut attempt = 0;
|
|
|
|
|
|
|
|
loop {
|
|
loop {
|
|
@@ -239,13 +262,13 @@ impl AiClient {
|
|
|
/// log is enabled, records the request together with its outcome.
|
|
/// log is enabled, records the request together with its outcome.
|
|
|
///
|
|
///
|
|
|
/// Inputs: `request_body`, the JSON payload for the chat completions endpoint.
|
|
/// Inputs: `request_body`, the JSON payload for the chat completions endpoint.
|
|
|
- /// Output: the first choice's message content, trimmed; an error on
|
|
|
|
|
- /// transport failure, non-2xx status (status code and truncated body, never
|
|
|
|
|
- /// the key), malformed JSON or empty reply.
|
|
|
|
|
|
|
+ /// Output: the reply (see `parse_reply`); an error on transport failure,
|
|
|
|
|
+ /// non-2xx status (status code and truncated body, never the key), malformed
|
|
|
|
|
+ /// JSON or empty reply.
|
|
|
/// Side effects: performs one HTTPS POST request; appends one record to the
|
|
/// Side effects: performs one HTTPS POST request; appends one record to the
|
|
|
/// log file if enabled. Log write failures are deliberately ignored: logging
|
|
/// log file if enabled. Log write failures are deliberately ignored: logging
|
|
|
/// must never make a reply fail, and the TUI must not be written over.
|
|
/// must never make a reply fail, and the TUI must not be written over.
|
|
|
- async fn send_body(&self, request_body: &Value) -> anyhow::Result<String> {
|
|
|
|
|
|
|
+ async fn send_body(&self, request_body: &Value) -> anyhow::Result<Reply> {
|
|
|
let outcome = self.request_reply(request_body).await;
|
|
let outcome = self.request_reply(request_body).await;
|
|
|
|
|
|
|
|
if let Some(path) = &self.log_path {
|
|
if let Some(path) = &self.log_path {
|
|
@@ -256,13 +279,13 @@ impl AiClient {
|
|
|
outcome
|
|
outcome
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- /// Performs the HTTPS POST and extracts the reply text.
|
|
|
|
|
|
|
+ /// Performs the HTTPS POST and extracts the reply.
|
|
|
///
|
|
///
|
|
|
/// Inputs: `request_body`, the JSON payload for the chat completions endpoint.
|
|
/// Inputs: `request_body`, the JSON payload for the chat completions endpoint.
|
|
|
- /// Output: the first choice's message content, trimmed; an error on
|
|
|
|
|
|
|
+ /// Output: the reply (see `parse_reply`); an error on
|
|
|
/// transport failure, non-2xx status, malformed JSON or empty reply.
|
|
/// transport failure, non-2xx status, malformed JSON or empty reply.
|
|
|
/// Side effects: performs one HTTPS POST request.
|
|
/// Side effects: performs one HTTPS POST request.
|
|
|
- async fn request_reply(&self, request_body: &Value) -> anyhow::Result<String> {
|
|
|
|
|
|
|
+ async fn request_reply(&self, request_body: &Value) -> anyhow::Result<Reply> {
|
|
|
let response = self
|
|
let response = self
|
|
|
.http
|
|
.http
|
|
|
.post(&self.endpoint)
|
|
.post(&self.endpoint)
|
|
@@ -320,10 +343,26 @@ fn retry_delay(attempt: u32) -> Duration {
|
|
|
/// Builds the JSON request body for the chat completions endpoint.
|
|
/// Builds the JSON request body for the chat completions endpoint.
|
|
|
///
|
|
///
|
|
|
/// Inputs: `model` name and the conversation `turns`.
|
|
/// Inputs: `model` name and the conversation `turns`.
|
|
|
-/// Output: `{"model": ..., "messages": [{"role": ..., "content": ...}]}`.
|
|
|
|
|
|
|
+/// Output: `{"model": ..., "messages": [{"role": ..., "content": ...}], "tools": [no_reply]}`.
|
|
|
/// Side effects: none.
|
|
/// Side effects: none.
|
|
|
fn build_request_body(model: &str, turns: &[ChatTurn]) -> Value {
|
|
fn build_request_body(model: &str, turns: &[ChatTurn]) -> Value {
|
|
|
- json!({ "model": model, "messages": build_messages(turns) })
|
|
|
|
|
|
|
+ json!({ "model": model, "messages": build_messages(turns), "tools": build_tools() })
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/// Builds the tool list offered to the model: only the parameterless `no_reply` function.
|
|
|
|
|
+///
|
|
|
|
|
+/// Inputs: none.
|
|
|
|
|
+/// Output: `[{"type":"function","function":{"name":"no_reply","description":...,"parameters":{...}}}]`.
|
|
|
|
|
+/// Side effects: none.
|
|
|
|
|
+fn build_tools() -> Value {
|
|
|
|
|
+ json!([{
|
|
|
|
|
+ "type": "function",
|
|
|
|
|
+ "function": {
|
|
|
|
|
+ "name": NO_REPLY_TOOL_NAME,
|
|
|
|
|
+ "description": NO_REPLY_TOOL_DESCRIPTION,
|
|
|
|
|
+ "parameters": { "type": "object", "properties": {} },
|
|
|
|
|
+ },
|
|
|
|
|
+ }])
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/// Converts conversation turns into wire-format messages.
|
|
/// Converts conversation turns into wire-format messages.
|
|
@@ -338,29 +377,18 @@ fn build_messages(turns: &[ChatTurn]) -> Vec<Value> {
|
|
|
.collect()
|
|
.collect()
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-/// Builds the request body for a vision completion.
|
|
|
|
|
|
|
+/// Builds the request body asking for the description of one image.
|
|
|
///
|
|
///
|
|
|
-/// Inputs: the vision `model` name, the conversation `turns` and the `image`.
|
|
|
|
|
-/// Output: like `build_request_body`, but the last user turn's content becomes
|
|
|
|
|
-/// `[text part, image_url part]`; with no user turn, a user message holding
|
|
|
|
|
-/// only the image part is appended.
|
|
|
|
|
|
|
+/// Inputs: the vision `model` name, the `request` text and the `image`.
|
|
|
|
|
+/// Output: `{"model": ..., "messages": [{"role": "user", "content": [request text, image part]}]}`,
|
|
|
|
|
+/// with no tools.
|
|
|
/// Side effects: none.
|
|
/// Side effects: none.
|
|
|
-fn build_vision_request_body(model: &str, turns: &[ChatTurn], image: &ImageInput) -> Value {
|
|
|
|
|
- let mut messages = build_messages(turns);
|
|
|
|
|
- let image_part = build_image_part(image);
|
|
|
|
|
-
|
|
|
|
|
- match turns.iter().rposition(|turn| turn.role == Role::User) {
|
|
|
|
|
- Some(index) => {
|
|
|
|
|
- let text_part = json!({ "type": TEXT_PART_TYPE, "text": turns[index].content });
|
|
|
|
|
- messages[index]["content"] = json!([text_part, image_part]);
|
|
|
|
|
- }
|
|
|
|
|
- None => messages.push(json!({
|
|
|
|
|
- "role": Role::User.as_wire_str(),
|
|
|
|
|
- "content": [image_part],
|
|
|
|
|
- })),
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- json!({ "model": model, "messages": messages })
|
|
|
|
|
|
|
+fn build_description_body(model: &str, request: &str, image: &ImageInput) -> Value {
|
|
|
|
|
+ let text_part = json!({ "type": TEXT_PART_TYPE, "text": request });
|
|
|
|
|
+ json!({
|
|
|
|
|
+ "model": model,
|
|
|
|
|
+ "messages": [{ "role": Role::User.as_wire_str(), "content": [text_part, build_image_part(image)] }],
|
|
|
|
|
+ })
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/// Builds the `image_url` content part carrying the image as a data URL.
|
|
/// Builds the `image_url` content part carrying the image as a data URL.
|
|
@@ -396,18 +424,23 @@ fn validate_image(image: &ImageInput) -> anyhow::Result<()> {
|
|
|
Ok(())
|
|
Ok(())
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-/// Extracts the reply text from a chat completions response body.
|
|
|
|
|
|
|
+/// Extracts the reply from a chat completions response body.
|
|
|
///
|
|
///
|
|
|
/// Inputs: the parsed JSON `body`.
|
|
/// Inputs: the parsed JSON `body`.
|
|
|
-/// Output: the first choice's message content, trimmed; an error if there are
|
|
|
|
|
-/// no choices, the content is missing, or it is empty after trimming.
|
|
|
|
|
|
|
+/// Output: `Reply::NoReply` if the first choice calls the `no_reply` tool (any
|
|
|
|
|
+/// text next to the call is ignored); otherwise `Reply::Text` with the first
|
|
|
|
|
+/// choice's message content, trimmed; an error if there are no choices, the
|
|
|
|
|
+/// content is missing, or it is empty after trimming.
|
|
|
/// Side effects: none.
|
|
/// Side effects: none.
|
|
|
-fn parse_reply(body: &Value) -> anyhow::Result<String> {
|
|
|
|
|
|
|
+fn parse_reply(body: &Value) -> anyhow::Result<Reply> {
|
|
|
let first_choice = body
|
|
let first_choice = body
|
|
|
.get("choices")
|
|
.get("choices")
|
|
|
.and_then(Value::as_array)
|
|
.and_then(Value::as_array)
|
|
|
.and_then(|choices| choices.first())
|
|
.and_then(|choices| choices.first())
|
|
|
.ok_or_else(|| anyhow!("risposta del servizio AI senza choices"))?;
|
|
.ok_or_else(|| anyhow!("risposta del servizio AI senza choices"))?;
|
|
|
|
|
+ if calls_no_reply(first_choice) {
|
|
|
|
|
+ return Ok(Reply::NoReply);
|
|
|
|
|
+ }
|
|
|
let content = first_choice
|
|
let content = first_choice
|
|
|
.pointer("/message/content")
|
|
.pointer("/message/content")
|
|
|
.and_then(Value::as_str)
|
|
.and_then(Value::as_str)
|
|
@@ -415,10 +448,26 @@ fn parse_reply(body: &Value) -> anyhow::Result<String> {
|
|
|
.unwrap_or_default();
|
|
.unwrap_or_default();
|
|
|
|
|
|
|
|
if content.is_empty() {
|
|
if content.is_empty() {
|
|
|
- bail!("risposta del servizio AI vuota");
|
|
|
|
|
|
|
+ bail!(EMPTY_REPLY_MESSAGE);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- Ok(content.to_string())
|
|
|
|
|
|
|
+ Ok(Reply::Text(content.to_string()))
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/// Tells whether a response choice calls the `no_reply` tool.
|
|
|
|
|
+///
|
|
|
|
|
+/// Inputs: `choice`, one element of the response `choices`.
|
|
|
|
|
+/// Output: `true` if any of its `message.tool_calls` is a function call named `no_reply`.
|
|
|
|
|
+/// Side effects: none.
|
|
|
|
|
+fn calls_no_reply(choice: &Value) -> bool {
|
|
|
|
|
+ choice
|
|
|
|
|
+ .pointer("/message/tool_calls")
|
|
|
|
|
+ .and_then(Value::as_array)
|
|
|
|
|
+ .is_some_and(|calls| {
|
|
|
|
|
+ calls.iter().any(|call| {
|
|
|
|
|
+ call.pointer("/function/name").and_then(Value::as_str) == Some(NO_REPLY_TOOL_NAME)
|
|
|
|
|
+ })
|
|
|
|
|
+ })
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/// Replaces a base64 data URL with a placeholder that omits the payload.
|
|
/// Replaces a base64 data URL with a placeholder that omits the payload.
|
|
@@ -481,13 +530,14 @@ fn format_log_record(
|
|
|
timestamp: &str,
|
|
timestamp: &str,
|
|
|
endpoint: &str,
|
|
endpoint: &str,
|
|
|
body: &Value,
|
|
body: &Value,
|
|
|
- outcome: &anyhow::Result<String>,
|
|
|
|
|
|
|
+ outcome: &anyhow::Result<Reply>,
|
|
|
) -> String {
|
|
) -> String {
|
|
|
let model = body.get("model").and_then(Value::as_str).unwrap_or_default();
|
|
let model = body.get("model").and_then(Value::as_str).unwrap_or_default();
|
|
|
let redacted = redact_images(body);
|
|
let redacted = redact_images(body);
|
|
|
let pretty = serde_json::to_string_pretty(&redacted).unwrap_or_else(|_| redacted.to_string());
|
|
let pretty = serde_json::to_string_pretty(&redacted).unwrap_or_else(|_| redacted.to_string());
|
|
|
let (label, text) = match outcome {
|
|
let (label, text) = match outcome {
|
|
|
- Ok(reply) => (LOG_REPLY_LABEL, reply.clone()),
|
|
|
|
|
|
|
+ Ok(Reply::Text(reply)) => (LOG_REPLY_LABEL, reply.clone()),
|
|
|
|
|
+ Ok(Reply::NoReply) => (LOG_REPLY_LABEL, LOG_NO_REPLY_TEXT.to_owned()),
|
|
|
Err(error) => (LOG_ERROR_LABEL, format!("{error:#}")),
|
|
Err(error) => (LOG_ERROR_LABEL, format!("{error:#}")),
|
|
|
};
|
|
};
|
|
|
|
|
|
|
@@ -539,9 +589,9 @@ mod tests {
|
|
|
openai_base_url: "https://example.test/v1".to_string(),
|
|
openai_base_url: "https://example.test/v1".to_string(),
|
|
|
openai_model: TEST_MODEL.to_string(),
|
|
openai_model: TEST_MODEL.to_string(),
|
|
|
system_prompt: Some("prompt".to_string()),
|
|
system_prompt: Some("prompt".to_string()),
|
|
|
|
|
+ image_description_prompt: None,
|
|
|
ai_log: false,
|
|
ai_log: false,
|
|
|
- cooldown_seconds: 30,
|
|
|
|
|
- group_cooldown_seconds: 300,
|
|
|
|
|
|
|
+ reply_delay_seconds: crate::config::DEFAULT_REPLY_DELAY_SECONDS,
|
|
|
catch_up_seconds: 7200,
|
|
catch_up_seconds: 7200,
|
|
|
data_dir: PathBuf::from("data"),
|
|
data_dir: PathBuf::from("data"),
|
|
|
openai_vision_model: None,
|
|
openai_vision_model: None,
|
|
@@ -580,78 +630,51 @@ mod tests {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
#[test]
|
|
#[test]
|
|
|
- fn vision_body_replaces_last_user_turn_with_text_and_image_parts() {
|
|
|
|
|
- let turns = [
|
|
|
|
|
- ChatTurn { role: Role::System, content: "be nice".into() },
|
|
|
|
|
- ChatTurn { role: Role::User, content: "first".into() },
|
|
|
|
|
- ChatTurn { role: Role::Assistant, content: "ok".into() },
|
|
|
|
|
- ChatTurn { role: Role::User, content: "look".into() },
|
|
|
|
|
- ];
|
|
|
|
|
- let image = test_image(vec![1, 2, 3]);
|
|
|
|
|
-
|
|
|
|
|
- let body = build_vision_request_body("vision-model", &turns, &image);
|
|
|
|
|
|
|
+ fn description_body_sends_request_text_and_image_without_tools() {
|
|
|
|
|
+ let body = build_description_body("vision-model", DEFAULT_IMAGE_DESCRIPTION_REQUEST, &test_image(vec![1, 2, 3]));
|
|
|
|
|
|
|
|
assert_eq!(
|
|
assert_eq!(
|
|
|
body,
|
|
body,
|
|
|
json!({
|
|
json!({
|
|
|
"model": "vision-model",
|
|
"model": "vision-model",
|
|
|
- "messages": [
|
|
|
|
|
- {"role": "system", "content": "be nice"},
|
|
|
|
|
- {"role": "user", "content": "first"},
|
|
|
|
|
- {"role": "assistant", "content": "ok"},
|
|
|
|
|
- {"role": "user", "content": [
|
|
|
|
|
- {"type": "text", "text": "look"},
|
|
|
|
|
- {"type": "image_url", "image_url": {"url": "data:image/png;base64,AQID"}},
|
|
|
|
|
- ]},
|
|
|
|
|
- ]
|
|
|
|
|
|
|
+ "messages": [{"role": "user", "content": [
|
|
|
|
|
+ {"type": "text", "text": DEFAULT_IMAGE_DESCRIPTION_REQUEST},
|
|
|
|
|
+ {"type": "image_url", "image_url": {"url": "data:image/png;base64,AQID"}},
|
|
|
|
|
+ ]}],
|
|
|
})
|
|
})
|
|
|
);
|
|
);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
#[test]
|
|
#[test]
|
|
|
- fn vision_body_appends_image_only_user_turn_when_none_present() {
|
|
|
|
|
- let turns = [ChatTurn { role: Role::System, content: "be nice".into() }];
|
|
|
|
|
-
|
|
|
|
|
- let body = build_vision_request_body("vision-model", &turns, &test_image(vec![1, 2, 3]));
|
|
|
|
|
|
|
+ fn description_request_comes_from_vision_md_or_falls_back_to_default() {
|
|
|
|
|
+ let default_client = AiClient::new(&test_config(Some(TEST_API_KEY))).unwrap();
|
|
|
|
|
+ let mut config = test_config(Some(TEST_API_KEY));
|
|
|
|
|
+ config.image_description_prompt = Some("Describe in English.\n".to_string());
|
|
|
|
|
+ let custom_client = AiClient::new(&config).unwrap();
|
|
|
|
|
|
|
|
- assert_eq!(
|
|
|
|
|
- body["messages"][1],
|
|
|
|
|
- json!({"role": "user", "content": [
|
|
|
|
|
- {"type": "image_url", "image_url": {"url": "data:image/png;base64,AQID"}},
|
|
|
|
|
- ]})
|
|
|
|
|
- );
|
|
|
|
|
- assert_eq!(body["messages"].as_array().unwrap().len(), 2);
|
|
|
|
|
|
|
+ assert_eq!(default_client.description_request, DEFAULT_IMAGE_DESCRIPTION_REQUEST);
|
|
|
|
|
+ assert_eq!(custom_client.description_request, "Describe in English.\n");
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
#[test]
|
|
#[test]
|
|
|
- fn complete_with_image_fails_without_vision_model() {
|
|
|
|
|
- let client = test_client(None);
|
|
|
|
|
-
|
|
|
|
|
- let error = poll_ready(client.complete_with_image(&[], &test_image(vec![1]))).unwrap_err();
|
|
|
|
|
|
|
+ fn describe_image_fails_without_vision_model_or_on_empty_image() {
|
|
|
|
|
+ let without_vision = poll_ready(test_client(None).describe_image(&test_image(vec![1]))).unwrap_err();
|
|
|
|
|
+ let empty = poll_ready(test_client(Some("vision-model")).describe_image(&test_image(Vec::new()))).unwrap_err();
|
|
|
|
|
|
|
|
- assert_eq!(error.to_string(), MISSING_VISION_MODEL_MESSAGE);
|
|
|
|
|
|
|
+ assert_eq!(without_vision.to_string(), MISSING_VISION_MODEL_MESSAGE);
|
|
|
|
|
+ assert_eq!(empty.to_string(), EMPTY_IMAGE_MESSAGE);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
#[test]
|
|
#[test]
|
|
|
- fn complete_with_image_fails_on_oversize_image_without_leaking_data() {
|
|
|
|
|
|
|
+ fn describe_image_fails_on_oversize_image_without_leaking_data() {
|
|
|
let client = test_client(Some("vision-model"));
|
|
let client = test_client(Some("vision-model"));
|
|
|
- let image = test_image(vec![7; MAX_IMAGE_BYTES + 1]);
|
|
|
|
|
|
|
|
|
|
- let error = poll_ready(client.complete_with_image(&[], &image)).unwrap_err();
|
|
|
|
|
|
|
+ let error = poll_ready(client.describe_image(&test_image(vec![7; MAX_IMAGE_BYTES + 1]))).unwrap_err();
|
|
|
|
|
|
|
|
assert!(error.to_string().contains(&(MAX_IMAGE_BYTES + 1).to_string()));
|
|
assert!(error.to_string().contains(&(MAX_IMAGE_BYTES + 1).to_string()));
|
|
|
assert!(!error.to_string().contains("base64"));
|
|
assert!(!error.to_string().contains("base64"));
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- #[test]
|
|
|
|
|
- fn complete_with_image_fails_on_empty_image() {
|
|
|
|
|
- let client = test_client(Some("vision-model"));
|
|
|
|
|
-
|
|
|
|
|
- let error = poll_ready(client.complete_with_image(&[], &test_image(Vec::new()))).unwrap_err();
|
|
|
|
|
-
|
|
|
|
|
- assert_eq!(error.to_string(), EMPTY_IMAGE_MESSAGE);
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
#[test]
|
|
#[test]
|
|
|
fn validate_image_accepts_exactly_max_size() {
|
|
fn validate_image_accepts_exactly_max_size() {
|
|
|
assert!(validate_image(&test_image(vec![0; MAX_IMAGE_BYTES])).is_ok());
|
|
assert!(validate_image(&test_image(vec![0; MAX_IMAGE_BYTES])).is_ok());
|
|
@@ -692,11 +715,22 @@ mod tests {
|
|
|
{"role": "system", "content": "be nice"},
|
|
{"role": "system", "content": "be nice"},
|
|
|
{"role": "user", "content": "hi"},
|
|
{"role": "user", "content": "hi"},
|
|
|
{"role": "assistant", "content": "hello"},
|
|
{"role": "assistant", "content": "hello"},
|
|
|
- ]
|
|
|
|
|
|
|
+ ],
|
|
|
|
|
+ "tools": build_tools(),
|
|
|
})
|
|
})
|
|
|
);
|
|
);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ #[test]
|
|
|
|
|
+ fn tools_offer_only_the_parameterless_no_reply_function() {
|
|
|
|
|
+ let tools = build_tools();
|
|
|
|
|
+
|
|
|
|
|
+ assert_eq!(tools.as_array().unwrap().len(), 1);
|
|
|
|
|
+ assert_eq!(tools[0]["type"], "function");
|
|
|
|
|
+ assert_eq!(tools[0]["function"]["name"], NO_REPLY_TOOL_NAME);
|
|
|
|
|
+ assert_eq!(tools[0]["function"]["parameters"], json!({"type": "object", "properties": {}}));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
#[test]
|
|
#[test]
|
|
|
fn parse_reply_returns_trimmed_first_choice() {
|
|
fn parse_reply_returns_trimmed_first_choice() {
|
|
|
let body = json!({"choices": [
|
|
let body = json!({"choices": [
|
|
@@ -704,7 +738,27 @@ mod tests {
|
|
|
{"message": {"content": "other"}},
|
|
{"message": {"content": "other"}},
|
|
|
]});
|
|
]});
|
|
|
|
|
|
|
|
- assert_eq!(parse_reply(&body).unwrap(), "ciao");
|
|
|
|
|
|
|
+ assert_eq!(parse_reply(&body).unwrap(), Reply::Text("ciao".into()));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// Builds a response whose first choice calls the tool `name` next to `content`.
|
|
|
|
|
+ fn tool_call_response(name: &str, content: Value) -> Value {
|
|
|
|
|
+ json!({"choices": [{"message": {
|
|
|
|
|
+ "content": content,
|
|
|
|
|
+ "tool_calls": [{"id": "call_1", "type": "function", "function": {"name": name, "arguments": "{}"}}],
|
|
|
|
|
+ }}]})
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ #[test]
|
|
|
|
|
+ fn parse_reply_detects_no_reply_tool_call_even_with_text() {
|
|
|
|
|
+ assert_eq!(parse_reply(&tool_call_response(NO_REPLY_TOOL_NAME, Value::Null)).unwrap(), Reply::NoReply);
|
|
|
|
|
+ assert_eq!(parse_reply(&tool_call_response(NO_REPLY_TOOL_NAME, json!("ok"))).unwrap(), Reply::NoReply);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ #[test]
|
|
|
|
|
+ fn parse_reply_ignores_other_tool_calls() {
|
|
|
|
|
+ assert_eq!(parse_reply(&tool_call_response("other", json!("ciao"))).unwrap(), Reply::Text("ciao".into()));
|
|
|
|
|
+ assert!(parse_reply(&tool_call_response("other", Value::Null)).is_err());
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
#[test]
|
|
#[test]
|
|
@@ -737,15 +791,14 @@ mod tests {
|
|
|
|
|
|
|
|
#[test]
|
|
#[test]
|
|
|
fn redact_images_replaces_base64_and_keeps_text() {
|
|
fn redact_images_replaces_base64_and_keeps_text() {
|
|
|
- let turns = [ChatTurn { role: Role::User, content: "look".into() }];
|
|
|
|
|
- let body = build_vision_request_body("vision-model", &turns, &test_image(vec![1, 2, 3]));
|
|
|
|
|
|
|
+ let body = build_description_body("vision-model", DEFAULT_IMAGE_DESCRIPTION_REQUEST, &test_image(vec![1, 2, 3]));
|
|
|
|
|
|
|
|
let redacted = redact_images(&body);
|
|
let redacted = redact_images(&body);
|
|
|
|
|
|
|
|
assert_eq!(
|
|
assert_eq!(
|
|
|
redacted["messages"][0]["content"],
|
|
redacted["messages"][0]["content"],
|
|
|
json!([
|
|
json!([
|
|
|
- {"type": "text", "text": "look"},
|
|
|
|
|
|
|
+ {"type": "text", "text": DEFAULT_IMAGE_DESCRIPTION_REQUEST},
|
|
|
{"type": "image_url", "image_url": {"url": "data:image/png;base64,<omessi 4 byte>"}},
|
|
{"type": "image_url", "image_url": {"url": "data:image/png;base64,<omessi 4 byte>"}},
|
|
|
])
|
|
])
|
|
|
);
|
|
);
|
|
@@ -769,21 +822,23 @@ mod tests {
|
|
|
let turns = [ChatTurn { role: Role::User, content: "ciao".into() }];
|
|
let turns = [ChatTurn { role: Role::User, content: "ciao".into() }];
|
|
|
let body = build_request_body(TEST_MODEL, &turns);
|
|
let body = build_request_body(TEST_MODEL, &turns);
|
|
|
|
|
|
|
|
- let ok = format_log_record("T0", "https://example.test/v1/chat/completions", &body, &Ok("risposta".into()));
|
|
|
|
|
|
|
+ let reply = Ok(Reply::Text("risposta".into()));
|
|
|
|
|
+ let ok = format_log_record("T0", "https://example.test/v1/chat/completions", &body, &reply);
|
|
|
|
|
+ let silent = format_log_record("T2", "ep", &body, &Ok(Reply::NoReply));
|
|
|
let failed = format_log_record("T1", "ep", &body, &Err(anyhow!("servizio AI ha risposto 500")));
|
|
let failed = format_log_record("T1", "ep", &body, &Err(anyhow!("servizio AI ha risposto 500")));
|
|
|
|
|
|
|
|
assert!(ok.contains(TEST_MODEL) && ok.contains("ciao") && ok.contains("T0"));
|
|
assert!(ok.contains(TEST_MODEL) && ok.contains("ciao") && ok.contains("T0"));
|
|
|
assert!(ok.contains(LOG_REPLY_LABEL) && ok.contains("risposta"));
|
|
assert!(ok.contains(LOG_REPLY_LABEL) && ok.contains("risposta"));
|
|
|
|
|
+ assert!(silent.contains(LOG_REPLY_LABEL) && silent.contains(LOG_NO_REPLY_TEXT));
|
|
|
assert!(failed.contains(LOG_ERROR_LABEL) && failed.contains("servizio AI ha risposto 500"));
|
|
assert!(failed.contains(LOG_ERROR_LABEL) && failed.contains("servizio AI ha risposto 500"));
|
|
|
assert!(!ok.contains(TEST_API_KEY) && !failed.contains(TEST_API_KEY));
|
|
assert!(!ok.contains(TEST_API_KEY) && !failed.contains(TEST_API_KEY));
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
#[test]
|
|
#[test]
|
|
|
fn log_record_never_contains_image_base64() {
|
|
fn log_record_never_contains_image_base64() {
|
|
|
- let turns = [ChatTurn { role: Role::User, content: "look".into() }];
|
|
|
|
|
- let body = build_vision_request_body("vision-model", &turns, &test_image(vec![1, 2, 3]));
|
|
|
|
|
|
|
+ let body = build_description_body("vision-model", DEFAULT_IMAGE_DESCRIPTION_REQUEST, &test_image(vec![1, 2, 3]));
|
|
|
|
|
|
|
|
- let record = format_log_record("T", "ep", &body, &Ok("ok".into()));
|
|
|
|
|
|
|
+ let record = format_log_record("T", "ep", &body, &Ok(Reply::Text("ok".into())));
|
|
|
|
|
|
|
|
assert!(!record.contains("AQID") && record.contains("<omessi 4 byte>"));
|
|
assert!(!record.contains("AQID") && record.contains("<omessi 4 byte>"));
|
|
|
}
|
|
}
|