1use std::collections::HashMap;
7use std::path::PathBuf;
8
9use opendev_tools_core::{BaseTool, ToolContext, ToolDisplayMeta, ToolResult};
10
11use crate::path_utils::validate_path_access;
12
13const IMAGE_MIME_TYPES: &[(&str, &str)] = &[
15 ("jpg", "image/jpeg"),
16 ("jpeg", "image/jpeg"),
17 ("png", "image/png"),
18 ("gif", "image/gif"),
19 ("webp", "image/webp"),
20];
21
22const VLM_TIMEOUT_SECS: u64 = 300;
24
25#[derive(Debug)]
27pub struct VlmTool;
28
29#[async_trait::async_trait]
30impl BaseTool for VlmTool {
31 fn name(&self) -> &str {
32 "vlm"
33 }
34
35 fn description(&self) -> &str {
36 "Analyze images using a Vision Language Model. Provide either a local \
37 image file path or a URL, along with a text prompt describing what to analyze."
38 }
39
40 fn parameter_schema(&self) -> serde_json::Value {
41 serde_json::json!({
42 "type": "object",
43 "properties": {
44 "prompt": {
45 "type": "string",
46 "description": "Text prompt describing what to analyze in the image"
47 },
48 "image_path": {
49 "type": "string",
50 "description": "Path to a local image file"
51 },
52 "image_url": {
53 "type": "string",
54 "description": "URL of an online image"
55 },
56 "provider": {
57 "type": "string",
58 "description": "Provider to use: 'openai' (default), 'fireworks', or 'anthropic'",
59 "enum": ["openai", "fireworks", "anthropic"]
60 },
61 "model": {
62 "type": "string",
63 "description": "Model ID to use (provider-specific)"
64 },
65 "max_tokens": {
66 "type": "integer",
67 "description": "Maximum tokens in the response (default: 4096)"
68 }
69 },
70 "required": ["prompt"]
71 })
72 }
73
74 async fn execute(
75 &self,
76 args: HashMap<String, serde_json::Value>,
77 ctx: &ToolContext,
78 ) -> ToolResult {
79 let prompt = match args.get("prompt").and_then(|v| v.as_str()) {
80 Some(p) if !p.trim().is_empty() => p,
81 _ => return ToolResult::fail("prompt is required"),
82 };
83
84 let image_path = args.get("image_path").and_then(|v| v.as_str());
85 let image_url = args.get("image_url").and_then(|v| v.as_str());
86
87 if image_path.is_none() && image_url.is_none() {
88 return ToolResult::fail("Either image_path or image_url must be provided");
89 }
90
91 let provider = args
92 .get("provider")
93 .and_then(|v| v.as_str())
94 .unwrap_or("openai");
95
96 let model = args
97 .get("model")
98 .and_then(|v| v.as_str())
99 .map(|s| s.to_string());
100
101 let max_tokens = args
102 .get("max_tokens")
103 .and_then(|v| v.as_u64())
104 .unwrap_or(4096) as u32;
105
106 let final_image_url = if let Some(path_str) = image_path {
108 let path = {
110 let p = PathBuf::from(path_str);
111 if p.is_absolute() {
112 p
113 } else {
114 ctx.working_dir.join(p)
115 }
116 };
117
118 if let Err(msg) = validate_path_access(&path, &ctx.working_dir) {
119 return ToolResult::fail(msg);
120 }
121
122 if !path.exists() {
123 return ToolResult::fail(format!("Image file not found: {path_str}"));
124 }
125
126 let data = match std::fs::read(&path) {
127 Ok(d) => d,
128 Err(e) => return ToolResult::fail(format!("Failed to read image file: {e}")),
129 };
130
131 let ext = path
132 .extension()
133 .and_then(|e| e.to_str())
134 .unwrap_or("jpeg")
135 .to_lowercase();
136
137 let mime_type = IMAGE_MIME_TYPES
138 .iter()
139 .find(|(e, _)| *e == ext)
140 .map(|(_, m)| *m)
141 .unwrap_or("image/jpeg");
142
143 use base64::Engine;
144 let b64 = base64::engine::general_purpose::STANDARD.encode(&data);
145 format!("data:{mime_type};base64,{b64}")
146 } else if let Some(url) = image_url {
147 if !url.starts_with("http://")
148 && !url.starts_with("https://")
149 && !url.starts_with("data:")
150 {
151 return ToolResult::fail(
152 "Invalid image URL: must start with http://, https://, or data:",
153 );
154 }
155 url.to_string()
156 } else {
157 unreachable!("Already checked above");
158 };
159
160 let (api_key_env, default_model, api_url) = match provider {
162 "openai" => (
163 "OPENAI_API_KEY",
164 "gpt-4o",
165 "https://api.openai.com/v1/chat/completions",
166 ),
167 "fireworks" => (
168 "FIREWORKS_API_KEY",
169 "accounts/fireworks/models/llama-v3p2-90b-vision-instruct",
170 "https://api.fireworks.ai/inference/v1/chat/completions",
171 ),
172 "anthropic" => {
173 return ToolResult::fail(
174 "Anthropic vision API requires a different request format. \
175 Please use 'openai' or 'fireworks' provider for VLM analysis.",
176 );
177 }
178 other => {
179 return ToolResult::fail(format!(
180 "Unsupported provider '{other}'. Use 'openai', 'fireworks', or 'anthropic'."
181 ));
182 }
183 };
184
185 let api_key = match std::env::var(api_key_env) {
186 Ok(k) if !k.is_empty() => k,
187 _ => {
188 return ToolResult::fail(format!(
189 "API key not found. Please set {api_key_env} environment variable."
190 ));
191 }
192 };
193
194 let model_id = model.as_deref().unwrap_or(default_model);
195
196 let payload = serde_json::json!({
198 "model": model_id,
199 "max_tokens": max_tokens,
200 "messages": [{
201 "role": "user",
202 "content": [
203 {"type": "text", "text": prompt},
204 {"type": "image_url", "image_url": {"url": final_image_url}}
205 ]
206 }]
207 });
208
209 let client = match reqwest::Client::builder()
210 .timeout(std::time::Duration::from_secs(VLM_TIMEOUT_SECS))
211 .build()
212 {
213 Ok(c) => c,
214 Err(e) => return ToolResult::fail(format!("Failed to create HTTP client: {e}")),
215 };
216
217 let response = match client
218 .post(api_url)
219 .header("Content-Type", "application/json")
220 .header("Authorization", format!("Bearer {api_key}"))
221 .header("Accept", "application/json")
222 .json(&payload)
223 .send()
224 .await
225 {
226 Ok(r) => r,
227 Err(e) => {
228 if e.is_timeout() {
229 return ToolResult::fail(format!(
230 "Request timed out after {VLM_TIMEOUT_SECS} seconds"
231 ));
232 }
233 return ToolResult::fail(format!("Request failed: {e}"));
234 }
235 };
236
237 let status = response.status().as_u16();
238 let body = match response.text().await {
239 Ok(t) => t,
240 Err(e) => return ToolResult::fail(format!("Failed to read response: {e}")),
241 };
242
243 if status != 200 {
244 return ToolResult::fail(format!("HTTP {status}: {body}"));
245 }
246
247 let response_json: serde_json::Value = match serde_json::from_str(&body) {
249 Ok(v) => v,
250 Err(e) => return ToolResult::fail(format!("Failed to parse response: {e}")),
251 };
252
253 let content = response_json
254 .get("choices")
255 .and_then(|c| c.as_array())
256 .and_then(|arr| arr.first())
257 .and_then(|choice| choice.get("message"))
258 .and_then(|msg| msg.get("content"))
259 .and_then(|c| c.as_str())
260 .unwrap_or("")
261 .to_string();
262
263 if content.is_empty() {
264 return ToolResult::fail("VLM returned empty response");
265 }
266
267 let mut metadata = HashMap::new();
268 metadata.insert("model".into(), serde_json::json!(model_id));
269 metadata.insert("provider".into(), serde_json::json!(provider));
270
271 ToolResult::ok_with_metadata(content, metadata)
272 }
273
274 fn display_meta(&self) -> Option<ToolDisplayMeta> {
275 Some(ToolDisplayMeta {
276 verb: "Vision",
277 label: "image",
278 category: "Web",
279 primary_arg_keys: &["image_path", "image_url", "prompt"],
280 })
281 }
282}
283
284#[cfg(test)]
285#[path = "vlm_tests.rs"]
286mod tests;