1use crate::{
2 Rest,
3 transform::{Converted, Report, TransformError},
4 wire::{claude, gemini, openai},
5};
6
7#[derive(Debug, Clone, PartialEq, Eq)]
8pub enum FilePurpose {
9 Assistants,
10 Batch,
11 FineTune,
12 UserData,
13 Vision,
14 AssistantsOutput,
15 BatchOutput,
16 FineTuneResults,
17 Other(String),
18}
19
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub enum FileStatusFacts {
22 Uploaded,
23 Processing,
24 Active,
25 Failed,
26 Unknown,
27}
28
29#[derive(Debug, Clone, PartialEq, Eq, Default)]
32pub struct FileFacts {
33 pub id: Option<String>,
34 pub filename: Option<String>,
35 pub mime: Option<String>,
36 pub bytes: Option<u64>,
37 pub created_at: Option<String>,
38 pub expires_at: Option<String>,
39 pub purpose: Option<FilePurpose>,
40 pub status: Option<FileStatusFacts>,
41 pub provider_scope: Option<String>,
42}
43
44fn require_id(facts: &FileFacts) -> Result<String, TransformError> {
45 facts
46 .id
47 .clone()
48 .filter(|id| !id.is_empty())
49 .ok_or_else(|| TransformError::missing_metadata("target file.id"))
50}
51
52pub fn openai_to_claude(
53 source: &openai::files::FileObject,
54 facts: &FileFacts,
55) -> Result<Converted<claude::files::FileMetadata>, TransformError> {
56 if source.bytes < 0 {
57 return Err(TransformError::invalid_result(
58 "file.bytes",
59 "negative source size",
60 ));
61 }
62 let id = require_id(facts)?;
63 let created = iso(facts
64 .created_at
65 .as_deref()
66 .unwrap_or(&source.created_at.to_string()))?;
67 let filename = facts
68 .filename
69 .clone()
70 .unwrap_or_else(|| source.filename.clone());
71 let mime = facts
72 .mime
73 .clone()
74 .ok_or_else(|| TransformError::missing_metadata("file.mime"))?;
75 let bytes = facts
76 .bytes
77 .or_else(|| u64::try_from(source.bytes).ok())
78 .ok_or_else(|| TransformError::missing_metadata("file.bytes"))?;
79 Ok(Converted {
80 value: claude::files::FileMetadata {
81 expires_at: facts
82 .expires_at
83 .as_ref()
84 .map(|v| iso(v).map(Some))
85 .map(crate::transform::optional)
86 .transpose()?
87 .flatten(),
88 id,
89 created_at: created,
90 filename,
91 mime_type: mime,
92 size_bytes: i64::try_from(bytes)
93 .map_err(|_| TransformError::shape("file.bytes", "size overflows target"))?,
94 type_: claude::files::FileType::File,
95 downloadable: None,
96 scope: None,
97 rest: Rest::new(),
98 },
99 report: mapping_report(),
100 })
101}
102
103pub fn openai_to_gemini(
104 source: &openai::files::FileObject,
105 facts: &FileFacts,
106) -> Result<Converted<gemini::files::File>, TransformError> {
107 let id = require_id(facts)?;
108 gemini_id(&id)?;
109 let source_size = u64::try_from(source.bytes)
110 .map_err(|_| TransformError::invalid_result("file.bytes", "negative size"))?;
111 Ok(Converted {
112 value: gemini::files::File {
113 name: Some(Some(id)),
114 display_name: Some(Some(
115 facts
116 .filename
117 .clone()
118 .unwrap_or_else(|| source.filename.clone()),
119 )),
120 mime_type: facts.mime.clone().map(Some),
121 size_bytes: Some(Some(gemini::files::FileSize::String(
122 facts.bytes.unwrap_or(source_size).to_string(),
123 ))),
124 create_time: Some(Some(iso(facts
125 .created_at
126 .as_deref()
127 .unwrap_or(&source.created_at.to_string()))?)),
128 update_time: None,
129 expiration_time: facts
130 .expires_at
131 .as_ref()
132 .map(|v| iso(v).map(Some))
133 .map(crate::transform::optional)
134 .transpose()?
135 .flatten()
136 .or(source
137 .expires_at
138 .flatten()
139 .map(|v| iso(&v.to_string()).map(Some))
140 .map(crate::transform::optional)
141 .transpose()?
142 .flatten()),
143 sha256_hash: None,
144 uri: None,
145 download_uri: None,
146 state: gemini_state(facts.status.as_ref()),
147 source: None,
148 video_metadata: None,
149 error: None,
150 rest: Rest::new(),
151 },
152 report: mapping_report(),
153 })
154}
155
156pub fn claude_to_openai(
157 source: &claude::files::FileMetadata,
158 facts: &FileFacts,
159) -> Result<Converted<openai::files::FileObject>, TransformError> {
160 openai_metadata(
161 source.size_bytes,
162 &source.filename,
163 &source.created_at,
164 facts,
165 )
166}
167
168fn openai_metadata(
169 source_size: i64,
170 filename: &str,
171 created: &str,
172 facts: &FileFacts,
173) -> Result<Converted<openai::files::FileObject>, TransformError> {
174 if source_size < 0 {
175 return Err(TransformError::invalid_result(
176 "file.bytes",
177 "negative size",
178 ));
179 }
180 let id = require_id(facts)?;
181 let created_at = epoch(facts.created_at.as_deref().unwrap_or(created))?;
182 let bytes = facts
183 .bytes
184 .or_else(|| u64::try_from(source_size).ok())
185 .ok_or_else(|| TransformError::missing_metadata("file.bytes"))?;
186 let purpose = match facts
187 .purpose
188 .as_ref()
189 .ok_or_else(|| TransformError::missing_metadata("file.purpose"))?
190 {
191 FilePurpose::Assistants => openai::files::FilePurpose::Assistants,
192 FilePurpose::Batch => openai::files::FilePurpose::Batch,
193 FilePurpose::FineTune => openai::files::FilePurpose::FineTune,
194 FilePurpose::UserData => openai::files::FilePurpose::UserData,
195 FilePurpose::Vision => openai::files::FilePurpose::Vision,
196 FilePurpose::AssistantsOutput => openai::files::FilePurpose::AssistantsOutput,
197 FilePurpose::BatchOutput => openai::files::FilePurpose::BatchOutput,
198 FilePurpose::FineTuneResults => openai::files::FilePurpose::FineTuneResults,
199 FilePurpose::Other(_) => {
200 return Err(TransformError::unsupported(
201 "file.purpose",
202 "purpose has no OpenAI equivalent",
203 ));
204 }
205 };
206 let status = match facts
207 .status
208 .as_ref()
209 .ok_or_else(|| TransformError::missing_metadata("file.status"))?
210 {
211 FileStatusFacts::Uploaded => openai::files::FileStatus::Uploaded,
212 FileStatusFacts::Active => openai::files::FileStatus::Processed,
213 FileStatusFacts::Processing => {
214 return Err(TransformError::unsupported(
215 "file.status",
216 "processing is not processed",
217 ));
218 }
219 FileStatusFacts::Failed => openai::files::FileStatus::Error,
220 FileStatusFacts::Unknown => return Err(TransformError::missing_metadata("file.status")),
221 };
222 Ok(Converted {
223 value: openai::files::FileObject {
224 id,
225 bytes: i64::try_from(bytes)
226 .map_err(|_| TransformError::shape("file.bytes", "size overflows target"))?,
227 created_at,
228 filename: facts
229 .filename
230 .clone()
231 .unwrap_or_else(|| filename.to_owned()),
232 object: openai::files::FileObjectType::File,
233 purpose,
234 status,
235 status_details: None,
236 expires_at: facts
237 .expires_at
238 .as_ref()
239 .map(|value| epoch(value).map(Some))
240 .map(crate::transform::optional)
241 .transpose()?
242 .flatten(),
243 rest: Rest::new(),
244 },
245 report: mapping_report(),
246 })
247}
248
249pub fn gemini_to_openai(
250 source: &gemini::files::File,
251 facts: &FileFacts,
252) -> Result<Converted<openai::files::FileObject>, TransformError> {
253 let size = gemini_size(source)?;
254 let filename = facts
255 .filename
256 .clone()
257 .or_else(|| source.display_name.clone().flatten())
258 .ok_or_else(|| TransformError::missing_metadata("file.filename"))?;
259 let created = facts
260 .created_at
261 .clone()
262 .or_else(|| source.create_time.clone().flatten())
263 .ok_or_else(|| TransformError::missing_metadata("file.created_at"))?;
264 let mut target_facts = facts.clone();
265 target_facts.created_at = Some(created);
266 target_facts.expires_at = target_facts
267 .expires_at
268 .or_else(|| source.expiration_time.clone().flatten());
269 if target_facts.status.is_none() {
270 target_facts.status =
271 source
272 .state
273 .as_ref()
274 .and_then(Option::as_ref)
275 .map(|state| match state {
276 gemini::files::FileState::Processing => FileStatusFacts::Processing,
277 gemini::files::FileState::Active => FileStatusFacts::Active,
278 gemini::files::FileState::Failed => FileStatusFacts::Failed,
279 gemini::files::FileState::StateUnspecified => FileStatusFacts::Unknown,
280 });
281 }
282 let size = i64::try_from(
283 facts
284 .bytes
285 .or(size)
286 .ok_or_else(|| TransformError::missing_metadata("file.bytes"))?,
287 )
288 .map_err(|_| TransformError::shape("file.bytes", "size exceeds i64"))?;
289 openai_metadata(
290 size,
291 &filename,
292 &target_facts.created_at.clone().unwrap(),
293 &target_facts,
294 )
295}
296
297pub fn claude_to_gemini(
298 source: &claude::files::FileMetadata,
299 facts: &FileFacts,
300) -> Result<Converted<gemini::files::File>, TransformError> {
301 let id = require_id(facts)?;
302 gemini_id(&id)?;
303 let size = u64::try_from(source.size_bytes)
304 .map_err(|_| TransformError::shape("file.bytes", "negative size"))?;
305 Ok(Converted {
306 value: gemini::files::File {
307 name: Some(Some(id)),
308 display_name: Some(Some(
309 facts
310 .filename
311 .clone()
312 .unwrap_or_else(|| source.filename.clone()),
313 )),
314 mime_type: Some(Some(
315 facts
316 .mime
317 .clone()
318 .unwrap_or_else(|| source.mime_type.clone()),
319 )),
320 size_bytes: Some(Some(gemini::files::FileSize::String(
321 facts.bytes.unwrap_or(size).to_string(),
322 ))),
323 create_time: Some(Some(iso(facts
324 .created_at
325 .as_deref()
326 .unwrap_or(&source.created_at))?)),
327 update_time: None,
328 expiration_time: facts
329 .expires_at
330 .as_ref()
331 .map(|v| iso(v).map(Some))
332 .map(crate::transform::optional)
333 .transpose()?
334 .flatten(),
335 sha256_hash: None,
336 uri: None,
337 download_uri: None,
338 state: gemini_state(facts.status.as_ref()),
339 source: None,
340 video_metadata: None,
341 error: None,
342 rest: Rest::new(),
343 },
344 report: mapping_report(),
345 })
346}
347
348pub fn gemini_to_claude(
349 source: &gemini::files::File,
350 facts: &FileFacts,
351) -> Result<Converted<claude::files::FileMetadata>, TransformError> {
352 let id = require_id(facts)?;
353 let source_size = gemini_size(source)?;
354 let bytes = facts
355 .bytes
356 .or(source_size)
357 .ok_or_else(|| TransformError::missing_metadata("file.bytes"))?;
358 let created = iso(&facts
359 .created_at
360 .clone()
361 .or_else(|| source.create_time.clone().flatten())
362 .ok_or_else(|| TransformError::missing_metadata("file.created_at"))?)?;
363 Ok(Converted {
364 value: claude::files::FileMetadata {
365 expires_at: facts
366 .expires_at
367 .as_ref()
368 .map(|v| iso(v).map(Some))
369 .map(crate::transform::optional)
370 .transpose()?
371 .flatten(),
372 id,
373 created_at: created,
374 filename: facts
375 .filename
376 .clone()
377 .or_else(|| source.display_name.as_ref().and_then(|v| v.clone()))
378 .ok_or_else(|| TransformError::missing_metadata("file.filename"))?,
379 mime_type: facts
380 .mime
381 .clone()
382 .or_else(|| source.mime_type.as_ref().and_then(|v| v.clone()))
383 .ok_or_else(|| TransformError::missing_metadata("file.mime"))?,
384 size_bytes: i64::try_from(bytes)
385 .map_err(|_| TransformError::shape("file.bytes", "size overflows target"))?,
386 type_: claude::files::FileType::File,
387 downloadable: None,
388 scope: None,
389 rest: Rest::new(),
390 },
391 report: mapping_report(),
392 })
393}
394
395fn date(value: &str) -> Result<time::OffsetDateTime, TransformError> {
396 if let Ok(seconds) = value.parse::<i64>() {
397 time::OffsetDateTime::from_unix_timestamp(seconds)
398 .map_err(|e| TransformError::shape("file.timestamp", e.to_string()))
399 } else {
400 time::OffsetDateTime::parse(value, &time::format_description::well_known::Rfc3339)
401 .map_err(|e| TransformError::shape("file.timestamp", e.to_string()))
402 }
403}
404
405fn iso(value: &str) -> Result<String, TransformError> {
406 date(value)?
407 .format(&time::format_description::well_known::Rfc3339)
408 .map_err(|e| TransformError::shape("file.timestamp", e.to_string()))
409}
410
411fn epoch(value: &str) -> Result<i64, TransformError> {
412 let value = date(value)?;
413 if value.nanosecond() != 0 {
414 return Err(TransformError::unsupported(
415 "file.timestamp",
416 "fractional timestamp cannot fit Unix seconds exactly",
417 ));
418 }
419 Ok(value.unix_timestamp())
420}
421
422fn gemini_size(source: &gemini::files::File) -> Result<Option<u64>, TransformError> {
423 source
424 .size_bytes
425 .as_ref()
426 .and_then(Option::as_ref)
427 .map(|value| match value {
428 gemini::files::FileSize::String(value) => value
429 .parse::<u64>()
430 .map_err(|_| TransformError::invalid_result("file.size", "invalid size")),
431 gemini::files::FileSize::Integer(value) => u64::try_from(*value)
432 .map_err(|_| TransformError::invalid_result("file.size", "negative size")),
433 })
434 .transpose()
435}
436
437fn gemini_id(value: &str) -> Result<(), TransformError> {
438 if !value.starts_with("files/") || value.len() == 6 {
439 Err(TransformError::shape(
440 "target file.name",
441 "files/{id} resource required",
442 ))
443 } else {
444 Ok(())
445 }
446}
447
448fn gemini_state(value: Option<&FileStatusFacts>) -> Option<Option<gemini::files::FileState>> {
449 match value {
450 Some(FileStatusFacts::Processing) => Some(Some(gemini::files::FileState::Processing)),
451 Some(FileStatusFacts::Active) => Some(Some(gemini::files::FileState::Active)),
452 Some(FileStatusFacts::Failed) => Some(Some(gemini::files::FileState::Failed)),
453 Some(FileStatusFacts::Uploaded | FileStatusFacts::Unknown) | None => None,
454 }
455}
456
457fn mapping_report() -> Report {
458 let mut report = Report::default();
459 report.omitted("file.provider_metadata","native URLs/hash/scope/downloadability and provider lifecycle details require separately bound resource facts");
460 report
461}