use super::types::{FileExpiration, FileObject};
use crate::{DeepSeekClient, DeepSeekError, api_post_multipart};
use reqwest::multipart;
pub async fn upload_file(
client: &DeepSeekClient,
filename: &str,
data: Vec<u8>,
expiration: Option<FileExpiration>,
) -> Result<FileObject, DeepSeekError> {
let part = multipart::Part::bytes(data)
.file_name(filename.to_string())
.mime_str(mime_from_filename(filename))?;
let mut form = multipart::Form::new()
.text("purpose", "user_data".to_string())
.part("file", part);
if let Some(exp) = expiration {
form = form
.text("expires_after[anchor]", exp.anchor)
.text("expires_after[seconds]", exp.seconds.to_string());
}
api_post_multipart("/files", form, client.clone()).await
}
pub async fn upload_file_from_path(
client: &DeepSeekClient,
path: impl AsRef<std::path::Path>,
expiration: Option<FileExpiration>,
) -> Result<FileObject, DeepSeekError> {
let path = path.as_ref();
let filename = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("image.jpg")
.to_string();
let data = tokio::fs::read(path).await?;
upload_file(client, &filename, data, expiration).await
}
fn mime_from_filename(filename: &str) -> &'static str {
let ext = filename
.rsplit_once('.')
.map(|(_, ext)| ext)
.unwrap_or_default()
.to_ascii_lowercase();
match ext.as_str() {
"jpg" | "jpeg" => "image/jpeg",
"png" => "image/png",
"gif" => "image/gif",
"webp" => "image/webp",
_ => "application/octet-stream",
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::chat::request::{ChatMessage, ChatRequestBuilder, UserContent, UserContentPart};
use crate::files::{FileListParams, delete_file, list_files, retrieve_file};
use crate::{DEFAULT_BASE_URL, DeepSeekClient, DeepSeekRequest};
#[test]
fn mime_inference_from_extension() {
assert_eq!(mime_from_filename("a.png"), "image/png");
assert_eq!(mime_from_filename("b.JPG"), "image/jpeg");
assert_eq!(mime_from_filename("c.jpeg"), "image/jpeg");
assert_eq!(mime_from_filename("d.gif"), "image/gif");
assert_eq!(mime_from_filename("e.webp"), "image/webp");
assert_eq!(mime_from_filename("f.txt"), "application/octet-stream");
assert_eq!(mime_from_filename("noext"), "application/octet-stream");
}
fn get_client() -> DeepSeekClient {
DeepSeekClient::new(
std::env::var("DEEPSEEK_API_KEY").expect("DEEPSEEK_API_KEY is not set"),
DEFAULT_BASE_URL.clone(),
)
}
fn crc32(data: &[u8]) -> u32 {
let mut table = [0u32; 256];
for i in 0..256u32 {
let mut c = i;
for _ in 0..8 {
c = if c & 1 != 0 {
0xEDB8_8320 ^ (c >> 1)
} else {
c >> 1
};
}
table[i as usize] = c;
}
let mut crc = 0xFFFF_FFFFu32;
for &b in data {
crc = table[((crc ^ b as u32) & 0xFF) as usize] ^ (crc >> 8);
}
crc ^ 0xFFFF_FFFF
}
fn png_chunk(chunk_type: &[u8; 4], data: &[u8]) -> Vec<u8> {
let mut crc_input = Vec::with_capacity(4 + data.len());
crc_input.extend_from_slice(chunk_type);
crc_input.extend_from_slice(data);
let mut chunk = Vec::with_capacity(12 + data.len());
chunk.extend_from_slice(&(data.len() as u32).to_be_bytes());
chunk.extend_from_slice(chunk_type);
chunk.extend_from_slice(data);
chunk.extend_from_slice(&crc32(&crc_input).to_be_bytes());
chunk
}
fn minimal_png() -> Vec<u8> {
let mut ihdr = Vec::new();
ihdr.extend_from_slice(&1u32.to_be_bytes()); ihdr.extend_from_slice(&1u32.to_be_bytes()); ihdr.push(8); ihdr.push(6); ihdr.push(0); ihdr.push(0); ihdr.push(0);
let raw_scanline = [0u8, 0, 0, 0, 0];
let len = raw_scanline.len() as u16;
let mut idat = vec![0x78, 0x01, 0x01];
idat.extend_from_slice(&len.to_le_bytes());
idat.extend_from_slice(&(!len).to_le_bytes());
idat.extend_from_slice(&raw_scanline);
idat.extend_from_slice(&0x0005_0001u32.to_be_bytes());
let mut png = vec![0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
png.extend_from_slice(&png_chunk(b"IHDR", &ihdr));
png.extend_from_slice(&png_chunk(b"IDAT", &idat));
png.extend_from_slice(&png_chunk(b"IEND", &[]));
png
}
#[tokio::test]
async fn files_upload_retrieve_use_and_delete_lifecycle() {
let client = get_client();
let image = minimal_png();
let uploaded = upload_file(&client, "opencode-test.png", image.clone(), None)
.await
.expect("upload should succeed");
assert!(uploaded.id.starts_with("file-api-"));
assert_eq!(uploaded.purpose, "user_data");
assert_eq!(uploaded.bytes, image.len() as u64);
assert_eq!(uploaded.filename, "opencode-test.png");
assert!(uploaded.expires_at.is_none());
let retrieved = retrieve_file(&client, &uploaded.id)
.await
.expect("retrieve should succeed");
assert_eq!(retrieved.id, uploaded.id);
let listed = list_files(
&client,
Some(FileListParams {
purpose: Some("user_data".to_string()),
..Default::default()
}),
)
.await
.expect("list should succeed");
assert!(listed.data.iter().any(|f| f.id == uploaded.id));
let req = ChatRequestBuilder::default()
.client(client.clone())
.model("deepseek-v4-flash-vision-exp")
.message(ChatMessage::User {
content: UserContent::Parts(vec![
UserContentPart::text("Reply with exactly one word: OK"),
UserContentPart::file_id(uploaded.id.clone()),
]),
name: None,
})
.max_tokens(16_u32)
.build()
.expect("request should build");
let resp = req.send().await.expect("vision request should succeed");
assert!(!resp.choices.is_empty());
let deleted = delete_file(&client, &uploaded.id)
.await
.expect("delete should succeed");
assert!(deleted.deleted);
let gone = retrieve_file(&client, &uploaded.id).await;
assert!(gone.is_err(), "deleted file should not be retrievable");
}
#[tokio::test]
async fn files_upload_with_expiration_sets_expires_at() {
let client = get_client();
let uploaded = upload_file(
&client,
"opencode-expiring-test.png",
minimal_png(),
Some(FileExpiration::created_at(3600)),
)
.await
.expect("upload should succeed");
assert!(uploaded.expires_at.is_some(), "expires_at should be set");
delete_file(&client, &uploaded.id)
.await
.expect("cleanup delete should succeed");
}
}