1use crate::utils::http::get_user_agent;
10use std::collections::HashMap;
11use std::path::PathBuf;
12
13use tokio::fs;
14
15const FILES_API_BETA_HEADER: &str = "files-api-2025-04-14,oauth-2025-04-20";
17const ANTHROPIC_VERSION: &str = "2023-06-01";
18
19const MAX_FILE_SIZE_BYTES: usize = 500 * 1024 * 1024;
21
22const MAX_RETRIES: u32 = 3;
23const BASE_DELAY_MS: u64 = 500;
24
25const DEFAULT_CONCURRENCY: usize = 5;
27
28fn 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
35fn log_debug(message: &str) {
37 log::debug!("[files-api] {}", message);
38}
39
40fn log_debug_error(message: &str) {
42 log::error!("[files-api] {}", message);
43}
44
45async fn sleep_ms(ms: u64) {
47 tokio::time::sleep(std::time::Duration::from_millis(ms)).await;
48}
49
50#[derive(Debug, Clone)]
53pub struct File {
54 pub file_id: String,
55 pub relative_path: String,
56}
57
58#[derive(Debug, Clone)]
60pub struct FilesApiConfig {
61 pub oauth_token: String,
63 pub base_url: Option<String>,
65 pub session_id: String,
67}
68
69#[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
79pub fn build_download_path(
83 base_path: &str,
84 session_id: &str,
85 relative_path: &str,
86) -> Option<PathBuf> {
87 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 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
134pub 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 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 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 match resp.bytes().await {
221 Ok(bytes) => {
222 let content = bytes.to_vec();
223 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 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
302pub 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 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#[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
358pub 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 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 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 let mut body_parts: Vec<Vec<u8>> = Vec::new();
407
408 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 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 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 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 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
548pub async fn upload_session_files(
550 files: Vec<(String, String)>, 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 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#[derive(Debug, Clone)]
593pub struct FileMetadata {
594 pub filename: String,
595 pub file_id: String,
596 pub size: usize,
597}
598
599pub 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(¶ms)
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
701pub fn parse_file_specs(file_specs: Vec<String>) -> Vec<File> {
708 let mut files = Vec::new();
709
710 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}