1use crate::{
18 HttpBody, WireRequest, WireResponse,
19 adapt::{JsonInvocation, invoke_json},
20 capability::{
21 PublicationKind, PublishedResource, ResourceAccess, ResourceMetadata, ResourceReference,
22 Upstream,
23 },
24 codec::{self, CodecLimits},
25 openai::images as o,
26 transform::{
27 Report, TransformError, TransformErrorKind,
28 images::{
29 self, ImageCallResult, ImageDialect, ImageDialectBody, ImageInput, ImageResponseFacts,
30 ImageTargetModels, ResolvedImageInput,
31 },
32 },
33};
34use base64::Engine;
35
36#[derive(Debug, Clone, Copy)]
37pub struct ImageLimits {
38 pub codec: CodecLimits,
39
40 pub max_input_bytes: u64,
41 pub max_total_input_bytes: u64,
42 pub max_output_bytes: u64,
43 pub max_total_output_bytes: u64,
44}
45
46#[derive(Debug)]
52pub struct ImageProgress<H> {
53 started: bool,
54 attempted_calls: usize,
55 calls: Vec<ImageCallResult>,
56 publication_ids: Vec<String>,
57 published: Vec<PublishedResource<H>>,
58}
59
60impl<H> Default for ImageProgress<H> {
61 fn default() -> Self {
62 Self {
63 started: false,
64 attempted_calls: 0,
65 calls: Vec::new(),
66 publication_ids: Vec::new(),
67 published: Vec::new(),
68 }
69 }
70}
71
72impl<H> ImageProgress<H> {
73 pub fn started(&self) -> bool {
74 self.started
75 }
76 pub fn attempted_calls(&self) -> usize {
77 self.attempted_calls
78 }
79 pub fn calls(&self) -> &[ImageCallResult] {
80 &self.calls
81 }
82 pub fn publication_ids(&self) -> &[String] {
83 &self.publication_ids
84 }
85 pub fn published(&self) -> &[PublishedResource<H>] {
86 &self.published
87 }
88}
89
90#[derive(Debug)]
91pub enum ImageError {
92 Transform(TransformError),
93 Rejected(Box<WireResponse<HttpBody>>),
94 InvalidResponses {
95 error: TransformError,
96 response:
97 Box<WireResponse<crate::openai::responses::response::GenerateContentResponseBody>>,
98 },
99 InvalidGemini {
100 error: TransformError,
101 response: Box<WireResponse<crate::gemini::GenerateContentResponseBody>>,
102 },
103}
104
105impl From<TransformError> for ImageError {
106 fn from(v: TransformError) -> Self {
107 Self::Transform(v)
108 }
109}
110
111impl std::fmt::Display for ImageError {
112 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
113 match self {
114 Self::Transform(e)
115 | Self::InvalidResponses { error: e, .. }
116 | Self::InvalidGemini { error: e, .. } => e.fmt(f),
117 Self::Rejected(r) => write!(f, "image upstream returned HTTP {}", r.status),
118 }
119 }
120}
121
122impl std::error::Error for ImageError {
123 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
124 match self {
125 Self::Transform(e)
126 | Self::InvalidResponses { error: e, .. }
127 | Self::InvalidGemini { error: e, .. } => Some(e),
128 Self::Rejected(_) => None,
129 }
130 }
131}
132
133#[derive(Debug)]
134pub struct ImageOutcome {
135 pub response: o::ImagesResponse,
136 pub report: Report,
137}
138
139fn limit(context: &str) -> TransformError {
140 TransformError::new(
141 TransformErrorKind::Limit,
142 context,
143 "image operation exceeds configured cap",
144 )
145}
146
147fn clone_request<T: Clone>(r: &WireRequest<T>) -> WireRequest<T> {
148 WireRequest {
149 method: r.method.clone(),
150 path: r.path.clone(),
151 query: r.query.clone(),
152 headers: r.headers.clone(),
153 body: r.body.clone(),
154 }
155}
156
157fn request_fits<T: serde::Serialize>(
158 r: &WireRequest<T>,
159 limits: CodecLimits,
160 max_write: u64,
161) -> Result<(), TransformError> {
162 codec::encode_json(
163 &r.body,
164 CodecLimits {
165 max_body_bytes: limits.max_body_bytes.min(max_write),
166 ..limits
167 },
168 )
169 .map(|_| ())
170 .map_err(|e| {
171 TransformError::new(
172 if e.kind() == codec::CodecErrorKind::Limit {
173 TransformErrorKind::Limit
174 } else {
175 TransformErrorKind::InvalidInput
176 },
177 "image.request",
178 e.to_string(),
179 )
180 })
181}
182
183#[allow(clippy::too_many_arguments)]
184pub async fn generate<A: ResourceAccess, U: Upstream>(
185 access: &A,
186 scope: &A::Scope,
187 upstream: &U,
188 target: &U::Target,
189 input: ImageInput,
190 dialect: ImageDialect,
191 models: &ImageTargetModels,
192 facts: &ImageResponseFacts,
193 limits: ImageLimits,
194 progress: &mut ImageProgress<A::PublishedHandle>,
195) -> Result<ImageOutcome, ImageError> {
196 if progress.started {
197 return Err(TransformError::new(TransformErrorKind::MissingState,"image.progress","generation already started; recover recorded publications, never replay uncertain upstream calls").into());
198 }
199 let (context, refs, mut report) = images::preflight(&input, dialect, models)?;
200 let delivery = context
201 .response_format
202 .unwrap_or(facts.default_response_format);
203
204 if facts.created < 0 {
205 return Err(TransformError::shape(
206 "image.created",
207 "nonnegative factual Unix timestamp required",
208 )
209 .into());
210 }
211 if delivery == o::ImageResponseFormat::Url {
212 if facts.operation_id.len() as u64 > limits.codec.max_value_bytes {
213 return Err(limit("image.operation_id").into());
214 }
215 if facts.operation_id.trim().is_empty() || facts.operation_id.chars().any(char::is_control)
216 {
217 return Err(TransformError::shape(
218 "image.operation_id",
219 "nonempty control-free operation identity required",
220 )
221 .into());
222 }
223 if facts
224 .publish_expires_at
225 .is_none_or(|t| t <= facts.observed_at)
226 {
227 return Err(TransformError::shape(
228 "image.publish.expires_at",
229 "future expiry required before generation",
230 )
231 .into());
232 }
233 }
234 let mut resolved = Vec::new();
235 let mut total = 0u64;
236 for reference in refs {
237 if resolved
238 .iter()
239 .any(|r: &ResolvedImageInput| r.reference == reference)
240 {
241 continue;
242 }
243 let remaining = limits
244 .max_total_input_bytes
245 .checked_sub(total)
246 .ok_or_else(|| limit("image.input.total"))?;
247 let (image, len) = read_image(
248 access,
249 scope,
250 &reference,
251 limits.codec,
252 limits.max_input_bytes.min(remaining),
253 facts.observed_at,
254 )
255 .await?;
256 total = total
257 .checked_add(len)
258 .ok_or_else(|| limit("image.input.total"))?;
259 resolved.push(image);
260 }
261 let prepared = images::build_request(input, dialect, models, &resolved)?.value;
262 match &prepared.body {
263 ImageDialectBody::Responses(r) => {
264 request_fits(r, limits.codec, upstream.limits().write_bytes)?
265 }
266 ImageDialectBody::Gemini(r) => {
267 request_fits(r, limits.codec, upstream.limits().write_bytes)?
268 }
269 };
270 if limits.max_output_bytes == 0
272 || limits.max_total_output_bytes == 0
273 || (delivery == o::ImageResponseFormat::Url && access.limits().write_bytes == 0)
274 {
275 return Err(limit("image.output.bytes").into());
276 }
277 progress.started = true;
278 let mut total_output = 0u64;
279 for _ in 0..context.n {
280 let cap = limits
281 .max_output_bytes
282 .min(if delivery == o::ImageResponseFormat::Url {
283 access.limits().write_bytes
284 } else {
285 u64::MAX
286 })
287 .min(
288 limits
289 .max_total_output_bytes
290 .checked_sub(total_output)
291 .ok_or_else(|| limit("image.output.total"))?,
292 );
293 if cap == 0 {
294 return Err(limit("image.output.total").into());
295 }
296 progress.attempted_calls += 1;
297 let result = match &prepared.body {
298 ImageDialectBody::Responses(r) => {
299 match invoke_json::<
300 _,
301 _,
302 crate::openai::responses::response::GenerateContentResponseBody,
303 >(upstream, target, clone_request(r), limits.codec)
304 .await?
305 {
306 JsonInvocation::Success(r) => {
307 match images::image_response_from_responses(&r.body, &context, cap) {
308 Ok(v) => v,
309 Err(error) => {
310 return Err(ImageError::InvalidResponses {
311 error,
312 response: Box::new(r),
313 });
314 }
315 }
316 }
317 JsonInvocation::Rejected(r) => return Err(ImageError::Rejected(Box::new(r))),
318 }
319 }
320 ImageDialectBody::Gemini(r) => match invoke_json::<
321 _,
322 _,
323 crate::gemini::GenerateContentResponseBody,
324 >(
325 upstream, target, clone_request(r), limits.codec
326 )
327 .await?
328 {
329 JsonInvocation::Success(r) => {
330 match images::image_response_from_gemini(&r.body, &context, cap) {
331 Ok(v) => v,
332 Err(error) => {
333 return Err(ImageError::InvalidGemini {
334 error,
335 response: Box::new(r),
336 });
337 }
338 }
339 }
340 JsonInvocation::Rejected(r) => return Err(ImageError::Rejected(Box::new(r))),
341 },
342 };
343 total_output = total_output
344 .checked_add(result.image.bytes.len() as u64)
345 .ok_or_else(|| limit("image.output.total"))?;
346 report
347 .diagnostics
348 .extend(result.report.diagnostics.iter().cloned());
349 progress.calls.push(result);
350 }
351 if delivery == o::ImageResponseFormat::Url
353 && progress
354 .calls
355 .iter()
356 .any(|c| c.image.bytes.len() as u64 > access.limits().write_bytes)
357 {
358 return Err(limit("image.publish.bytes").into());
359 }
360 let mut data = Vec::new();
361 for (index, call) in progress.calls.iter().enumerate() {
362 let mut image = o::GeneratedImage::builder().build();
363 image.revised_prompt = call.image.revised_prompt.clone();
364 if delivery == o::ImageResponseFormat::Url {
365 let operation = format!(
367 "image:{}:{}:{}",
368 facts.operation_id.len(),
369 facts.operation_id,
370 index
371 );
372 progress.publication_ids.push(operation.clone());
373 let publication = access
374 .publish(
375 scope,
376 &operation,
377 PublicationKind::Url,
378 ResourceMetadata {
379 mime: Some(call.image.metadata.mime().into()),
380 length: Some(call.image.bytes.len() as u64),
381 filename: None,
382 expires_at: facts.publish_expires_at,
383 },
384 HttpBody::Bytes(call.image.bytes.clone()),
385 )
386 .await
387 .map_err(TransformError::from)?;
388 progress.published.push(publication);
390 let publication = progress.published.last().unwrap();
391 let ResourceReference::Url(url) = &publication.reference else {
392 return Err(TransformError::invalid_result(
393 "image.publish",
394 "host returned an ID instead of URL",
395 )
396 .into());
397 };
398 if url.trim().is_empty()
399 || publication.metadata.mime.as_deref() != Some(call.image.metadata.mime())
400 || publication.metadata.length != Some(call.image.bytes.len() as u64)
401 || publication
402 .metadata
403 .expires_at
404 .is_none_or(|t| t <= facts.observed_at)
405 {
406 return Err(TransformError::invalid_result(
407 "image.publish",
408 "publication metadata contradicts image or is expired",
409 )
410 .into());
411 }
412 image.url = Some(url.clone());
413 } else {
414 image.b64_json =
415 Some(base64::engine::general_purpose::STANDARD.encode(&call.image.bytes));
416 }
417 data.push(image);
418 }
419 let first = &progress.calls[0].image.metadata;
420 let format = progress
421 .calls
422 .iter()
423 .all(|c| c.image.metadata.format == first.format)
424 .then_some(first.format);
425 let size =
426 if progress.calls.iter().all(|c| {
427 (c.image.metadata.width, c.image.metadata.height) == (first.width, first.height)
428 }) {
429 match (first.width, first.height) {
430 (1024, 1024) => Some(o::OutputImageSize::Square),
431 (1536, 1024) => Some(o::OutputImageSize::Landscape),
432 (1024, 1536) => Some(o::OutputImageSize::Portrait),
433 (width, height) => Some(o::OutputImageSize::Custom(format!("{width}x{height}"))),
434 }
435 } else {
436 None
437 };
438 if progress.calls.iter().any(|c| c.usage.is_some()) {
439 report.changed("usage","native usage is retained per call in ImageProgress; native generic token counters cannot supply Images image/text token breakdown");
440 }
441 let mut response = o::ImagesResponse::builder(facts.created).data(data).build();
442 response.output_format = format;
443 response.size = size;
444 Ok(ImageOutcome { response, report })
446}
447
448async fn read_image<A: ResourceAccess>(
449 access: &A,
450 scope: &A::Scope,
451 reference: &ResourceReference,
452 codec_limits: CodecLimits,
453 max_bytes: u64,
454 observed_at: std::time::SystemTime,
455) -> Result<(ResolvedImageInput, u64), TransformError> {
456 let (bytes, mime) = if let ResourceReference::Url(url) = reference {
457 if let Some(data) = url.strip_prefix("data:") {
458 let (mime, encoded) = data.split_once(";base64,").ok_or_else(|| {
459 TransformError::shape("image.data_url", "base64 data URL required")
460 })?;
461 let image =
462 images::decode_image(encoded, Some(mime), max_bytes).map_err(input_error)?;
463 (image.bytes, image.metadata.mime().to_owned())
464 } else {
465 read_resource(
466 access,
467 scope,
468 reference,
469 codec_limits,
470 max_bytes,
471 observed_at,
472 )
473 .await?
474 }
475 } else {
476 read_resource(
477 access,
478 scope,
479 reference,
480 codec_limits,
481 max_bytes,
482 observed_at,
483 )
484 .await?
485 };
486 let len = bytes.len() as u64;
487 Ok((
488 ResolvedImageInput {
489 reference: reference.clone(),
490 bytes_base64: base64::engine::general_purpose::STANDARD.encode(bytes),
491 mime_type: mime,
492 },
493 len,
494 ))
495}
496
497async fn read_resource<A: ResourceAccess>(
498 access: &A,
499 scope: &A::Scope,
500 reference: &ResourceReference,
501 codec_limits: CodecLimits,
502 max_bytes: u64,
503 observed_at: std::time::SystemTime,
504) -> Result<(bytes::Bytes, String), TransformError> {
505 let read = access
506 .read(scope, reference)
507 .await
508 .map_err(TransformError::from)?;
509 let cap = max_bytes.min(access.limits().read_bytes);
510 if read.metadata.length.is_some_and(|n| n > cap) {
511 return Err(limit("image.read.length"));
512 }
513 if read.metadata.expires_at.is_some_and(|t| t <= observed_at) {
514 return Err(TransformError::invalid_result(
515 "image.read",
516 "host returned expired resource",
517 ));
518 }
519 let mime = read
520 .metadata
521 .mime
522 .ok_or_else(|| TransformError::missing_metadata("image.mime"))?;
523 let bytes = codec::read_http_body(
524 read.body,
525 CodecLimits {
526 max_body_bytes: cap,
527 ..codec_limits
528 },
529 )
530 .await
531 .map_err(|e| {
532 TransformError::new(
533 if e.kind() == codec::CodecErrorKind::Limit {
534 TransformErrorKind::Limit
535 } else {
536 TransformErrorKind::Host
537 },
538 "image.read",
539 e.to_string(),
540 )
541 })?;
542 if read
543 .metadata
544 .length
545 .is_some_and(|n| n != bytes.len() as u64)
546 {
547 return Err(TransformError::invalid_result(
548 "image.read.length",
549 "metadata length differs from actual bytes",
550 ));
551 }
552 let metadata = images::inspect_image(&bytes)?;
553 if metadata.mime() != mime {
554 return Err(TransformError::invalid_result(
555 "image.read.mime",
556 "MIME differs from actual image bytes",
557 ));
558 }
559 Ok((bytes, mime))
560}
561
562fn input_error(error: TransformError) -> TransformError {
563 TransformError::with_source(
564 if error.kind() == TransformErrorKind::InvalidResult {
565 TransformErrorKind::InvalidInput
566 } else {
567 error.kind()
568 },
569 "image.input",
570 error.to_string(),
571 error,
572 )
573}