diff --git a/CHANGELOG.md b/CHANGELOG.md index e93b821b..65c50587 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ title: Changelog description: Release notes for claude-code-proxy. --- +## Unreleased + +- Optional Codex Images API routes reuse the existing ChatGPT OAuth session for + `gpt-image-2` generation and JSON or multipart image edits, with strict size, + concurrency, upstream-host, header, and diagnostic privacy boundaries. + ## v0.1.27 (2026-07-29) - Grok streams remain reliable during long responses, keepalive events, and diff --git a/Cargo.lock b/Cargo.lock index 6e8fd1b2..7f30bc7a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -137,6 +137,7 @@ dependencies = [ "matchit", "memchr", "mime", + "multer", "percent-encoding", "pin-project-lite", "serde_core", @@ -560,6 +561,15 @@ version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -1225,6 +1235,23 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "multer" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" +dependencies = [ + "bytes", + "encoding_rs", + "futures-util", + "http", + "httparse", + "memchr", + "mime", + "spin", + "version_check", +] + [[package]] name = "normalize-line-endings" version = "0.3.0" @@ -1963,6 +1990,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" + [[package]] name = "stable_deref_trait" version = "1.2.1" diff --git a/Cargo.toml b/Cargo.toml index 70e8f3f9..ad25c231 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,7 +13,7 @@ serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" anyhow = "1.0" thiserror = "2.0" -axum = { version = "0.8", features = ["macros"] } +axum = { version = "0.8", features = ["macros", "multipart"] } tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "signal", "time", "fs"] } tower = { version = "0.5", features = ["util"] } http = "1" diff --git a/README.md b/README.md index 79755f9f..d681eb2b 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,17 @@ CLAUDE_CODE_DISABLE_NONSTREAMING_FALLBACK=1 \ See [Getting started](https://claude-code-proxy.raine.dev/getting-started/) for the complete first session. +Optional Codex image generation and editing can reuse the same ChatGPT login: + +```sh +CCP_CODEX_IMAGES_API=1 claude-code-proxy serve +curl http://127.0.0.1:18765/v1/images/generations \ + -H 'Content-Type: application/json' \ + -d '{"prompt":"A paper-cut fox","model":"gpt-image-2"}' +``` + +The opt-in Images API returns base64 image data and consumes the signed-in account's image quota. Image prompts and payloads are excluded from traffic captures. See the [HTTP API](https://claude-code-proxy.raine.dev/reference/http-api/) for generation and edit schemas. + ## Providers | Provider | Account | Model selection | diff --git a/docs/src/content/docs/providers/codex.md b/docs/src/content/docs/providers/codex.md index 2305c0cf..9862c53b 100644 --- a/docs/src/content/docs/providers/codex.md +++ b/docs/src/content/docs/providers/codex.md @@ -104,6 +104,20 @@ While the native request is active, the monitor shows `compacting`. Structured l The Responses route preserves native JSON or SSE response bodies for registered Codex models. The Chat Completions route translates standard text messages, reasoning effort, JSON object or JSON Schema output, and buffered or streaming responses. Its omitted reasoning effort defaults to `medium`; the proxy-wide Codex effort override still takes precedence. -The proxy replaces incoming credentials with stored Codex auth for both routes. Images API, response retrieval or deletion, function calling through Chat Completions, and WebSocket ingress are outside their scope. See [HTTP API](/reference/http-api/) for supported Chat Completions fields and error behavior. +The proxy replaces incoming credentials with stored Codex auth for both routes. Response retrieval or deletion, function calling through Chat Completions, and WebSocket ingress are outside their scope. See [HTTP API](/reference/http-api/) for supported Chat Completions fields and error behavior. + +## Images API + +`CCP_CODEX_IMAGES_API=1` separately enables `POST /v1/images/generations` and `POST /v1/images/edits`. The routes reuse the proxy's stored ChatGPT OAuth session and target the ChatGPT Codex image backend; no OpenAI Platform API key is required. + +```sh +CCP_CODEX_IMAGES_API=1 claude-code-proxy serve +``` + +The model defaults to and is restricted to `gpt-image-2`. Generation accepts JSON. Editing accepts either Codex JSON data URLs or OpenAI-style multipart uploads, which the proxy validates and converts into the Codex JSON contract. Results are returned as `data[].b64_json`. Masks, remote URLs, URL-formatted output, and image variations are not supported. + +This is an internal ChatGPT Codex interface rather than the public Platform Images API. It consumes the signed-in account's image quota and can change without public API compatibility guarantees. Image prompts, uploads, generated base64, and upstream error bodies are excluded from traffic captures and persistent error diagnostics. + +Because callers are not authenticated, binding to a LAN address lets every firewall-admitted host consume the signed-in account's quota. Restrict the listener to a trusted interface/subnet and never expose it through router forwarding, UPnP, a public tunnel, or permissive IPv6 rules. See [Configuration](/reference/configuration/) for every Codex setting and [Troubleshooting](/using/troubleshooting/) for auth, model, and transport failures. diff --git a/docs/src/content/docs/reference/compatibility-and-limitations.md b/docs/src/content/docs/reference/compatibility-and-limitations.md index 56ad29e7..9267f6ef 100644 --- a/docs/src/content/docs/reference/compatibility-and-limitations.md +++ b/docs/src/content/docs/reference/compatibility-and-limitations.md @@ -27,6 +27,7 @@ claude-code-proxy targets Claude Code's practical Anthropic API usage rather tha - Claude Code title generation and other structured background requests are forwarded and consume provider tokens. - Anthropic-specific fields without a provider mapping can be dropped. - Native OpenAI Responses passthrough is opt-in and limited to registered Codex models and response creation. +- Codex Images passthrough is separately opt-in, restricted to `gpt-image-2`, and supports generation plus JSON or multipart edits. Variations, masks, remote image URLs, and URL-formatted outputs are unsupported. ## Models and context @@ -73,7 +74,8 @@ claude-code-proxy targets Claude Code's practical Anthropic API usage rather tha - Structured logs redact known credential keys, but user-provided strings can still contain secrets. - Error captures contain complete redacted failed responses. -- Traffic captures intentionally preserve prompts and tool content. +- Traffic captures intentionally preserve prompts and tool content for message/Responses diagnostics. +- Image generation and edit routes never create traffic captures or persist prompts, uploads, data URLs, generated base64, or upstream error bodies. - Verbose logging and traffic capture should be scoped to a focused local investigation. For provider-specific behavior, use the [provider pages](/providers/choosing-a-provider/). For released changes, see the [Changelog](/reference/changelog/). diff --git a/docs/src/content/docs/reference/configuration.md b/docs/src/content/docs/reference/configuration.md index f3194812..c5ccc1d7 100644 --- a/docs/src/content/docs/reference/configuration.md +++ b/docs/src/content/docs/reference/configuration.md @@ -26,7 +26,9 @@ These settings configure the proxy process. Claude Code client settings such as "transport": "websocket", "previousResponseId": false, "serverCompaction": false, - "responsesApi": false + "responsesApi": false, + "imagesApi": false, + "imagesBaseUrl": "https://chatgpt.com/backend-api/codex" }, "kimi": { "userAgent": "KimiCLI/1.37.0", @@ -105,6 +107,8 @@ Proxy URLs may use `http`, `https`, `socks4`, `socks4a`, `socks5`, or `socks5h`. | `CCP_CODEX_PREVIOUS_RESPONSE_ID` | `codex.previousResponseId` | `false` | Enables append-only WebSocket continuation for `1`, `true`, or `yes`. | | `CCP_CODEX_SERVER_COMPACTION` | `codex.serverCompaction` | `false` | Enables or disables native compaction for standard boolean words. | | `CCP_CODEX_RESPONSES_API` | `codex.responsesApi` | `false` | Enables `/v1/responses` and `/v1/chat/completions` for `1`, `true`, or `yes`. | +| `CCP_CODEX_IMAGES_API` | `codex.imagesApi` | `false` | Enables `/v1/images/generations` and `/v1/images/edits` for `1`, `true`, or `yes`. | +| `CCP_CODEX_IMAGES_BASE_URL` | `codex.imagesBaseUrl` | `https://chatgpt.com/backend-api/codex` | Sets the trusted Codex Images API root; production use is restricted to HTTPS `chatgpt.com/backend-api/codex`. | | `CCP_CODEX_ORIGINATOR` | `codex.originator` | `claude-code-proxy` | Changes the Codex `originator` header. | | `CCP_CODEX_USER_AGENT` | `codex.userAgent` | `claude-code-proxy/` | Changes the Codex user-agent. | diff --git a/docs/src/content/docs/reference/http-api.md b/docs/src/content/docs/reference/http-api.md index b49fea95..aebd405a 100644 --- a/docs/src/content/docs/reference/http-api.md +++ b/docs/src/content/docs/reference/http-api.md @@ -82,6 +82,33 @@ A buffered response uses the standard `chat.completion` object. A streaming resp Function calls, hosted tools, images, audio, log probabilities, multiple choices, storage, and output token limits are outside this compatibility surface. Unsupported fields, including `max_tokens` and `max_completion_tokens`, return an OpenAI `invalid_request_error` with the field in `error.param`. +## `POST /v1/images/generations` + +This route exists only when `CCP_CODEX_IMAGES_API=1` or `codex.imagesApi` is true. It reuses the proxy-owned ChatGPT/Codex OAuth session and forwards a bounded JSON request to the Codex image service: + +```json +{ + "prompt": "A paper-cut fox in a moonlit forest", + "model": "gpt-image-2", + "background": "auto", + "quality": "auto", + "size": "auto" +} +``` + +`prompt` is required. `model` defaults to and is restricted to `gpt-image-2`; `background`, `quality`, and `size` default to `auto`. Optional `n` must be between 1 and 10. Unknown fields and URL response formats are rejected rather than silently forwarded. Successful responses contain `data[].b64_json`; the proxy never writes generated image data to traffic captures. + +## `POST /v1/images/edits` + +This route uses the same opt-in gate and accepts either: + +- Codex JSON with `images: [{"image_url":"data:image/png;base64,..."}]`; or +- OpenAI-style `multipart/form-data` with one to five repeated `image` or `image[]` files and text fields `prompt`, `model`, `background`, `quality`, `size`, and `n`. + +Multipart PNG, JPEG, WebP, and GIF signatures are validated and translated to Codex data URLs. The internal Codex edit contract is JSON, so multipart is an ingress compatibility adapter. Masks, remote image URLs, variations, unsupported fields, and other media types return a 4xx OpenAI error. Request bodies, individual files, aggregate inputs, responses, and concurrency are bounded to protect the proxy process. + +The Images API is an internal ChatGPT Codex integration, not the public OpenAI Platform Images API. It consumes the signed-in ChatGPT account's entitlement and quota, and the internal contract can change independently of the public API. + ## `POST /v1/responses` This route exists only when `CCP_CODEX_RESPONSES_API=1` or `codex.responsesApi` is true. It accepts a native OpenAI Responses request for a registered Codex model. @@ -94,7 +121,7 @@ The proxy: - preserves native JSON responses and SSE bodies - records the request in the monitor and optional traffic capture -It does not implement Images API, stored response retrieval or deletion, or WebSocket client ingress. +It does not implement stored response retrieval or deletion, image variations, or WebSocket client ingress. ## Other routes diff --git a/src/config.rs b/src/config.rs index 61d93875..4aa7e1d5 100644 --- a/src/config.rs +++ b/src/config.rs @@ -60,6 +60,10 @@ struct CodexConfig { pub server_compaction: Option, #[serde(rename = "responsesApi")] pub responses_api: Option, + #[serde(rename = "imagesApi")] + pub images_api: Option, + #[serde(rename = "imagesBaseUrl")] + pub images_base_url: Option, #[serde(rename = "serviceTier")] pub service_tier: Option, #[serde(rename = "reasoningSummary")] @@ -226,6 +230,12 @@ pub fn config_override_summary_lines(cfg: &LoadedConfig) -> Vec { if env.contains_key("CCP_CODEX_RESPONSES_API") { out.push("codex.responsesApi (env)".to_string()); } + if env.contains_key("CCP_CODEX_IMAGES_API") { + out.push("codex.imagesApi (env)".to_string()); + } + if env.contains_key("CCP_CODEX_IMAGES_BASE_URL") { + out.push("codex.imagesBaseUrl (env)".to_string()); + } if env.contains_key("CCP_KIMI_OAUTH_HOST") { out.push("kimi.oauthHost (env)".to_string()); } @@ -299,6 +309,12 @@ pub fn config_override_summary_lines(cfg: &LoadedConfig) -> Vec { if codex.responses_api == Some(true) { out.push("codex.responsesApi: true".to_string()); } + if codex.images_api == Some(true) { + out.push("codex.imagesApi: true".to_string()); + } + if codex.images_base_url.is_some() { + out.push("codex.imagesBaseUrl (config)".to_string()); + } } } out @@ -542,6 +558,36 @@ pub fn codex_responses_api() -> bool { false } +pub fn codex_images_api() -> bool { + let env: HashMap<_, _> = std::env::vars().collect(); + if let Some(raw) = env.get("CCP_CODEX_IMAGES_API") { + return matches!(raw.to_ascii_lowercase().as_str(), "1" | "true" | "yes"); + } + let config_dir = paths::config_dir(); + if let Some(file) = read_file_config(&config_dir) + && let Some(codex) = file.codex + && let Some(enabled) = codex.images_api + { + return enabled; + } + false +} + +pub fn codex_images_base_url() -> String { + let env: HashMap<_, _> = std::env::vars().collect(); + if let Some(raw) = env.get("CCP_CODEX_IMAGES_BASE_URL") { + return raw.clone(); + } + let config_dir = paths::config_dir(); + if let Some(file) = read_file_config(&config_dir) + && let Some(codex) = file.codex + && let Some(url) = codex.images_base_url + { + return url; + } + "https://chatgpt.com/backend-api/codex".to_string() +} + pub fn codex_service_tier() -> Option { let env: HashMap<_, _> = std::env::vars().collect(); if let Some(raw) = env.get("CCP_CODEX_SERVICE_TIER") { @@ -749,6 +795,8 @@ mod tests { std::env::remove_var("CCP_CODEX_REASONING_SUMMARY"); std::env::remove_var("CCP_CODEX_SERVER_COMPACTION"); std::env::remove_var("CCP_CODEX_RESPONSES_API"); + std::env::remove_var("CCP_CODEX_IMAGES_API"); + std::env::remove_var("CCP_CODEX_IMAGES_BASE_URL"); std::env::remove_var("CCP_AUTO_REVIEW_MODEL"); } } @@ -945,6 +993,35 @@ mod tests { } } + #[test] + fn codex_images_api_defaults_to_disabled_and_env_overrides_config() { + let _guard = ENV_LOCK.lock().unwrap(); + clear_env(); + let config = tempfile::TempDir::new().unwrap(); + std::fs::write( + config.path().join("config.json"), + r#"{"codex":{"imagesApi":true,"imagesBaseUrl":"https://chatgpt.com/backend-api/codex-custom"}}"#, + ) + .unwrap(); + let _config_env = EnvGuard::set("CCP_CONFIG_DIR", config.path()); + + assert!(codex_images_api()); + assert_eq!( + codex_images_base_url(), + "https://chatgpt.com/backend-api/codex-custom" + ); + let _enabled_env = EnvGuard::set("CCP_CODEX_IMAGES_API", "false"); + let _base_env = EnvGuard::set( + "CCP_CODEX_IMAGES_BASE_URL", + "https://chatgpt.com/backend-api/codex", + ); + assert!(!codex_images_api()); + assert_eq!( + codex_images_base_url(), + "https://chatgpt.com/backend-api/codex" + ); + } + #[test] fn codex_reasoning_summary_reads_config() { let _guard = ENV_LOCK.lock().unwrap(); diff --git a/src/monitor.rs b/src/monitor.rs index b87b3732..a433ac26 100644 --- a/src/monitor.rs +++ b/src/monitor.rs @@ -18,6 +18,7 @@ pub enum EndpointKind { CountTokens, Responses, ChatCompletions, + Images, } impl EndpointKind { @@ -27,6 +28,7 @@ impl EndpointKind { Self::CountTokens => "count_tokens", Self::Responses => "responses", Self::ChatCompletions => "chat_completions", + Self::Images => "images", } } } diff --git a/src/providers/codex/client.rs b/src/providers/codex/client.rs index cab38ab3..1e16fd9b 100644 --- a/src/providers/codex/client.rs +++ b/src/providers/codex/client.rs @@ -202,6 +202,49 @@ pub fn build_codex_search_headers( Ok(headers) } +pub fn build_codex_image_headers( + auth: &StoredAuth, + ctx: &RequestContext, +) -> Result { + let mut headers = http::HeaderMap::new(); + headers.insert( + http::header::CONTENT_TYPE, + header_value("content-type", "application/json")?, + ); + headers.insert( + http::header::ACCEPT, + header_value("accept", "application/json")?, + ); + headers.insert( + http::header::AUTHORIZATION, + header_value("authorization", &format!("Bearer {}", auth.access))?, + ); + headers.insert( + "originator", + header_value("originator", &config::codex_originator(ORIGINATOR))?, + ); + if let Some(account_id) = auth.account_id.as_deref() { + headers.insert( + "ChatGPT-Account-Id", + header_value("ChatGPT-Account-Id", account_id)?, + ); + } + if let Some(session_id) = ctx.session_id.as_deref() { + headers.insert( + "x-client-request-id", + header_value("x-client-request-id", session_id)?, + ); + } + let user_agent = config::codex_user_agent(&default_user_agent(false)); + if !user_agent.is_empty() { + headers.insert( + http::header::USER_AGENT, + header_value("user-agent", &user_agent)?, + ); + } + Ok(headers) +} + fn header_value(name: &str, value: &str) -> Result { http::HeaderValue::from_str(value).map_err(|e| CodexError { status: 500, @@ -278,6 +321,7 @@ pub struct CodexResponse { const MAX_BUFFERED_TRANSPORT_RETRIES: u32 = 3; const MAX_BUFFERED_TRANSPORT_ATTEMPTS: u32 = MAX_BUFFERED_TRANSPORT_RETRIES + 1; const HTTP_RESPONSE_BODY_IDLE_TIMEOUT_MS: u64 = 300_000; +const IMAGE_HEADER_TIMEOUT_MS: u64 = 300_000; #[derive(Clone)] struct ProxyEnvironment { @@ -535,6 +579,91 @@ impl CodexHttpClient { self.body_idle_timeout_ms } + pub(crate) async fn post_image_json( + &self, + base_url: &str, + operation: super::images::ImageOperation, + body: &serde_json::Value, + ctx: &RequestContext, + ) -> Result { + let body_json = serde_json::to_vec(body).map_err(|error| CodexError { + status: 500, + message: "Failed to serialize image request".to_string(), + detail: Some(error.to_string()), + retry_after: None, + origin: CodexErrorOrigin::Http, + })?; + let url = format!( + "{}/{}", + base_url.trim_end_matches('/'), + operation.upstream_path() + ); + let mut auth = self + .auth_manager + .get_auth() + .await + .map_err(|error| CodexError { + status: 401, + message: "Auth error".to_string(), + detail: Some(error.to_string()), + retry_after: None, + origin: CodexErrorOrigin::Auth, + })?; + let mut refresh_attempted = false; + + loop { + let headers = build_codex_image_headers(&auth, ctx)?; + let response = self + .attempt_image_json(&url, &headers, body_json.clone()) + .await?; + if response.status() == reqwest::StatusCode::UNAUTHORIZED && !refresh_attempted { + refresh_attempted = true; + drop(response); + auth = self + .auth_manager + .force_refresh(&auth.access) + .await + .map_err(auth_refresh_error)?; + continue; + } + return Ok(response); + } + } + + async fn attempt_image_json( + &self, + url: &str, + headers: &http::HeaderMap, + body_json: Vec, + ) -> Result { + let mut request = self.native_client.post(url); + for (key, value) in headers { + request = request.header(key.as_str(), value.as_bytes()); + } + tokio::time::timeout( + Duration::from_millis(IMAGE_HEADER_TIMEOUT_MS), + request.body(body_json).send(), + ) + .await + .map_err(|_| CodexError { + status: 0, + message: format!( + "Timed out waiting {}ms for Codex image response headers", + IMAGE_HEADER_TIMEOUT_MS + ), + detail: None, + retry_after: None, + origin: CodexErrorOrigin::Http, + })? + .map_err(|error| CodexError { + status: 0, + message: format!("Codex image transport error: {error}"), + detail: None, + retry_after: None, + origin: CodexErrorOrigin::Http, + }) + } + pub async fn post_native_responses( &self, body: &serde_json::Value, @@ -1959,6 +2088,135 @@ mod tests { } } + #[tokio::test] + async fn image_request_refreshes_once_after_unauthorized() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let client = Arc::new(authenticated_http_test_client(format!( + "http://{addr}/responses" + ))); + let server_client = client.clone(); + let server = tokio::spawn(async move { + for attempt in 0..2 { + let (mut stream, _) = listener.accept().await.unwrap(); + let request = read_http_request(&mut stream).await; + let request = String::from_utf8_lossy(&request); + if attempt == 0 { + assert!(request.contains("authorization: Bearer test")); + server_client.auth_manager().set_test_auth(StoredAuth { + access: "rotated".into(), + refresh: "rotated-refresh".into(), + account_id: Some("acct-rotated".into()), + expires: u64::MAX, + }); + stream + .write_all( + b"HTTP/1.1 401 Unauthorized\r\ncontent-length: 0\r\nconnection: close\r\n\r\n", + ) + .await + .unwrap(); + } else { + assert!(request.contains("authorization: Bearer rotated")); + assert!(request.contains("chatgpt-account-id: acct-rotated")); + stream + .write_all( + b"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: 2\r\nconnection: close\r\n\r\n{}", + ) + .await + .unwrap(); + } + } + }); + + let response = client + .post_image_json( + &format!("http://{addr}"), + super::super::images::ImageOperation::Generation, + &serde_json::json!({"model":"gpt-image-2","prompt":"draw"}), + &http_test_context(), + ) + .await + .unwrap(); + assert_eq!(response.status(), reqwest::StatusCode::OK); + server.await.unwrap(); + } + + #[tokio::test] + async fn image_request_does_not_retry_server_errors() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + let _request = read_http_request(&mut stream).await; + stream + .write_all( + b"HTTP/1.1 503 Service Unavailable\r\ncontent-length: 0\r\nconnection: close\r\n\r\n", + ) + .await + .unwrap(); + assert!( + tokio::time::timeout(Duration::from_millis(50), listener.accept()) + .await + .is_err() + ); + }); + + let client = authenticated_http_test_client(format!("http://{addr}/responses")); + let response = client + .post_image_json( + &format!("http://{addr}"), + super::super::images::ImageOperation::Generation, + &serde_json::json!({"model":"gpt-image-2","prompt":"draw"}), + &http_test_context(), + ) + .await + .unwrap(); + assert_eq!(response.status(), reqwest::StatusCode::SERVICE_UNAVAILABLE); + server.await.unwrap(); + } + + #[tokio::test] + async fn image_request_uses_fixed_path_oauth_and_json_body() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + let request = read_http_request(&mut stream).await; + let header_end = request + .windows(4) + .position(|part| part == b"\r\n\r\n") + .unwrap(); + let headers = String::from_utf8_lossy(&request[..header_end]); + assert!(headers.starts_with("POST /root/images/generations HTTP/1.1")); + assert!(headers.contains("authorization: Bearer test")); + assert!(headers.contains("chatgpt-account-id: acct")); + let body: serde_json::Value = + serde_json::from_slice(&request[header_end + 4..]).unwrap(); + assert_eq!(body["model"], "gpt-image-2"); + assert_eq!(body["prompt"], "draw a fox"); + let response = br#"{"created":1,"data":[{"b64_json":"aW1n"}]}"#; + let head = format!( + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n", + response.len() + ); + stream.write_all(head.as_bytes()).await.unwrap(); + stream.write_all(response).await.unwrap(); + }); + + let client = authenticated_http_test_client(format!("http://{addr}/responses")); + let response = client + .post_image_json( + &format!("http://{addr}/root"), + super::super::images::ImageOperation::Generation, + &serde_json::json!({"model":"gpt-image-2","prompt":"draw a fox"}), + &http_test_context(), + ) + .await + .unwrap(); + assert_eq!(response.status(), reqwest::StatusCode::OK); + server.await.unwrap(); + } + #[tokio::test] async fn native_responses_replaces_auth_and_preserves_json_body() { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); @@ -2504,6 +2762,33 @@ mod tests { assert!(is_retryable_transport_error(&err)); } + #[test] + fn image_headers_reuse_oauth_without_responses_beta_headers() { + let auth = StoredAuth { + access: "tok".into(), + refresh: String::new(), + account_id: Some("acct".into()), + expires: u64::MAX, + }; + let headers = build_codex_image_headers(&auth, &http_test_context()).unwrap(); + + assert_eq!( + headers.get(http::header::AUTHORIZATION).unwrap(), + "Bearer tok" + ); + assert_eq!(headers.get("chatgpt-account-id").unwrap(), "acct"); + assert_eq!( + headers.get(http::header::CONTENT_TYPE).unwrap(), + "application/json" + ); + assert_eq!( + headers.get(http::header::ACCEPT).unwrap(), + "application/json" + ); + assert!(headers.get("openai-beta").is_none()); + assert!(headers.get("x-codex-beta-features").is_none()); + } + #[test] fn codex_headers_include_session_and_beta() { let auth = StoredAuth { diff --git a/src/providers/codex/images.rs b/src/providers/codex/images.rs new file mode 100644 index 00000000..646ea1d6 --- /dev/null +++ b/src/providers/codex/images.rs @@ -0,0 +1,976 @@ +use bytes::Bytes; // EFFICIENCY: avoid memcpy on multipart upload +use http::StatusCode; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +pub const IMAGE_MODEL: &str = "gpt-image-2"; +pub const MAX_GENERATION_REQUEST_BYTES: usize = 256 * 1024; +pub const MAX_EDIT_REQUEST_BYTES: usize = 64 * 1024 * 1024; +pub const MAX_IMAGE_RESPONSE_BYTES: usize = 128 * 1024 * 1024; +pub const MAX_EDIT_IMAGES: usize = 5; +pub const MAX_SINGLE_IMAGE_BYTES: usize = 20 * 1024 * 1024; +pub const MAX_EDIT_IMAGE_BYTES: usize = 50 * 1024 * 1024; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ImageOperation { + Generation, + Edit, +} + +impl ImageOperation { + pub fn upstream_path(self) -> &'static str { + match self { + Self::Generation => "images/generations", + Self::Edit => "images/edits", + } + } + + pub fn label(self) -> &'static str { + match self { + Self::Generation => "generation", + Self::Edit => "edit", + } + } +} + +#[derive(Debug)] +pub struct ImageRequestError { + pub status: StatusCode, + pub message: String, + pub param: Option<&'static str>, + pub code: Option<&'static str>, +} + +impl ImageRequestError { + fn invalid(message: impl Into, param: Option<&'static str>) -> Self { + Self { + status: StatusCode::BAD_REQUEST, + message: message.into(), + param, + code: Some("invalid_request"), + } + } + + fn upstream_invalid(message: impl Into) -> Self { + Self { + status: StatusCode::BAD_GATEWAY, + message: message.into(), + param: None, + code: Some("invalid_upstream_response"), + } + } +} + +#[derive(Debug, Deserialize)] +struct ImageResponse<'a> { + created: u64, + #[serde(borrow)] + data: Vec>, + #[serde(default)] + usage: Option, +} + +#[derive(Debug, Deserialize)] +struct ImageResponseItem<'a> { + #[serde(borrow)] + b64_json: &'a str, +} + +#[derive(Debug, Deserialize)] +struct ImageUsage { + #[serde(default)] + input_tokens: Option, + #[serde(default)] + output_tokens: Option, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct GenerationRequest { + prompt: String, + #[serde(default)] + model: Option, + #[serde(default)] + background: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + n: Option, + #[serde(default)] + quality: Option, + #[serde(default)] + size: Option, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct ImageUrl { + image_url: String, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct EditRequest { + prompt: String, + images: Vec, + #[serde(default)] + model: Option, + #[serde(default)] + background: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + n: Option, + #[serde(default)] + quality: Option, + #[serde(default)] + size: Option, +} + +#[derive(Debug)] +pub struct UploadedImage { + pub bytes: Bytes, +} + +#[derive(Debug, Default)] +pub struct MultipartEditInput { + pub prompt: Option, + pub model: Option, + pub background: Option, + pub n: Option, + pub quality: Option, + pub size: Option, + pub images: Vec, +} + +#[derive(Debug)] +pub struct PreparedImageRequest { + pub body: Value, + pub model: String, + pub image_count: usize, +} + +pub struct CodexImagesBackend { + client: std::sync::Arc, + base_url: String, + limiter: std::sync::Arc, +} + +impl CodexImagesBackend { + pub fn new() -> Result { + let base_url = validate_image_base_url(&crate::config::codex_images_base_url())?; + Ok(Self { + client: std::sync::Arc::new(super::client::CodexHttpClient::new()), + base_url, + limiter: std::sync::Arc::new(tokio::sync::Semaphore::new(2)), + }) + } + + #[cfg(test)] + fn new_for_test( + client: std::sync::Arc, + base_url: String, + ) -> Self { + Self { + client, + base_url: base_url.trim_end_matches('/').to_string(), + limiter: std::sync::Arc::new(tokio::sync::Semaphore::new(2)), + } + } + + pub async fn handle( + &self, + operation: ImageOperation, + prepared: PreparedImageRequest, + ctx: crate::provider::RequestContext, + ) -> axum::response::Response { + use axum::response::IntoResponse; + + let _permit = match self.limiter.clone().try_acquire_owned() { + Ok(permit) => permit, + Err(_) => { + return image_error_response(ImageRequestError { + status: StatusCode::TOO_MANY_REQUESTS, + message: "Too many concurrent image requests".to_string(), + param: None, + code: Some("local_capacity_exceeded"), + }); + } + }; + if let Some(monitor) = ctx.monitor.as_ref() { + monitor.model_resolved(&ctx.req_id, &prepared.model); + monitor.upstream_started(&ctx.req_id); + } + let upstream = match self + .client + .post_image_json(&self.base_url, operation, &prepared.body, &ctx) + .await + { + Ok(response) => response, + Err(error) => return image_transport_error_response(error), + }; + let status = upstream.status(); + let headers = upstream.headers().clone(); + // EFFICIENCY: check status and content-length vs size budget before reading body + if status.is_redirection() { + return image_error_response(ImageRequestError::upstream_invalid( + "Codex image service returned an unexpected redirect", + )); + } + if upstream + .content_length() + .is_some_and(|length| length > MAX_IMAGE_RESPONSE_BYTES as u64) + { + return image_error_response(ImageRequestError::upstream_invalid( + "Codex image response exceeded the size limit", + )); + } + if !status.is_success() { + // EFFICIENCY: for error responses, consume a small diagnostic prefix rather than full body + let mut response = image_error_response(ImageRequestError { + status, + message: format!("Codex image service returned HTTP {}", status.as_u16()), + param: None, + code: Some("upstream_error"), + }); + copy_safe_image_headers(&headers, response.headers_mut()); + return response; + } + let body = + match collect_image_response_body(upstream, self.client.body_idle_timeout_ms(), &ctx) + .await + { + Ok(body) => body, + Err(error) => return image_error_response(error), + }; + let usage = match validate_success_response(&body) { + Ok(usage) => usage, + Err(error) => return image_error_response(error), + }; + if let Some(monitor) = ctx.monitor.as_ref() { + monitor.usage_updated(&ctx.req_id, usage.0, usage.1); + } + let mut response = ( + StatusCode::OK, + [(http::header::CONTENT_TYPE, "application/json")], + body, + ) + .into_response(); + response.headers_mut().insert( + http::header::CACHE_CONTROL, + http::HeaderValue::from_static("no-store"), + ); + response.headers_mut().insert( + http::header::X_CONTENT_TYPE_OPTIONS, + http::HeaderValue::from_static("nosniff"), + ); + copy_safe_image_headers(&headers, response.headers_mut()); + response + } +} + +async fn collect_image_response_body( + mut response: reqwest::Response, + body_idle_timeout_ms: u64, + ctx: &crate::provider::RequestContext, +) -> Result, ImageRequestError> { + // EFFICIENCY: preallocate from Content-Length when available to avoid repeated reallocs + let cap = response + .content_length() + .map(|l| l as usize) + .unwrap_or(0) + .min(MAX_IMAGE_RESPONSE_BYTES); + let mut body = Vec::with_capacity(cap); + let mut started = false; + loop { + let chunk = tokio::time::timeout( + std::time::Duration::from_millis(body_idle_timeout_ms), + response.chunk(), + ) + .await + .map_err(|_| ImageRequestError::upstream_invalid("Timed out reading Codex image response"))? + .map_err(|_| ImageRequestError::upstream_invalid("Failed to read Codex image response"))?; + let Some(chunk) = chunk else { + break; + }; + if body.len().saturating_add(chunk.len()) > MAX_IMAGE_RESPONSE_BYTES { + return Err(ImageRequestError::upstream_invalid( + "Codex image response exceeded the size limit", + )); + } + if !started { + if let Some(monitor) = ctx.monitor.as_ref() { + monitor.generation_started(&ctx.req_id); + } + started = true; + } + body.extend_from_slice(&chunk); + } + Ok(body) +} + +fn copy_safe_image_headers(source: &http::HeaderMap, target: &mut http::HeaderMap) { + for name in [ + "retry-after", + "x-request-id", + "openai-processing-ms", + "openai-version", + "x-ratelimit-limit-requests", + "x-ratelimit-limit-tokens", + "x-ratelimit-remaining-requests", + "x-ratelimit-remaining-tokens", + "x-ratelimit-reset-requests", + "x-ratelimit-reset-tokens", + ] { + if let Some(value) = source.get(name) { + target.insert(http::HeaderName::from_static(name), value.clone()); + } + } +} + +fn image_transport_error_response(error: super::client::CodexError) -> axum::response::Response { + let status = match error.status { + 401 => StatusCode::UNAUTHORIZED, + 403 => StatusCode::FORBIDDEN, + 429 => StatusCode::TOO_MANY_REQUESTS, + value if (400..=599).contains(&value) => { + StatusCode::from_u16(value).unwrap_or(StatusCode::BAD_GATEWAY) + } + _ => StatusCode::BAD_GATEWAY, + }; + let mut response = image_error_response(ImageRequestError { + status, + message: if error.status == 0 { + "Codex image service is unavailable".to_string() + } else { + format!("Codex image service returned HTTP {}", error.status) + }, + param: None, + code: Some(if status == StatusCode::UNAUTHORIZED { + "authentication_error" + } else if status == StatusCode::FORBIDDEN { + "permission_error" + } else if status == StatusCode::TOO_MANY_REQUESTS { + "rate_limit_error" + } else { + "upstream_error" + }), + }); + if let Some(retry_after) = error.retry_after + && let Ok(value) = http::HeaderValue::from_str(&retry_after) + { + response + .headers_mut() + .insert(http::header::RETRY_AFTER, value); + } + response +} + +pub fn image_error_response(error: ImageRequestError) -> axum::response::Response { + use axum::response::IntoResponse; + + let error_type = match error.status { + StatusCode::UNAUTHORIZED => "authentication_error", + StatusCode::FORBIDDEN => "permission_error", + StatusCode::TOO_MANY_REQUESTS => "rate_limit_error", + status if status.is_client_error() => "invalid_request_error", + _ => "api_error", + }; + ( + error.status, + [ + (http::header::CONTENT_TYPE, "application/json"), + (http::header::CACHE_CONTROL, "no-store"), + ], + axum::Json(serde_json::json!({ + "error": { + "message": error.message, + "type": error_type, + "param": error.param, + "code": error.code, + } + })), + ) + .into_response() +} + +pub fn prepare_json_request( + operation: ImageOperation, + bytes: &[u8], +) -> Result { + match operation { + ImageOperation::Generation => prepare_generation_request(bytes), + ImageOperation::Edit => prepare_edit_request(bytes), + } +} + +fn prepare_generation_request(bytes: &[u8]) -> Result { + let mut request: GenerationRequest = serde_json::from_slice(bytes).map_err(|error| { + ImageRequestError::invalid(format!("Invalid JSON image request: {error}"), None) + })?; + validate_and_default_common( + &request.prompt, + &mut request.model, + &mut request.background, + request.n, + &mut request.quality, + &mut request.size, + )?; + let model = request.model.clone().expect("model defaulted"); + let body = serde_json::to_value(request).map_err(|error| ImageRequestError { + status: StatusCode::INTERNAL_SERVER_ERROR, + message: format!("Failed to serialize image request: {error}"), + param: None, + code: Some("internal_error"), + })?; + Ok(PreparedImageRequest { + body, + model, + image_count: 0, + }) +} + +pub fn prepare_multipart_edit( + input: MultipartEditInput, +) -> Result { + use base64::Engine as _; + + if input.images.is_empty() || input.images.len() > MAX_EDIT_IMAGES { + return Err(ImageRequestError::invalid( + format!("'image' must contain between 1 and {MAX_EDIT_IMAGES} files"), + Some("image"), + )); + } + let total_bytes = input.images.iter().try_fold(0usize, |total, image| { + if image.bytes.len() > MAX_SINGLE_IMAGE_BYTES { + return Err(ImageRequestError { + status: StatusCode::PAYLOAD_TOO_LARGE, + message: format!("Each image must be at most {MAX_SINGLE_IMAGE_BYTES} bytes"), + param: Some("image"), + code: Some("request_too_large"), + }); + } + total + .checked_add(image.bytes.len()) + .ok_or(ImageRequestError { + status: StatusCode::PAYLOAD_TOO_LARGE, + message: "Combined image payload is too large".to_string(), + param: Some("image"), + code: Some("request_too_large"), + }) + })?; + if total_bytes > MAX_EDIT_IMAGE_BYTES { + return Err(ImageRequestError { + status: StatusCode::PAYLOAD_TOO_LARGE, + message: format!("Combined images must be at most {MAX_EDIT_IMAGE_BYTES} bytes"), + param: Some("image"), + code: Some("request_too_large"), + }); + } + + let images = input + .images + .into_iter() + .map(|image| { + let mime = detect_image_mime(&image.bytes).ok_or_else(|| { + ImageRequestError::invalid("Unsupported or malformed image file", Some("image")) + })?; + // EFFICIENCY: preallocate data-URL prefix, then encode image bytes into the same buffer + let mut data_url = format!("data:{mime};base64,"); + base64::engine::general_purpose::STANDARD.encode_string(&image.bytes, &mut data_url); + Ok(ImageUrl { + image_url: data_url, + }) + }) + .collect::, ImageRequestError>>()?; + let request = EditRequest { + prompt: input.prompt.ok_or_else(|| { + ImageRequestError::invalid("Missing required 'prompt' field", Some("prompt")) + })?, + images, + model: input.model, + background: input.background, + n: input.n, + quality: input.quality, + size: input.size, + }; + prepare_edit_value(request) +} + +fn prepare_edit_request(bytes: &[u8]) -> Result { + let request: EditRequest = serde_json::from_slice(bytes).map_err(|error| { + ImageRequestError::invalid(format!("Invalid JSON image edit request: {error}"), None) + })?; + prepare_edit_value(request) +} + +fn prepare_edit_value(mut request: EditRequest) -> Result { + if request.images.is_empty() || request.images.len() > MAX_EDIT_IMAGES { + return Err(ImageRequestError::invalid( + format!("'images' must contain between 1 and {MAX_EDIT_IMAGES} items"), + Some("images"), + )); + } + let total_bytes = request.images.iter().try_fold(0usize, |total, image| { + let image_bytes = validate_data_url(&image.image_url)?; + total.checked_add(image_bytes).ok_or(ImageRequestError { + status: StatusCode::PAYLOAD_TOO_LARGE, + message: "Combined image payload is too large".to_string(), + param: Some("images"), + code: Some("request_too_large"), + }) + })?; + if total_bytes > MAX_EDIT_IMAGE_BYTES { + return Err(ImageRequestError { + status: StatusCode::PAYLOAD_TOO_LARGE, + message: format!("Combined images must be at most {MAX_EDIT_IMAGE_BYTES} bytes"), + param: Some("images"), + code: Some("request_too_large"), + }); + } + validate_and_default_common( + &request.prompt, + &mut request.model, + &mut request.background, + request.n, + &mut request.quality, + &mut request.size, + )?; + let model = request.model.clone().expect("model defaulted"); + let image_count = request.images.len(); + let body = serde_json::to_value(request).map_err(|error| ImageRequestError { + status: StatusCode::INTERNAL_SERVER_ERROR, + message: format!("Failed to serialize image edit request: {error}"), + param: None, + code: Some("internal_error"), + })?; + Ok(PreparedImageRequest { + body, + model, + image_count, + }) +} + +fn validate_data_url(value: &str) -> Result { + use base64::Engine as _; + + let (metadata, encoded) = value.split_once(',').ok_or_else(|| { + ImageRequestError::invalid("Image must be a base64 data URL", Some("images")) + })?; + let mime = metadata + .strip_prefix("data:") + .and_then(|value| value.strip_suffix(";base64")) + .ok_or_else(|| { + ImageRequestError::invalid("Image must be a base64 data URL", Some("images")) + })?; + let decoded = base64::engine::general_purpose::STANDARD + .decode(encoded) + .map_err(|_| { + ImageRequestError::invalid("Image data is not valid base64", Some("images")) + })?; + let detected = detect_image_mime(&decoded).ok_or_else(|| { + ImageRequestError::invalid("Unsupported or malformed image data", Some("images")) + })?; + if mime != detected { + return Err(ImageRequestError::invalid( + format!("Image media type '{mime}' does not match '{detected}' data"), + Some("images"), + )); + } + if decoded.len() > MAX_SINGLE_IMAGE_BYTES { + return Err(ImageRequestError { + status: StatusCode::PAYLOAD_TOO_LARGE, + message: format!("Each image must be at most {MAX_SINGLE_IMAGE_BYTES} bytes"), + param: Some("images"), + code: Some("request_too_large"), + }); + } + Ok(decoded.len()) +} + +pub fn detect_image_mime(bytes: &[u8]) -> Option<&'static str> { + if bytes.starts_with(b"\x89PNG\r\n\x1a\n") { + Some("image/png") + } else if bytes.starts_with(b"\xff\xd8\xff") { + Some("image/jpeg") + } else if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") { + Some("image/gif") + } else if bytes.len() >= 12 && &bytes[..4] == b"RIFF" && &bytes[8..12] == b"WEBP" { + Some("image/webp") + } else { + None + } +} + +pub fn validate_success_response( + bytes: &[u8], +) -> Result<(Option, Option), ImageRequestError> { + let response: ImageResponse<'_> = serde_json::from_slice(bytes).map_err(|_| { + ImageRequestError::upstream_invalid("Codex image service returned invalid JSON") + })?; + let _created = response.created; + if response.data.is_empty() || response.data.iter().any(|item| item.b64_json.is_empty()) { + return Err(ImageRequestError::upstream_invalid( + "Codex image service returned no image data", + )); + } + Ok(response + .usage + .map(|usage| (usage.input_tokens, usage.output_tokens)) + .unwrap_or((None, None))) +} + +pub fn validate_image_base_url(raw: &str) -> Result { + let parsed = + url::Url::parse(raw).map_err(|error| format!("Invalid Codex images base URL: {error}"))?; + if parsed.scheme() != "https" + || parsed.host_str() != Some("chatgpt.com") + || parsed.port_or_known_default() != Some(443) + || !parsed.username().is_empty() + || parsed.password().is_some() + || parsed.query().is_some() + || parsed.fragment().is_some() + || !parsed.path().starts_with("/backend-api/codex") + { + return Err( + "Codex images base URL must be an HTTPS chatgpt.com/backend-api/codex URL without credentials, query, or fragment" + .to_string(), + ); + } + Ok(raw.trim_end_matches('/').to_string()) +} + +fn validate_and_default_common( + prompt: &str, + model: &mut Option, + background: &mut Option, + n: Option, + quality: &mut Option, + size: &mut Option, +) -> Result<(), ImageRequestError> { + if prompt.trim().is_empty() { + return Err(ImageRequestError::invalid( + "'prompt' must not be empty", + Some("prompt"), + )); + } + match model.as_deref() { + Some(IMAGE_MODEL) | None => {} + Some(other) => { + return Err(ImageRequestError::invalid( + format!("Unsupported image model '{other}'; expected '{IMAGE_MODEL}'"), + Some("model"), + )); + } + } + if n.is_some_and(|n| !(1..=10).contains(&n)) { + return Err(ImageRequestError::invalid( + "'n' must be between 1 and 10", + Some("n"), + )); + } + validate_choice( + "background", + background.as_deref(), + &["auto", "transparent", "opaque"], + )?; + validate_choice( + "quality", + quality.as_deref(), + &["auto", "low", "medium", "high"], + )?; + if size.as_deref().is_some_and(str::is_empty) { + return Err(ImageRequestError::invalid( + "'size' must not be empty", + Some("size"), + )); + } + model.get_or_insert_with(|| IMAGE_MODEL.to_string()); + background.get_or_insert_with(|| "auto".to_string()); + quality.get_or_insert_with(|| "auto".to_string()); + size.get_or_insert_with(|| "auto".to_string()); + Ok(()) +} + +fn validate_choice( + field: &'static str, + value: Option<&str>, + allowed: &[&str], +) -> Result<(), ImageRequestError> { + if let Some(value) = value + && !allowed.contains(&value) + { + return Err(ImageRequestError::invalid( + format!("Invalid '{field}' value '{value}'"), + Some(field), + )); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn json_edit_enforces_decoded_image_size_limits() { + use base64::Engine as _; + + let mut bytes = b"\x89PNG\r\n\x1a\n".to_vec(); + bytes.resize(MAX_SINGLE_IMAGE_BYTES + 1, 0); + let data_url = format!( + "data:image/png;base64,{}", + base64::engine::general_purpose::STANDARD.encode(bytes) + ); + let body = serde_json::to_vec(&serde_json::json!({ + "prompt": "x", + "images": [{"image_url": data_url}] + })) + .unwrap(); + let error = prepare_json_request(ImageOperation::Edit, &body).unwrap_err(); + assert_eq!(error.status, StatusCode::PAYLOAD_TOO_LARGE); + } + + #[tokio::test] + async fn auth_and_rate_limit_errors_use_openai_error_types() { + use axum::body::to_bytes; + + for (status, expected_type) in [ + (StatusCode::UNAUTHORIZED, "authentication_error"), + (StatusCode::FORBIDDEN, "permission_error"), + (StatusCode::TOO_MANY_REQUESTS, "rate_limit_error"), + (StatusCode::BAD_GATEWAY, "api_error"), + ] { + let response = image_error_response(ImageRequestError { + status, + message: "error".to_string(), + param: None, + code: None, + }); + let body = to_bytes(response.into_body(), 4096).await.unwrap(); + assert_eq!( + serde_json::from_slice::(&body).unwrap()["error"]["type"], + expected_type + ); + } + } + + #[test] + fn request_validation_rejects_unsupported_and_unsafe_inputs() { + for body in [ + br#"{"prompt":"x","model":"gpt-image-1"}"#.as_slice(), + br#"{"prompt":"x","n":0}"#, + br#"{"prompt":"x","response_format":"url"}"#, + br#"{"prompt":" "}"#, + ] { + assert!(prepare_json_request(ImageOperation::Generation, body).is_err()); + } + assert!( + prepare_json_request( + ImageOperation::Edit, + br#"{"prompt":"x","images":[{"image_url":"https://example.com/x.png"}]}"#, + ) + .is_err() + ); + } + + #[tokio::test] + async fn backend_rejects_oversized_upstream_response_before_body_read() { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::net::TcpListener; + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + let mut request = [0_u8; 4096]; + assert!(stream.read(&mut request).await.unwrap() > 0); + let head = format!( + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n", + MAX_IMAGE_RESPONSE_BYTES + 1 + ); + stream.write_all(head.as_bytes()).await.unwrap(); + }); + + let client = super::super::client::CodexHttpClient::new_for_test( + reqwest::Client::builder().no_proxy().build().unwrap(), + format!("http://{addr}/responses"), + 1_000, + 1_000, + 0, + ); + client + .auth_manager() + .set_test_auth(super::super::auth::token_store::StoredAuth { + access: "test".into(), + refresh: String::new(), + account_id: Some("acct".into()), + expires: u64::MAX, + }); + let backend = + CodexImagesBackend::new_for_test(std::sync::Arc::new(client), format!("http://{addr}")); + let response = backend + .handle( + ImageOperation::Generation, + prepare_json_request(ImageOperation::Generation, br#"{"prompt":"x"}"#).unwrap(), + crate::provider::RequestContext { + req_id: "oversized".into(), + session_id: None, + session_seq: None, + provider: "codex".into(), + traffic: None, + monitor: None, + }, + ) + .await; + server.await.unwrap(); + assert_eq!(response.status(), StatusCode::BAD_GATEWAY); + } + + #[tokio::test] + async fn backend_passes_through_valid_bounded_image_json() { + use axum::body::to_bytes; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::net::TcpListener; + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + let mut request = [0_u8; 16 * 1024]; + assert!(stream.read(&mut request).await.unwrap() > 0); + let response = br#"{"created":1,"data":[{"b64_json":"aW1n"}],"quality":"medium"}"#; + let head = format!( + "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", + response.len() + ); + stream.write_all(head.as_bytes()).await.unwrap(); + stream.write_all(response).await.unwrap(); + }); + + let client = super::super::client::CodexHttpClient::new_for_test( + reqwest::Client::builder().no_proxy().build().unwrap(), + format!("http://{addr}/responses"), + 1_000, + 1_000, + 0, + ); + client + .auth_manager() + .set_test_auth(super::super::auth::token_store::StoredAuth { + access: "test".into(), + refresh: String::new(), + account_id: Some("acct".into()), + expires: u64::MAX, + }); + let backend = CodexImagesBackend::new_for_test( + std::sync::Arc::new(client), + format!("http://{addr}/root"), + ); + let prepared = + prepare_json_request(ImageOperation::Generation, br#"{"prompt":"draw a fox"}"#) + .unwrap(); + let response = backend + .handle( + ImageOperation::Generation, + prepared, + crate::provider::RequestContext { + req_id: "image-test".into(), + session_id: None, + session_seq: None, + provider: "codex".into(), + traffic: None, + monitor: None, + }, + ) + .await; + server.await.unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.headers()[http::header::CACHE_CONTROL], "no-store"); + assert_eq!(response.headers()["x-request-id"], "upstream-1"); + assert!(response.headers().get(http::header::SET_COOKIE).is_none()); + let body = to_bytes(response.into_body(), MAX_IMAGE_RESPONSE_BYTES) + .await + .unwrap(); + assert_eq!( + serde_json::from_slice::(&body).unwrap()["quality"], + "medium" + ); + } + + #[test] + fn success_response_requires_created_and_nonempty_base64_items() { + let valid = br#"{"created":1,"data":[{"b64_json":"aW1n"}],"usage":{"input_tokens":3}}"#; + let usage = validate_success_response(valid).expect("valid response"); + assert_eq!(usage, (Some(3), None)); + + assert!(validate_success_response(br#"{"data":[{"b64_json":"aW1n"}]}"#).is_err()); + assert!(validate_success_response(br#"{"created":1,"data":[]}"#).is_err()); + assert!(validate_success_response(br#"{"created":1,"data":[{"b64_json":""}]}"#).is_err()); + } + + #[test] + fn production_image_base_url_is_locked_to_chatgpt_https() { + assert_eq!( + validate_image_base_url("https://chatgpt.com/backend-api/codex/").unwrap(), + "https://chatgpt.com/backend-api/codex" + ); + assert!(validate_image_base_url("http://chatgpt.com/backend-api/codex").is_err()); + assert!(validate_image_base_url("https://example.com/backend-api/codex").is_err()); + assert!(validate_image_base_url("https://chatgpt.com/backend-api/codex?x=1").is_err()); + } + + #[test] + fn multipart_edit_is_translated_to_codex_data_urls() { + let prepared = prepare_multipart_edit(MultipartEditInput { + prompt: Some("make it blue".to_string()), + model: None, + background: None, + n: None, + quality: None, + size: None, + images: vec![UploadedImage { + bytes: Bytes::from_static(b"\x89PNG\r\n\x1a\n"), + }], + }) + .expect("multipart edit should be valid"); + + assert_eq!(prepared.image_count, 1); + assert_eq!( + prepared.body["images"][0]["image_url"], + "data:image/png;base64,iVBORw0KGgo=" + ); + } + + #[test] + fn json_edit_request_accepts_data_urls_and_applies_defaults() { + let prepared = prepare_json_request( + ImageOperation::Edit, + br#"{"prompt":"make it blue","images":[{"image_url":"data:image/png;base64,iVBORw0KGgo="}]}"#, + ) + .expect("edit request should be valid"); + + assert_eq!(prepared.model, IMAGE_MODEL); + assert_eq!(prepared.image_count, 1); + assert_eq!( + prepared.body["images"][0]["image_url"], + "data:image/png;base64,iVBORw0KGgo=" + ); + assert_eq!(prepared.body["background"], "auto"); + assert_eq!(prepared.body["quality"], "auto"); + assert_eq!(prepared.body["size"], "auto"); + } + + #[test] + fn generation_request_applies_safe_defaults() { + let prepared = + prepare_json_request(ImageOperation::Generation, br#"{"prompt":"draw a fox"}"#) + .expect("generation request should be valid"); + + assert_eq!(prepared.model, IMAGE_MODEL); + assert_eq!(prepared.image_count, 0); + assert_eq!(prepared.body["prompt"], "draw a fox"); + assert_eq!(prepared.body["model"], IMAGE_MODEL); + assert_eq!(prepared.body["background"], "auto"); + assert_eq!(prepared.body["quality"], "auto"); + assert_eq!(prepared.body["size"], "auto"); + assert!(prepared.body.get("n").is_none()); + } +} diff --git a/src/providers/codex/mod.rs b/src/providers/codex/mod.rs index 22d7a51a..bb148f74 100644 --- a/src/providers/codex/mod.rs +++ b/src/providers/codex/mod.rs @@ -5,6 +5,7 @@ pub mod compaction; pub mod continuation; pub mod count_tokens; pub(crate) mod events; +pub mod images; pub mod native; pub mod request_summary; pub mod search; diff --git a/src/server.rs b/src/server.rs index ac35dfad..46fda058 100644 --- a/src/server.rs +++ b/src/server.rs @@ -6,6 +6,11 @@ use crate::{ provider::RequestContext, providers::codex::{ chat_completions::{ChatCompletionsBackend, request::translate_request}, + images::{ + CodexImagesBackend, ImageOperation, ImageRequestError, MAX_EDIT_REQUEST_BYTES, + MAX_GENERATION_REQUEST_BYTES, MultipartEditInput, UploadedImage, image_error_response, + prepare_json_request, prepare_multipart_edit, + }, native::{ CodexNativeBackend, NativeResponseOutcome, openai_error, validate_native_request_model, }, @@ -17,7 +22,7 @@ use crate::{ use axum::{ Json, Router, body::Body, - extract::{Query, State}, + extract::{DefaultBodyLimit, FromRequest, Multipart, Query, State}, http::{Request, StatusCode}, response::Response, routing::{get, post}, @@ -160,11 +165,31 @@ pub async fn serve_listener( } pub fn app(registry: Arc) -> Router { - app_with_options(registry, None, crate::config::codex_responses_api()) + app_with_features( + registry, + None, + AppFeatures { + responses_api: crate::config::codex_responses_api(), + images_api: crate::config::codex_images_api(), + }, + ) } pub fn app_with_monitor(registry: Arc, monitor: Option) -> Router { - app_with_options(registry, monitor, crate::config::codex_responses_api()) + app_with_features( + registry, + monitor, + AppFeatures { + responses_api: crate::config::codex_responses_api(), + images_api: crate::config::codex_images_api(), + }, + ) +} + +#[derive(Debug, Clone, Copy, Default)] +pub struct AppFeatures { + pub responses_api: bool, + pub images_api: bool, } pub fn app_with_options( @@ -172,26 +197,70 @@ pub fn app_with_options( monitor: Option, responses_api: bool, ) -> Router { - let native_responses = responses_api.then(|| Arc::new(CodexNativeBackend::new())); - let chat_completions = responses_api.then(|| Arc::new(ChatCompletionsBackend::new())); + app_with_features( + registry, + monitor, + AppFeatures { + responses_api, + images_api: false, + }, + ) +} + +pub fn app_with_features( + registry: Arc, + monitor: Option, + features: AppFeatures, +) -> Router { + let native_responses = features + .responses_api + .then(|| Arc::new(CodexNativeBackend::new())); + let chat_completions = features + .responses_api + .then(|| Arc::new(ChatCompletionsBackend::new())); + let images = if features.images_api { + match CodexImagesBackend::new() { + Ok(backend) => Some(Arc::new(backend)), + Err(error) => { + create_logger("server").warn( + "codex images backend disabled by invalid configuration", + Some(Map::from_iter([("error".to_string(), json!(error))])), + ); + None + } + } + } else { + None + }; let state = Arc::new(AppState { registry, monitor, native_responses, chat_completions, + images, }); let router = Router::new() .route("/healthz", get(healthz)) .route("/v1/messages", post(handler_messages)) .route("/v1/messages/count_tokens", post(handler_count_tokens)) .route("/v1/models", get(handler_models)); - let router = if responses_api { + let router = if features.responses_api { router .route("/v1/responses", post(handler_responses)) .route("/v1/chat/completions", post(handler_chat_completions)) } else { router }; + let router = if features.images_api { + router + .route("/v1/images/generations", post(handler_image_generation)) + .route( + "/v1/images/edits", + post(handler_image_edit).layer(DefaultBodyLimit::max(MAX_EDIT_REQUEST_BYTES)), + ) + } else { + router + }; router.fallback(fallback_handler).with_state(state) } @@ -201,6 +270,7 @@ struct AppState { monitor: Option, native_responses: Option>, chat_completions: Option>, + images: Option>, } async fn healthz() -> Json { @@ -253,6 +323,265 @@ async fn handler_count_tokens(State(state): State>, req: Request>, + req: Request, +) -> Response { + dispatch_image_request(state, req, ImageOperation::Generation).await +} + +async fn handler_image_edit(State(state): State>, req: Request) -> Response { + dispatch_image_request(state, req, ImageOperation::Edit).await +} + +async fn dispatch_image_request( + state: Arc, + req: Request, + operation: ImageOperation, +) -> Response { + let started_at = Instant::now(); + let log = create_logger("server"); + let req_id = Uuid::new_v4().to_string(); + let uri = req.uri().clone(); + let headers = req.headers().clone(); + let path = uri.path().to_string(); + log.info( + "request", + Some(Map::from_iter([ + ("reqId".to_string(), json!(&req_id)), + ("method".to_string(), json!("POST")), + ("path".to_string(), json!(&path)), + ("query".to_string(), json!(redacted_query(&uri))), + ])), + ); + let session_id = native_session_id(&headers); + if let Some(monitor) = state.monitor.as_ref() { + monitor.request_started(&req_id, session_id.clone(), None, EndpointKind::Images); + } + let request_guard = RequestMonitorGuard::new(state.monitor.clone(), req_id.clone()); + + if uri.query().is_some() { + let response = image_error_response(ImageRequestError { + status: StatusCode::BAD_REQUEST, + message: "Image endpoints do not accept query parameters".to_string(), + param: None, + code: Some("invalid_request"), + }); + log_native_request_completed( + &log, + &req_id, + operation.label(), + None, + response.status(), + started_at, + ); + return monitor_response_body(response, request_guard); + } + let content_type = headers + .get(http::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .unwrap_or_default() + .to_ascii_lowercase(); + let prepared = if content_type.starts_with("application/json") { + let limit = match operation { + ImageOperation::Generation => MAX_GENERATION_REQUEST_BYTES, + ImageOperation::Edit => MAX_EDIT_REQUEST_BYTES, + }; + let body = match axum::body::to_bytes(req.into_body(), limit).await { + Ok(body) => body, + Err(_) => { + let response = image_error_response(ImageRequestError { + status: StatusCode::PAYLOAD_TOO_LARGE, + message: "Image request exceeded the size limit".to_string(), + param: None, + code: Some("request_too_large"), + }); + log_native_request_completed( + &log, + &req_id, + operation.label(), + None, + response.status(), + started_at, + ); + return monitor_response_body(response, request_guard); + } + }; + prepare_json_request(operation, &body) + } else if operation == ImageOperation::Edit && content_type.starts_with("multipart/form-data") { + let multipart = match Multipart::from_request(req, &()).await { + Ok(multipart) => multipart, + Err(_) => { + let response = image_error_response(ImageRequestError { + status: StatusCode::BAD_REQUEST, + message: "Invalid multipart image edit request".to_string(), + param: None, + code: Some("invalid_multipart"), + }); + log_native_request_completed( + &log, + &req_id, + operation.label(), + None, + response.status(), + started_at, + ); + return monitor_response_body(response, request_guard); + } + }; + parse_multipart_image_edit(multipart).await + } else { + let response = image_error_response(ImageRequestError { + status: StatusCode::UNSUPPORTED_MEDIA_TYPE, + message: if operation == ImageOperation::Edit { + "Image edit request must use application/json or multipart/form-data".to_string() + } else { + "Image generation request must use application/json".to_string() + }, + param: None, + code: Some("unsupported_media_type"), + }); + log_native_request_completed( + &log, + &req_id, + operation.label(), + None, + response.status(), + started_at, + ); + return monitor_response_body(response, request_guard); + }; + let prepared = match prepared { + Ok(prepared) => prepared, + Err(error) => { + let response = image_error_response(error); + log_native_request_completed( + &log, + &req_id, + operation.label(), + None, + response.status(), + started_at, + ); + return monitor_response_body(response, request_guard); + } + }; + let model = prepared.model.clone(); + if let Some(monitor) = state.monitor.as_ref() { + monitor.provider_selected(&req_id, "codex", &model, None); + } + let context = RequestContext { + req_id: req_id.clone(), + session_id, + session_seq: None, + provider: "codex".to_string(), + traffic: None, + monitor: state.monitor.clone(), + }; + let response = match state.images.as_ref() { + Some(backend) => backend.handle(operation, prepared, context).await, + None => image_error_response(ImageRequestError { + status: StatusCode::SERVICE_UNAVAILABLE, + message: "Codex Images API is unavailable".to_string(), + param: None, + code: Some("images_api_unavailable"), + }), + }; + log_native_request_completed( + &log, + &req_id, + operation.label(), + Some(&model), + response.status(), + started_at, + ); + monitor_response_body(response, request_guard) +} + +async fn parse_multipart_image_edit( + mut multipart: Multipart, +) -> Result { + let mut input = MultipartEditInput::default(); + while let Some(field) = multipart + .next_field() + .await + .map_err(|_| ImageRequestError { + status: StatusCode::BAD_REQUEST, + message: "Invalid multipart image edit request".to_string(), + param: None, + code: Some("invalid_multipart"), + })? + { + let name = field.name().unwrap_or_default().to_string(); + match name.as_str() { + "image" | "image[]" => { + let bytes = field.bytes().await.map_err(|_| ImageRequestError { + status: StatusCode::BAD_REQUEST, + message: "Failed to read uploaded image".to_string(), + param: Some("image"), + code: Some("invalid_image"), + })?; + input.images.push(UploadedImage { + bytes, // EFFICIENCY: field.bytes() already returns owned Bytes, avoid to_vec() copy + }); + } + "prompt" => { + input.prompt = Some(multipart_text(field, "prompt").await?); + } + "model" => { + input.model = Some(multipart_text(field, "model").await?); + } + "background" => { + input.background = Some(multipart_text(field, "background").await?); + } + "quality" => { + input.quality = Some(multipart_text(field, "quality").await?); + } + "size" => { + input.size = Some(multipart_text(field, "size").await?); + } + "n" => { + let raw = multipart_text(field, "n").await?; + input.n = Some(raw.parse::().map_err(|_| ImageRequestError { + status: StatusCode::BAD_REQUEST, + message: "'n' must be an integer between 1 and 10".to_string(), + param: Some("n"), + code: Some("invalid_request"), + })?); + } + "mask" => { + return Err(ImageRequestError { + status: StatusCode::BAD_REQUEST, + message: "Image masks are not supported by the Codex image backend".to_string(), + param: Some("mask"), + code: Some("unsupported_parameter"), + }); + } + _ => { + return Err(ImageRequestError { + status: StatusCode::BAD_REQUEST, + message: format!("Unsupported multipart field '{name}'"), + param: None, + code: Some("unsupported_parameter"), + }); + } + } + } + prepare_multipart_edit(input) +} + +async fn multipart_text( + field: axum::extract::multipart::Field<'_>, + param: &'static str, +) -> Result { + field.text().await.map_err(|_| ImageRequestError { + status: StatusCode::BAD_REQUEST, + message: format!("Invalid multipart '{param}' field"), + param: Some(param), + code: Some("invalid_multipart"), + }) +} + async fn handler_responses(State(state): State>, req: Request) -> Response { let started_at = Instant::now(); let log = create_logger("server"); diff --git a/tests/server.rs b/tests/server.rs index cb86954e..0873b1c1 100644 --- a/tests/server.rs +++ b/tests/server.rs @@ -3,7 +3,10 @@ use axum::http::{Method, Request, StatusCode}; use claude_code_proxy::{ monitor::{MonitorHandle, RequestStatus}, registry::Registry, - server::{app, app_with_monitor, app_with_options, bind_proxy_listener}, + server::{ + AppFeatures, app, app_with_features, app_with_monitor, app_with_options, + bind_proxy_listener, + }, }; use serde_json::{Value, json}; use std::sync::Arc; @@ -251,6 +254,176 @@ async fn opus_5_alias_routes_to_provider() { assert_eq!(response.status(), StatusCode::OK); } +#[tokio::test] +async fn image_routes_reject_variations_wrong_media_and_oversized_generation() { + let features = AppFeatures { + responses_api: false, + images_api: true, + }; + let variation = app_with_features(Arc::new(Registry::with_default_alias()), None, features) + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/v1/images/variations") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(variation.status(), StatusCode::NOT_FOUND); + + let wrong_media = app_with_features(Arc::new(Registry::with_default_alias()), None, features) + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/v1/images/generations") + .header("content-type", "multipart/form-data; boundary=x") + .body(Body::from("--x--\r\n")) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(wrong_media.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE); + + let oversized = app_with_features(Arc::new(Registry::with_default_alias()), None, features) + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/v1/images/generations") + .header("content-type", "application/json") + .body(Body::from(vec![b'x'; 256 * 1024 + 1])) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(oversized.status(), StatusCode::PAYLOAD_TOO_LARGE); +} + +#[tokio::test] +async fn monitor_tracks_image_endpoint_without_session_affinity() { + let monitor = MonitorHandle::new(10); + let app = app_with_features( + Arc::new(Registry::with_default_alias()), + Some(monitor.clone()), + AppFeatures { + responses_api: false, + images_api: true, + }, + ); + let response = app + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/v1/images/generations") + .header("content-type", "application/json") + .body(body_string("{")) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let _ = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + + let state = monitor.snapshot(); + assert_eq!(state.recent.len(), 1); + assert_eq!(state.recent[0].endpoint.label(), "images"); + assert_eq!(state.recent[0].status, RequestStatus::Failed); + assert!(state.recent[0].session_seq.is_none()); + assert!(state.recent[0].traffic_capture_path.is_none()); +} + +#[tokio::test] +async fn image_edit_accepts_multipart_and_validates_fields() { + let app = app_with_features( + Arc::new(Registry::with_default_alias()), + None, + AppFeatures { + responses_api: false, + images_api: true, + }, + ); + let boundary = "ccp-image-test"; + let mut body = Vec::new(); + body.extend_from_slice( + format!( + "--{boundary}\r\nContent-Disposition: form-data; name=\"image\"; filename=\"input.png\"\r\nContent-Type: image/png\r\n\r\n" + ) + .as_bytes(), + ); + body.extend_from_slice(b"\x89PNG\r\n\x1a\n"); + body.extend_from_slice(format!("\r\n--{boundary}--\r\n").as_bytes()); + + let response = app + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/v1/images/edits") + .header( + "content-type", + format!("multipart/form-data; boundary={boundary}"), + ) + .body(Body::from(body)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let body: Value = serde_json::from_slice( + &axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(), + ) + .unwrap(); + assert_eq!(body["error"]["param"], "prompt"); +} + +#[tokio::test] +async fn image_routes_are_independently_opt_in() { + let disabled = app_with_features( + Arc::new(Registry::with_default_alias()), + None, + AppFeatures { + responses_api: false, + images_api: false, + }, + ); + let response = disabled + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/v1/images/generations") + .header("content-type", "application/json") + .body(body_string("{")) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::NOT_FOUND); + + let enabled = app_with_features( + Arc::new(Registry::with_default_alias()), + None, + AppFeatures { + responses_api: false, + images_api: true, + }, + ); + let response = enabled + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/v1/images/generations") + .header("content-type", "application/json") + .body(body_string("{")) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); +} + #[tokio::test] async fn native_responses_route_is_disabled_by_default_option() { let app = app_with_options(Arc::new(Registry::with_default_alias()), None, false);