Skip to main content

gproxy_protocol/transform/files/
mapping.rs

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/// Facts supplied by the host or a provider response. `None` means unknown and
30/// remains unknown through conversion.
31#[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}