1use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5
6use crate::{
7 ArtifactCollisionPolicy, BridgeError, ErrorCode, ImageAction, ImageOperation, ImageRequest,
8 ImageSource, NegativePromptMode, OutputFormat, ResponseFormat, SessionMode,
9};
10
11const MAX_EMBEDDED_REQUEST_TEXT_BYTES: usize = 12 * 1024;
12const MAX_VALIDATION_ISSUES: usize = 64;
13const MAX_DETAILED_INPUT_VALIDATIONS: usize = 64;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
17#[serde(deny_unknown_fields)]
18pub struct RequestLimits {
19 pub max_prompt_bytes: usize,
21 pub max_negative_prompt_bytes: usize,
23 pub max_outputs: u8,
25 pub max_inputs: usize,
27 pub max_inline_encoded_bytes: usize,
29 pub max_edge: u32,
31 pub max_timeout_ms: u64,
33 pub max_identifier_bytes: usize,
35}
36
37impl Default for RequestLimits {
38 fn default() -> Self {
39 Self {
40 max_prompt_bytes: 128 * 1024,
41 max_negative_prompt_bytes: 64 * 1024,
42 max_outputs: 16,
43 max_inputs: 16,
44 max_inline_encoded_bytes: 64 * 1024 * 1024,
45 max_edge: 16_384,
46 max_timeout_ms: 30 * 60 * 1_000,
47 max_identifier_bytes: 256,
48 }
49 }
50}
51
52#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
54#[serde(deny_unknown_fields)]
55pub struct ValidationIssue {
56 pub field: String,
58 pub code: String,
60 pub message: String,
62}
63
64pub fn validate_request(request: &ImageRequest, limits: RequestLimits) -> Result<(), BridgeError> {
66 let issues = validation_issues(request, limits);
67 if issues.is_empty() {
68 return Ok(());
69 }
70 Err(
71 BridgeError::new(ErrorCode::InvalidRequest, "request validation failed")
72 .with_detail("issues", issues),
73 )
74}
75
76#[must_use]
78pub fn validation_issues(request: &ImageRequest, limits: RequestLimits) -> Vec<ValidationIssue> {
79 let mut issues = Vec::with_capacity(MAX_VALIDATION_ISSUES);
80 let mut issues_truncated = false;
81 let mut issue = |field: &str, code: &str, message: &str| {
82 if issues.len() < MAX_VALIDATION_ISSUES - 1 {
83 issues.push(ValidationIssue {
84 field: field.to_owned(),
85 code: code.to_owned(),
86 message: message.to_owned(),
87 });
88 } else {
89 issues_truncated = true;
90 }
91 };
92
93 if request.version != crate::CONTRACT_VERSION {
94 issue(
95 "version",
96 "unsupported_version",
97 "only contract version 1 is supported",
98 );
99 }
100 if request.prompt.trim().is_empty() {
101 issue("prompt", "required", "prompt must not be empty");
102 } else if request.prompt.len() > limits.max_prompt_bytes {
103 issue(
104 "prompt",
105 "too_large",
106 "prompt exceeds the configured byte limit",
107 );
108 }
109 if let Some(negative_prompt) = &request.negative_prompt {
110 if negative_prompt.trim().is_empty() {
111 issue(
112 "negative_prompt",
113 "empty",
114 "negative prompt must be omitted instead of empty",
115 );
116 } else if negative_prompt.len() > limits.max_negative_prompt_bytes {
117 issue(
118 "negative_prompt",
119 "too_large",
120 "negative prompt exceeds the configured byte limit",
121 );
122 }
123 if request.policies.negative_prompt == NegativePromptMode::Reject {
124 issue(
125 "policies.negative_prompt",
126 "rejected_by_policy",
127 "negative prompt is present while its policy is reject",
128 );
129 }
130 }
131
132 if request.parameters.n == 0 || request.parameters.n > limits.max_outputs {
133 issue(
134 "parameters.n",
135 "out_of_range",
136 "output count is outside the configured generic range",
137 );
138 }
139 if let Some((width, height)) = request.parameters.size.dimensions()
140 && (width > limits.max_edge || height > limits.max_edge)
141 {
142 issue(
143 "parameters.size",
144 "out_of_range",
145 "an image edge exceeds the configured generic limit",
146 );
147 }
148 if !request.parameters.size.is_auto()
149 && (request.parameters.aspect_ratio.is_some() || request.parameters.resolution.is_some())
150 {
151 issue(
152 "parameters.size",
153 "ambiguous",
154 "explicit size cannot be combined with aspect_ratio or resolution hints",
155 );
156 }
157 if request
158 .parameters
159 .output_compression
160 .is_some_and(|value| value > 100)
161 {
162 issue(
163 "parameters.output_compression",
164 "out_of_range",
165 "output compression must be between 0 and 100",
166 );
167 }
168 if request.parameters.output_compression.is_some()
169 && !matches!(
170 request.parameters.output_format,
171 OutputFormat::Jpeg | OutputFormat::Webp
172 )
173 {
174 issue(
175 "parameters.output_compression",
176 "incompatible",
177 "output compression is valid only for jpeg or webp",
178 );
179 }
180 if request.parameters.partial_images > 3 {
181 issue(
182 "parameters.partial_images",
183 "out_of_range",
184 "partial image count must be between 0 and 3",
185 );
186 }
187
188 validate_identifier(
189 request.routing.provider.as_deref(),
190 "routing.provider",
191 limits,
192 &mut issue,
193 );
194 validate_identifier(
195 request.routing.model.as_deref(),
196 "routing.model",
197 limits,
198 &mut issue,
199 );
200 if request.routing.fallbacks.len() > 4 {
201 issue(
202 "routing.fallbacks",
203 "too_many",
204 "at most four fallback routes may be configured",
205 );
206 }
207 let mut fallback_names = std::collections::BTreeSet::new();
208 for (index, route) in request.routing.fallbacks.iter().enumerate() {
209 validate_identifier(
210 Some(&route.provider),
211 &format!("routing.fallbacks[{index}].provider"),
212 limits,
213 &mut issue,
214 );
215 validate_identifier(
216 route.model.as_deref(),
217 &format!("routing.fallbacks[{index}].model"),
218 limits,
219 &mut issue,
220 );
221 if !fallback_names.insert((&route.provider, route.model.as_deref())) {
222 issue(
223 &format!("routing.fallbacks[{index}]"),
224 "duplicate",
225 "fallback routes must be unique",
226 );
227 }
228 if request.routing.provider.as_deref() == Some(route.provider.as_str())
229 && request.routing.model.as_deref() == route.model.as_deref()
230 {
231 issue(
232 &format!("routing.fallbacks[{index}]"),
233 "duplicate",
234 "a fallback route must differ from the explicit primary route",
235 );
236 }
237 }
238 if !request.routing.fallbacks.is_empty() && request.session.mode != SessionMode::Isolated {
239 issue(
240 "routing.fallbacks",
241 "incompatible",
242 "provider fallback is supported only for isolated sessions",
243 );
244 }
245 if request.policies.batch_execution == crate::BatchExecution::Parallel
246 && request.session.mode != SessionMode::Isolated
247 {
248 issue(
249 "policies.batch_execution",
250 "incompatible",
251 "parallel batch execution requires an isolated session",
252 );
253 }
254 validate_identifier(
255 request.idempotency_key.as_deref(),
256 "idempotency_key",
257 limits,
258 &mut issue,
259 );
260 validate_identifier(request.user.as_deref(), "user", limits, &mut issue);
261
262 if request.output.transparency.transparent_threshold
263 >= request.output.transparency.opaque_threshold
264 {
265 issue(
266 "output.transparency.transparent_threshold",
267 "out_of_range",
268 "transparent threshold must be lower than opaque threshold",
269 );
270 }
271 if let Some(key) = request.output.transparency.key_color.as_deref()
272 && !valid_hex_color(key)
273 {
274 issue(
275 "output.transparency.key_color",
276 "invalid_format",
277 "chroma key must be a hex RGB color like #00ff00",
278 );
279 }
280
281 match request.session.mode {
282 SessionMode::Isolated => {
283 if request.session.key.is_some() || request.session.thread_id.is_some() {
284 issue(
285 "session",
286 "incompatible",
287 "isolated mode cannot include a key or thread_id",
288 );
289 }
290 }
291 SessionMode::Persistent => {
292 if request.session.key.as_deref().is_none_or(str::is_empty) {
293 issue(
294 "session.key",
295 "required",
296 "persistent mode requires a non-empty session key",
297 );
298 }
299 if request.session.thread_id.is_some() {
300 issue(
301 "session.thread_id",
302 "incompatible",
303 "persistent mode uses a key, not an explicit thread_id",
304 );
305 }
306 }
307 SessionMode::Thread => {
308 if request
309 .session
310 .thread_id
311 .as_deref()
312 .is_none_or(str::is_empty)
313 {
314 issue(
315 "session.thread_id",
316 "required",
317 "thread mode requires a non-empty thread_id",
318 );
319 }
320 if request.session.key.is_some() {
321 issue(
322 "session.key",
323 "incompatible",
324 "thread mode cannot include a persistent key",
325 );
326 }
327 }
328 }
329 validate_identifier(
330 request.session.key.as_deref(),
331 "session.key",
332 limits,
333 &mut issue,
334 );
335 validate_identifier(
336 request.session.thread_id.as_deref(),
337 "session.thread_id",
338 limits,
339 &mut issue,
340 );
341
342 if let Some(timeout_ms) = request.timeout_ms
343 && (timeout_ms == 0 || timeout_ms > limits.max_timeout_ms)
344 {
345 issue(
346 "timeout_ms",
347 "out_of_range",
348 "timeout is outside the configured range",
349 );
350 }
351
352 let artifact_delivery = matches!(
353 request.output.response_format,
354 ResponseFormat::Artifact | ResponseFormat::Url
355 );
356 if !artifact_delivery && request.output.filename_prefix.is_some() {
357 issue(
358 "output.filename_prefix",
359 "incompatible",
360 "filename_prefix requires artifact response format",
361 );
362 }
363 if !artifact_delivery
364 && (request.output.directory.is_some() || request.output.filename.is_some())
365 {
366 issue(
367 "output",
368 "incompatible",
369 "artifact path controls require artifact or url response format",
370 );
371 }
372 if request.output.filename.is_some() && request.output.filename_prefix.is_some() {
373 issue(
374 "output.filename",
375 "incompatible",
376 "filename cannot be combined with filename_prefix",
377 );
378 }
379 if request.output.filename.is_some() && request.parameters.n != 1 {
380 issue(
381 "output.filename",
382 "incompatible",
383 "an exact filename requires a single output",
384 );
385 }
386 if request.output.filename.is_none()
387 && request.output.collision != ArtifactCollisionPolicy::Error
388 {
389 issue(
390 "output.collision",
391 "incompatible",
392 "collision policy applies only to an explicit filename",
393 );
394 }
395 if request.output.metadata.writes_sidecar()
396 && request.output.response_format != ResponseFormat::Artifact
397 {
398 issue(
399 "output.metadata",
400 "incompatible",
401 "metadata sidecars require artifact response format",
402 );
403 }
404 if request.output.metadata.embeds()
405 && request.output.response_format == ResponseFormat::Metadata
406 {
407 issue(
408 "output.metadata",
409 "incompatible",
410 "embedded metadata requires an image-bearing response format",
411 );
412 }
413 if request.output.metadata.embeds()
414 && request
415 .prompt
416 .len()
417 .saturating_add(request.negative_prompt.as_deref().map_or(0, str::len))
418 > MAX_EMBEDDED_REQUEST_TEXT_BYTES
419 {
420 issue(
421 "output.metadata",
422 "too_large",
423 "embedded metadata requires combined prompt text no larger than 12 KiB",
424 );
425 }
426 if let Some(directory) = request.output.directory.as_deref()
427 && !valid_portable_directory(directory)
428 {
429 issue(
430 "output.directory",
431 "invalid",
432 "output directory must be a safe portable relative path",
433 );
434 }
435 if let Some(filename) = request.output.filename.as_deref()
436 && !valid_output_filename(filename, request.parameters.output_format)
437 {
438 issue(
439 "output.filename",
440 "invalid",
441 "output filename must be a safe component with a matching image extension",
442 );
443 }
444
445 let (edit_images, mask, references) = match &request.operation {
446 ImageOperation::Generate { reference_images } => (0, None, reference_images.as_slice()),
447 ImageOperation::Edit {
448 images,
449 mask,
450 reference_images,
451 } => {
452 if images.is_empty() {
453 issue(
454 "images",
455 "required",
456 "edit operation requires at least one source image",
457 );
458 }
459 (images.len(), mask.as_ref(), reference_images.as_slice())
460 }
461 };
462 let input_count = edit_images
463 .saturating_add(usize::from(mask.is_some()))
464 .saturating_add(references.len());
465 if input_count > limits.max_inputs {
466 issue(
467 "operation",
468 "too_many_inputs",
469 "total input image count exceeds the configured limit",
470 );
471 }
472 let mut detailed_inputs = 0_usize;
473 if let ImageOperation::Edit { images, .. } = &request.operation {
474 for (index, input) in images
475 .iter()
476 .take(MAX_DETAILED_INPUT_VALIDATIONS)
477 .enumerate()
478 {
479 validate_input(input, &format!("images[{index}]"), limits, &mut issue);
480 detailed_inputs += 1;
481 }
482 }
483 if let Some(mask) = mask
484 && detailed_inputs < MAX_DETAILED_INPUT_VALIDATIONS
485 {
486 validate_input(mask, "mask", limits, &mut issue);
487 detailed_inputs += 1;
488 }
489 for (index, input) in references
490 .iter()
491 .take(MAX_DETAILED_INPUT_VALIDATIONS.saturating_sub(detailed_inputs))
492 .enumerate()
493 {
494 validate_input(
495 input,
496 &format!("reference_images[{index}]"),
497 limits,
498 &mut issue,
499 );
500 }
501 if request.parameters.input_fidelity.is_some() && edit_images + references.len() == 0 {
502 issue(
503 "parameters.input_fidelity",
504 "incompatible",
505 "input_fidelity requires at least one source or reference image",
506 );
507 }
508 match (&request.operation, request.parameters.action) {
509 (ImageOperation::Generate { reference_images }, ImageAction::Edit)
510 if reference_images.is_empty() =>
511 {
512 issue(
513 "parameters.action",
514 "incompatible",
515 "edit action requires an image in context",
516 );
517 }
518 (ImageOperation::Edit { .. }, ImageAction::Generate) => issue(
519 "parameters.action",
520 "incompatible",
521 "generate action conflicts with the edit operation",
522 ),
523 _ => {}
524 }
525
526 if issues_truncated {
527 issues.push(ValidationIssue {
528 field: "$".to_owned(),
529 code: "too_many_issues".to_owned(),
530 message: "additional validation issues were omitted".to_owned(),
531 });
532 }
533 issues.sort_by(|left, right| {
534 left.field
535 .cmp(&right.field)
536 .then(left.code.cmp(&right.code))
537 });
538 issues
539}
540
541fn valid_hex_color(value: &str) -> bool {
542 let value = value.strip_prefix('#').unwrap_or(value);
543 value.len() == 6 && value.bytes().all(|byte| byte.is_ascii_hexdigit())
544}
545
546fn valid_portable_directory(value: &str) -> bool {
547 !value.is_empty()
548 && value.len() <= 512
549 && value.split('/').all(|component| {
550 !component.is_empty()
551 && component != "."
552 && component != ".."
553 && component.len() <= 128
554 && !component.starts_with('.')
555 && component
556 .bytes()
557 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
558 })
559}
560
561fn valid_output_filename(value: &str, format: OutputFormat) -> bool {
562 if value.is_empty()
563 || value.len() > 160
564 || value.starts_with('.')
565 || value.contains(['/', '\\'])
566 || !value
567 .bytes()
568 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
569 {
570 return false;
571 }
572 let Some((_, extension)) = value.rsplit_once('.') else {
573 return true;
574 };
575 match format {
576 OutputFormat::Png => extension.eq_ignore_ascii_case("png"),
577 OutputFormat::Jpeg => {
578 extension.eq_ignore_ascii_case("jpg") || extension.eq_ignore_ascii_case("jpeg")
579 }
580 OutputFormat::Webp => extension.eq_ignore_ascii_case("webp"),
581 }
582}
583
584fn validate_identifier(
585 value: Option<&str>,
586 field: &str,
587 limits: RequestLimits,
588 issue: &mut impl FnMut(&str, &str, &str),
589) {
590 let Some(value) = value else { return };
591 if value.trim().is_empty() {
592 issue(
593 field,
594 "empty",
595 "identifier must be omitted instead of empty",
596 );
597 } else if value.len() > limits.max_identifier_bytes {
598 issue(
599 field,
600 "too_large",
601 "identifier exceeds the configured byte limit",
602 );
603 } else if value.chars().any(char::is_control) {
604 issue(
605 field,
606 "invalid",
607 "identifier must not contain control characters",
608 );
609 }
610}
611
612fn validate_input(
613 input: &crate::ImageInput,
614 field: &str,
615 limits: RequestLimits,
616 issue: &mut impl FnMut(&str, &str, &str),
617) {
618 match &input.source {
619 ImageSource::File { path } if path.as_os_str().is_empty() => {
620 issue(field, "empty", "file path must not be empty");
621 }
622 ImageSource::Url { url } if url.trim().is_empty() => {
623 issue(field, "empty", "URL must not be empty");
624 }
625 ImageSource::DataUrl { data_url } if data_url.len() > limits.max_inline_encoded_bytes => {
626 issue(
627 field,
628 "too_large",
629 "data URL exceeds the encoded byte limit",
630 );
631 }
632 ImageSource::DataUrl { data_url } if !data_url.starts_with("data:image/") => {
633 issue(
634 field,
635 "invalid",
636 "data URL must contain an image media type",
637 );
638 }
639 ImageSource::Base64 { data } if data.len() > limits.max_inline_encoded_bytes => {
640 issue(
641 field,
642 "too_large",
643 "base64 input exceeds the encoded byte limit",
644 );
645 }
646 ImageSource::Base64 { data } if data.is_empty() => {
647 issue(field, "empty", "base64 input must not be empty");
648 }
649 _ => {}
650 }
651 if input.filename.as_deref().is_some_and(|name| {
652 name.is_empty()
653 || name.contains('/')
654 || name.contains('\\')
655 || name.chars().any(char::is_control)
656 }) {
657 issue(
658 field,
659 "invalid_filename",
660 "logical filename must be a single safe path component",
661 );
662 }
663}
664
665#[cfg(test)]
666mod tests {
667 use super::*;
668 use crate::{ArtifactMetadataPolicy, ImageInput, InputFidelity, SessionOptions};
669
670 #[test]
671 fn default_generation_request_is_valid() {
672 assert!(
673 validate_request(
674 &ImageRequest::generate("a lighthouse"),
675 RequestLimits::default()
676 )
677 .is_ok()
678 );
679 }
680
681 #[test]
682 fn validation_accumulates_and_sorts_issues() {
683 let mut request = ImageRequest::generate(" ");
684 request.parameters.n = 0;
685 request.parameters.output_compression = Some(80);
686 request.timeout_ms = Some(0);
687
688 let issues = validation_issues(&request, RequestLimits::default());
689 let fields: Vec<_> = issues.iter().map(|item| item.field.as_str()).collect();
690 assert_eq!(
691 fields,
692 [
693 "parameters.n",
694 "parameters.output_compression",
695 "prompt",
696 "timeout_ms"
697 ]
698 );
699 }
700
701 #[test]
702 fn validation_bounds_issue_and_input_amplification() {
703 let invalid = ImageInput {
704 source: ImageSource::Base64 {
705 data: String::new(),
706 },
707 media_type: None,
708 filename: Some("../invalid.png".to_owned()),
709 };
710 let mut request = ImageRequest::generate("test");
711 request.operation = ImageOperation::Edit {
712 images: vec![invalid; 10_000],
713 mask: None,
714 reference_images: Vec::new(),
715 };
716
717 let issues = validation_issues(&request, RequestLimits::default());
718 assert_eq!(issues.len(), MAX_VALIDATION_ISSUES);
719 assert!(issues.iter().any(|item| item.code == "too_many_inputs"));
720 assert!(issues.iter().any(|item| item.code == "too_many_issues"));
721 }
722
723 #[test]
724 fn persistent_session_requires_only_a_key() {
725 let mut request = ImageRequest::generate("test");
726 request.session = SessionOptions {
727 mode: SessionMode::Persistent,
728 key: None,
729 thread_id: Some("thread-1".to_owned()),
730 };
731 let issues = validation_issues(&request, RequestLimits::default());
732 assert!(issues.iter().any(|item| item.field == "session.key"));
733 assert!(issues.iter().any(|item| item.field == "session.thread_id"));
734 }
735
736 #[test]
737 fn edit_requires_an_image() {
738 let mut request = ImageRequest::generate("edit it");
739 request.operation = ImageOperation::Edit {
740 images: Vec::new(),
741 mask: None,
742 reference_images: Vec::new(),
743 };
744 assert!(
745 validation_issues(&request, RequestLimits::default())
746 .iter()
747 .any(|item| item.field == "images")
748 );
749 }
750
751 #[test]
752 fn unsafe_logical_filename_is_rejected() {
753 let mut request = ImageRequest::generate("test");
754 request.operation = ImageOperation::Generate {
755 reference_images: vec![ImageInput {
756 source: ImageSource::Base64 {
757 data: "AA==".to_owned(),
758 },
759 media_type: Some("image/png".to_owned()),
760 filename: Some("../escape.png".to_owned()),
761 }],
762 };
763 assert!(
764 validation_issues(&request, RequestLimits::default())
765 .iter()
766 .any(|item| item.code == "invalid_filename")
767 );
768 }
769
770 #[test]
771 fn fidelity_and_edit_action_require_image_context() {
772 let mut request = ImageRequest::generate("edit it");
773 request.parameters.input_fidelity = Some(InputFidelity::High);
774 request.parameters.action = ImageAction::Edit;
775 let issues = validation_issues(&request, RequestLimits::default());
776 assert!(
777 issues
778 .iter()
779 .any(|item| item.field == "parameters.input_fidelity")
780 );
781 assert!(issues.iter().any(|item| item.field == "parameters.action"));
782 }
783
784 #[test]
785 fn artifact_paths_are_portable_and_match_the_image_format() {
786 let mut request = ImageRequest::generate("test");
787 request.output.response_format = ResponseFormat::Artifact;
788 request.output.directory = Some("../outside".to_owned());
789 request.output.filename = Some("portrait.jpg".to_owned());
790 let issues = validation_issues(&request, RequestLimits::default());
791 assert!(issues.iter().any(|item| item.field == "output.directory"));
792 assert!(issues.iter().any(|item| item.field == "output.filename"));
793 }
794
795 #[test]
796 fn exact_filename_requires_one_image_and_controls_collision() {
797 let mut request = ImageRequest::generate("test");
798 request.output.response_format = ResponseFormat::Artifact;
799 request.output.filename = Some("portrait.png".to_owned());
800 request.parameters.n = 2;
801 assert!(
802 validation_issues(&request, RequestLimits::default())
803 .iter()
804 .any(|item| item.field == "output.filename" && item.code == "incompatible")
805 );
806
807 request.output.filename = None;
808 request.parameters.n = 1;
809 request.output.collision = ArtifactCollisionPolicy::Suffix;
810 assert!(
811 validation_issues(&request, RequestLimits::default())
812 .iter()
813 .any(|item| item.field == "output.collision")
814 );
815 }
816
817 #[test]
818 fn metadata_sidecars_require_artifact_delivery() {
819 let mut request = ImageRequest::generate("test");
820 request.output.metadata = ArtifactMetadataPolicy::Sidecar;
821 assert!(
822 validation_issues(&request, RequestLimits::default())
823 .iter()
824 .any(|item| item.field == "output.metadata")
825 );
826 request.output.response_format = ResponseFormat::Artifact;
827 assert!(validate_request(&request, RequestLimits::default()).is_ok());
828
829 request.output.metadata = ArtifactMetadataPolicy::SidecarAndEmbedded;
830 assert!(validate_request(&request, RequestLimits::default()).is_ok());
831 }
832
833 #[test]
834 fn embedded_metadata_requires_image_bytes_but_not_artifact_delivery() {
835 let mut request = ImageRequest::generate("test");
836 request.output.metadata = ArtifactMetadataPolicy::Embedded;
837 assert!(validate_request(&request, RequestLimits::default()).is_ok());
838
839 request.output.response_format = ResponseFormat::Metadata;
840 assert!(
841 validation_issues(&request, RequestLimits::default())
842 .iter()
843 .any(|item| item.field == "output.metadata")
844 );
845
846 request.output.response_format = ResponseFormat::B64Json;
847 request.prompt = "x".repeat(MAX_EMBEDDED_REQUEST_TEXT_BYTES + 1);
848 assert!(
849 validation_issues(&request, RequestLimits::default())
850 .iter()
851 .any(|item| item.field == "output.metadata" && item.code == "too_large")
852 );
853 }
854
855 #[test]
856 fn validates_fallback_isolation_and_transparency_controls() {
857 let mut request = ImageRequest::generate("test");
858 request.routing.fallbacks.push(crate::ProviderRoute {
859 provider: "fallback".to_owned(),
860 model: Some("gpt-image-2".to_owned()),
861 });
862 request.session.mode = SessionMode::Persistent;
863 request.session.key = Some("character".to_owned());
864 request.output.transparency.key_color = Some("not-a-color".to_owned());
865 request.output.transparency.transparent_threshold = 100;
866 request.output.transparency.opaque_threshold = 50;
867 let issues = validation_issues(&request, RequestLimits::default());
868 assert!(
869 issues
870 .iter()
871 .any(|item| item.field == "routing.fallbacks" && item.code == "incompatible")
872 );
873 assert!(
874 issues
875 .iter()
876 .any(|item| item.field == "output.transparency.key_color")
877 );
878 assert!(
879 issues
880 .iter()
881 .any(|item| { item.field == "output.transparency.transparent_threshold" })
882 );
883 }
884}