1use std::path::PathBuf;
4
5use schemars::JsonSchema;
6use serde::{Deserialize, Deserializer, Serialize, de};
7
8use crate::{
9 ArtifactCollisionPolicy, ArtifactMetadataPolicy, AspectRatio, Background, BatchExecution,
10 CompatibilityMode, FallbackPolicy, ImageAction, ImageSize, InputFidelity, Moderation,
11 MultiImageFailurePolicy, NegativePromptMode, OutputFormat, Quality, Resolution, ResponseFormat,
12 RevisedPromptPolicy, SessionMode, TransparencyMode,
13};
14
15const COMMON_REQUEST_FIELDS: &[&str] = &[
16 "version",
17 "prompt",
18 "negative_prompt",
19 "parameters",
20 "routing",
21 "session",
22 "output",
23 "policies",
24 "idempotency_key",
25 "timeout_ms",
26 "user",
27];
28
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)]
31pub struct ImageRequest {
32 #[serde(default = "default_contract_version")]
34 pub version: String,
35 pub prompt: String,
37 #[serde(skip_serializing_if = "Option::is_none")]
39 pub negative_prompt: Option<String>,
40 #[serde(flatten)]
42 pub operation: ImageOperation,
43 #[serde(default)]
45 pub parameters: GenerationParameters,
46 #[serde(default)]
48 pub routing: RoutingOptions,
49 #[serde(default)]
51 pub session: SessionOptions,
52 #[serde(default)]
54 pub output: OutputOptions,
55 #[serde(default)]
57 pub policies: RequestPolicies,
58 #[serde(skip_serializing_if = "Option::is_none")]
60 pub idempotency_key: Option<String>,
61 #[serde(skip_serializing_if = "Option::is_none")]
63 pub timeout_ms: Option<u64>,
64 #[serde(skip_serializing_if = "Option::is_none")]
66 pub user: Option<String>,
67}
68
69impl<'de> Deserialize<'de> for ImageRequest {
70 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
71 where
72 D: Deserializer<'de>,
73 {
74 let mut object = serde_json::Map::<String, serde_json::Value>::deserialize(deserializer)?;
75 for field in object.keys() {
76 if field != "operation"
77 && field != "reference_images"
78 && field != "images"
79 && field != "mask"
80 && !COMMON_REQUEST_FIELDS.contains(&field.as_str())
81 {
82 return Err(de::Error::unknown_field(
83 field,
84 &[
85 "version",
86 "prompt",
87 "negative_prompt",
88 "operation",
89 "reference_images",
90 "images",
91 "mask",
92 "parameters",
93 "routing",
94 "session",
95 "output",
96 "policies",
97 "idempotency_key",
98 "timeout_ms",
99 "user",
100 ],
101 ));
102 }
103 }
104
105 let operation_tag = object
106 .remove("operation")
107 .ok_or_else(|| de::Error::missing_field("operation"))?;
108 let operation_name = operation_tag
109 .as_str()
110 .ok_or_else(|| de::Error::custom("operation must be a string"))?;
111 let operation_fields: &[&str] = match operation_name {
112 "generate" => &["reference_images"],
113 "edit" => &["images", "mask", "reference_images"],
114 _ => {
115 return Err(de::Error::unknown_variant(
116 operation_name,
117 &["generate", "edit"],
118 ));
119 }
120 };
121 for forbidden in ["images", "mask", "reference_images"] {
122 if object.contains_key(forbidden) && !operation_fields.contains(&forbidden) {
123 return Err(de::Error::custom(format!(
124 "field '{forbidden}' is invalid for operation '{operation_name}'"
125 )));
126 }
127 }
128 let mut operation = serde_json::Map::new();
129 operation.insert("operation".to_owned(), operation_tag);
130 for field in operation_fields {
131 if let Some(value) = object.remove(*field) {
132 operation.insert((*field).to_owned(), value);
133 }
134 }
135 let operation = serde_json::from_value(serde_json::Value::Object(operation))
136 .map_err(de::Error::custom)?;
137 let fields: RequestFields =
138 serde_json::from_value(serde_json::Value::Object(object)).map_err(de::Error::custom)?;
139 Ok(Self {
140 version: fields.version,
141 prompt: fields.prompt,
142 negative_prompt: fields.negative_prompt,
143 operation,
144 parameters: fields.parameters,
145 routing: fields.routing,
146 session: fields.session,
147 output: fields.output,
148 policies: fields.policies,
149 idempotency_key: fields.idempotency_key,
150 timeout_ms: fields.timeout_ms,
151 user: fields.user,
152 })
153 }
154}
155
156#[derive(Deserialize)]
157#[serde(deny_unknown_fields)]
158struct RequestFields {
159 #[serde(default = "default_contract_version")]
160 version: String,
161 prompt: String,
162 negative_prompt: Option<String>,
163 #[serde(default)]
164 parameters: GenerationParameters,
165 #[serde(default)]
166 routing: RoutingOptions,
167 #[serde(default)]
168 session: SessionOptions,
169 #[serde(default)]
170 output: OutputOptions,
171 #[serde(default)]
172 policies: RequestPolicies,
173 idempotency_key: Option<String>,
174 timeout_ms: Option<u64>,
175 user: Option<String>,
176}
177
178impl ImageRequest {
179 #[must_use]
181 pub fn generate(prompt: impl Into<String>) -> Self {
182 Self {
183 version: default_contract_version(),
184 prompt: prompt.into(),
185 negative_prompt: None,
186 operation: ImageOperation::Generate {
187 reference_images: Vec::new(),
188 },
189 parameters: GenerationParameters::default(),
190 routing: RoutingOptions::default(),
191 session: SessionOptions::default(),
192 output: OutputOptions::default(),
193 policies: RequestPolicies::default(),
194 idempotency_key: None,
195 timeout_ms: None,
196 user: None,
197 }
198 }
199}
200
201fn default_contract_version() -> String {
202 crate::CONTRACT_VERSION.to_owned()
203}
204
205#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
207#[serde(tag = "operation", rename_all = "snake_case", deny_unknown_fields)]
208pub enum ImageOperation {
209 Generate {
211 #[serde(default, skip_serializing_if = "Vec::is_empty")]
213 reference_images: Vec<ImageInput>,
214 },
215 Edit {
217 images: Vec<ImageInput>,
219 #[serde(skip_serializing_if = "Option::is_none")]
221 mask: Option<Box<ImageInput>>,
222 #[serde(default, skip_serializing_if = "Vec::is_empty")]
224 reference_images: Vec<ImageInput>,
225 },
226}
227
228impl ImageOperation {
229 #[must_use]
231 pub fn reference_images(&self) -> &[ImageInput] {
232 match self {
233 Self::Generate { reference_images }
234 | Self::Edit {
235 reference_images, ..
236 } => reference_images,
237 }
238 }
239}
240
241#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
243#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
244pub enum ImageSource {
245 File {
247 path: PathBuf,
249 },
250 Url {
252 url: String,
254 },
255 DataUrl {
257 data_url: String,
259 },
260 Base64 {
262 data: String,
264 },
265}
266
267#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)]
269#[schemars(deny_unknown_fields)]
270pub struct ImageInput {
271 #[serde(flatten)]
273 pub source: ImageSource,
274 #[serde(skip_serializing_if = "Option::is_none")]
276 pub media_type: Option<String>,
277 #[serde(skip_serializing_if = "Option::is_none")]
279 pub filename: Option<String>,
280}
281
282impl<'de> Deserialize<'de> for ImageInput {
283 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
284 where
285 D: Deserializer<'de>,
286 {
287 let mut fields = serde_json::Map::<String, serde_json::Value>::deserialize(deserializer)?;
288 let media_type = fields
289 .remove("media_type")
290 .map(serde_json::from_value::<Option<String>>)
291 .transpose()
292 .map_err(de::Error::custom)?
293 .flatten();
294 let filename = fields
295 .remove("filename")
296 .map(serde_json::from_value::<Option<String>>)
297 .transpose()
298 .map_err(de::Error::custom)?
299 .flatten();
300 let source =
301 serde_json::from_value(serde_json::Value::Object(fields)).map_err(de::Error::custom)?;
302 Ok(Self {
303 source,
304 media_type,
305 filename,
306 })
307 }
308}
309
310#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
312#[serde(default, deny_unknown_fields)]
313pub struct GenerationParameters {
314 pub n: u8,
316 pub size: ImageSize,
318 #[serde(skip_serializing_if = "Option::is_none")]
320 pub aspect_ratio: Option<AspectRatio>,
321 #[serde(skip_serializing_if = "Option::is_none")]
323 pub resolution: Option<Resolution>,
324 pub quality: Quality,
326 pub output_format: OutputFormat,
328 #[serde(skip_serializing_if = "Option::is_none")]
330 pub output_compression: Option<u8>,
331 pub background: Background,
333 pub moderation: Moderation,
335 pub partial_images: u8,
337 pub failure_policy: MultiImageFailurePolicy,
339 #[serde(skip_serializing_if = "Option::is_none")]
341 pub input_fidelity: Option<InputFidelity>,
342 pub action: ImageAction,
344}
345
346impl Default for GenerationParameters {
347 fn default() -> Self {
348 Self {
349 n: 1,
350 size: ImageSize::default(),
351 aspect_ratio: None,
352 resolution: None,
353 quality: Quality::default(),
354 output_format: OutputFormat::default(),
355 output_compression: None,
356 background: Background::default(),
357 moderation: Moderation::default(),
358 partial_images: 0,
359 failure_policy: MultiImageFailurePolicy::default(),
360 input_fidelity: None,
361 action: ImageAction::default(),
362 }
363 }
364}
365
366#[cfg(test)]
367mod serde_tests {
368 #![allow(clippy::unwrap_used)]
369
370 use super::*;
371
372 #[test]
373 fn image_request_round_trips_with_flattened_operation() {
374 let request = ImageRequest::generate("test");
375 let encoded = serde_json::to_value(&request).unwrap();
376 assert_eq!(encoded["operation"], "generate");
377 let decoded: ImageRequest = serde_json::from_value(encoded).unwrap();
378 assert_eq!(decoded, request);
379 }
380
381 #[test]
382 fn image_request_rejects_unknown_and_operation_specific_fields() {
383 let unknown = serde_json::json!({
384 "prompt": "test",
385 "operation": "generate",
386 "surprise": true
387 });
388 assert!(serde_json::from_value::<ImageRequest>(unknown).is_err());
389 let inconsistent = serde_json::json!({
390 "prompt": "test",
391 "operation": "generate",
392 "images": []
393 });
394 assert!(serde_json::from_value::<ImageRequest>(inconsistent).is_err());
395 }
396
397 #[test]
398 fn every_image_input_source_round_trips_and_rejects_unknown_fields() {
399 let inputs = [
400 ImageInput {
401 source: ImageSource::File {
402 path: PathBuf::from("fixture.png"),
403 },
404 media_type: Some("image/png".to_owned()),
405 filename: Some("fixture.png".to_owned()),
406 },
407 ImageInput {
408 source: ImageSource::Url {
409 url: "https://example.test/fixture.png".to_owned(),
410 },
411 media_type: None,
412 filename: None,
413 },
414 ImageInput {
415 source: ImageSource::DataUrl {
416 data_url: "data:image/png;base64,aW1hZ2U=".to_owned(),
417 },
418 media_type: None,
419 filename: None,
420 },
421 ImageInput {
422 source: ImageSource::Base64 {
423 data: "aW1hZ2U=".to_owned(),
424 },
425 media_type: Some("image/png".to_owned()),
426 filename: None,
427 },
428 ];
429 for input in inputs {
430 let encoded = serde_json::to_value(&input).unwrap();
431 assert_eq!(
432 serde_json::from_value::<ImageInput>(encoded).unwrap(),
433 input
434 );
435 }
436
437 let unknown = serde_json::json!({
438 "type": "url",
439 "url": "https://example.test/fixture.png",
440 "unexpected": true
441 });
442 assert!(serde_json::from_value::<ImageInput>(unknown).is_err());
443 }
444}
445
446#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
448#[serde(default, deny_unknown_fields)]
449pub struct RoutingOptions {
450 #[serde(skip_serializing_if = "Option::is_none")]
452 pub provider: Option<String>,
453 #[serde(skip_serializing_if = "Option::is_none")]
455 pub model: Option<String>,
456 #[serde(default, skip_serializing_if = "Vec::is_empty")]
458 pub fallbacks: Vec<ProviderRoute>,
459 pub fallback_policy: FallbackPolicy,
461}
462
463#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
465#[serde(deny_unknown_fields)]
466pub struct ProviderRoute {
467 pub provider: String,
469 #[serde(skip_serializing_if = "Option::is_none")]
471 pub model: Option<String>,
472}
473
474#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
476#[serde(default, deny_unknown_fields)]
477pub struct SessionOptions {
478 pub mode: SessionMode,
480 #[serde(skip_serializing_if = "Option::is_none")]
482 pub key: Option<String>,
483 #[serde(skip_serializing_if = "Option::is_none")]
485 pub thread_id: Option<String>,
486}
487
488#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
490#[serde(default, deny_unknown_fields)]
491pub struct OutputOptions {
492 pub response_format: ResponseFormat,
494 #[serde(skip_serializing_if = "Option::is_none")]
496 pub filename_prefix: Option<String>,
497 #[serde(skip_serializing_if = "Option::is_none")]
499 pub directory: Option<String>,
500 #[serde(skip_serializing_if = "Option::is_none")]
502 pub filename: Option<String>,
503 pub collision: ArtifactCollisionPolicy,
505 pub metadata: ArtifactMetadataPolicy,
507 pub transparency: TransparencyOptions,
509}
510
511#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
513#[serde(default, deny_unknown_fields)]
514pub struct TransparencyOptions {
515 pub mode: TransparencyMode,
517 #[serde(skip_serializing_if = "Option::is_none")]
519 pub key_color: Option<String>,
520 pub transparent_threshold: u8,
522 pub opaque_threshold: u8,
524 pub despill: bool,
526}
527
528impl Default for TransparencyOptions {
529 fn default() -> Self {
530 Self {
531 mode: TransparencyMode::Auto,
532 key_color: None,
533 transparent_threshold: 12,
534 opaque_threshold: 96,
535 despill: true,
536 }
537 }
538}
539
540#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
542#[serde(default, deny_unknown_fields)]
543pub struct RequestPolicies {
544 pub compatibility: CompatibilityMode,
546 pub negative_prompt: NegativePromptMode,
548 pub revised_prompt: RevisedPromptPolicy,
550 pub batch_execution: BatchExecution,
552}