Skip to main content

imagegen_bridge_runtime/
materialize.rs

1//! Independent provider-output verification and delivery projection.
2
3use std::sync::Arc;
4
5use base64::{Engine as _, engine::general_purpose::STANDARD};
6use imagegen_bridge_artifacts::{
7    ArtifactPublication, ArtifactStore, ImageLimits, ImageMetadata, MAX_EMBEDDED_METADATA_BYTES,
8    RemoteImageFetcher, StoredArtifactContent, detect_border_chroma_key, embed_image_metadata,
9    inspect_image, inspect_transparent_alpha, remove_chroma_key, thumbnail_png,
10};
11use imagegen_bridge_core::{
12    BridgeError, CompatibilityMode, ErrorCode, GeneratedImage, GenerationParameters,
13    ImageOperation, ImagePayload, ImageRequest, ImageResponse, ImageSize, MultiImageFailurePolicy,
14    Normalization, OutputFormat, OutputOptions, RequestPolicies, ResponseFormat,
15};
16use serde::Serialize;
17use url::Url;
18
19use crate::transparency::TransparencyPlan;
20
21/// Output verification, remote retrieval, and artifact delivery configuration.
22#[derive(Clone)]
23pub struct MaterializationConfig {
24    /// Independent decoder limits for every provider output.
25    pub image_limits: ImageLimits,
26    /// Maximum encoded base64 characters accepted from a provider.
27    pub max_base64_chars: usize,
28    /// Maximum aggregate provider, decoded, and projected response payload bytes.
29    pub max_response_bytes: usize,
30    /// Optional bridge-owned artifact store.
31    pub artifact_store: Option<Arc<ArtifactStore>>,
32    /// Optional SSRF-resistant fetcher for provider-hosted image URLs.
33    pub remote_output_fetcher: Option<RemoteImageFetcher>,
34    /// Public base URL used only for bridge-owned URL responses.
35    pub public_artifact_base_url: Option<Url>,
36}
37
38impl std::fmt::Debug for MaterializationConfig {
39    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40        formatter
41            .debug_struct("MaterializationConfig")
42            .field("image_limits", &self.image_limits)
43            .field("max_base64_chars", &self.max_base64_chars)
44            .field("max_response_bytes", &self.max_response_bytes)
45            .field("artifact_store", &self.artifact_store.is_some())
46            .field(
47                "remote_output_fetcher",
48                &self.remote_output_fetcher.is_some(),
49            )
50            .field("public_artifact_base_url", &self.public_artifact_base_url)
51            .finish()
52    }
53}
54
55impl Default for MaterializationConfig {
56    fn default() -> Self {
57        Self {
58            image_limits: ImageLimits::default(),
59            max_base64_chars: 128 * 1024 * 1024,
60            max_response_bytes: 256 * 1024 * 1024,
61            artifact_store: None,
62            remote_output_fetcher: None,
63            public_artifact_base_url: None,
64        }
65    }
66}
67
68pub(crate) struct OutputMaterializer {
69    config: MaterializationConfig,
70}
71
72impl OutputMaterializer {
73    pub(crate) fn new(config: MaterializationConfig) -> Result<Self, BridgeError> {
74        if config.max_base64_chars == 0 || config.max_response_bytes == 0 {
75            return Err(configuration_error(
76                "output payload limits must be greater than zero",
77            ));
78        }
79        if let Some(base) = &config.public_artifact_base_url {
80            let valid = matches!(base.scheme(), "http" | "https")
81                && base.username().is_empty()
82                && base.password().is_none()
83                && base.query().is_none()
84                && base.fragment().is_none()
85                && base.path().ends_with('/');
86            if !valid {
87                return Err(configuration_error(
88                    "public artifact base URL must be credential-free HTTP(S) ending in a slash",
89                ));
90            }
91            if config.artifact_store.is_none() {
92                return Err(configuration_error(
93                    "public artifact URL delivery requires an artifact store",
94                ));
95            }
96        }
97        Ok(Self { config })
98    }
99
100    pub(crate) const fn has_artifact_store(&self) -> bool {
101        self.config.artifact_store.is_some()
102    }
103
104    pub(crate) fn read_artifact(
105        &self,
106        artifact_id: &str,
107    ) -> Result<StoredArtifactContent, BridgeError> {
108        self.config
109            .artifact_store
110            .as_ref()
111            .ok_or_else(|| configuration_error("artifact output is not configured"))?
112            .read(artifact_id)
113    }
114
115    pub(crate) fn read_thumbnail(
116        &self,
117        artifact_id: &str,
118        maximum_edge: u32,
119    ) -> Result<Vec<u8>, BridgeError> {
120        let artifact = self.read_artifact(artifact_id)?;
121        thumbnail_png(&artifact.bytes, maximum_edge, self.config.image_limits).map_err(|error| {
122            BridgeError {
123                code: ErrorCode::Artifact,
124                ..error
125            }
126        })
127    }
128
129    pub(crate) async fn materialize(
130        &self,
131        mut response: ImageResponse,
132        original: &ImageRequest,
133        effective: &ImageRequest,
134        transparency: Option<TransparencyPlan>,
135    ) -> Result<ImageResponse, BridgeError> {
136        Self::validate_output_set(
137            &response,
138            effective.parameters.n,
139            effective.parameters.failure_policy,
140        )?;
141
142        let mut aggregate_bytes = response_payload_bytes(&response)?;
143        self.check_response_budget(aggregate_bytes)?;
144        let provider_images = std::mem::take(&mut response.data);
145        let mut verified = Vec::with_capacity(provider_images.len());
146        for image in provider_images {
147            let mut image = self
148                .verify(&image, effective.parameters.output_format)
149                .await?;
150            if let Some(plan) = transparency {
151                let detected_key = detect_border_chroma_key(&image.bytes, self.config.image_limits)
152                    .map_err(as_artifact_error)?;
153                let mut chroma = plan.chroma;
154                chroma.key = detected_key;
155                let processed = remove_chroma_key(
156                    &image.bytes,
157                    effective.parameters.output_format,
158                    chroma,
159                    self.config.image_limits,
160                )
161                .map_err(as_artifact_error)?;
162                response.normalizations.push(Normalization {
163                    field: format!("data[{}].transparency.key_color", image.index),
164                    requested: Some(serde_json::json!(plan.chroma.key.hex())),
165                    effective: Some(serde_json::json!(detected_key.hex())),
166                    reason: "sampled_provider_output_border".to_owned(),
167                });
168                response.normalizations.push(alpha_normalization(
169                    image.index,
170                    processed.alpha,
171                    "validated_chroma_key_alpha",
172                ));
173                image.bytes = processed.bytes;
174                image.metadata = processed.metadata;
175            } else if effective.parameters.background
176                == imagegen_bridge_core::Background::Transparent
177            {
178                let alpha = inspect_transparent_alpha(&image.bytes, self.config.image_limits)
179                    .map_err(as_artifact_error)?;
180                response.normalizations.push(alpha_normalization(
181                    image.index,
182                    alpha,
183                    "validated_native_alpha",
184                ));
185            }
186            aggregate_bytes = aggregate_bytes
187                .checked_add(image.bytes.len())
188                .ok_or_else(|| protocol_error("aggregate response size overflowed"))?;
189            self.check_response_budget(aggregate_bytes)?;
190            verified.push(image);
191        }
192
193        Self::reconcile_dimensions(
194            &mut response,
195            &verified,
196            &effective.parameters.size,
197            effective.policies.compatibility,
198        )?;
199
200        let mut projected = Vec::with_capacity(verified.len());
201        for mut image in verified {
202            if effective.output.metadata.embeds() {
203                let encoded = embedded_metadata(original, effective, &response, &image)?;
204                let (bytes, metadata) = embed_image_metadata(
205                    &image.bytes,
206                    image.metadata.format,
207                    &encoded,
208                    self.config.image_limits,
209                )?;
210                image.bytes = bytes;
211                image.metadata = metadata;
212            }
213            let projected_image = self.project(image, &effective.output)?;
214            aggregate_bytes = aggregate_bytes
215                .checked_add(payload_bytes(&projected_image.payload))
216                .ok_or_else(|| protocol_error("aggregate response size overflowed"))?;
217            self.check_response_budget(aggregate_bytes)?;
218            projected.push(projected_image);
219        }
220        response.data = projected;
221        Ok(response)
222    }
223
224    fn check_response_budget(&self, bytes: usize) -> Result<(), BridgeError> {
225        if bytes > self.config.max_response_bytes {
226            return Err(protocol_error(
227                "aggregate response payload exceeds the configured byte limit",
228            )
229            .with_detail("maximum_bytes", self.config.max_response_bytes));
230        }
231        Ok(())
232    }
233
234    pub(crate) fn attach_metadata(
235        &self,
236        original: &ImageRequest,
237        effective: &ImageRequest,
238        response: &mut ImageResponse,
239    ) -> Result<(), BridgeError> {
240        if !effective.output.metadata.writes_sidecar() {
241            return Ok(());
242        }
243        let store = self
244            .config
245            .artifact_store
246            .as_ref()
247            .ok_or_else(|| configuration_error("artifact output is not configured"))?;
248        let snapshot = response.clone();
249        let operation = operation_summary(&original.operation);
250        for image in &mut response.data {
251            let (id, name) = match &image.payload {
252                ImagePayload::Artifact {
253                    id,
254                    name: Some(name),
255                } => (id.as_str(), name.as_str()),
256                _ => {
257                    return Err(protocol_error(
258                        "metadata sidecar policy requires bridge artifact output",
259                    ));
260                }
261            };
262            let encoded = serde_json::to_vec_pretty(&ArtifactMetadataSidecar {
263                version: 1,
264                request_id: &snapshot.id,
265                created: snapshot.created,
266                operation: &operation,
267                original_prompt: &original.prompt,
268                effective_prompt: &effective.prompt,
269                negative_prompt: original.negative_prompt.as_deref(),
270                policies: &effective.policies,
271                provider: &snapshot.provider,
272                model: &snapshot.model,
273                requested: &snapshot.requested,
274                effective: &snapshot.effective,
275                normalizations: &snapshot.normalizations,
276                attempts: &snapshot.attempts,
277                revised_prompt: snapshot.revised_prompt.as_deref(),
278                usage: snapshot.usage.as_ref(),
279                session: snapshot.session.as_ref(),
280                timings: &snapshot.timings,
281                warnings: &snapshot.warnings,
282                image: ArtifactMetadataImage {
283                    index: image.index,
284                    artifact_name: name,
285                    format: image.format,
286                    width: image.width,
287                    height: image.height,
288                    bytes: image.bytes,
289                    sha256: &image.sha256,
290                    generation_ms: image.generation_ms,
291                },
292            })
293            .map_err(|_| artifact_error("could not encode artifact metadata"))?;
294            image.metadata_name = Some(store.attach_metadata(id, name, &encoded)?.name);
295        }
296        Ok(())
297    }
298
299    fn validate_output_set(
300        response: &ImageResponse,
301        expected_count: u8,
302        expected_failure_policy: MultiImageFailurePolicy,
303    ) -> Result<(), BridgeError> {
304        let allows_failures = expected_failure_policy == MultiImageFailurePolicy::BestEffort;
305        if !allows_failures && !response.failures.is_empty() {
306            return Err(protocol_error(
307                "provider returned partial failures for a fail-fast request",
308            ));
309        }
310        let actual = response.data.len().saturating_add(response.failures.len());
311        if actual != usize::from(expected_count) {
312            return Err(protocol_error(
313                "provider returned a different number of output results than negotiated",
314            )
315            .with_detail("expected", expected_count)
316            .with_detail("actual", actual));
317        }
318        let mut indices = response
319            .data
320            .iter()
321            .map(|image| image.index)
322            .chain(response.failures.iter().map(|failure| failure.index))
323            .collect::<Vec<_>>();
324        indices.sort_unstable();
325        if indices != (0..expected_count).collect::<Vec<_>>() {
326            return Err(protocol_error(
327                "provider returned duplicate or missing output indices",
328            ));
329        }
330        if !response
331            .data
332            .windows(2)
333            .all(|pair| pair[0].index < pair[1].index)
334            || !response
335                .failures
336                .windows(2)
337                .all(|pair| pair[0].index < pair[1].index)
338        {
339            return Err(protocol_error(
340                "provider returned output results in unstable index order",
341            ));
342        }
343        Ok(())
344    }
345
346    fn reconcile_dimensions(
347        response: &mut ImageResponse,
348        verified: &[VerifiedImage],
349        expected_size: &ImageSize,
350        compatibility: CompatibilityMode,
351    ) -> Result<(), BridgeError> {
352        let Some((expected_width, expected_height)) = expected_size.dimensions() else {
353            return Ok(());
354        };
355        let Some(first) = verified.first() else {
356            return Ok(());
357        };
358        let actual = (first.metadata.width, first.metadata.height);
359        if verified
360            .iter()
361            .any(|image| (image.metadata.width, image.metadata.height) != actual)
362        {
363            return Err(protocol_error(
364                "provider returned inconsistent dimensions across generated images",
365            ));
366        }
367        if actual == (expected_width, expected_height) {
368            return Ok(());
369        }
370        if compatibility == CompatibilityMode::Strict {
371            return Err(protocol_error(
372                "provider output dimensions do not match the negotiated size",
373            )
374            .with_detail("expected", expected_size.to_string())
375            .with_detail("actual", format!("{}x{}", actual.0, actual.1)));
376        }
377        let actual_size = ImageSize::exact(actual.0, actual.1)?;
378        response.effective.size = actual_size.clone();
379        response.normalizations.push(Normalization {
380            field: "parameters.size".to_owned(),
381            requested: Some(serde_json::Value::String(expected_size.to_string())),
382            effective: Some(serde_json::Value::String(actual_size.to_string())),
383            reason: "provider_output_dimensions_differed".to_owned(),
384        });
385        if !response
386            .warnings
387            .iter()
388            .any(|warning| warning == "provider_output_dimensions_differed")
389        {
390            response
391                .warnings
392                .push("provider_output_dimensions_differed".to_owned());
393        }
394        Ok(())
395    }
396
397    async fn verify(
398        &self,
399        image: &GeneratedImage,
400        expected_format: OutputFormat,
401    ) -> Result<VerifiedImage, BridgeError> {
402        let (bytes, metadata) = match &image.payload {
403            ImagePayload::B64Json { b64_json } => {
404                if b64_json.len() > self.config.max_base64_chars {
405                    return Err(protocol_error(
406                        "provider image base64 exceeds the configured limit",
407                    ));
408                }
409                let bytes = STANDARD
410                    .decode(b64_json.trim())
411                    .map_err(|_| protocol_error("provider returned malformed base64 image data"))?;
412                let metadata =
413                    inspect_image(&bytes, self.config.image_limits).map_err(as_artifact_error)?;
414                (bytes, metadata)
415            }
416            ImagePayload::Url { url } => {
417                let fetcher = self.config.remote_output_fetcher.as_ref().ok_or_else(|| {
418                    configuration_error("provider URL output retrieval is not configured")
419                })?;
420                let loaded = fetcher.fetch(url).await.map_err(as_artifact_error)?;
421                (loaded.bytes, loaded.metadata)
422            }
423            ImagePayload::Artifact { .. } | ImagePayload::Metadata => {
424                return Err(protocol_error(
425                    "provider returned an unverifiable internal payload type",
426                ));
427            }
428        };
429        if metadata.format != expected_format {
430            return Err(protocol_error(
431                "provider output format does not match the negotiated format",
432            ));
433        }
434        if image.format != metadata.format
435            || image.width != metadata.width
436            || image.height != metadata.height
437            || image.bytes != metadata.bytes
438            || image.sha256 != metadata.sha256
439        {
440            return Err(protocol_error(
441                "provider output metadata does not match the decoded image",
442            ));
443        }
444        Ok(VerifiedImage {
445            index: image.index,
446            bytes,
447            metadata,
448            generation_ms: image.generation_ms,
449        })
450    }
451
452    fn project(
453        &self,
454        image: VerifiedImage,
455        output: &OutputOptions,
456    ) -> Result<GeneratedImage, BridgeError> {
457        let payload = match output.response_format {
458            ResponseFormat::B64Json => ImagePayload::B64Json {
459                b64_json: STANDARD.encode(&image.bytes),
460            },
461            ResponseFormat::Metadata => ImagePayload::Metadata,
462            ResponseFormat::Artifact | ResponseFormat::Url => {
463                let public_base = if output.response_format == ResponseFormat::Url {
464                    Some(
465                        self.config
466                            .public_artifact_base_url
467                            .as_ref()
468                            .ok_or_else(|| {
469                                configuration_error("public artifact base URL is not configured")
470                            })?,
471                    )
472                } else {
473                    None
474                };
475                let store = self
476                    .config
477                    .artifact_store
478                    .as_ref()
479                    .ok_or_else(|| configuration_error("artifact output is not configured"))?;
480                let stored = store.publish_with_options(
481                    &image.bytes,
482                    output.filename_prefix.as_deref(),
483                    Some(image.metadata.format),
484                    ArtifactPublication {
485                        directory: output.directory.as_deref(),
486                        filename: output.filename.as_deref(),
487                        collision: output.collision,
488                    },
489                )?;
490                if output.response_format == ResponseFormat::Artifact {
491                    ImagePayload::Artifact {
492                        id: stored.id,
493                        name: Some(stored.name),
494                    }
495                } else {
496                    let base = public_base.ok_or_else(|| {
497                        configuration_error("public artifact base URL is not configured")
498                    })?;
499                    let url = base.join(&stored.name).map_err(|_| {
500                        configuration_error("could not construct public artifact URL")
501                    })?;
502                    ImagePayload::Url {
503                        url: url.to_string(),
504                    }
505                }
506            }
507        };
508        Ok(GeneratedImage {
509            index: image.index,
510            payload,
511            format: image.metadata.format,
512            width: image.metadata.width,
513            height: image.metadata.height,
514            bytes: image.metadata.bytes,
515            sha256: image.metadata.sha256,
516            generation_ms: image.generation_ms,
517            metadata_name: None,
518        })
519    }
520}
521
522fn alpha_normalization(
523    index: u8,
524    alpha: imagegen_bridge_artifacts::AlphaSummary,
525    reason: &str,
526) -> Normalization {
527    Normalization {
528        field: format!("data[{index}].transparency.alpha"),
529        requested: None,
530        effective: Some(serde_json::json!({
531            "total_pixels": alpha.total_pixels,
532            "transparent_pixels": alpha.transparent_pixels,
533            "partial_pixels": alpha.partial_pixels,
534            "opaque_pixels": alpha.opaque_pixels,
535            "transparent_corners": alpha.transparent_corners,
536        })),
537        reason: reason.to_owned(),
538    }
539}
540
541#[derive(Serialize)]
542struct ArtifactMetadataSidecar<'a> {
543    version: u8,
544    request_id: &'a str,
545    created: u64,
546    operation: &'a ArtifactOperationSummary,
547    original_prompt: &'a str,
548    effective_prompt: &'a str,
549    #[serde(skip_serializing_if = "Option::is_none")]
550    negative_prompt: Option<&'a str>,
551    policies: &'a RequestPolicies,
552    provider: &'a str,
553    model: &'a str,
554    requested: &'a GenerationParameters,
555    effective: &'a GenerationParameters,
556    normalizations: &'a [Normalization],
557    #[serde(skip_serializing_if = "slice_is_empty")]
558    attempts: &'a [imagegen_bridge_core::ProviderAttempt],
559    #[serde(skip_serializing_if = "Option::is_none")]
560    revised_prompt: Option<&'a str>,
561    #[serde(skip_serializing_if = "Option::is_none")]
562    usage: Option<&'a imagegen_bridge_core::Usage>,
563    #[serde(skip_serializing_if = "Option::is_none")]
564    session: Option<&'a imagegen_bridge_core::SessionMetadata>,
565    timings: &'a imagegen_bridge_core::Timings,
566    warnings: &'a [String],
567    image: ArtifactMetadataImage<'a>,
568}
569
570#[derive(Serialize)]
571struct EmbeddedArtifactMetadata<'a> {
572    version: u8,
573    kind: &'static str,
574    request_id: &'a str,
575    created: u64,
576    operation: &'a ArtifactOperationSummary,
577    original_prompt: &'a str,
578    #[serde(skip_serializing_if = "Option::is_none")]
579    effective_prompt: Option<&'a str>,
580    #[serde(skip_serializing_if = "Option::is_none")]
581    negative_prompt: Option<&'a str>,
582    policies: &'a RequestPolicies,
583    provider: &'a str,
584    model: &'a str,
585    requested: &'a GenerationParameters,
586    effective: &'a GenerationParameters,
587    #[serde(skip_serializing_if = "slice_is_empty")]
588    normalizations: &'a [Normalization],
589    #[serde(skip_serializing_if = "Option::is_none")]
590    revised_prompt: Option<&'a str>,
591    #[serde(skip_serializing_if = "Option::is_none")]
592    usage: Option<&'a imagegen_bridge_core::Usage>,
593    #[serde(skip_serializing_if = "Option::is_none")]
594    session: Option<&'a imagegen_bridge_core::SessionMetadata>,
595    timings: EmbeddedMetadataTimings,
596    #[serde(skip_serializing_if = "slice_is_empty")]
597    warnings: &'a [String],
598    image: EmbeddedMetadataImage,
599}
600
601#[derive(Serialize)]
602struct EmbeddedMetadataTimings {
603    #[serde(rename = "queue_ms")]
604    queue: u64,
605    #[serde(rename = "provider_ms")]
606    provider: u64,
607    #[serde(rename = "generation_ms", skip_serializing_if = "Option::is_none")]
608    generation: Option<u64>,
609}
610
611#[derive(Serialize)]
612struct EmbeddedMetadataImage {
613    index: u8,
614    format: OutputFormat,
615    width: u32,
616    height: u32,
617}
618
619fn embedded_metadata(
620    original: &ImageRequest,
621    effective: &ImageRequest,
622    response: &ImageResponse,
623    image: &VerifiedImage,
624) -> Result<Vec<u8>, BridgeError> {
625    let operation = operation_summary(&original.operation);
626    let mut value = serde_json::to_value(EmbeddedArtifactMetadata {
627        version: 1,
628        kind: "imagegen_bridge_generation",
629        request_id: &response.id,
630        created: response.created,
631        operation: &operation,
632        original_prompt: &original.prompt,
633        effective_prompt: (original.prompt != effective.prompt)
634            .then_some(effective.prompt.as_str()),
635        negative_prompt: original.negative_prompt.as_deref(),
636        policies: &effective.policies,
637        provider: &response.provider,
638        model: &response.model,
639        requested: &response.requested,
640        effective: &response.effective,
641        normalizations: &response.normalizations,
642        revised_prompt: response.revised_prompt.as_deref(),
643        usage: response.usage.as_ref(),
644        session: response.session.as_ref(),
645        timings: EmbeddedMetadataTimings {
646            queue: response.timings.queue_ms,
647            provider: response.timings.provider_ms,
648            generation: image.generation_ms,
649        },
650        warnings: &response.warnings,
651        image: EmbeddedMetadataImage {
652            index: image.index,
653            format: image.metadata.format,
654            width: image.metadata.width,
655            height: image.metadata.height,
656        },
657    })
658    .map_err(|_| artifact_error("could not encode embedded artifact metadata"))?;
659    let mut encoded = serde_json::to_vec(&value)
660        .map_err(|_| artifact_error("could not encode embedded artifact metadata"))?;
661    if encoded.len() <= MAX_EMBEDDED_METADATA_BYTES {
662        return Ok(encoded);
663    }
664
665    let object = value
666        .as_object_mut()
667        .ok_or_else(|| artifact_error("embedded artifact metadata is not an object"))?;
668    let mut omitted = Vec::new();
669    for field in [
670        "usage",
671        "session",
672        "normalizations",
673        "warnings",
674        "revised_prompt",
675    ] {
676        if object.remove(field).is_none() {
677            continue;
678        }
679        omitted.push(field);
680        object.insert(
681            "omitted_fields".to_owned(),
682            serde_json::to_value(&omitted)
683                .map_err(|_| artifact_error("could not encode omitted metadata fields"))?,
684        );
685        encoded = serde_json::to_vec(&object)
686            .map_err(|_| artifact_error("could not encode embedded artifact metadata"))?;
687        if encoded.len() <= MAX_EMBEDDED_METADATA_BYTES {
688            return Ok(encoded);
689        }
690    }
691    Err(artifact_error(
692        "embedded artifact metadata exceeds the portable 40 KiB limit",
693    ))
694}
695
696fn slice_is_empty<T>(value: &&[T]) -> bool {
697    value.is_empty()
698}
699
700#[derive(Serialize)]
701struct ArtifactMetadataImage<'a> {
702    index: u8,
703    artifact_name: &'a str,
704    format: OutputFormat,
705    width: u32,
706    height: u32,
707    bytes: u64,
708    sha256: &'a str,
709    #[serde(skip_serializing_if = "Option::is_none")]
710    generation_ms: Option<u64>,
711}
712
713#[derive(Serialize)]
714struct ArtifactOperationSummary {
715    kind: &'static str,
716    input_images: usize,
717    reference_images: usize,
718    mask: bool,
719}
720
721fn operation_summary(operation: &ImageOperation) -> ArtifactOperationSummary {
722    match operation {
723        ImageOperation::Generate { reference_images } => ArtifactOperationSummary {
724            kind: "generate",
725            input_images: 0,
726            reference_images: reference_images.len(),
727            mask: false,
728        },
729        ImageOperation::Edit {
730            images,
731            mask,
732            reference_images,
733        } => ArtifactOperationSummary {
734            kind: "edit",
735            input_images: images.len(),
736            reference_images: reference_images.len(),
737            mask: mask.is_some(),
738        },
739    }
740}
741
742struct VerifiedImage {
743    index: u8,
744    bytes: Vec<u8>,
745    metadata: ImageMetadata,
746    generation_ms: Option<u64>,
747}
748
749fn response_payload_bytes(response: &ImageResponse) -> Result<usize, BridgeError> {
750    let mut total = response
751        .id
752        .len()
753        .checked_add(response.provider.len())
754        .and_then(|value| value.checked_add(response.model.len()))
755        .and_then(|value| value.checked_add(response.revised_prompt.as_deref().map_or(0, str::len)))
756        .ok_or_else(|| protocol_error("aggregate response size overflowed"))?;
757    for warning in &response.warnings {
758        total = total
759            .checked_add(warning.len())
760            .ok_or_else(|| protocol_error("aggregate response size overflowed"))?;
761    }
762    for image in &response.data {
763        total = total
764            .checked_add(payload_bytes(&image.payload))
765            .ok_or_else(|| protocol_error("aggregate response size overflowed"))?;
766    }
767    Ok(total)
768}
769
770fn payload_bytes(payload: &ImagePayload) -> usize {
771    match payload {
772        ImagePayload::B64Json { b64_json } => b64_json.len(),
773        ImagePayload::Url { url } => url.len(),
774        ImagePayload::Artifact { id, name } => {
775            id.len().saturating_add(name.as_deref().map_or(0, str::len))
776        }
777        ImagePayload::Metadata => 0,
778    }
779}
780
781fn as_artifact_error(error: BridgeError) -> BridgeError {
782    BridgeError {
783        code: ErrorCode::Artifact,
784        message: "provider output image failed independent verification".to_owned(),
785        provider: error.provider,
786        details: error.details,
787        ..error
788    }
789}
790
791fn configuration_error(message: impl Into<String>) -> BridgeError {
792    BridgeError::new(ErrorCode::Configuration, message)
793}
794
795fn artifact_error(message: impl Into<String>) -> BridgeError {
796    BridgeError::new(ErrorCode::Artifact, message)
797}
798
799fn protocol_error(message: impl Into<String>) -> BridgeError {
800    BridgeError::new(ErrorCode::Protocol, message)
801}
802
803#[cfg(test)]
804mod tests {
805    #![allow(clippy::unwrap_used)]
806
807    use std::fs;
808
809    use imagegen_bridge_artifacts::extract_embedded_metadata;
810    use imagegen_bridge_core::{
811        ArtifactMetadataPolicy, GenerationParameters, ImageFailure, Timings,
812    };
813
814    use super::*;
815    use crate::transparency::TransparencyPlan;
816
817    const ONE_PIXEL_PNG: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=";
818
819    fn image(index: u8) -> GeneratedImage {
820        GeneratedImage {
821            index,
822            payload: ImagePayload::Metadata,
823            format: OutputFormat::Png,
824            width: 1,
825            height: 1,
826            bytes: 1,
827            sha256: "0".repeat(64),
828            generation_ms: Some(1),
829            metadata_name: None,
830        }
831    }
832
833    fn keyed_image() -> GeneratedImage {
834        let mut rgba = image::RgbaImage::from_pixel(20, 20, image::Rgba([0, 255, 0, 255]));
835        for y in 5..15 {
836            for x in 5..15 {
837                rgba.put_pixel(x, y, image::Rgba([220, 30, 20, 255]));
838            }
839        }
840        let mut encoded = std::io::Cursor::new(Vec::new());
841        image::DynamicImage::ImageRgba8(rgba)
842            .write_to(&mut encoded, image::ImageFormat::Png)
843            .unwrap();
844        let bytes = encoded.into_inner();
845        let metadata = inspect_image(&bytes, ImageLimits::default()).unwrap();
846        GeneratedImage {
847            index: 0,
848            payload: ImagePayload::B64Json {
849                b64_json: STANDARD.encode(bytes),
850            },
851            format: metadata.format,
852            width: metadata.width,
853            height: metadata.height,
854            bytes: metadata.bytes,
855            sha256: metadata.sha256,
856            generation_ms: None,
857            metadata_name: None,
858        }
859    }
860
861    #[tokio::test]
862    async fn chroma_postprocessing_is_applied_before_projection() {
863        let materializer = OutputMaterializer::new(MaterializationConfig::default()).unwrap();
864        let mut request = ImageRequest::generate("isolated red square");
865        request.parameters.background = imagegen_bridge_core::Background::Transparent;
866        let projected = materializer
867            .materialize(
868                response(vec![keyed_image()], Vec::new()),
869                &request,
870                &request,
871                Some(TransparencyPlan {
872                    chroma: imagegen_bridge_artifacts::ChromaKeyOptions::default(),
873                }),
874            )
875            .await
876            .unwrap();
877        assert!(matches!(
878            projected.data[0].payload,
879            ImagePayload::B64Json { .. }
880        ));
881        let ImagePayload::B64Json { b64_json } = &projected.data[0].payload else {
882            return;
883        };
884        let decoded = image::load_from_memory(&STANDARD.decode(b64_json).unwrap())
885            .unwrap()
886            .to_rgba8();
887        assert_eq!(decoded.get_pixel(0, 0).0, [0, 0, 0, 0]);
888        assert_eq!(decoded.get_pixel(10, 10).0, [220, 30, 20, 255]);
889        assert!(projected.normalizations.iter().any(|item| {
890            item.field == "data[0].transparency.alpha"
891                && item.reason == "validated_chroma_key_alpha"
892        }));
893    }
894
895    fn response(data: Vec<GeneratedImage>, failures: Vec<ImageFailure>) -> ImageResponse {
896        ImageResponse {
897            id: "test".to_owned(),
898            created: 0,
899            provider: "test".to_owned(),
900            model: "test".to_owned(),
901            requested: GenerationParameters::default(),
902            effective: GenerationParameters::default(),
903            normalizations: Vec::new(),
904            attempts: Vec::new(),
905            data,
906            failures,
907            revised_prompt: None,
908            usage: None,
909            session: None,
910            timings: Timings::default(),
911            warnings: Vec::new(),
912        }
913    }
914
915    #[test]
916    fn accepts_complete_indexed_best_effort_results() {
917        let response = response(
918            vec![image(0), image(2)],
919            vec![ImageFailure {
920                index: 1,
921                error: BridgeError::new(ErrorCode::Upstream, "failed"),
922                generation_ms: 1,
923            }],
924        );
925        OutputMaterializer::validate_output_set(&response, 3, MultiImageFailurePolicy::BestEffort)
926            .unwrap();
927    }
928
929    #[test]
930    fn rejects_partial_failures_in_fail_fast_and_duplicate_indices() {
931        let response = response(
932            vec![image(0), image(1)],
933            vec![ImageFailure {
934                index: 1,
935                error: BridgeError::new(ErrorCode::Upstream, "failed"),
936                generation_ms: 1,
937            }],
938        );
939        assert!(
940            OutputMaterializer::validate_output_set(
941                &response,
942                3,
943                MultiImageFailurePolicy::FailFast,
944            )
945            .is_err()
946        );
947        assert!(
948            OutputMaterializer::validate_output_set(
949                &response,
950                3,
951                MultiImageFailurePolicy::BestEffort,
952            )
953            .is_err()
954        );
955    }
956
957    #[tokio::test]
958    async fn rejects_outputs_that_exceed_the_aggregate_response_budget() {
959        let bytes = STANDARD.decode(ONE_PIXEL_PNG).unwrap();
960        let metadata = inspect_image(&bytes, ImageLimits::default()).unwrap();
961        let generated = |index| GeneratedImage {
962            index,
963            payload: ImagePayload::B64Json {
964                b64_json: ONE_PIXEL_PNG.to_owned(),
965            },
966            format: metadata.format,
967            width: metadata.width,
968            height: metadata.height,
969            bytes: metadata.bytes,
970            sha256: metadata.sha256.clone(),
971            generation_ms: None,
972            metadata_name: None,
973        };
974        let materializer = OutputMaterializer::new(MaterializationConfig {
975            max_response_bytes: 120,
976            ..MaterializationConfig::default()
977        })
978        .unwrap();
979        let mut request = ImageRequest::generate("two images");
980        request.parameters.n = 2;
981        let error = materializer
982            .materialize(
983                response(vec![generated(0), generated(1)], Vec::new()),
984                &request,
985                &request,
986                None,
987            )
988            .await
989            .unwrap_err();
990        assert_eq!(error.code, ErrorCode::Protocol);
991        assert!(error.message.contains("aggregate response payload"));
992    }
993
994    #[test]
995    fn artifact_projection_honors_per_request_placement() {
996        let root = tempfile::tempdir().unwrap();
997        let store = Arc::new(ArtifactStore::new(root.path(), ImageLimits::default()).unwrap());
998        let materializer = OutputMaterializer::new(MaterializationConfig {
999            artifact_store: Some(Arc::clone(&store)),
1000            ..MaterializationConfig::default()
1001        })
1002        .unwrap();
1003        let bytes = STANDARD.decode(ONE_PIXEL_PNG).unwrap();
1004        let metadata = inspect_image(&bytes, ImageLimits::default()).unwrap();
1005        let output = OutputOptions {
1006            response_format: ResponseFormat::Artifact,
1007            directory: Some("portraits".to_owned()),
1008            filename: Some("woman.png".to_owned()),
1009            metadata: ArtifactMetadataPolicy::Sidecar,
1010            ..OutputOptions::default()
1011        };
1012        let projected = materializer
1013            .project(
1014                VerifiedImage {
1015                    index: 0,
1016                    bytes,
1017                    metadata,
1018                    generation_ms: Some(1),
1019                },
1020                &output,
1021            )
1022            .unwrap();
1023        assert!(matches!(
1024            projected.payload,
1025            ImagePayload::Artifact { name: Some(ref name), .. } if name == "portraits/woman.png"
1026        ));
1027        assert!(root.path().join("portraits/woman.png").is_file());
1028
1029        let mut original = ImageRequest::generate("an original prompt");
1030        original.negative_prompt = Some("blur".to_owned());
1031        original.output = output;
1032        let mut response = response(vec![projected], Vec::new());
1033        response.provider = "codex-app-server".to_owned();
1034        response.model = "gpt-image-2".to_owned();
1035        materializer
1036            .attach_metadata(&original, &original, &mut response)
1037            .unwrap();
1038        let metadata_name = response.data[0].metadata_name.as_deref().unwrap();
1039        let sidecar: serde_json::Value =
1040            serde_json::from_slice(&fs::read(root.path().join(metadata_name)).unwrap()).unwrap();
1041        assert_eq!(sidecar["original_prompt"], "an original prompt");
1042        assert_eq!(sidecar["negative_prompt"], "blur");
1043        assert_eq!(sidecar["provider"], "codex-app-server");
1044        assert_eq!(sidecar["model"], "gpt-image-2");
1045        assert_eq!(sidecar["image"]["artifact_name"], "portraits/woman.png");
1046        assert_eq!(sidecar["image"]["width"], 1);
1047    }
1048
1049    #[tokio::test]
1050    async fn embedded_policy_updates_payload_checksum_and_carries_generation_contract() {
1051        let materializer = OutputMaterializer::new(MaterializationConfig::default()).unwrap();
1052        let bytes = STANDARD.decode(ONE_PIXEL_PNG).unwrap();
1053        let metadata = inspect_image(&bytes, ImageLimits::default()).unwrap();
1054        let generated = GeneratedImage {
1055            index: 0,
1056            payload: ImagePayload::B64Json {
1057                b64_json: STANDARD.encode(&bytes),
1058            },
1059            format: metadata.format,
1060            width: metadata.width,
1061            height: metadata.height,
1062            bytes: metadata.bytes,
1063            sha256: metadata.sha256.clone(),
1064            generation_ms: Some(41),
1065            metadata_name: None,
1066        };
1067        let mut provider_response = response(vec![generated], Vec::new());
1068        provider_response.provider = "codex-app-server".to_owned();
1069        provider_response.model = "gpt-image-2".to_owned();
1070        provider_response.timings.queue_ms = 3;
1071        provider_response.timings.provider_ms = 40;
1072        let mut request = ImageRequest::generate("a red paper fox");
1073        request.output.metadata = ArtifactMetadataPolicy::Embedded;
1074
1075        let projected = materializer
1076            .materialize(provider_response, &request, &request, None)
1077            .await
1078            .unwrap();
1079        let b64_json = match &projected.data[0].payload {
1080            ImagePayload::B64Json { b64_json } => Some(b64_json),
1081            _ => None,
1082        }
1083        .unwrap();
1084        let embedded = STANDARD.decode(b64_json).unwrap();
1085        let record: serde_json::Value = serde_json::from_slice(
1086            &extract_embedded_metadata(&embedded, ImageLimits::default())
1087                .unwrap()
1088                .unwrap(),
1089        )
1090        .unwrap();
1091        assert_eq!(record["kind"], "imagegen_bridge_generation");
1092        assert_eq!(record["original_prompt"], "a red paper fox");
1093        assert_eq!(record["provider"], "codex-app-server");
1094        assert_eq!(record["model"], "gpt-image-2");
1095        assert_eq!(record["timings"]["provider_ms"], 40);
1096        assert_eq!(record["timings"]["generation_ms"], 41);
1097        assert_ne!(projected.data[0].sha256, metadata.sha256);
1098        assert_eq!(
1099            projected.data[0].sha256,
1100            inspect_image(&embedded, ImageLimits::default())
1101                .unwrap()
1102                .sha256
1103        );
1104    }
1105
1106    #[tokio::test]
1107    async fn combined_policy_publishes_embedded_xmp_and_matching_sidecar() {
1108        let root = tempfile::tempdir().unwrap();
1109        let store = Arc::new(ArtifactStore::new(root.path(), ImageLimits::default()).unwrap());
1110        let materializer = OutputMaterializer::new(MaterializationConfig {
1111            artifact_store: Some(Arc::clone(&store)),
1112            ..MaterializationConfig::default()
1113        })
1114        .unwrap();
1115        let bytes = STANDARD.decode(ONE_PIXEL_PNG).unwrap();
1116        let metadata = inspect_image(&bytes, ImageLimits::default()).unwrap();
1117        let generated = GeneratedImage {
1118            index: 0,
1119            payload: ImagePayload::B64Json {
1120                b64_json: STANDARD.encode(&bytes),
1121            },
1122            format: metadata.format,
1123            width: metadata.width,
1124            height: metadata.height,
1125            bytes: metadata.bytes,
1126            sha256: metadata.sha256,
1127            generation_ms: Some(7),
1128            metadata_name: None,
1129        };
1130        let mut request = ImageRequest::generate("a glass lighthouse");
1131        request.output = OutputOptions {
1132            response_format: ResponseFormat::Artifact,
1133            filename: Some("lighthouse.png".to_owned()),
1134            metadata: ArtifactMetadataPolicy::SidecarAndEmbedded,
1135            ..OutputOptions::default()
1136        };
1137        let mut provider_response = response(vec![generated], Vec::new());
1138        provider_response.provider = "codex-app-server".to_owned();
1139        provider_response.model = "gpt-image-2".to_owned();
1140        let mut projected = materializer
1141            .materialize(provider_response, &request, &request, None)
1142            .await
1143            .unwrap();
1144        materializer
1145            .attach_metadata(&request, &request, &mut projected)
1146            .unwrap();
1147
1148        let (id, name) = match &projected.data[0].payload {
1149            ImagePayload::Artifact { id, name } => Some((id, name)),
1150            _ => None,
1151        }
1152        .unwrap();
1153        assert_eq!(name.as_deref(), Some("lighthouse.png"));
1154        let stored = store.read(id).unwrap();
1155        let embedded: serde_json::Value = serde_json::from_slice(
1156            &extract_embedded_metadata(&stored.bytes, ImageLimits::default())
1157                .unwrap()
1158                .unwrap(),
1159        )
1160        .unwrap();
1161        assert_eq!(embedded["original_prompt"], "a glass lighthouse");
1162        assert_eq!(stored.metadata.sha256, projected.data[0].sha256);
1163        assert!(
1164            root.path()
1165                .join(projected.data[0].metadata_name.as_deref().unwrap())
1166                .is_file()
1167        );
1168    }
1169
1170    #[test]
1171    fn embedded_record_reports_optional_fields_omitted_for_container_portability() {
1172        let bytes = STANDARD.decode(ONE_PIXEL_PNG).unwrap();
1173        let metadata = inspect_image(&bytes, ImageLimits::default()).unwrap();
1174        let image = VerifiedImage {
1175            index: 0,
1176            bytes,
1177            metadata,
1178            generation_ms: Some(9),
1179        };
1180        let request = ImageRequest::generate("a small prompt");
1181        let mut provider_response = response(Vec::new(), Vec::new());
1182        provider_response.revised_prompt = Some("r".repeat(50 * 1024));
1183
1184        let encoded = embedded_metadata(&request, &request, &provider_response, &image).unwrap();
1185        assert!(encoded.len() <= MAX_EMBEDDED_METADATA_BYTES);
1186        let value: serde_json::Value = serde_json::from_slice(&encoded).unwrap();
1187        assert!(value.get("revised_prompt").is_none());
1188        assert!(
1189            value["omitted_fields"]
1190                .as_array()
1191                .unwrap()
1192                .iter()
1193                .any(|field| field == "revised_prompt")
1194        );
1195    }
1196}