use super::analyze_image::{AnalyzeImageTool, base64_encode, detect_mime_type};
use super::r#trait::{Tool, ToolCapability, ToolExecutionContext, ToolResult};
use crate::brain::provider::factory::VisionCandidate;
use async_trait::async_trait;
use serde_json::Value;
use uuid::Uuid;
pub(crate) const VISION_SETUP_HINT: &str = "Image analysis isn't set up yet. To enable it, either: (1) set a \
multimodal `vision_model` on your active provider via the \
`config_manager` tool (works for OpenAI-compatible providers), \
or (2) add a Google Gemini vision key via the `/image` \
wizard (or an `[image.vision]` section). It hot-reloads, so no \
restart is needed. Tell the user this.";
pub struct ProviderVisionTool {
pinned: Option<Vec<VisionCandidate>>,
gemini_fallback: Option<AnalyzeImageTool>,
}
impl Default for ProviderVisionTool {
fn default() -> Self {
Self::new()
}
}
impl ProviderVisionTool {
pub fn new() -> Self {
Self {
pinned: None,
gemini_fallback: None,
}
}
pub fn with_candidates(candidates: Vec<(String, String, String)>) -> Self {
Self {
pinned: Some(
candidates
.into_iter()
.map(|(api_key, base_url, model)| VisionCandidate {
api_key,
base_url,
model,
max_tokens: None,
})
.collect(),
),
gemini_fallback: None,
}
}
fn candidates_for(session_provider: Option<&str>) -> Vec<VisionCandidate> {
crate::brain::provider::factory::vision_candidates_for(
&crate::config::Config::current(),
session_provider,
)
}
pub fn with_gemini_fallback(mut self, api_key: String, model: String) -> Self {
self.gemini_fallback = Some(AnalyzeImageTool::new(api_key, model));
self
}
async fn fallback_or(
&self,
input: &Value,
context: &ToolExecutionContext,
primary: ToolResult,
reason: &str,
) -> super::error::Result<ToolResult> {
if let Some(fb) = &self.gemini_fallback {
tracing::warn!(
"analyze_image: provider vision failed ({reason}); falling back to Gemini"
);
return fb.execute(input.clone(), context).await;
}
Ok(primary)
}
}
pub(crate) const VISION_FAILURE_REASON_LIMIT: usize = 240;
pub(crate) fn clip_failure_reason(reason: &str) -> String {
if reason.chars().count() <= VISION_FAILURE_REASON_LIMIT {
return reason.to_string();
}
let mut clipped: String = reason.chars().take(VISION_FAILURE_REASON_LIMIT).collect();
clipped.push_str("... [truncated]");
clipped
}
pub(crate) fn format_vision_failures(failures: &[String]) -> String {
let mut out = format!("All {} provider vision candidates failed:", failures.len());
for (i, entry) in failures.iter().enumerate() {
out.push_str(&format!("\n [{}] {}", i + 1, entry));
}
out.push_str(
"\nEach line above is one tried candidate; correct or drop the offending \
[providers.*].vision_model or [fallback.vision] entry. Full reasons are \
in the log under 'analyze_image: vision candidate'.",
);
out
}
#[async_trait]
impl Tool for ProviderVisionTool {
fn name(&self) -> &str {
"analyze_image"
}
fn description(&self) -> &str {
"Analyze an image file (local path) or URL using the provider's vision model. \
Use when: the current model doesn't support vision, you need to analyze a saved file, \
or the user sends an image. The vision model describes the image so you can understand it."
}
fn input_schema(&self) -> Value {
serde_json::json!({
"type": "object",
"properties": {
"image": {
"type": "string",
"description": "Local file path (e.g. /home/user/photo.png) or HTTPS URL to the image"
},
"question": {
"type": "string",
"description": "What to ask about the image. Defaults to 'Describe this image in detail.'"
}
},
"required": ["image"]
})
}
fn capabilities(&self) -> Vec<ToolCapability> {
vec![ToolCapability::Network, ToolCapability::ReadFiles]
}
fn requires_approval(&self) -> bool {
false
}
async fn execute(
&self,
input: Value,
context: &ToolExecutionContext,
) -> super::error::Result<ToolResult> {
let image_src = match input["image"].as_str() {
Some(s) if !s.is_empty() => s.to_string(),
_ => {
return Ok(ToolResult::error(
"Missing required parameter: image".to_string(),
));
}
};
let question = input["question"]
.as_str()
.unwrap_or("Describe this image in detail.")
.to_string();
let image_url = if image_src.starts_with("http://") || image_src.starts_with("https://") {
image_src.clone()
} else {
let resolved = super::error::resolve_tool_path(&image_src, &context.working_dir());
let resolved_str = resolved.to_string_lossy().to_string();
let bytes = tokio::fs::read(&resolved).await.map_err(|e| {
super::error::ToolError::Execution(format!(
"Failed to read image file '{}': {}",
resolved_str, e
))
})?;
let mime = detect_mime_type(&resolved_str);
let b64 = base64_encode(&bytes);
format!("data:{};base64,{}", mime, b64)
};
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(120))
.build()
.map_err(|e| super::error::ToolError::Execution(e.to_string()))?;
let mut failures: Vec<String> = Vec::new();
let candidates = match &self.pinned {
Some(pinned) => pinned.clone(),
None => Self::candidates_for(context.session_provider.as_deref()),
};
if candidates.is_empty() && self.gemini_fallback.is_none() {
return Ok(ToolResult::error(VISION_SETUP_HINT.to_string()));
}
for candidate in &candidates {
match try_vision_candidate(
&client,
candidate,
&question,
&image_url,
Some(context.session_id),
)
.await
{
Ok(text) => return Ok(ToolResult::success(text)),
Err(reason) => {
tracing::warn!(
"analyze_image: vision candidate model={} url={} failed: {reason}, \
trying next candidate",
candidate.model,
candidate.base_url
);
failures.push(format!(
"{} @ {}: {}",
candidate.model,
candidate.base_url,
clip_failure_reason(&reason)
));
}
}
}
let summary = if failures.is_empty() {
"no vision candidates configured".to_string()
} else {
format_vision_failures(&failures)
};
if !failures.is_empty() {
tracing::error!("analyze_image: {summary}");
}
self.fallback_or(
&input,
context,
ToolResult::error(summary.clone()),
&summary,
)
.await
}
}
pub(crate) fn vision_headers(
base_url: &str,
api_key: &str,
session: Option<Uuid>,
) -> Vec<(String, String)> {
let mut headers = vec![
("Content-Type".to_string(), "application/json".to_string()),
("Authorization".to_string(), format!("Bearer {api_key}")),
];
headers.extend(crate::brain::provider::identity::headers_for(
base_url, session,
));
headers
}
pub(crate) const VISION_RETRY_MAX_TOKENS: u32 = 65000;
pub(crate) fn vision_request_body(
model: &str,
question: &str,
image_url: &str,
max_tokens: Option<u32>,
) -> Value {
let mut body = serde_json::json!({
"model": model,
"messages": [{
"role": "user",
"content": [
{ "type": "text", "text": question },
{ "type": "image_url", "image_url": { "url": image_url } }
]
}]
});
if let Some(cap) = max_tokens {
body["max_tokens"] = Value::from(cap);
}
body
}
pub(crate) fn needs_max_tokens(reason: &str) -> bool {
let m = reason.to_lowercase();
m.contains("max_tokens") || m.contains("max_completion_tokens")
}
pub(crate) async fn try_vision_candidate(
client: &reqwest::Client,
candidate: &VisionCandidate,
question: &str,
image_url: &str,
session: Option<Uuid>,
) -> std::result::Result<String, String> {
let api_key = &candidate.api_key;
let base_url = &candidate.base_url;
let vision_model = &candidate.model;
let send = |max_tokens: Option<u32>| {
let body = vision_request_body(vision_model, question, image_url, max_tokens);
let mut request = client.post(base_url.as_str());
for (key, value) in vision_headers(base_url, api_key, session) {
request = request.header(key.as_str(), value.as_str());
}
async move {
let response = request
.json(&body)
.send()
.await
.map_err(|e| format!("Vision request failed: {e}"))?;
if !response.status().is_success() {
let status = response.status().as_u16();
let err_body = response.text().await.unwrap_or_default();
return Err(format!("Vision API error {status}: {err_body}"));
}
let json: Value = response
.json()
.await
.map_err(|e| format!("Vision response was not JSON: {e}"))?;
let choice = json["choices"]
.as_array()
.and_then(|choices| choices.first());
if choice.and_then(|c| c["finish_reason"].as_str()) == Some("length") {
tracing::warn!(
"analyze_image: {vision_model} finished on 'length' (cap {max_tokens:?}); \
the answer may be truncated. Set providers.<name>.vision_max_tokens \
to raise it."
);
}
let result_text = choice
.and_then(|choice| choice["message"]["content"].as_str())
.unwrap_or("")
.to_string();
if result_text.is_empty() {
return Err("No text response from vision model".to_string());
}
Ok(result_text)
}
};
match send(candidate.max_tokens).await {
Ok(text) => Ok(text),
Err(reason) if candidate.max_tokens.is_none() && needs_max_tokens(&reason) => {
tracing::warn!(
"analyze_image: {vision_model} @ {base_url} rejected a request without \
max_tokens ({reason}); retrying once with {VISION_RETRY_MAX_TOKENS}"
);
send(Some(VISION_RETRY_MAX_TOKENS)).await
}
Err(reason) => Err(reason),
}
}
pub struct VisionSetupHintTool;
#[async_trait]
impl Tool for VisionSetupHintTool {
fn name(&self) -> &str {
"analyze_image"
}
fn description(&self) -> &str {
"Analyze an image. NOTE: image analysis is not configured on this \
install yet — calling this returns setup instructions to relay to the \
user."
}
fn input_schema(&self) -> Value {
serde_json::json!({
"type": "object",
"properties": {
"image": { "type": "string", "description": "Image path or URL" }
},
"required": ["image"]
})
}
fn capabilities(&self) -> Vec<ToolCapability> {
vec![]
}
fn requires_approval(&self) -> bool {
false
}
async fn execute(
&self,
_input: Value,
_context: &ToolExecutionContext,
) -> super::error::Result<ToolResult> {
Ok(ToolResult::error(VISION_SETUP_HINT.to_string()))
}
}