1use bytes::Bytes; use http::StatusCode;
3use serde::{Deserialize, Serialize};
4use serde_json::Value;
5
6pub const IMAGE_MODEL: &str = "gpt-image-2";
7pub const MAX_GENERATION_REQUEST_BYTES: usize = 256 * 1024;
8pub const MAX_EDIT_REQUEST_BYTES: usize = 64 * 1024 * 1024;
9pub const MAX_IMAGE_RESPONSE_BYTES: usize = 128 * 1024 * 1024;
10pub const MAX_EDIT_IMAGES: usize = 5;
11pub const MAX_SINGLE_IMAGE_BYTES: usize = 20 * 1024 * 1024;
12pub const MAX_EDIT_IMAGE_BYTES: usize = 50 * 1024 * 1024;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum ImageOperation {
16 Generation,
17 Edit,
18}
19
20impl ImageOperation {
21 pub fn upstream_path(self) -> &'static str {
22 match self {
23 Self::Generation => "images/generations",
24 Self::Edit => "images/edits",
25 }
26 }
27
28 pub fn label(self) -> &'static str {
29 match self {
30 Self::Generation => "generation",
31 Self::Edit => "edit",
32 }
33 }
34}
35
36#[derive(Debug)]
37pub struct ImageRequestError {
38 pub status: StatusCode,
39 pub message: String,
40 pub param: Option<&'static str>,
41 pub code: Option<&'static str>,
42}
43
44impl ImageRequestError {
45 fn invalid(message: impl Into<String>, param: Option<&'static str>) -> Self {
46 Self {
47 status: StatusCode::BAD_REQUEST,
48 message: message.into(),
49 param,
50 code: Some("invalid_request"),
51 }
52 }
53
54 fn upstream_invalid(message: impl Into<String>) -> Self {
55 Self {
56 status: StatusCode::BAD_GATEWAY,
57 message: message.into(),
58 param: None,
59 code: Some("invalid_upstream_response"),
60 }
61 }
62}
63
64#[derive(Debug, Deserialize)]
65struct ImageResponse<'a> {
66 created: u64,
67 #[serde(borrow)]
68 data: Vec<ImageResponseItem<'a>>,
69 #[serde(default)]
70 usage: Option<ImageUsage>,
71}
72
73#[derive(Debug, Deserialize)]
74struct ImageResponseItem<'a> {
75 #[serde(borrow)]
76 b64_json: &'a str,
77}
78
79#[derive(Debug, Deserialize)]
80struct ImageUsage {
81 #[serde(default)]
82 input_tokens: Option<u64>,
83 #[serde(default)]
84 output_tokens: Option<u64>,
85}
86
87#[derive(Debug, Deserialize, Serialize)]
88#[serde(deny_unknown_fields)]
89struct GenerationRequest {
90 prompt: String,
91 #[serde(default)]
92 model: Option<String>,
93 #[serde(default)]
94 background: Option<String>,
95 #[serde(default, skip_serializing_if = "Option::is_none")]
96 n: Option<u8>,
97 #[serde(default)]
98 quality: Option<String>,
99 #[serde(default)]
100 size: Option<String>,
101}
102
103#[derive(Debug, Deserialize, Serialize)]
104#[serde(deny_unknown_fields)]
105struct ImageUrl {
106 image_url: String,
107}
108
109#[derive(Debug, Deserialize, Serialize)]
110#[serde(deny_unknown_fields)]
111struct EditRequest {
112 prompt: String,
113 images: Vec<ImageUrl>,
114 #[serde(default)]
115 model: Option<String>,
116 #[serde(default)]
117 background: Option<String>,
118 #[serde(default, skip_serializing_if = "Option::is_none")]
119 n: Option<u8>,
120 #[serde(default)]
121 quality: Option<String>,
122 #[serde(default)]
123 size: Option<String>,
124}
125
126#[derive(Debug)]
127pub struct UploadedImage {
128 pub bytes: Bytes,
129}
130
131#[derive(Debug, Default)]
132pub struct MultipartEditInput {
133 pub prompt: Option<String>,
134 pub model: Option<String>,
135 pub background: Option<String>,
136 pub n: Option<u8>,
137 pub quality: Option<String>,
138 pub size: Option<String>,
139 pub images: Vec<UploadedImage>,
140}
141
142#[derive(Debug)]
143pub struct PreparedImageRequest {
144 pub body: Value,
145 pub model: String,
146 pub image_count: usize,
147}
148
149pub struct CodexImagesBackend {
150 client: std::sync::Arc<super::client::CodexHttpClient>,
151 base_url: String,
152 limiter: std::sync::Arc<tokio::sync::Semaphore>,
153}
154
155impl CodexImagesBackend {
156 pub fn new() -> Result<Self, String> {
157 let base_url = validate_image_base_url(&crate::config::codex_images_base_url())?;
158 Ok(Self {
159 client: std::sync::Arc::new(super::client::CodexHttpClient::new()),
160 base_url,
161 limiter: std::sync::Arc::new(tokio::sync::Semaphore::new(2)),
162 })
163 }
164
165 #[cfg(test)]
166 fn new_for_test(
167 client: std::sync::Arc<super::client::CodexHttpClient>,
168 base_url: String,
169 ) -> Self {
170 Self {
171 client,
172 base_url: base_url.trim_end_matches('/').to_string(),
173 limiter: std::sync::Arc::new(tokio::sync::Semaphore::new(2)),
174 }
175 }
176
177 pub async fn handle(
178 &self,
179 operation: ImageOperation,
180 prepared: PreparedImageRequest,
181 ctx: crate::provider::RequestContext,
182 ) -> axum::response::Response {
183 use axum::response::IntoResponse;
184
185 let _permit = match self.limiter.clone().try_acquire_owned() {
186 Ok(permit) => permit,
187 Err(_) => {
188 return image_error_response(ImageRequestError {
189 status: StatusCode::TOO_MANY_REQUESTS,
190 message: "Too many concurrent image requests".to_string(),
191 param: None,
192 code: Some("local_capacity_exceeded"),
193 });
194 }
195 };
196 if let Some(monitor) = ctx.monitor.as_ref() {
197 monitor.model_resolved(&ctx.req_id, &prepared.model);
198 monitor.upstream_started(&ctx.req_id);
199 }
200 let upstream = match self
201 .client
202 .post_image_json(&self.base_url, operation, &prepared.body, &ctx)
203 .await
204 {
205 Ok(response) => response,
206 Err(error) => return image_transport_error_response(error),
207 };
208 let status = upstream.status();
209 let headers = upstream.headers().clone();
210 if status.is_redirection() {
212 return image_error_response(ImageRequestError::upstream_invalid(
213 "Codex image service returned an unexpected redirect",
214 ));
215 }
216 if upstream
217 .content_length()
218 .is_some_and(|length| length > MAX_IMAGE_RESPONSE_BYTES as u64)
219 {
220 return image_error_response(ImageRequestError::upstream_invalid(
221 "Codex image response exceeded the size limit",
222 ));
223 }
224 if !status.is_success() {
225 let mut response = image_error_response(ImageRequestError {
227 status,
228 message: format!("Codex image service returned HTTP {}", status.as_u16()),
229 param: None,
230 code: Some("upstream_error"),
231 });
232 copy_safe_image_headers(&headers, response.headers_mut());
233 return response;
234 }
235 let body =
236 match collect_image_response_body(upstream, self.client.body_idle_timeout_ms(), &ctx)
237 .await
238 {
239 Ok(body) => body,
240 Err(error) => return image_error_response(error),
241 };
242 let usage = match validate_success_response(&body) {
243 Ok(usage) => usage,
244 Err(error) => return image_error_response(error),
245 };
246 if let Some(monitor) = ctx.monitor.as_ref() {
247 monitor.usage_updated(&ctx.req_id, usage.0, usage.1);
248 }
249 let mut response = (
250 StatusCode::OK,
251 [(http::header::CONTENT_TYPE, "application/json")],
252 body,
253 )
254 .into_response();
255 response.headers_mut().insert(
256 http::header::CACHE_CONTROL,
257 http::HeaderValue::from_static("no-store"),
258 );
259 response.headers_mut().insert(
260 http::header::X_CONTENT_TYPE_OPTIONS,
261 http::HeaderValue::from_static("nosniff"),
262 );
263 copy_safe_image_headers(&headers, response.headers_mut());
264 response
265 }
266}
267
268async fn collect_image_response_body(
269 mut response: reqwest::Response,
270 body_idle_timeout_ms: u64,
271 ctx: &crate::provider::RequestContext,
272) -> Result<Vec<u8>, ImageRequestError> {
273 let cap = response
275 .content_length()
276 .map(|l| l as usize)
277 .unwrap_or(0)
278 .min(MAX_IMAGE_RESPONSE_BYTES);
279 let mut body = Vec::with_capacity(cap);
280 let mut started = false;
281 loop {
282 let chunk = tokio::time::timeout(
283 std::time::Duration::from_millis(body_idle_timeout_ms),
284 response.chunk(),
285 )
286 .await
287 .map_err(|_| ImageRequestError::upstream_invalid("Timed out reading Codex image response"))?
288 .map_err(|_| ImageRequestError::upstream_invalid("Failed to read Codex image response"))?;
289 let Some(chunk) = chunk else {
290 break;
291 };
292 if body.len().saturating_add(chunk.len()) > MAX_IMAGE_RESPONSE_BYTES {
293 return Err(ImageRequestError::upstream_invalid(
294 "Codex image response exceeded the size limit",
295 ));
296 }
297 if !started {
298 if let Some(monitor) = ctx.monitor.as_ref() {
299 monitor.generation_started(&ctx.req_id);
300 }
301 started = true;
302 }
303 body.extend_from_slice(&chunk);
304 }
305 Ok(body)
306}
307
308fn copy_safe_image_headers(source: &http::HeaderMap, target: &mut http::HeaderMap) {
309 for name in [
310 "retry-after",
311 "x-request-id",
312 "openai-processing-ms",
313 "openai-version",
314 "x-ratelimit-limit-requests",
315 "x-ratelimit-limit-tokens",
316 "x-ratelimit-remaining-requests",
317 "x-ratelimit-remaining-tokens",
318 "x-ratelimit-reset-requests",
319 "x-ratelimit-reset-tokens",
320 ] {
321 if let Some(value) = source.get(name) {
322 target.insert(http::HeaderName::from_static(name), value.clone());
323 }
324 }
325}
326
327fn image_transport_error_response(error: super::client::CodexError) -> axum::response::Response {
328 let status = match error.status {
329 401 => StatusCode::UNAUTHORIZED,
330 403 => StatusCode::FORBIDDEN,
331 429 => StatusCode::TOO_MANY_REQUESTS,
332 value if (400..=599).contains(&value) => {
333 StatusCode::from_u16(value).unwrap_or(StatusCode::BAD_GATEWAY)
334 }
335 _ => StatusCode::BAD_GATEWAY,
336 };
337 let mut response = image_error_response(ImageRequestError {
338 status,
339 message: if error.status == 0 {
340 "Codex image service is unavailable".to_string()
341 } else {
342 format!("Codex image service returned HTTP {}", error.status)
343 },
344 param: None,
345 code: Some(if status == StatusCode::UNAUTHORIZED {
346 "authentication_error"
347 } else if status == StatusCode::FORBIDDEN {
348 "permission_error"
349 } else if status == StatusCode::TOO_MANY_REQUESTS {
350 "rate_limit_error"
351 } else {
352 "upstream_error"
353 }),
354 });
355 if let Some(retry_after) = error.retry_after
356 && let Ok(value) = http::HeaderValue::from_str(&retry_after)
357 {
358 response
359 .headers_mut()
360 .insert(http::header::RETRY_AFTER, value);
361 }
362 response
363}
364
365pub fn image_error_response(error: ImageRequestError) -> axum::response::Response {
366 use axum::response::IntoResponse;
367
368 let error_type = match error.status {
369 StatusCode::UNAUTHORIZED => "authentication_error",
370 StatusCode::FORBIDDEN => "permission_error",
371 StatusCode::TOO_MANY_REQUESTS => "rate_limit_error",
372 status if status.is_client_error() => "invalid_request_error",
373 _ => "api_error",
374 };
375 (
376 error.status,
377 [
378 (http::header::CONTENT_TYPE, "application/json"),
379 (http::header::CACHE_CONTROL, "no-store"),
380 ],
381 axum::Json(serde_json::json!({
382 "error": {
383 "message": error.message,
384 "type": error_type,
385 "param": error.param,
386 "code": error.code,
387 }
388 })),
389 )
390 .into_response()
391}
392
393pub fn prepare_json_request(
394 operation: ImageOperation,
395 bytes: &[u8],
396) -> Result<PreparedImageRequest, ImageRequestError> {
397 match operation {
398 ImageOperation::Generation => prepare_generation_request(bytes),
399 ImageOperation::Edit => prepare_edit_request(bytes),
400 }
401}
402
403fn prepare_generation_request(bytes: &[u8]) -> Result<PreparedImageRequest, ImageRequestError> {
404 let mut request: GenerationRequest = serde_json::from_slice(bytes).map_err(|error| {
405 ImageRequestError::invalid(format!("Invalid JSON image request: {error}"), None)
406 })?;
407 validate_and_default_common(
408 &request.prompt,
409 &mut request.model,
410 &mut request.background,
411 request.n,
412 &mut request.quality,
413 &mut request.size,
414 )?;
415 let model = request.model.clone().expect("model defaulted");
416 let body = serde_json::to_value(request).map_err(|error| ImageRequestError {
417 status: StatusCode::INTERNAL_SERVER_ERROR,
418 message: format!("Failed to serialize image request: {error}"),
419 param: None,
420 code: Some("internal_error"),
421 })?;
422 Ok(PreparedImageRequest {
423 body,
424 model,
425 image_count: 0,
426 })
427}
428
429pub fn prepare_multipart_edit(
430 input: MultipartEditInput,
431) -> Result<PreparedImageRequest, ImageRequestError> {
432 use base64::Engine as _;
433
434 if input.images.is_empty() || input.images.len() > MAX_EDIT_IMAGES {
435 return Err(ImageRequestError::invalid(
436 format!("'image' must contain between 1 and {MAX_EDIT_IMAGES} files"),
437 Some("image"),
438 ));
439 }
440 let total_bytes = input.images.iter().try_fold(0usize, |total, image| {
441 if image.bytes.len() > MAX_SINGLE_IMAGE_BYTES {
442 return Err(ImageRequestError {
443 status: StatusCode::PAYLOAD_TOO_LARGE,
444 message: format!("Each image must be at most {MAX_SINGLE_IMAGE_BYTES} bytes"),
445 param: Some("image"),
446 code: Some("request_too_large"),
447 });
448 }
449 total
450 .checked_add(image.bytes.len())
451 .ok_or(ImageRequestError {
452 status: StatusCode::PAYLOAD_TOO_LARGE,
453 message: "Combined image payload is too large".to_string(),
454 param: Some("image"),
455 code: Some("request_too_large"),
456 })
457 })?;
458 if total_bytes > MAX_EDIT_IMAGE_BYTES {
459 return Err(ImageRequestError {
460 status: StatusCode::PAYLOAD_TOO_LARGE,
461 message: format!("Combined images must be at most {MAX_EDIT_IMAGE_BYTES} bytes"),
462 param: Some("image"),
463 code: Some("request_too_large"),
464 });
465 }
466
467 let images = input
468 .images
469 .into_iter()
470 .map(|image| {
471 let mime = detect_image_mime(&image.bytes).ok_or_else(|| {
472 ImageRequestError::invalid("Unsupported or malformed image file", Some("image"))
473 })?;
474 let mut data_url = format!("data:{mime};base64,");
476 base64::engine::general_purpose::STANDARD.encode_string(&image.bytes, &mut data_url);
477 Ok(ImageUrl {
478 image_url: data_url,
479 })
480 })
481 .collect::<Result<Vec<_>, ImageRequestError>>()?;
482 let request = EditRequest {
483 prompt: input.prompt.ok_or_else(|| {
484 ImageRequestError::invalid("Missing required 'prompt' field", Some("prompt"))
485 })?,
486 images,
487 model: input.model,
488 background: input.background,
489 n: input.n,
490 quality: input.quality,
491 size: input.size,
492 };
493 prepare_edit_value(request)
494}
495
496fn prepare_edit_request(bytes: &[u8]) -> Result<PreparedImageRequest, ImageRequestError> {
497 let request: EditRequest = serde_json::from_slice(bytes).map_err(|error| {
498 ImageRequestError::invalid(format!("Invalid JSON image edit request: {error}"), None)
499 })?;
500 prepare_edit_value(request)
501}
502
503fn prepare_edit_value(mut request: EditRequest) -> Result<PreparedImageRequest, ImageRequestError> {
504 if request.images.is_empty() || request.images.len() > MAX_EDIT_IMAGES {
505 return Err(ImageRequestError::invalid(
506 format!("'images' must contain between 1 and {MAX_EDIT_IMAGES} items"),
507 Some("images"),
508 ));
509 }
510 let total_bytes = request.images.iter().try_fold(0usize, |total, image| {
511 let image_bytes = validate_data_url(&image.image_url)?;
512 total.checked_add(image_bytes).ok_or(ImageRequestError {
513 status: StatusCode::PAYLOAD_TOO_LARGE,
514 message: "Combined image payload is too large".to_string(),
515 param: Some("images"),
516 code: Some("request_too_large"),
517 })
518 })?;
519 if total_bytes > MAX_EDIT_IMAGE_BYTES {
520 return Err(ImageRequestError {
521 status: StatusCode::PAYLOAD_TOO_LARGE,
522 message: format!("Combined images must be at most {MAX_EDIT_IMAGE_BYTES} bytes"),
523 param: Some("images"),
524 code: Some("request_too_large"),
525 });
526 }
527 validate_and_default_common(
528 &request.prompt,
529 &mut request.model,
530 &mut request.background,
531 request.n,
532 &mut request.quality,
533 &mut request.size,
534 )?;
535 let model = request.model.clone().expect("model defaulted");
536 let image_count = request.images.len();
537 let body = serde_json::to_value(request).map_err(|error| ImageRequestError {
538 status: StatusCode::INTERNAL_SERVER_ERROR,
539 message: format!("Failed to serialize image edit request: {error}"),
540 param: None,
541 code: Some("internal_error"),
542 })?;
543 Ok(PreparedImageRequest {
544 body,
545 model,
546 image_count,
547 })
548}
549
550fn validate_data_url(value: &str) -> Result<usize, ImageRequestError> {
551 use base64::Engine as _;
552
553 let (metadata, encoded) = value.split_once(',').ok_or_else(|| {
554 ImageRequestError::invalid("Image must be a base64 data URL", Some("images"))
555 })?;
556 let mime = metadata
557 .strip_prefix("data:")
558 .and_then(|value| value.strip_suffix(";base64"))
559 .ok_or_else(|| {
560 ImageRequestError::invalid("Image must be a base64 data URL", Some("images"))
561 })?;
562 let decoded = base64::engine::general_purpose::STANDARD
563 .decode(encoded)
564 .map_err(|_| {
565 ImageRequestError::invalid("Image data is not valid base64", Some("images"))
566 })?;
567 let detected = detect_image_mime(&decoded).ok_or_else(|| {
568 ImageRequestError::invalid("Unsupported or malformed image data", Some("images"))
569 })?;
570 if mime != detected {
571 return Err(ImageRequestError::invalid(
572 format!("Image media type '{mime}' does not match '{detected}' data"),
573 Some("images"),
574 ));
575 }
576 if decoded.len() > MAX_SINGLE_IMAGE_BYTES {
577 return Err(ImageRequestError {
578 status: StatusCode::PAYLOAD_TOO_LARGE,
579 message: format!("Each image must be at most {MAX_SINGLE_IMAGE_BYTES} bytes"),
580 param: Some("images"),
581 code: Some("request_too_large"),
582 });
583 }
584 Ok(decoded.len())
585}
586
587pub fn detect_image_mime(bytes: &[u8]) -> Option<&'static str> {
588 if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
589 Some("image/png")
590 } else if bytes.starts_with(b"\xff\xd8\xff") {
591 Some("image/jpeg")
592 } else if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") {
593 Some("image/gif")
594 } else if bytes.len() >= 12 && &bytes[..4] == b"RIFF" && &bytes[8..12] == b"WEBP" {
595 Some("image/webp")
596 } else {
597 None
598 }
599}
600
601pub fn validate_success_response(
602 bytes: &[u8],
603) -> Result<(Option<u64>, Option<u64>), ImageRequestError> {
604 let response: ImageResponse<'_> = serde_json::from_slice(bytes).map_err(|_| {
605 ImageRequestError::upstream_invalid("Codex image service returned invalid JSON")
606 })?;
607 let _created = response.created;
608 if response.data.is_empty() || response.data.iter().any(|item| item.b64_json.is_empty()) {
609 return Err(ImageRequestError::upstream_invalid(
610 "Codex image service returned no image data",
611 ));
612 }
613 Ok(response
614 .usage
615 .map(|usage| (usage.input_tokens, usage.output_tokens))
616 .unwrap_or((None, None)))
617}
618
619pub fn validate_image_base_url(raw: &str) -> Result<String, String> {
620 let parsed =
621 url::Url::parse(raw).map_err(|error| format!("Invalid Codex images base URL: {error}"))?;
622 if parsed.scheme() != "https"
623 || parsed.host_str() != Some("chatgpt.com")
624 || parsed.port_or_known_default() != Some(443)
625 || !parsed.username().is_empty()
626 || parsed.password().is_some()
627 || parsed.query().is_some()
628 || parsed.fragment().is_some()
629 || !parsed.path().starts_with("/backend-api/codex")
630 {
631 return Err(
632 "Codex images base URL must be an HTTPS chatgpt.com/backend-api/codex URL without credentials, query, or fragment"
633 .to_string(),
634 );
635 }
636 Ok(raw.trim_end_matches('/').to_string())
637}
638
639fn validate_and_default_common(
640 prompt: &str,
641 model: &mut Option<String>,
642 background: &mut Option<String>,
643 n: Option<u8>,
644 quality: &mut Option<String>,
645 size: &mut Option<String>,
646) -> Result<(), ImageRequestError> {
647 if prompt.trim().is_empty() {
648 return Err(ImageRequestError::invalid(
649 "'prompt' must not be empty",
650 Some("prompt"),
651 ));
652 }
653 match model.as_deref() {
654 Some(IMAGE_MODEL) | None => {}
655 Some(other) => {
656 return Err(ImageRequestError::invalid(
657 format!("Unsupported image model '{other}'; expected '{IMAGE_MODEL}'"),
658 Some("model"),
659 ));
660 }
661 }
662 if n.is_some_and(|n| !(1..=10).contains(&n)) {
663 return Err(ImageRequestError::invalid(
664 "'n' must be between 1 and 10",
665 Some("n"),
666 ));
667 }
668 validate_choice(
669 "background",
670 background.as_deref(),
671 &["auto", "transparent", "opaque"],
672 )?;
673 validate_choice(
674 "quality",
675 quality.as_deref(),
676 &["auto", "low", "medium", "high"],
677 )?;
678 if size.as_deref().is_some_and(str::is_empty) {
679 return Err(ImageRequestError::invalid(
680 "'size' must not be empty",
681 Some("size"),
682 ));
683 }
684 model.get_or_insert_with(|| IMAGE_MODEL.to_string());
685 background.get_or_insert_with(|| "auto".to_string());
686 quality.get_or_insert_with(|| "auto".to_string());
687 size.get_or_insert_with(|| "auto".to_string());
688 Ok(())
689}
690
691fn validate_choice(
692 field: &'static str,
693 value: Option<&str>,
694 allowed: &[&str],
695) -> Result<(), ImageRequestError> {
696 if let Some(value) = value
697 && !allowed.contains(&value)
698 {
699 return Err(ImageRequestError::invalid(
700 format!("Invalid '{field}' value '{value}'"),
701 Some(field),
702 ));
703 }
704 Ok(())
705}
706
707#[cfg(test)]
708mod tests {
709 use super::*;
710
711 #[test]
712 fn json_edit_enforces_decoded_image_size_limits() {
713 use base64::Engine as _;
714
715 let mut bytes = b"\x89PNG\r\n\x1a\n".to_vec();
716 bytes.resize(MAX_SINGLE_IMAGE_BYTES + 1, 0);
717 let data_url = format!(
718 "data:image/png;base64,{}",
719 base64::engine::general_purpose::STANDARD.encode(bytes)
720 );
721 let body = serde_json::to_vec(&serde_json::json!({
722 "prompt": "x",
723 "images": [{"image_url": data_url}]
724 }))
725 .unwrap();
726 let error = prepare_json_request(ImageOperation::Edit, &body).unwrap_err();
727 assert_eq!(error.status, StatusCode::PAYLOAD_TOO_LARGE);
728 }
729
730 #[tokio::test]
731 async fn auth_and_rate_limit_errors_use_openai_error_types() {
732 use axum::body::to_bytes;
733
734 for (status, expected_type) in [
735 (StatusCode::UNAUTHORIZED, "authentication_error"),
736 (StatusCode::FORBIDDEN, "permission_error"),
737 (StatusCode::TOO_MANY_REQUESTS, "rate_limit_error"),
738 (StatusCode::BAD_GATEWAY, "api_error"),
739 ] {
740 let response = image_error_response(ImageRequestError {
741 status,
742 message: "error".to_string(),
743 param: None,
744 code: None,
745 });
746 let body = to_bytes(response.into_body(), 4096).await.unwrap();
747 assert_eq!(
748 serde_json::from_slice::<Value>(&body).unwrap()["error"]["type"],
749 expected_type
750 );
751 }
752 }
753
754 #[test]
755 fn request_validation_rejects_unsupported_and_unsafe_inputs() {
756 for body in [
757 br#"{"prompt":"x","model":"gpt-image-1"}"#.as_slice(),
758 br#"{"prompt":"x","n":0}"#,
759 br#"{"prompt":"x","response_format":"url"}"#,
760 br#"{"prompt":" "}"#,
761 ] {
762 assert!(prepare_json_request(ImageOperation::Generation, body).is_err());
763 }
764 assert!(
765 prepare_json_request(
766 ImageOperation::Edit,
767 br#"{"prompt":"x","images":[{"image_url":"https://example.com/x.png"}]}"#,
768 )
769 .is_err()
770 );
771 }
772
773 #[tokio::test]
774 async fn backend_rejects_oversized_upstream_response_before_body_read() {
775 use tokio::io::{AsyncReadExt, AsyncWriteExt};
776 use tokio::net::TcpListener;
777
778 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
779 let addr = listener.local_addr().unwrap();
780 let server = tokio::spawn(async move {
781 let (mut stream, _) = listener.accept().await.unwrap();
782 let mut request = [0_u8; 4096];
783 assert!(stream.read(&mut request).await.unwrap() > 0);
784 let head = format!(
785 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n",
786 MAX_IMAGE_RESPONSE_BYTES + 1
787 );
788 stream.write_all(head.as_bytes()).await.unwrap();
789 });
790
791 let client = super::super::client::CodexHttpClient::new_for_test(
792 reqwest::Client::builder().no_proxy().build().unwrap(),
793 format!("http://{addr}/responses"),
794 1_000,
795 1_000,
796 0,
797 );
798 client
799 .auth_manager()
800 .set_test_auth(super::super::auth::token_store::StoredAuth {
801 access: "test".into(),
802 refresh: String::new(),
803 account_id: Some("acct".into()),
804 expires: u64::MAX,
805 });
806 let backend =
807 CodexImagesBackend::new_for_test(std::sync::Arc::new(client), format!("http://{addr}"));
808 let response = backend
809 .handle(
810 ImageOperation::Generation,
811 prepare_json_request(ImageOperation::Generation, br#"{"prompt":"x"}"#).unwrap(),
812 crate::provider::RequestContext {
813 req_id: "oversized".into(),
814 session_id: None,
815 session_seq: None,
816 provider: "codex".into(),
817 traffic: None,
818 monitor: None,
819 passthrough: None,
820 },
821 )
822 .await;
823 server.await.unwrap();
824 assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
825 }
826
827 #[tokio::test]
828 async fn backend_passes_through_valid_bounded_image_json() {
829 use axum::body::to_bytes;
830 use tokio::io::{AsyncReadExt, AsyncWriteExt};
831 use tokio::net::TcpListener;
832
833 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
834 let addr = listener.local_addr().unwrap();
835 let server = tokio::spawn(async move {
836 let (mut stream, _) = listener.accept().await.unwrap();
837 let mut request = [0_u8; 16 * 1024];
838 assert!(stream.read(&mut request).await.unwrap() > 0);
839 let response = br#"{"created":1,"data":[{"b64_json":"aW1n"}],"quality":"medium"}"#;
840 let head = format!(
841 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\nx-request-id: upstream-1\r\nset-cookie: secret=1\r\ncontent-length: {}\r\nconnection: close\r\n\r\n",
842 response.len()
843 );
844 stream.write_all(head.as_bytes()).await.unwrap();
845 stream.write_all(response).await.unwrap();
846 });
847
848 let client = super::super::client::CodexHttpClient::new_for_test(
849 reqwest::Client::builder().no_proxy().build().unwrap(),
850 format!("http://{addr}/responses"),
851 1_000,
852 1_000,
853 0,
854 );
855 client
856 .auth_manager()
857 .set_test_auth(super::super::auth::token_store::StoredAuth {
858 access: "test".into(),
859 refresh: String::new(),
860 account_id: Some("acct".into()),
861 expires: u64::MAX,
862 });
863 let backend = CodexImagesBackend::new_for_test(
864 std::sync::Arc::new(client),
865 format!("http://{addr}/root"),
866 );
867 let prepared =
868 prepare_json_request(ImageOperation::Generation, br#"{"prompt":"draw a fox"}"#)
869 .unwrap();
870 let response = backend
871 .handle(
872 ImageOperation::Generation,
873 prepared,
874 crate::provider::RequestContext {
875 req_id: "image-test".into(),
876 session_id: None,
877 session_seq: None,
878 provider: "codex".into(),
879 traffic: None,
880 monitor: None,
881 passthrough: None,
882 },
883 )
884 .await;
885 server.await.unwrap();
886
887 assert_eq!(response.status(), StatusCode::OK);
888 assert_eq!(response.headers()[http::header::CACHE_CONTROL], "no-store");
889 assert_eq!(response.headers()["x-request-id"], "upstream-1");
890 assert!(response.headers().get(http::header::SET_COOKIE).is_none());
891 let body = to_bytes(response.into_body(), MAX_IMAGE_RESPONSE_BYTES)
892 .await
893 .unwrap();
894 assert_eq!(
895 serde_json::from_slice::<Value>(&body).unwrap()["quality"],
896 "medium"
897 );
898 }
899
900 #[test]
901 fn success_response_requires_created_and_nonempty_base64_items() {
902 let valid = br#"{"created":1,"data":[{"b64_json":"aW1n"}],"usage":{"input_tokens":3}}"#;
903 let usage = validate_success_response(valid).expect("valid response");
904 assert_eq!(usage, (Some(3), None));
905
906 assert!(validate_success_response(br#"{"data":[{"b64_json":"aW1n"}]}"#).is_err());
907 assert!(validate_success_response(br#"{"created":1,"data":[]}"#).is_err());
908 assert!(validate_success_response(br#"{"created":1,"data":[{"b64_json":""}]}"#).is_err());
909 }
910
911 #[test]
912 fn production_image_base_url_is_locked_to_chatgpt_https() {
913 assert_eq!(
914 validate_image_base_url("https://chatgpt.com/backend-api/codex/").unwrap(),
915 "https://chatgpt.com/backend-api/codex"
916 );
917 assert!(validate_image_base_url("http://chatgpt.com/backend-api/codex").is_err());
918 assert!(validate_image_base_url("https://example.com/backend-api/codex").is_err());
919 assert!(validate_image_base_url("https://chatgpt.com/backend-api/codex?x=1").is_err());
920 }
921
922 #[test]
923 fn multipart_edit_is_translated_to_codex_data_urls() {
924 let prepared = prepare_multipart_edit(MultipartEditInput {
925 prompt: Some("make it blue".to_string()),
926 model: None,
927 background: None,
928 n: None,
929 quality: None,
930 size: None,
931 images: vec![UploadedImage {
932 bytes: Bytes::from_static(b"\x89PNG\r\n\x1a\n"),
933 }],
934 })
935 .expect("multipart edit should be valid");
936
937 assert_eq!(prepared.image_count, 1);
938 assert_eq!(
939 prepared.body["images"][0]["image_url"],
940 "data:image/png;base64,iVBORw0KGgo="
941 );
942 }
943
944 #[test]
945 fn json_edit_request_accepts_data_urls_and_applies_defaults() {
946 let prepared = prepare_json_request(
947 ImageOperation::Edit,
948 br#"{"prompt":"make it blue","images":[{"image_url":"data:image/png;base64,iVBORw0KGgo="}]}"#,
949 )
950 .expect("edit request should be valid");
951
952 assert_eq!(prepared.model, IMAGE_MODEL);
953 assert_eq!(prepared.image_count, 1);
954 assert_eq!(
955 prepared.body["images"][0]["image_url"],
956 "data:image/png;base64,iVBORw0KGgo="
957 );
958 assert_eq!(prepared.body["background"], "auto");
959 assert_eq!(prepared.body["quality"], "auto");
960 assert_eq!(prepared.body["size"], "auto");
961 }
962
963 #[test]
964 fn generation_request_applies_safe_defaults() {
965 let prepared =
966 prepare_json_request(ImageOperation::Generation, br#"{"prompt":"draw a fox"}"#)
967 .expect("generation request should be valid");
968
969 assert_eq!(prepared.model, IMAGE_MODEL);
970 assert_eq!(prepared.image_count, 0);
971 assert_eq!(prepared.body["prompt"], "draw a fox");
972 assert_eq!(prepared.body["model"], IMAGE_MODEL);
973 assert_eq!(prepared.body["background"], "auto");
974 assert_eq!(prepared.body["quality"], "auto");
975 assert_eq!(prepared.body["size"], "auto");
976 assert!(prepared.body.get("n").is_none());
977 }
978}