Skip to main content

opendev_tools_impl/
vlm.rs

1//! Vision Language Model (VLM) tool — analyze images using vision-capable LLMs.
2//!
3//! Supports multiple providers (OpenAI, Fireworks, Anthropic) for image analysis.
4//! Images can be provided as local file paths (base64-encoded) or URLs.
5
6use 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
13/// Supported image file extensions and their MIME types.
14const 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
22/// Default request timeout in seconds for VLM calls.
23const VLM_TIMEOUT_SECS: u64 = 300;
24
25/// Tool for analyzing images using Vision Language Models.
26#[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        // Resolve the image URL
107        let final_image_url = if let Some(path_str) = image_path {
108            // Local file — encode to base64
109            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        // Get API key from environment
161        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        // Build the request
197        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        // Parse response
248        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;