Skip to main content

openhelm_http/
lib.rs

1use std::sync::Arc;
2
3use anyhow::{Context, Result};
4use async_trait::async_trait;
5use reqwest::Client;
6use serde_json::{Value, json};
7
8use openhelm_sdk::{Skill, Tool, ToolDefinition, ToolOutput};
9
10const DEFAULT_MAX_BODY_BYTES: usize = 15 * 1024 * 1024; // 15 MB
11
12struct HttpClient {
13    client: Client,
14    max_body_bytes: usize,
15}
16
17impl HttpClient {
18    fn new(max_body_bytes: usize) -> Self {
19        let client = Client::builder()
20            .user_agent("openhelm-http/0.1.0")
21            .build()
22            .expect("Failed to build HTTP client");
23        Self {
24            client,
25            max_body_bytes,
26        }
27    }
28}
29
30fn url_arg(args: &Value) -> Result<&str> {
31    args["url"]
32        .as_str()
33        .context("Missing required 'url' argument")
34}
35
36fn apply_headers(mut builder: reqwest::RequestBuilder, args: &Value) -> reqwest::RequestBuilder {
37    if let Some(headers) = args.get("headers").and_then(|header| header.as_object()) {
38        for (key, value) in headers {
39            if let Some(val) = value.as_str() {
40                builder = builder.header(key.as_str(), val);
41            }
42        }
43    }
44    builder
45}
46
47fn format_headers(headers: &reqwest::header::HeaderMap) -> String {
48    headers
49        .iter()
50        .filter_map(|(name, value)| {
51            value
52                .to_str()
53                .ok()
54                .map(|val| format!("  {}: {}\n", name, val))
55        })
56        .collect()
57}
58
59fn format_response(
60    status: reqwest::StatusCode,
61    headers: &reqwest::header::HeaderMap,
62    body: Option<&str>,
63    max_body_bytes: usize,
64) -> String {
65    let mut out = format!("Status: {}\nHeaders:\n{}", status, format_headers(headers));
66    if let Some(body) = body {
67        out.push('\n');
68        if body.len() > max_body_bytes {
69            out.push_str(&body[..max_body_bytes]);
70            out.push_str(&format!(
71                "\n\n--- truncated ({} bytes total, showing first {}) ---",
72                body.len(),
73                max_body_bytes
74            ));
75        } else {
76            out.push_str(body);
77        }
78    }
79    out
80}
81
82struct HttpGetTool(Arc<HttpClient>);
83
84#[async_trait]
85impl Tool for HttpGetTool {
86    fn name(&self) -> &'static str {
87        "http_get"
88    }
89
90    fn definition(&self) -> ToolDefinition {
91        ToolDefinition::function(
92            self.name(),
93            "Perform an HTTP GET request and return the response",
94            json!({
95                "type": "object",
96                "properties": {
97                    "url": { "type": "string", "description": "The URL to request" },
98                    "headers": {
99                        "type": "object",
100                        "description": "Optional HTTP headers as key-value pairs",
101                        "additionalProperties": { "type": "string" }
102                    }
103                },
104                "required": ["url"]
105            }),
106        )
107    }
108
109    async fn execute(&self, args: &Value) -> Result<ToolOutput> {
110        let url = url_arg(args)?;
111        let builder = apply_headers(self.0.client.get(url), args);
112        let response = builder.send().await.context("HTTP GET request failed")?;
113        let status = response.status();
114        let headers = response.headers().clone();
115        let body = response
116            .text()
117            .await
118            .context("Failed to read response body")?;
119
120        Ok(ToolOutput {
121            success: status.is_success(),
122            output: format_response(status, &headers, Some(&body), self.0.max_body_bytes),
123        })
124    }
125}
126
127struct HttpPostTool(Arc<HttpClient>);
128
129#[async_trait]
130impl Tool for HttpPostTool {
131    fn name(&self) -> &'static str {
132        "http_post"
133    }
134
135    fn definition(&self) -> ToolDefinition {
136        ToolDefinition::function(
137            self.name(),
138            "Perform an HTTP POST request with an optional JSON body and return the response",
139            json!({
140                "type": "object",
141                "properties": {
142                    "url": { "type": "string", "description": "The URL to request" },
143                    "headers": {
144                        "type": "object",
145                        "description": "Optional HTTP headers as key-value pairs",
146                        "additionalProperties": { "type": "string" }
147                    },
148                    "body": {
149                        "type": "object",
150                        "description": "Optional JSON body to send with the request"
151                    }
152                },
153                "required": ["url"]
154            }),
155        )
156    }
157
158    async fn execute(&self, args: &Value) -> Result<ToolOutput> {
159        let url = url_arg(args)?;
160        let mut builder = apply_headers(self.0.client.post(url), args);
161        if let Some(body) = args.get("body") {
162            builder = builder.json(body);
163        }
164        let response = builder.send().await.context("HTTP POST request failed")?;
165        let status = response.status();
166        let headers = response.headers().clone();
167        let body = response
168            .text()
169            .await
170            .context("Failed to read response body")?;
171
172        Ok(ToolOutput {
173            success: status.is_success(),
174            output: format_response(status, &headers, Some(&body), self.0.max_body_bytes),
175        })
176    }
177}
178
179struct HttpPutTool(Arc<HttpClient>);
180
181#[async_trait]
182impl Tool for HttpPutTool {
183    fn name(&self) -> &'static str {
184        "http_put"
185    }
186
187    fn definition(&self) -> ToolDefinition {
188        ToolDefinition::function(
189            self.name(),
190            "Perform an HTTP PUT request with an optional JSON body and return the response",
191            json!({
192                "type": "object",
193                "properties": {
194                    "url": { "type": "string", "description": "The URL to request" },
195                    "headers": {
196                        "type": "object",
197                        "description": "Optional HTTP headers as key-value pairs",
198                        "additionalProperties": { "type": "string" }
199                    },
200                    "body": {
201                        "type": "object",
202                        "description": "Optional JSON body to send with the request"
203                    }
204                },
205                "required": ["url"]
206            }),
207        )
208    }
209
210    async fn execute(&self, args: &Value) -> Result<ToolOutput> {
211        let url = url_arg(args)?;
212        let mut builder = apply_headers(self.0.client.put(url), args);
213        if let Some(body) = args.get("body") {
214            builder = builder.json(body);
215        }
216        let response = builder.send().await.context("HTTP PUT request failed")?;
217        let status = response.status();
218        let headers = response.headers().clone();
219        let body = response
220            .text()
221            .await
222            .context("Failed to read response body")?;
223
224        Ok(ToolOutput {
225            success: status.is_success(),
226            output: format_response(status, &headers, Some(&body), self.0.max_body_bytes),
227        })
228    }
229}
230
231struct HttpPatchTool(Arc<HttpClient>);
232
233#[async_trait]
234impl Tool for HttpPatchTool {
235    fn name(&self) -> &'static str {
236        "http_patch"
237    }
238
239    fn definition(&self) -> ToolDefinition {
240        ToolDefinition::function(
241            self.name(),
242            "Perform an HTTP PATCH request with an optional JSON body and return the response",
243            json!({
244                "type": "object",
245                "properties": {
246                    "url": { "type": "string", "description": "The URL to request" },
247                    "headers": {
248                        "type": "object",
249                        "description": "Optional HTTP headers as key-value pairs",
250                        "additionalProperties": { "type": "string" }
251                    },
252                    "body": {
253                        "type": "object",
254                        "description": "Optional JSON body to send with the request"
255                    }
256                },
257                "required": ["url"]
258            }),
259        )
260    }
261
262    async fn execute(&self, args: &Value) -> Result<ToolOutput> {
263        let url = url_arg(args)?;
264        let mut builder = apply_headers(self.0.client.patch(url), args);
265        if let Some(body) = args.get("body") {
266            builder = builder.json(body);
267        }
268        let response = builder.send().await.context("HTTP PATCH request failed")?;
269        let status = response.status();
270        let headers = response.headers().clone();
271        let body = response
272            .text()
273            .await
274            .context("Failed to read response body")?;
275
276        Ok(ToolOutput {
277            success: status.is_success(),
278            output: format_response(status, &headers, Some(&body), self.0.max_body_bytes),
279        })
280    }
281}
282
283struct HttpDeleteTool(Arc<HttpClient>);
284
285#[async_trait]
286impl Tool for HttpDeleteTool {
287    fn name(&self) -> &'static str {
288        "http_delete"
289    }
290
291    fn definition(&self) -> ToolDefinition {
292        ToolDefinition::function(
293            self.name(),
294            "Perform an HTTP DELETE request and return the response",
295            json!({
296                "type": "object",
297                "properties": {
298                    "url": { "type": "string", "description": "The URL to request" },
299                    "headers": {
300                        "type": "object",
301                        "description": "Optional HTTP headers as key-value pairs",
302                        "additionalProperties": { "type": "string" }
303                    }
304                },
305                "required": ["url"]
306            }),
307        )
308    }
309
310    async fn execute(&self, args: &Value) -> Result<ToolOutput> {
311        let url = url_arg(args)?;
312        let builder = apply_headers(self.0.client.delete(url), args);
313        let response = builder.send().await.context("HTTP DELETE request failed")?;
314        let status = response.status();
315        let headers = response.headers().clone();
316        let body = response
317            .text()
318            .await
319            .context("Failed to read response body")?;
320
321        Ok(ToolOutput {
322            success: status.is_success(),
323            output: format_response(status, &headers, Some(&body), self.0.max_body_bytes),
324        })
325    }
326}
327
328struct HttpHeadTool(Arc<HttpClient>);
329
330#[async_trait]
331impl Tool for HttpHeadTool {
332    fn name(&self) -> &'static str {
333        "http_head"
334    }
335
336    fn definition(&self) -> ToolDefinition {
337        ToolDefinition::function(
338            self.name(),
339            "Perform an HTTP HEAD request and return status and headers (no body)",
340            json!({
341                "type": "object",
342                "properties": {
343                    "url": { "type": "string", "description": "The URL to request" },
344                    "headers": {
345                        "type": "object",
346                        "description": "Optional HTTP headers as key-value pairs",
347                        "additionalProperties": { "type": "string" }
348                    }
349                },
350                "required": ["url"]
351            }),
352        )
353    }
354
355    async fn execute(&self, args: &Value) -> Result<ToolOutput> {
356        let url = url_arg(args)?;
357        let builder = apply_headers(self.0.client.head(url), args);
358        let response = builder.send().await.context("HTTP HEAD request failed")?;
359        let status = response.status();
360        let headers = response.headers().clone();
361
362        Ok(ToolOutput {
363            success: status.is_success(),
364            output: format_response(status, &headers, None, 0),
365        })
366    }
367}
368
369pub struct HttpSkill;
370
371#[async_trait]
372impl Skill for HttpSkill {
373    fn name(&self) -> &'static str {
374        "http"
375    }
376
377    async fn build_tools(&self, config: Option<&toml::Value>) -> Result<Vec<Box<dyn Tool>>> {
378        let max_body_bytes = config
379            .and_then(|cfg| cfg.get("max_body_bytes"))
380            .and_then(|val| val.as_integer())
381            .map(|bytes| bytes as usize)
382            .unwrap_or(DEFAULT_MAX_BODY_BYTES);
383
384        let client = Arc::new(HttpClient::new(max_body_bytes));
385
386        Ok(vec![
387            Box::new(HttpGetTool(client.clone())),
388            Box::new(HttpPostTool(client.clone())),
389            Box::new(HttpPutTool(client.clone())),
390            Box::new(HttpPatchTool(client.clone())),
391            Box::new(HttpDeleteTool(client.clone())),
392            Box::new(HttpHeadTool(client)),
393        ])
394    }
395}