Skip to main content

claude_codex/providers/codex/
images.rs

1use bytes::Bytes; // EFFICIENCY: avoid memcpy on multipart upload
2use 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        // EFFICIENCY: check status and content-length vs size budget before reading body
211        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            // EFFICIENCY: for error responses, consume a small diagnostic prefix rather than full body
226            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    // EFFICIENCY: preallocate from Content-Length when available to avoid repeated reallocs
274    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            // EFFICIENCY: preallocate data-URL prefix, then encode image bytes into the same buffer
475            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}