Skip to main content

agentik_sdk/resources/
files.rs

1use crate::types::{
2    FileObject, FileUploadParams, FileListParams, FileList, FileDownload,
3    UploadProgress, StorageInfo, AnthropicError, Result,
4};
5use crate::http::HttpClient;
6use std::sync::Arc;
7use std::time::{Duration, Instant};
8use tokio::time::sleep;
9
10/// Resource for managing files via the Anthropic Files API
11#[derive(Debug, Clone)]
12pub struct FilesResource {
13    http_client: Arc<HttpClient>,
14}
15
16impl FilesResource {
17    /// Create a new files resource
18    pub fn new(http_client: Arc<HttpClient>) -> Self {
19        Self { http_client }
20    }
21
22    /// Upload a file to the Anthropic API
23    /// 
24    /// # Arguments
25    /// * `params` - Upload parameters including content, filename, and purpose
26    /// 
27    /// # Returns
28    /// A new `FileObject` with the uploaded file information
29    /// 
30    /// # Errors
31    /// Returns an error if the upload fails or if the parameters are invalid
32    pub async fn upload(&self, params: FileUploadParams) -> Result<FileObject> {
33        // Validate parameters
34        params.validate()?;
35
36        // Create multipart form
37        let form = self.create_multipart_form(params)?;
38
39        let response = self
40            .http_client
41            .post("/v1/files")
42            .multipart(form)
43            .send()
44            .await?;
45
46        let file_object: FileObject = response.json().await?;
47        Ok(file_object)
48    }
49
50    /// Upload a file with progress tracking
51    /// 
52    /// # Arguments
53    /// * `params` - Upload parameters
54    /// * `progress_callback` - Called with progress updates during upload
55    /// 
56    /// # Returns
57    /// The uploaded `FileObject`
58    /// 
59    /// # Errors
60    /// Returns an error if the upload fails
61    pub async fn upload_with_progress<F>(
62        &self,
63        params: FileUploadParams,
64        mut progress_callback: F,
65    ) -> Result<FileObject>
66    where
67        F: FnMut(UploadProgress),
68    {
69        // Validate parameters
70        params.validate()?;
71
72        let total_size = params.content.len() as u64;
73        let start_time = Instant::now();
74
75        // Simulate upload progress (in a real implementation, this would track actual upload)
76        let mut uploaded = 0u64;
77        let chunk_size = (total_size / 20).max(1024); // 20 progress updates minimum
78
79        while uploaded < total_size {
80            let chunk = chunk_size.min(total_size - uploaded);
81            uploaded += chunk;
82
83            let elapsed = start_time.elapsed().as_secs_f64();
84            let speed = if elapsed > 0.0 { uploaded as f64 / elapsed } else { 0.0 };
85
86            let progress = UploadProgress::new(uploaded, total_size).with_speed(speed);
87            progress_callback(progress);
88
89            // Simulate upload time
90            sleep(Duration::from_millis(50)).await;
91        }
92
93        // Perform actual upload
94        self.upload(params).await
95    }
96
97    /// Retrieve a file by ID
98    /// 
99    /// # Arguments
100    /// * `file_id` - The ID of the file to retrieve
101    /// 
102    /// # Returns
103    /// The `FileObject` with current information
104    /// 
105    /// # Errors
106    /// Returns an error if the file is not found or if the request fails
107    pub async fn get(&self, file_id: &str) -> Result<FileObject> {
108        let response = self
109            .http_client
110            .get(&format!("/v1/files/{}", file_id))
111            .send()
112            .await?;
113
114        let file_object: FileObject = response.json().await?;
115        Ok(file_object)
116    }
117
118    /// List files with optional filtering and pagination
119    /// 
120    /// # Arguments
121    /// * `params` - Optional parameters for filtering and pagination
122    /// 
123    /// # Returns
124    /// A `FileList` containing files and pagination information
125    /// 
126    /// # Errors
127    /// Returns an error if the request fails
128    pub async fn list(&self, params: Option<FileListParams>) -> Result<FileList> {
129        let mut request = self.http_client.get("/v1/files");
130
131        if let Some(params) = params {
132            if let Some(purpose) = params.purpose {
133                request = request.query(&[("purpose", serde_json::to_string(&purpose)?)]);
134            }
135            if let Some(after) = params.after {
136                request = request.query(&[("after", after)]);
137            }
138            if let Some(limit) = params.limit {
139                request = request.query(&[("limit", limit.to_string())]);
140            }
141            if let Some(order) = params.order {
142                request = request.query(&[("order", serde_json::to_string(&order)?)]);
143            }
144        }
145
146        let response = request.send().await?;
147        let file_list: FileList = response.json().await?;
148        Ok(file_list)
149    }
150
151    /// Download file content
152    /// 
153    /// # Arguments
154    /// * `file_id` - The ID of the file to download
155    /// 
156    /// # Returns
157    /// A `FileDownload` containing the file content and metadata
158    /// 
159    /// # Errors
160    /// Returns an error if the file is not found or cannot be downloaded
161    pub async fn download(&self, file_id: &str) -> Result<FileDownload> {
162        let response = self
163            .http_client
164            .get(&format!("/v1/files/{}/content", file_id))
165            .send()
166            .await?;
167
168        let content_type = response
169            .headers()
170            .get("content-type")
171            .and_then(|v| v.to_str().ok())
172            .unwrap_or("application/octet-stream")
173            .to_string();
174
175        let content_disposition = response
176            .headers()
177            .get("content-disposition")
178            .and_then(|v| v.to_str().ok());
179
180        let filename = extract_filename_from_disposition(content_disposition)
181            .unwrap_or_else(|| format!("file_{}", file_id));
182
183        let content = response.bytes().await?;
184        let size = content.len() as u64;
185
186        Ok(FileDownload {
187            content: content.to_vec(),
188            content_type,
189            filename,
190            size,
191        })
192    }
193
194    /// Delete a file
195    /// 
196    /// # Arguments
197    /// * `file_id` - The ID of the file to delete
198    /// 
199    /// # Returns
200    /// The updated `FileObject` with deletion status
201    /// 
202    /// # Errors
203    /// Returns an error if the file cannot be deleted or if the request fails
204    pub async fn delete(&self, file_id: &str) -> Result<FileObject> {
205        let response = self
206            .http_client
207            .delete(&format!("/v1/files/{}", file_id))
208            .send()
209            .await?;
210
211        let file_object: FileObject = response.json().await?;
212        Ok(file_object)
213    }
214
215    /// Get storage information and quotas
216    /// 
217    /// # Returns
218    /// `StorageInfo` with current usage and quotas
219    /// 
220    /// # Errors
221    /// Returns an error if the request fails
222    pub async fn get_storage_info(&self) -> Result<StorageInfo> {
223        let response = self
224            .http_client
225            .get("/v1/files/storage")
226            .send()
227            .await?;
228
229        let storage_info: StorageInfo = response.json().await?;
230        Ok(storage_info)
231    }
232
233    /// Wait for a file to be processed
234    /// 
235    /// # Arguments
236    /// * `file_id` - The ID of the file to wait for
237    /// * `poll_interval` - How often to check the status (default: 2 seconds)
238    /// * `timeout` - Maximum time to wait (default: 5 minutes)
239    /// 
240    /// # Returns
241    /// The processed `FileObject`
242    /// 
243    /// # Errors
244    /// Returns an error if the file processing fails or times out
245    pub async fn wait_for_processing(
246        &self,
247        file_id: &str,
248        poll_interval: Option<Duration>,
249        timeout: Option<Duration>,
250    ) -> Result<FileObject> {
251        let poll_interval = poll_interval.unwrap_or(Duration::from_secs(2));
252        let timeout = timeout.unwrap_or(Duration::from_secs(300)); // 5 minutes
253
254        let start_time = Instant::now();
255
256        loop {
257            let file = self.get(file_id).await?;
258
259            if file.status.is_ready() {
260                return Ok(file);
261            }
262
263            if file.status.has_error() {
264                return Err(AnthropicError::Other(format!(
265                    "File processing failed for file {}",
266                    file_id
267                )));
268            }
269
270            if start_time.elapsed() > timeout {
271                return Err(AnthropicError::Timeout);
272            }
273
274            sleep(poll_interval).await;
275        }
276    }
277
278    /// Create a multipart form for file upload
279    fn create_multipart_form(&self, params: FileUploadParams) -> Result<reqwest::multipart::Form> {
280        let mut form = reqwest::multipart::Form::new();
281
282        // Add file content
283        let file_part = reqwest::multipart::Part::bytes(params.content)
284            .file_name(params.filename.clone())
285            .mime_str(&params.content_type)?;
286        form = form.part("file", file_part);
287
288        // Add purpose
289        form = form.text("purpose", serde_json::to_string(&params.purpose)?);
290
291        // Add metadata if present
292        if !params.metadata.is_empty() {
293            form = form.text("metadata", serde_json::to_string(&params.metadata)?);
294        }
295
296        Ok(form)
297    }
298}
299
300/// High-level file management utilities
301impl FilesResource {
302    /// Upload multiple files concurrently
303    /// 
304    /// # Arguments
305    /// * `uploads` - Vector of upload parameters
306    /// * `max_concurrent` - Maximum number of concurrent uploads
307    /// 
308    /// # Returns
309    /// Vector of uploaded file objects
310    /// 
311    /// # Errors
312    /// Returns an error if any upload fails
313    pub async fn upload_batch(
314        &self,
315        uploads: Vec<FileUploadParams>,
316        max_concurrent: Option<usize>,
317    ) -> Result<Vec<FileObject>> {
318        let max_concurrent = max_concurrent.unwrap_or(3);
319        let semaphore = Arc::new(tokio::sync::Semaphore::new(max_concurrent));
320        
321        let tasks: Vec<_> = uploads
322            .into_iter()
323            .map(|params| {
324                let files_resource = self.clone();
325                let semaphore = semaphore.clone();
326                
327                tokio::spawn(async move {
328                    let _permit = semaphore.acquire().await.unwrap();
329                    files_resource.upload(params).await
330                })
331            })
332            .collect();
333
334        let mut results = Vec::new();
335        for task in tasks {
336            let result = task.await.map_err(|e| AnthropicError::Other(e.to_string()))??;
337            results.push(result);
338        }
339
340        Ok(results)
341    }
342
343    /// Clean up old files based on age
344    /// 
345    /// # Arguments
346    /// * `max_age` - Maximum age for files to keep
347    /// 
348    /// # Returns
349    /// Number of files deleted
350    /// 
351    /// # Errors
352    /// Returns an error if the cleanup operation fails
353    pub async fn cleanup_old_files(&self, max_age: Duration) -> Result<u32> {
354        let files = self.list(None).await?;
355        let cutoff_time = chrono::Utc::now() - chrono::Duration::from_std(max_age)?;
356        
357        let mut deleted_count = 0;
358        
359        for file in files.data {
360            if file.created_at < cutoff_time {
361                if let Ok(_) = self.delete(&file.id).await {
362                    deleted_count += 1;
363                }
364            }
365        }
366        
367        Ok(deleted_count)
368    }
369
370    /// Get files by purpose with optional filtering
371    /// 
372    /// # Arguments
373    /// * `purpose` - File purpose to filter by
374    /// * `limit` - Maximum number of files to return
375    /// 
376    /// # Returns
377    /// Vector of matching file objects
378    /// 
379    /// # Errors
380    /// Returns an error if the request fails
381    pub async fn get_files_by_purpose(
382        &self,
383        purpose: crate::types::FilePurpose,
384        limit: Option<u32>,
385    ) -> Result<Vec<FileObject>> {
386        let params = FileListParams::new()
387            .purpose(purpose)
388            .limit(limit.unwrap_or(50));
389        
390        let file_list = self.list(Some(params)).await?;
391        Ok(file_list.data)
392    }
393}
394
395/// Helper function to extract filename from Content-Disposition header
396fn extract_filename_from_disposition(disposition: Option<&str>) -> Option<String> {
397    disposition.and_then(|d| {
398        // Look for filename="value" or filename*=UTF-8''value
399        if let Some(start) = d.find("filename=") {
400            let start = start + 9; // "filename=".len()
401            let rest = &d[start..];
402            
403            if rest.starts_with('"') {
404                // Quoted filename
405                rest[1..].split('"').next().map(|s| s.to_string())
406            } else {
407                // Unquoted filename
408                rest.split(';').next().map(|s| s.trim().to_string())
409            }
410        } else {
411            None
412        }
413    })
414}
415
416#[cfg(test)]
417mod tests {
418    use super::*;
419    use crate::types::FilePurpose;
420
421    #[test]
422    fn test_extract_filename_from_disposition() {
423        assert_eq!(
424            extract_filename_from_disposition(Some(r#"attachment; filename="test.txt""#)),
425            Some("test.txt".to_string())
426        );
427        
428        assert_eq!(
429            extract_filename_from_disposition(Some(r#"attachment; filename=test.txt"#)),
430            Some("test.txt".to_string())
431        );
432        
433        assert_eq!(
434            extract_filename_from_disposition(Some(r#"inline"#)),
435            None
436        );
437        
438        assert_eq!(
439            extract_filename_from_disposition(None),
440            None
441        );
442    }
443
444    #[test]
445    fn test_upload_params_creation() {
446        let params = FileUploadParams::new(
447            b"test content".to_vec(),
448            "test.txt",
449            "text/plain",
450            FilePurpose::Document,
451        );
452
453        assert_eq!(params.filename, "test.txt");
454        assert_eq!(params.content_type, "text/plain");
455        assert_eq!(params.purpose, FilePurpose::Document);
456        assert_eq!(params.content, b"test content");
457    }
458
459    #[test]
460    fn test_file_list_params_builder() {
461        let params = FileListParams::new()
462            .purpose(FilePurpose::Vision)
463            .limit(10)
464            .after("file_123");
465
466        assert_eq!(params.purpose, Some(FilePurpose::Vision));
467        assert_eq!(params.limit, Some(10));
468        assert_eq!(params.after, Some("file_123".to_string()));
469    }
470}