gproxy_protocol/transform/video/
response.rs1use crate::{
2 transform::{Converted, Report, TransformError},
3 wire::{DeclaredFields, gemini::video as g, openai::video as o},
4};
5use std::collections::BTreeMap;
6
7#[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#[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}