Skip to main content

gproxy_protocol/transform/video/
response.rs

1use crate::{
2    transform::{Converted, Report, TransformError},
3    wire::{DeclaredFields, gemini::video as g, openai::video as o},
4};
5use std::collections::BTreeMap;
6
7/// A host-registered mapping; source identity must match this operation before
8/// advertising its proxy polling URL. Generated bytes require published URLs.
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct OpenAiVideoResponseContext {
11    pub operation_name: String,
12    pub polling_url: String,
13    pub resources: BTreeMap<String, super::request::ResolvedVideoResource>,
14}
15
16/// Reverse mapping must use an actual Gemini-facing operation resource owned
17/// by the host, never reinterpret an arbitrary OpenRouter job ID as its name.
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct GeminiOperationContext {
20    pub source_id: String,
21    pub source_polling_url: String,
22    pub operation_name: String,
23}
24
25pub(super) fn operation_name(value: &str) -> Result<(), TransformError> {
26    let segments: Vec<_> = value.split('/').collect();
27    if segments.len() < 2
28        || segments[segments.len() - 2] != "operations"
29        || segments
30            .iter()
31            .any(|v| v.is_empty() || matches!(*v, "." | ".."))
32        || value.contains(['?', '#', '\\'])
33        || value.chars().any(char::is_control)
34    {
35        return Err(TransformError::shape(
36            "operation.name",
37            "valid operations/{id} resource required",
38        ));
39    }
40    Ok(())
41}
42
43pub fn gemini_operation_to_openai_response(
44    input: g::VideoOperation,
45    context: &OpenAiVideoResponseContext,
46) -> Result<Converted<o::VideoGenerationResponseBody>, TransformError> {
47    let input = input.into_declared();
48    let id = input
49        .name
50        .as_ref()
51        .filter(|v| !v.is_empty())
52        .ok_or_else(|| TransformError::missing_metadata("operation.name"))?;
53    operation_name(id)?;
54    if id != &context.operation_name {
55        return Err(TransformError::shape(
56            "operation.binding",
57            "operation name differs from registered polling context",
58        ));
59    }
60    let polling_url = super::resources::url(&context.polling_url)?;
61    if input.error.is_some() && input.response.is_some() {
62        return Err(TransformError::invalid_result(
63            "operation",
64            "error and response cannot coexist",
65        ));
66    }
67    if input.done != Some(true) && (input.error.is_some() || input.response.is_some()) {
68        return Err(TransformError::invalid_result(
69            "operation.done",
70            "result/error on pending operation",
71        ));
72    }
73    let mut report = Report::default();
74    if input.metadata.is_some() {
75        report.omitted("metadata", "OpenRouter response lacks operation metadata");
76    }
77    let (status, error, urls) = if let Some(error) = input.error {
78        let message = error
79            .message
80            .filter(|v| !v.is_empty())
81            .or_else(|| error.code.map(|v| format!("Google RPC code {v}")))
82            .ok_or_else(|| TransformError::missing_metadata("operation.error message/code"))?;
83        if error.details.is_some() {
84            report.omitted("error.details", "OpenRouter exposes error text only");
85        }
86        (o::VideoStatus::Failed, Some(message), None)
87    } else if input.done == Some(true) {
88        let result = input
89            .response
90            .and_then(|r| r.generate_video_response)
91            .ok_or_else(|| {
92                TransformError::invalid_result(
93                    "response.generateVideoResponse",
94                    "completed operation has no video result",
95                )
96            })?;
97        let filtered = result.rai_media_filtered_count.unwrap_or(0);
98        if filtered < 0 {
99            return Err(TransformError::invalid_result(
100                "raiMediaFilteredCount",
101                "negative count",
102            ));
103        }
104        let mut urls = Vec::new();
105        for sample in result.generated_samples.unwrap_or_default() {
106            let video = sample.video.ok_or_else(|| {
107                TransformError::invalid_result("generatedSamples.video", "sample missing video")
108            })?;
109            urls.push(super::resources::video_url(&video, &context.resources)?);
110        }
111        if filtered > 0 || result.rai_media_filtered_reasons.is_some() {
112            report.omitted(
113                "rai_filtering",
114                "OpenRouter has no filtered count/reason fields; surviving samples retained",
115            );
116        }
117        if urls.is_empty() {
118            if filtered > 0 {
119                (
120                    o::VideoStatus::Failed,
121                    Some(
122                        result
123                            .rai_media_filtered_reasons
124                            .filter(|v| !v.is_empty())
125                            .map(|v| v.join("; "))
126                            .unwrap_or_else(|| format!("{filtered} video samples filtered")),
127                    ),
128                    None,
129                )
130            } else {
131                return Err(TransformError::invalid_result(
132                    "generatedSamples",
133                    "completed operation produced no video",
134                ));
135            }
136        } else {
137            (o::VideoStatus::Completed, None, Some(urls))
138        }
139    } else {
140        (o::VideoStatus::InProgress, None, None)
141    };
142    let mut out = o::VideoGenerationResponseBody::builder(id.clone(), polling_url, status).build();
143    out.error = error;
144    out.unsigned_urls = urls;
145    Ok(Converted { value: out, report })
146}
147
148pub fn openai_response_to_gemini_operation(
149    input: o::VideoGenerationResponseBody,
150    context: &GeminiOperationContext,
151) -> Result<Converted<g::VideoOperation>, TransformError> {
152    let input = input.into_declared();
153    if input.id != context.source_id || input.polling_url != context.source_polling_url {
154        return Err(TransformError::shape(
155            "operation.binding",
156            "OpenRouter id/polling URL differ from stored invocation",
157        ));
158    }
159    super::resources::url(&input.polling_url)?;
160    operation_name(&context.operation_name)?;
161    let mut out = g::VideoOperation::builder()
162        .name(context.operation_name.clone())
163        .build();
164    let mut report = Report::default();
165    match input.status {
166        o::VideoStatus::Pending | o::VideoStatus::InProgress => {
167            if input.error.is_some() || input.unsigned_urls.as_ref().is_some_and(|v| !v.is_empty())
168            {
169                return Err(TransformError::invalid_result(
170                    "status",
171                    "pending response carries terminal error/videos",
172                ));
173            }
174            out.done = Some(false);
175        }
176        o::VideoStatus::Failed => {
177            if input.unsigned_urls.as_ref().is_some_and(|v| !v.is_empty()) {
178                return Err(TransformError::invalid_result(
179                    "status",
180                    "failed video carries successful URLs",
181                ));
182            }
183            let error = input
184                .error
185                .filter(|v| !v.is_empty())
186                .ok_or_else(|| TransformError::missing_metadata("failed video error"))?;
187            out.done = Some(true);
188            out.error = Some(g::GoogleRpcStatus::builder().message(error).build());
189        }
190        o::VideoStatus::Completed => {
191            if input.error.is_some() {
192                return Err(TransformError::invalid_result(
193                    "status/error",
194                    "completed response has error",
195                ));
196            }
197            let urls = input
198                .unsigned_urls
199                .filter(|v| !v.is_empty())
200                .ok_or_else(|| {
201                    TransformError::invalid_result(
202                        "unsigned_urls",
203                        "completed video requires actual URLs",
204                    )
205                })?;
206            let samples = urls
207                .into_iter()
208                .map(|url| {
209                    super::resources::url(&url).map(|url| {
210                        g::GeneratedVideoSample::builder()
211                            .video(g::Video::builder().uri(url).build())
212                            .build()
213                    })
214                })
215                .filter_map(|value| crate::transform::optional(value).transpose())
216                .collect::<Result<Vec<_>, _>>()?;
217            out.done = Some(true);
218            out.response = Some(
219                g::VideoOperationResponse::builder()
220                    .generate_video_response(
221                        g::GenerateVideoResponse::builder()
222                            .generated_samples(samples)
223                            .build(),
224                    )
225                    .build(),
226            );
227        }
228        o::VideoStatus::Cancelled => {
229            if input.unsigned_urls.as_ref().is_some_and(|v| !v.is_empty()) {
230                return Err(TransformError::invalid_result(
231                    "status",
232                    "cancelled response carries successful video URLs",
233                ));
234            }
235            out.done = Some(true);
236            out.error = Some(
237                g::GoogleRpcStatus::builder()
238                    .code(1)
239                    .message(
240                        input
241                            .error
242                            .unwrap_or_else(|| "Video generation cancelled".into()),
243                    )
244                    .build(),
245            );
246        }
247        o::VideoStatus::Expired => {
248            return Err(TransformError::unsupported(
249                "status.expired",
250                "expired OpenRouter result has no identical Gemini operation lifecycle",
251            ));
252        }
253    }
254    if input.generation_id.is_some() {
255        report.omitted(
256            "generation_id",
257            "Gemini has one operation resource identity",
258        );
259    }
260    if input.usage.is_some() {
261        report.omitted("usage", "Gemini operation lacks usage fields");
262    }
263    Ok(Converted { value: out, report })
264}