use serde_json::{Value, json};
use crate::agent::protocol::AgentImage;
use crate::config::schema::AgentConfig;
use crate::provider::models::{model_by_id, read_models, sees_images, vision_model};
pub const TIMEOUT_MS: u64 = 60_000;
const INSTRUCTION: &str = "Describe this image for another model that cannot see it, in a way \
that lets it act. Transcribe any text, code, error message or stack trace exactly, \
including punctuation and line breaks. Describe the layout only where it carries meaning. \
Do not interpret, advise, or add anything that is not in the image.";
#[derive(Debug, Clone, PartialEq)]
pub struct Describer {
pub base_url: String,
pub model: String,
pub credential: String,
}
#[derive(Debug, Clone)]
pub struct PostResponse {
pub status: u16,
pub body: Value,
}
#[derive(Debug, Clone)]
pub struct PostRequest {
pub headers: Vec<(String, String)>,
pub body: String,
pub timeout_ms: u64,
}
pub trait Post: Send + Sync {
fn post(
&self,
url: String,
request: PostRequest,
) -> impl std::future::Future<Output = Result<PostResponse, String>> + Send;
}
#[derive(Debug, thiserror::Error)]
#[error("{0}")]
pub struct VisionError(pub String);
fn message_content(body: &Value) -> Option<String> {
body.get("choices")?
.as_array()?
.first()?
.get("message")?
.get("content")?
.as_str()
.map(str::to_owned)
}
fn detail(body: &Value) -> String {
if let Some(message) = body
.get("error")
.and_then(|error| error.get("message"))
.and_then(Value::as_str)
{
return message.to_owned();
}
body.get("message")
.and_then(Value::as_str)
.map_or_else(|| "it did not say why".to_owned(), str::to_owned)
}
pub async fn describe_images(
describer: &Describer,
images: &[AgentImage],
question: &str,
post: &impl Post,
) -> Result<String, VisionError> {
let asked = question.trim();
let text = if asked.is_empty() {
INSTRUCTION.to_owned()
} else {
format!("{INSTRUCTION}\n\nAsked: {asked}")
};
let mut content = vec![json!({ "type": "text", "text": text })];
content.extend(images.iter().map(|image| {
json!({
"type": "image_url",
"image_url": { "url": format!("data:{};base64,{}", image.mime_type, image.data) },
})
}));
let base = describer.base_url.trim_end_matches('/');
let answer = post
.post(
format!("{base}/chat/completions"),
PostRequest {
headers: vec![
(
"Authorization".to_owned(),
format!("Bearer {}", describer.credential),
),
("Content-Type".to_owned(), "application/json".to_owned()),
],
body: json!({
"model": describer.model,
"messages": [{ "role": "user", "content": content }],
})
.to_string(),
timeout_ms: TIMEOUT_MS,
},
)
.await
.map_err(|error| VisionError(format!("it could not be reached: {error}")))?;
if answer.status >= 400 {
return Err(VisionError(format!(
"{} refused to describe it: {}",
describer.model,
detail(&answer.body)
)));
}
let text = message_content(&answer.body);
match text {
Some(text) if !text.trim().is_empty() => Ok(text.trim().to_owned()),
_ => Err(VisionError(format!(
"{} returned no description",
describer.model
))),
}
}
pub fn described_block(model: &str, description: &str) -> String {
[
&format!("An image was attached. This session's model cannot see images, so {model}")[..],
"was asked to describe it. What follows is that description, not the image:",
"",
description,
]
.join("\n")
}
pub struct ImageDescriber<P: Post> {
pub model: String,
describer: Describer,
post: P,
}
impl<P: Post> ImageDescriber<P> {
pub async fn describe(
&self,
images: Vec<AgentImage>,
question: &str,
) -> Result<String, VisionError> {
let described = describe_images(&self.describer, &images, question, &self.post).await?;
Ok(described_block(&self.describer.model, &described))
}
}
pub fn image_describer<P: Post>(
agent: &AgentConfig,
directory: Option<&str>,
post: P,
) -> Option<ImageDescriber<P>> {
let models = read_models(directory, &agent.provider);
let own = model_by_id(&models, agent.model.as_deref());
if own.is_none() || sees_images(own) {
return None;
}
let chosen = vision_model(&models, agent.vision_model.as_deref())?;
let describer = Describer {
base_url: chosen.base_url.clone()?,
model: chosen.id.clone(),
credential: agent.credential.clone(),
};
Some(ImageDescriber {
model: chosen.id,
describer,
post,
})
}
#[derive(Clone, Copy, Default)]
pub struct HttpPost;
impl Post for HttpPost {
async fn post(&self, url: String, request: PostRequest) -> Result<PostResponse, String> {
let client = reqwest::Client::new();
let mut sent = client.post(url);
for (name, value) in &request.headers {
sent = sent.header(name.as_str(), value.as_str());
}
let answer = sent
.header("Content-Type", "application/json")
.body(request.body)
.timeout(std::time::Duration::from_millis(request.timeout_ms))
.send()
.await
.map_err(|error| error.to_string())?;
let status = answer.status().as_u16();
let body = answer.json::<serde_json::Value>().await.unwrap_or_default();
Ok(PostResponse { status, body })
}
}
#[cfg(test)]
mod tests;