Skip to main content

ai_agent/services/api/
files_api.rs

1// Source: /data/home/swei/claudecode/openclaudecode/src/services/api/filesApi.ts
2//! Files API client for managing files
3//!
4//! This module provides functionality to download and upload files to Anthropic Public Files API.
5//! Used by the Claude Code agent to download file attachments at session startup.
6//!
7//! API Reference: https://docs.anthropic.com/en/api/files-content
8
9use crate::utils::http::get_user_agent;
10use std::collections::HashMap;
11use std::path::PathBuf;
12
13use tokio::fs;
14
15/// Files API beta header
16const FILES_API_BETA_HEADER: &str = "files-api-2025-04-14,oauth-2025-04-20";
17const ANTHROPIC_VERSION: &str = "2023-06-01";
18
19/// Maximum file size: 500MB
20const MAX_FILE_SIZE_BYTES: usize = 500 * 1024 * 1024;
21
22const MAX_RETRIES: u32 = 3;
23const BASE_DELAY_MS: u64 = 500;
24
25/// Default concurrency limit for parallel downloads
26const DEFAULT_CONCURRENCY: usize = 5;
27
28/// Get default API base URL
29fn get_default_api_base_url() -> String {
30    std::env::var("AI_CODE_BASE_URL")
31        .or_else(|_| std::env::var("AI_CODE_API_BASE_URL"))
32        .unwrap_or_else(|_| "https://api.anthropic.com".to_string())
33}
34
35/// Log debug message
36fn log_debug(message: &str) {
37    log::debug!("[files-api] {}", message);
38}
39
40/// Log debug error message
41fn log_debug_error(message: &str) {
42    log::error!("[files-api] {}", message);
43}
44
45/// Sleep for specified milliseconds
46async fn sleep_ms(ms: u64) {
47    tokio::time::sleep(std::time::Duration::from_millis(ms)).await;
48}
49
50/// File specification parsed from CLI args
51/// Format: --file=<file_id>:<relative_path>
52#[derive(Debug, Clone)]
53pub struct File {
54    pub file_id: String,
55    pub relative_path: String,
56}
57
58/// Configuration for the files API client
59#[derive(Debug, Clone)]
60pub struct FilesApiConfig {
61    /// OAuth token for authentication (from session JWT)
62    pub oauth_token: String,
63    /// Base URL for the API (default: https://api.anthropic.com)
64    pub base_url: Option<String>,
65    /// Session ID for creating session-specific directories
66    pub session_id: String,
67}
68
69/// Result of a file download operation
70#[derive(Debug, Clone)]
71pub struct DownloadResult {
72    pub file_id: String,
73    pub path: String,
74    pub success: bool,
75    pub error: Option<String>,
76    pub bytes_written: Option<usize>,
77}
78
79/// Normalizes a relative path, strips redundant prefixes, and builds the full
80/// download path under {basePath}/{session_id}/uploads/.
81/// Returns None if the path is invalid (e.g., path traversal).
82pub fn build_download_path(
83    base_path: &str,
84    session_id: &str,
85    relative_path: &str,
86) -> Option<PathBuf> {
87    // Check for path traversal in original path
88    let normalized_original =
89        std::path::Path::new(relative_path)
90            .components()
91            .fold(PathBuf::new(), |mut acc, c| {
92                match c {
93                    std::path::Component::Normal(p) => acc.push(p),
94                    std::path::Component::ParentDir => {
95                        acc.pop();
96                    }
97                    _ => {}
98                }
99                acc
100            });
101
102    // Check for path traversal - original path shouldn't start with ".."
103    // after normalization, if we went up directories
104    let normalized_str = normalized_original.to_string_lossy().to_string();
105    if normalized_str.starts_with("..") || relative_path.starts_with("..") {
106        log_debug_error(&format!(
107            "Invalid file path: {}. Path must not traverse above workspace",
108            relative_path
109        ));
110        return None;
111    }
112
113    let uploads_base = PathBuf::from(base_path).join(session_id).join("uploads");
114
115    let redundant_prefixes = vec![
116        uploads_base.to_string_lossy().to_string() + std::path::MAIN_SEPARATOR_STR,
117        std::path::MAIN_SEPARATOR_STR.to_string() + "uploads" + std::path::MAIN_SEPARATOR_STR,
118    ];
119
120    let clean_path = redundant_prefixes
121        .iter()
122        .find_map(|p| {
123            if normalized_str.starts_with(p) {
124                Some(normalized_str[p.len()..].to_string())
125            } else {
126                None
127            }
128        })
129        .unwrap_or(normalized_str);
130
131    Some(uploads_base.join(clean_path))
132}
133
134/// Downloads a file and saves it to the session-specific workspace directory
135pub async fn download_and_save_file(
136    attachment: &File,
137    config: &FilesApiConfig,
138    base_path: &str,
139) -> DownloadResult {
140    let file_id = attachment.file_id.clone();
141    let relative_path = attachment.relative_path.clone();
142
143    let full_path = match build_download_path(base_path, &config.session_id, &relative_path) {
144        Some(p) => p,
145        None => {
146            return DownloadResult {
147                file_id: file_id.clone(),
148                path: String::new(),
149                success: false,
150                error: Some(format!("Invalid file path: {}", relative_path)),
151                bytes_written: None,
152            };
153        }
154    };
155
156    let full_path_str = full_path.to_string_lossy().to_string();
157    let base_url = config
158        .base_url
159        .clone()
160        .unwrap_or_else(get_default_api_base_url);
161    let url = format!("{}/v1/files/{}/content", base_url, file_id);
162
163    let client = match reqwest::Client::builder()
164        .timeout(std::time::Duration::from_millis(60000))
165        .build()
166    {
167        Ok(c) => c,
168        Err(e) => {
169            return DownloadResult {
170                file_id,
171                path: full_path_str,
172                success: false,
173                error: Some(e.to_string()),
174                bytes_written: None,
175            };
176        }
177    };
178
179    let mut headers = reqwest::header::HeaderMap::new();
180    headers.insert(
181        reqwest::header::AUTHORIZATION,
182        format!("Bearer {}", config.oauth_token).parse().unwrap(),
183    );
184    headers.insert("anthropic-version", ANTHROPIC_VERSION.parse().unwrap());
185    headers.insert("anthropic-beta", FILES_API_BETA_HEADER.parse().unwrap());
186    headers.insert("User-Agent", get_user_agent().parse().unwrap());
187
188    // Download with retries
189    let mut last_error = String::new();
190    for attempt in 1..=MAX_RETRIES {
191        let response = client.get(&url).headers(headers.clone()).send().await;
192
193        match response {
194            Ok(resp) => {
195                if !resp.status().is_success() {
196                    last_error = match resp.status() {
197                        s if s == reqwest::StatusCode::NOT_FOUND => {
198                            format!("File not found: {}", file_id)
199                        }
200                        s if s == reqwest::StatusCode::UNAUTHORIZED => {
201                            "Authentication failed".to_string()
202                        }
203                        s if s == reqwest::StatusCode::FORBIDDEN => {
204                            format!("Access denied to file: {}", file_id)
205                        }
206                        _ => format!("status {}", resp.status()),
207                    };
208                    // Non-retriable for 4xx
209                    if resp.status().is_client_error() {
210                        return DownloadResult {
211                            file_id,
212                            path: full_path_str,
213                            success: false,
214                            error: Some(last_error),
215                            bytes_written: None,
216                        };
217                    }
218                } else {
219                    // Success - read content
220                    match resp.bytes().await {
221                        Ok(bytes) => {
222                            let content = bytes.to_vec();
223                            // Ensure the parent directory exists
224                            if let Some(parent) = full_path.parent() {
225                                if let Err(e) = fs::create_dir_all(parent).await {
226                                    log_debug_error(&format!("Failed to create directory: {}", e));
227                                    return DownloadResult {
228                                        file_id,
229                                        path: full_path_str,
230                                        success: false,
231                                        error: Some(e.to_string()),
232                                        bytes_written: None,
233                                    };
234                                }
235                            }
236                            // Write the file
237                            match fs::write(&full_path, &content).await {
238                                Ok(_) => {
239                                    log_debug(&format!(
240                                        "Saved file {} to {} ({} bytes)",
241                                        file_id,
242                                        full_path.display(),
243                                        content.len()
244                                    ));
245                                    return DownloadResult {
246                                        file_id,
247                                        path: full_path_str,
248                                        success: true,
249                                        bytes_written: Some(content.len()),
250                                        error: None,
251                                    };
252                                }
253                                Err(e) => {
254                                    log_debug_error(&format!(
255                                        "Failed to write file {}: {}",
256                                        file_id, e
257                                    ));
258                                    return DownloadResult {
259                                        file_id,
260                                        path: full_path_str,
261                                        success: false,
262                                        error: Some(e.to_string()),
263                                        bytes_written: None,
264                                    };
265                                }
266                            }
267                        }
268                        Err(e) => {
269                            last_error = e.to_string();
270                        }
271                    }
272                }
273            }
274            Err(e) => {
275                last_error = e.to_string();
276            }
277        }
278
279        if attempt < MAX_RETRIES {
280            let delay_ms = BASE_DELAY_MS * 2u64.pow(attempt - 1);
281            log_debug(&format!(
282                "Download file {} attempt {}/{} failed: {}, retrying in {}ms",
283                file_id, attempt, MAX_RETRIES, last_error, delay_ms
284            ));
285            sleep_ms(delay_ms).await;
286        }
287    }
288
289    log_debug_error(&format!(
290        "Failed to download file {}: {}",
291        file_id, last_error
292    ));
293    DownloadResult {
294        file_id,
295        path: full_path_str,
296        success: false,
297        error: Some(format!("{} after {} attempts", last_error, MAX_RETRIES)),
298        bytes_written: None,
299    }
300}
301
302/// Downloads all file attachments for a session
303pub async fn download_session_files(
304    files: Vec<File>,
305    config: FilesApiConfig,
306    base_path: &str,
307    _concurrency: usize,
308) -> Vec<DownloadResult> {
309    if files.is_empty() {
310        return Vec::new();
311    }
312
313    log_debug(&format!(
314        "Downloading {} file(s) for session {}",
315        files.len(),
316        config.session_id
317    ));
318
319    let start_time = std::time::Instant::now();
320    let base_path_owned = base_path.to_string();
321
322    // Sequential for now
323    let file_count = files.len();
324    let mut results = Vec::with_capacity(file_count);
325    for file in files {
326        let result = download_and_save_file(&file, &config, &base_path_owned).await;
327        results.push(result);
328    }
329
330    let elapsed_ms = start_time.elapsed().as_millis() as u64;
331    let success_count = results.iter().filter(|r| r.success).count();
332    log_debug(&format!(
333        "Downloaded {}/{} file(s) in {}ms",
334        success_count, file_count, elapsed_ms
335    ));
336
337    results
338}
339
340// ============================================================================
341// Upload Functions (BYOC mode)
342// ============================================================================
343
344/// Result of a file upload operation
345#[derive(Debug, Clone)]
346pub enum UploadResult {
347    Success {
348        path: String,
349        file_id: String,
350        size: usize,
351    },
352    Failure {
353        path: String,
354        error: String,
355    },
356}
357
358/// Upload a single file to the Files API (BYOC mode)
359pub async fn upload_file(
360    file_path: &str,
361    relative_path: &str,
362    config: &FilesApiConfig,
363) -> UploadResult {
364    let base_url = config
365        .base_url
366        .clone()
367        .unwrap_or_else(get_default_api_base_url);
368    let url = format!("{}/v1/files", base_url);
369
370    log_debug(&format!(
371        "Uploading file {} as {}",
372        file_path, relative_path
373    ));
374
375    // Read file content first
376    let content = match fs::read(file_path).await {
377        Ok(c) => c,
378        Err(e) => {
379            return UploadResult::Failure {
380                path: relative_path.to_string(),
381                error: e.to_string(),
382            };
383        }
384    };
385
386    let file_size = content.len();
387
388    if file_size > MAX_FILE_SIZE_BYTES {
389        return UploadResult::Failure {
390            path: relative_path.to_string(),
391            error: format!(
392                "File exceeds maximum size of {} bytes (actual: {})",
393                MAX_FILE_SIZE_BYTES, file_size
394            ),
395        };
396    }
397
398    // Use UUID for boundary
399    let boundary = format!("----FormBoundary{}", uuid::Uuid::new_v4());
400    let filename = std::path::Path::new(relative_path)
401        .file_name()
402        .map(|n| n.to_string_lossy().to_string())
403        .unwrap_or_else(|| relative_path.to_string());
404
405    // Build the multipart body
406    let mut body_parts: Vec<Vec<u8>> = Vec::new();
407
408    // File part
409    body_parts.push(
410        format!(
411            "--{}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"{}\"\r\nContent-Type: application/octet-stream\r\n\r\n",
412            boundary, filename
413        )
414        .as_bytes()
415        .to_vec(),
416    );
417    body_parts.push(content);
418    body_parts.push(b"\r\n".to_vec());
419
420    // Purpose part
421    body_parts.push(
422        format!(
423            "--{}\r\nContent-Disposition: form-data; name=\"purpose\"\r\n\r\nuser_data\r\n",
424            boundary
425        )
426        .as_bytes()
427        .to_vec(),
428    );
429
430    // End boundary
431    body_parts.push(format!("--{}--\r\n", boundary).as_bytes().to_vec());
432
433    let body: Vec<u8> = body_parts.into_iter().flatten().collect();
434
435    let client = match reqwest::Client::builder()
436        .timeout(std::time::Duration::from_millis(120000))
437        .build()
438    {
439        Ok(c) => c,
440        Err(e) => {
441            return UploadResult::Failure {
442                path: relative_path.to_string(),
443                error: e.to_string(),
444            };
445        }
446    };
447
448    let mut headers = reqwest::header::HeaderMap::new();
449    headers.insert(
450        reqwest::header::AUTHORIZATION,
451        format!("Bearer {}", config.oauth_token).parse().unwrap(),
452    );
453    headers.insert("anthropic-version", ANTHROPIC_VERSION.parse().unwrap());
454    headers.insert("anthropic-beta", FILES_API_BETA_HEADER.parse().unwrap());
455    headers.insert(
456        reqwest::header::CONTENT_TYPE,
457        format!("multipart/form-data; boundary={}", boundary)
458            .parse()
459            .unwrap(),
460    );
461    headers.insert(
462        reqwest::header::CONTENT_LENGTH,
463        body.len().to_string().parse().unwrap(),
464    );
465    headers.insert("User-Agent", get_user_agent().parse().unwrap());
466
467    let mut last_error = String::new();
468    for attempt in 1..=MAX_RETRIES {
469        let response = client
470            .post(&url)
471            .headers(headers.clone())
472            .body(body.clone())
473            .send()
474            .await;
475
476        match response {
477            Ok(resp) => {
478                if resp.status() == reqwest::StatusCode::OK
479                    || resp.status() == reqwest::StatusCode::CREATED
480                {
481                    // Try to get the file ID from response
482                    match resp.json::<serde_json::Value>().await {
483                        Ok(data) => {
484                            let file_id_opt =
485                                data.get("id").and_then(|v| v.as_str()).map(String::from);
486
487                            if let Some(file_id) = file_id_opt {
488                                log_debug(&format!(
489                                    "Uploaded file {} -> {} ({} bytes)",
490                                    file_path, file_id, file_size
491                                ));
492                                return UploadResult::Success {
493                                    path: relative_path.to_string(),
494                                    file_id,
495                                    size: file_size,
496                                };
497                            } else {
498                                last_error = "Upload succeeded but no file ID returned".to_string();
499                            }
500                        }
501                        Err(e) => {
502                            last_error = e.to_string();
503                        }
504                    }
505                } else if resp.status().is_client_error() {
506                    // Non-retriable errors for 4xx
507                    let error_msg = match resp.status() {
508                        s if s == reqwest::StatusCode::UNAUTHORIZED => {
509                            "Authentication failed: invalid or missing API key".to_string()
510                        }
511                        s if s == reqwest::StatusCode::FORBIDDEN => {
512                            "Access denied for upload".to_string()
513                        }
514                        s if s == reqwest::StatusCode::PAYLOAD_TOO_LARGE => {
515                            "File too large for upload".to_string()
516                        }
517                        _ => format!("status {}", resp.status()),
518                    };
519                    return UploadResult::Failure {
520                        path: relative_path.to_string(),
521                        error: error_msg,
522                    };
523                } else {
524                    last_error = format!("status {}", resp.status());
525                }
526            }
527            Err(e) => {
528                last_error = e.to_string();
529            }
530        }
531
532        if attempt < MAX_RETRIES {
533            let delay_ms = BASE_DELAY_MS * 2u64.pow(attempt - 1);
534            log_debug(&format!(
535                "Upload file {} attempt {}/{} failed: {}, retrying in {}ms",
536                relative_path, attempt, MAX_RETRIES, last_error, delay_ms
537            ));
538            sleep_ms(delay_ms).await;
539        }
540    }
541
542    UploadResult::Failure {
543        path: relative_path.to_string(),
544        error: format!("{} after {} attempts", last_error, MAX_RETRIES),
545    }
546}
547
548/// Upload multiple files (BYOC mode)
549pub async fn upload_session_files(
550    files: Vec<(String, String)>, // (path, relative_path)
551    config: FilesApiConfig,
552    _concurrency: usize,
553) -> Vec<UploadResult> {
554    if files.is_empty() {
555        return Vec::new();
556    }
557
558    log_debug(&format!(
559        "Uploading {} file(s) for session {}",
560        files.len(),
561        config.session_id
562    ));
563
564    let start_time = std::time::Instant::now();
565
566    // Sequential for now
567    let file_count = files.len();
568    let mut results = Vec::with_capacity(file_count);
569    for (path, relative_path) in files {
570        let result = upload_file(&path, &relative_path, &config).await;
571        results.push(result);
572    }
573
574    let elapsed_ms = start_time.elapsed().as_millis() as u64;
575    let success_count = results
576        .iter()
577        .filter(|r| matches!(r, UploadResult::Success { .. }))
578        .count();
579    log_debug(&format!(
580        "Uploaded {}/{} file(s) in {}ms",
581        success_count, file_count, elapsed_ms
582    ));
583
584    results
585}
586
587// ============================================================================
588// List Files Functions (1P/Cloud mode)
589// ============================================================================
590
591/// File metadata returned from list_files_created_after
592#[derive(Debug, Clone)]
593pub struct FileMetadata {
594    pub filename: String,
595    pub file_id: String,
596    pub size: usize,
597}
598
599/// List files created after a given timestamp (1P/Cloud mode).
600pub async fn list_files_created_after(
601    after_created_at: &str,
602    config: &FilesApiConfig,
603) -> Result<Vec<FileMetadata>, String> {
604    let base_url = config
605        .base_url
606        .clone()
607        .unwrap_or_else(get_default_api_base_url);
608    let url = format!("{}/v1/files", base_url);
609
610    let mut headers = reqwest::header::HeaderMap::new();
611    headers.insert(
612        reqwest::header::AUTHORIZATION,
613        format!("Bearer {}", config.oauth_token).parse().unwrap(),
614    );
615    headers.insert("anthropic-version", ANTHROPIC_VERSION.parse().unwrap());
616    headers.insert("anthropic-beta", FILES_API_BETA_HEADER.parse().unwrap());
617    headers.insert("User-Agent", get_user_agent().parse().unwrap());
618
619    log_debug(&format!("Listing files created after {}", after_created_at));
620
621    let mut all_files: Vec<FileMetadata> = Vec::new();
622    let mut after_id: Option<String> = None;
623
624    let client = reqwest::Client::builder()
625        .timeout(std::time::Duration::from_millis(60000))
626        .build()
627        .map_err(|e| e.to_string())?;
628
629    loop {
630        let mut params = HashMap::new();
631        params.insert("after_created_at", after_created_at.to_string());
632
633        if let Some(ref aid) = after_id {
634            params.insert("after_id", aid.clone());
635        }
636
637        let response = client
638            .get(&url)
639            .headers(headers.clone())
640            .query(&params)
641            .send()
642            .await
643            .map_err(|e| e.to_string())?;
644
645        if response.status() != reqwest::StatusCode::OK {
646            if response.status().is_client_error() {
647                return Ok(Vec::new());
648            }
649            return Err(format!("status {}", response.status()));
650        }
651
652        let data: serde_json::Value = response.json().await.map_err(|e| e.to_string())?;
653
654        let files: Vec<FileMetadata> = data
655            .get("data")
656            .and_then(|v| v.as_array())
657            .map(|arr| {
658                arr.iter()
659                    .map(|f| FileMetadata {
660                        filename: f
661                            .get("filename")
662                            .and_then(|v| v.as_str())
663                            .unwrap_or("")
664                            .to_string(),
665                        file_id: f
666                            .get("id")
667                            .and_then(|v| v.as_str())
668                            .unwrap_or("")
669                            .to_string(),
670                        size: f.get("size_bytes").and_then(|v| v.as_u64()).unwrap_or(0) as usize,
671                    })
672                    .collect()
673            })
674            .unwrap_or_default();
675
676        all_files.extend(files);
677
678        let has_more = data
679            .get("has_more")
680            .and_then(|v| v.as_bool())
681            .unwrap_or(false);
682        if !has_more {
683            break;
684        }
685
686        if let Some(last_file) = all_files.last() {
687            after_id = Some(last_file.file_id.clone());
688        } else {
689            break;
690        }
691    }
692
693    log_debug(&format!(
694        "Listed {} files created after {}",
695        all_files.len(),
696        after_created_at
697    ));
698    Ok(all_files)
699}
700
701// ============================================================================
702// Parse Functions
703// ============================================================================
704
705/// Parse file attachment specs from CLI arguments
706/// Format: <file_id>:<relative_path>
707pub fn parse_file_specs(file_specs: Vec<String>) -> Vec<File> {
708    let mut files = Vec::new();
709
710    // Sandbox-gateway may pass multiple specs as a single space-separated string
711    let expanded_specs: Vec<String> = file_specs
712        .into_iter()
713        .flat_map(|s| {
714            s.split(' ')
715                .filter(|s2| !s2.is_empty())
716                .map(String::from)
717                .collect::<Vec<_>>()
718        })
719        .collect();
720
721    for spec in expanded_specs {
722        let Some(colon_index) = spec.find(':') else {
723            continue;
724        };
725
726        let file_id = spec[..colon_index].to_string();
727        let relative_path = spec[colon_index + 1..].to_string();
728
729        if file_id.is_empty() || relative_path.is_empty() {
730            log_debug_error(&format!(
731                "Invalid file spec: {}. Both file_id and path are required",
732                spec
733            ));
734            continue;
735        }
736
737        files.push(File {
738            file_id,
739            relative_path,
740        });
741    }
742
743    files
744}
745
746#[cfg(test)]
747mod tests {
748    use super::*;
749
750    #[test]
751    fn test_build_download_path_simple() {
752        let result = build_download_path("/workspace", "session123", "file.txt");
753        assert!(result.is_some());
754        let path = result.unwrap();
755        assert!(
756            path.to_string_lossy()
757                .ends_with("session123/uploads/file.txt")
758        );
759    }
760
761    #[test]
762    fn test_build_download_path_traversal() {
763        let result = build_download_path("/workspace", "session123", "../etc/passwd");
764        assert!(result.is_none());
765    }
766
767    #[test]
768    fn test_parse_file_specs_simple() {
769        let files = parse_file_specs(vec!["file_123:path/to/file.txt".to_string()]);
770        assert_eq!(files.len(), 1);
771        assert_eq!(files[0].file_id, "file_123");
772        assert_eq!(files[0].relative_path, "path/to/file.txt");
773    }
774
775    #[test]
776    fn test_parse_file_specs_multiple() {
777        let files = parse_file_specs(vec![
778            "file_1:path1.txt".to_string(),
779            "file_2:path2.txt".to_string(),
780        ]);
781        assert_eq!(files.len(), 2);
782    }
783
784    #[test]
785    fn test_parse_file_specs_invalid() {
786        let files = parse_file_specs(vec!["invalid_spec".to_string()]);
787        assert!(files.is_empty());
788    }
789
790    #[test]
791    fn test_parse_file_specs_spaced() {
792        let files = parse_file_specs(vec!["file_1:path1.txt file_2:path2.txt".to_string()]);
793        assert_eq!(files.len(), 2);
794    }
795}