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