From 7ba54b1bbe8f8a5d4e3b8f871f6c0d1e81c20305 Mon Sep 17 00:00:00 2001 From: Materal Date: Fri, 4 Sep 2026 15:29:34 +0800 Subject: [PATCH 1/3] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E5=9B=BE=E7=89=87?= =?UTF-8?q?=E7=94=9F=E6=88=90=E4=B8=8E=E7=BC=96=E8=BE=91=E6=8E=A5=E5=8F=A3?= =?UTF-8?q?=E4=BB=A3=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/codex-plus-core/src/launcher.rs | 170 ++++++++++++++ crates/codex-plus-core/src/protocol_proxy.rs | 222 ++++++++++++++++++ .../codex-plus-core/tests/protocol_proxy.rs | 212 ++++++++++++++++- 3 files changed, 601 insertions(+), 3 deletions(-) diff --git a/crates/codex-plus-core/src/launcher.rs b/crates/codex-plus-core/src/launcher.rs index c0a3d6f22..dd3fc69f6 100644 --- a/crates/codex-plus-core/src/launcher.rs +++ b/crates/codex-plus-core/src/launcher.rs @@ -1139,6 +1139,35 @@ async fn handle_helper_connection( ) .await; } + if (crate::protocol_proxy::is_image_generations_proxy_path(path) + || crate::protocol_proxy::is_image_edits_proxy_path(path)) + && method == "OPTIONS" + { + write_http_response( + &mut stream, + "204 No Content", + "application/json; charset=utf-8", + &[], + ) + .await?; + stream.shutdown().await?; + return Ok(()); + } + if (crate::protocol_proxy::is_image_generations_proxy_path(path) + || crate::protocol_proxy::is_image_edits_proxy_path(path)) + && method == "POST" + { + return handle_image_proxy_connection( + &mut stream, + &request.body, + request_content_type.as_deref(), + request_user_agent.as_deref(), + method, + path, + remote_addr_text, + ) + .await; + } if crate::protocol_proxy::is_responses_proxy_path(path) && method == "GET" { let body = serde_json::to_vec(&serde_json::json!({ "status": "upgrade_required", @@ -1758,6 +1787,78 @@ async fn handle_audio_transcriptions_proxy_connection( Ok(()) } +async fn handle_image_proxy_connection( + stream: &mut tokio::net::TcpStream, + request_body: &[u8], + request_content_type: Option<&str>, + request_user_agent: Option<&str>, + method: &str, + path: &str, + remote_addr_text: Option, +) -> anyhow::Result<()> { + let upstream = if crate::protocol_proxy::is_image_generations_proxy_path(path) { + crate::protocol_proxy::open_image_generations_proxy_request( + request_body, + request_user_agent, + ) + .await + } else { + crate::protocol_proxy::open_image_edits_proxy_request( + request_body, + request_content_type.unwrap_or_default(), + request_user_agent, + ) + .await + }; + let upstream = match upstream { + Ok(upstream) => upstream, + Err(error) => { + let body = serde_json::to_vec(&serde_json::json!({ + "status": "failed", + "message": error.to_string() + }))?; + write_http_response( + stream, + "502 Bad Gateway", + "application/json; charset=utf-8", + &body, + ) + .await?; + log_helper_response( + "helper.image_proxy_failed", + method, + path, + "502 Bad Gateway", + remote_addr_text, + ); + stream.shutdown().await?; + return Ok(()); + } + }; + let status = upstream.status(); + let is_success = upstream.is_success(); + let content_type = if upstream.content_type.is_empty() { + "application/json; charset=utf-8".to_string() + } else { + upstream.content_type.clone() + }; + let body = upstream.response.bytes().await?.to_vec(); + write_http_response(stream, &status, &content_type, &body).await?; + log_helper_response( + if is_success { + "helper.image_proxy_ok" + } else { + "helper.image_proxy_upstream_error" + }, + method, + path, + &status, + remote_addr_text, + ); + stream.shutdown().await?; + Ok(()) +} + async fn handle_chat_completions_proxy_connection( stream: &mut tokio::net::TcpStream, request_body: &str, @@ -3287,6 +3388,75 @@ mod tests { assert!(String::from_utf8_lossy(&response).starts_with("HTTP/1.1 426 Upgrade Required")); } + #[tokio::test] + async fn helper_keeps_unknown_image_path_as_not_found() { + let response = send_raw_helper_request( + b"POST /v1/images/unknown HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: application/json\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{}", + ) + .await; + + let response = String::from_utf8_lossy(&response); + assert!(response.starts_with("HTTP/1.1 404 Not Found")); + assert!(response.contains("未知后端路径")); + } + + #[tokio::test] + async fn helper_proxies_image_generation_upstream_error_response() { + let _settings_guard = crate::paths::settings_path_test_guard(); + let temp = tempfile::tempdir().unwrap(); + let settings_path = temp.path().join("settings.json"); + let previous_settings_path = + crate::paths::set_settings_path_for_tests(Some(settings_path.clone())); + let upstream_listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)) + .await + .unwrap(); + let upstream_addr = upstream_listener.local_addr().unwrap(); + let settings = serde_json::json!({ + "relayProfiles": [{ + "id": "images", + "name": "Images", + "baseUrl": format!("http://{upstream_addr}/v1"), + "upstreamBaseUrl": format!("http://{upstream_addr}/v1"), + "apiKey": "sk-test", + "protocol": "responses", + "relayMode": "mixedApi" + }], + "activeRelayId": "images" + }); + std::fs::write(settings_path, serde_json::to_vec_pretty(&settings).unwrap()).unwrap(); + let upstream = tokio::spawn(async move { + let (mut stream, _) = upstream_listener.accept().await.unwrap(); + let request = read_http_request(&mut stream).await.unwrap(); + let body = br#"{"error":{"message":"rate limited"}}"#; + let response = format!( + "HTTP/1.1 429 Too Many Requests\r\nContent-Type: application/problem+json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ); + stream.write_all(response.as_bytes()).await.unwrap(); + stream.write_all(body).await.unwrap(); + request + }); + let request_body = br#"{"model":"gpt-image-2","prompt":"draw a square"}"#; + let headers = format!( + "POST /v1/images/generations HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + request_body.len() + ); + let mut request = headers.into_bytes(); + request.extend_from_slice(request_body); + + let response = send_raw_helper_request(&request).await; + + let response_text = String::from_utf8_lossy(&response); + assert!(response_text.starts_with("HTTP/1.1 429 Too Many Requests")); + assert!(response_text.contains("Content-Type: application/problem+json")); + assert!(response.ends_with(br#"{"error":{"message":"rate limited"}}"#)); + let upstream_request = upstream.await.unwrap(); + let request_line = String::from_utf8_lossy(&upstream_request.headers); + assert!(request_line.starts_with("POST /v1/images/generations HTTP/1.1")); + assert_eq!(upstream_request.body, request_body); + crate::paths::set_settings_path_for_tests(previous_settings_path); + } + #[test] fn protocol_proxy_request_body_decodes_zstd() { let body = br#"{"model":"gpt-5.6-sol","input":"probe","stream":false}"#; diff --git a/crates/codex-plus-core/src/protocol_proxy.rs b/crates/codex-plus-core/src/protocol_proxy.rs index a8f62dd9f..ccb1c9af4 100644 --- a/crates/codex-plus-core/src/protocol_proxy.rs +++ b/crates/codex-plus-core/src/protocol_proxy.rs @@ -17,6 +17,7 @@ pub const NO_AUTH_PROXY_BEARER_TOKEN: &str = "codex-plus-no-auth"; const UPSTREAM_CONNECT_TIMEOUT: Duration = Duration::from_secs(5); const UPSTREAM_HEADER_TIMEOUT: Duration = Duration::from_secs(30); const UPSTREAM_STREAM_HEADER_TIMEOUT: Duration = Duration::from_secs(120); +const UPSTREAM_IMAGE_HEADER_TIMEOUT: Duration = Duration::from_secs(300); const THINK_OPEN_TAG: &str = ""; const THINK_CLOSE_TAG: &str = ""; const EXTRA_CHAT_PASSTHROUGH_FIELDS: &[&str] = &[ @@ -301,6 +302,8 @@ pub enum UpstreamWireApi { Responses, ChatCompletions, AudioTranscriptions, + ImageGenerations, + ImageEdits, } #[derive(Debug, Clone)] @@ -513,6 +516,25 @@ pub fn is_audio_transcriptions_proxy_path(path: &str) -> bool { ) } +pub fn is_image_generations_proxy_path(path: &str) -> bool { + let path = path.split_once('?').map_or(path, |(path, _)| path); + matches!( + path, + "/images/generations" + | "/v1/images/generations" + | "/v1/v1/images/generations" + | "/codex/v1/images/generations" + ) +} + +pub fn is_image_edits_proxy_path(path: &str) -> bool { + let path = path.split_once('?').map_or(path, |(path, _)| path); + matches!( + path, + "/images/edits" | "/v1/images/edits" | "/v1/v1/images/edits" | "/codex/v1/images/edits" + ) +} + pub async fn open_responses_proxy_request( body: &str, original_user_agent: Option<&str>, @@ -854,6 +876,137 @@ pub async fn open_audio_transcriptions_proxy_request( }) } +pub async fn open_image_generations_proxy_request( + body: &[u8], + original_user_agent: Option<&str>, +) -> anyhow::Result { + open_image_proxy_request( + body, + "application/json", + original_user_agent, + ImageProxyEndpoint::Generations, + ) + .await +} + +pub async fn open_image_edits_proxy_request( + body: &[u8], + content_type: &str, + original_user_agent: Option<&str>, +) -> anyhow::Result { + open_image_proxy_request( + body, + content_type, + original_user_agent, + ImageProxyEndpoint::Edits, + ) + .await +} + +#[derive(Debug, Clone, Copy)] +enum ImageProxyEndpoint { + Generations, + Edits, +} + +impl ImageProxyEndpoint { + fn url(self, base_url: &str) -> String { + match self { + Self::Generations => image_generations_url(base_url), + Self::Edits => image_edits_url(base_url), + } + } + + fn wire_api(self) -> UpstreamWireApi { + match self { + Self::Generations => UpstreamWireApi::ImageGenerations, + Self::Edits => UpstreamWireApi::ImageEdits, + } + } + + fn name(self) -> &'static str { + match self { + Self::Generations => "image_generations", + Self::Edits => "image_edits", + } + } +} + +async fn open_image_proxy_request( + body: &[u8], + content_type: &str, + original_user_agent: Option<&str>, + endpoint_kind: ImageProxyEndpoint, +) -> anyhow::Result { + let settings = SettingsStore::default().load().unwrap_or_default(); + let relay = crate::relay_rotation::select_relay_for_probe(&settings)?; + let base_url = if relay.upstream_base_url.trim().is_empty() { + crate::relay_config::relay_profile_base_url(&relay) + } else { + relay.upstream_base_url.trim().to_string() + }; + if is_local_protocol_proxy_base_url(&base_url) { + anyhow::bail!("图片上游 Base URL 不能指向本地协议代理"); + } + if base_url.trim().is_empty() { + anyhow::bail!("图片上游 Base URL 不能为空"); + } + if relay.api_key.trim().is_empty() && !relay.uses_no_auth() { + anyhow::bail!("图片上游 Key 不能为空"); + } + let content_type = content_type.trim(); + let content_type = if content_type.is_empty() { + match endpoint_kind { + ImageProxyEndpoint::Generations => "application/json", + ImageProxyEndpoint::Edits => { + anyhow::bail!("图片 edits 请求缺少 Content-Type"); + } + } + } else { + content_type + }; + let endpoint = endpoint_kind.url(&base_url); + let wire_api = endpoint_kind.wire_api(); + let _ = crate::diagnostic_log::append_diagnostic_log( + "protocol_proxy.image_request", + json!({ + "relayId": relay.id, + "relayName": relay.name, + "endpoint": endpoint, + "wireApi": wire_api, + "bodyBytes": body.len(), + "endpointKind": endpoint_kind.name() + }), + ); + let request = crate::http_client::proxied_client(&effective_user_agent( + &relay.user_agent, + original_user_agent, + ))? + .post(endpoint) + .header(reqwest::header::CONTENT_TYPE, content_type) + .body(body.to_vec()); + let upstream = send_upstream_request_with_header_timeout( + with_relay_auth(request, &relay), + UPSTREAM_IMAGE_HEADER_TIMEOUT, + ) + .await?; + let status_code = upstream.status().as_u16(); + let content_type = upstream + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .unwrap_or("application/json; charset=utf-8") + .to_string(); + + Ok(UpstreamProxyResponse { + status_code, + is_stream: false, + content_type, + wire_api, + response: upstream, + }) +} + fn response_header_timeout(is_stream: bool) -> Duration { if is_stream { UPSTREAM_STREAM_HEADER_TIMEOUT @@ -1175,6 +1328,75 @@ pub fn audio_transcriptions_url(base_url: &str) -> String { url } +pub fn image_generations_url(base_url: &str) -> String { + image_endpoint_url(base_url, "generations") +} + +pub fn image_edits_url(base_url: &str) -> String { + image_endpoint_url(base_url, "edits") +} + +fn image_endpoint_url(base_url: &str, endpoint: &str) -> String { + let skip_version_prefix = base_url.trim().ends_with('#'); + let base = base_url.trim().trim_end_matches('#').trim_end_matches('/'); + if base + .to_ascii_lowercase() + .ends_with(&format!("/images/{endpoint}")) + { + return base.to_string(); + } + let origin_only = base + .split_once("://") + .map_or(!base.contains('/'), |(_, rest)| !rest.contains('/')); + let mut url = if skip_version_prefix || has_version_suffix(base) || !origin_only { + format!("{base}/images/{endpoint}") + } else { + format!("{base}/v1/images/{endpoint}") + }; + while url.contains("/v1/v1") { + url = url.replace("/v1/v1", "/v1"); + } + url +} + +fn is_local_protocol_proxy_base_url(base_url: &str) -> bool { + let Ok(url) = reqwest::Url::parse(base_url.trim()) else { + return false; + }; + if !url.scheme().eq_ignore_ascii_case("http") || url.port() != Some(DEFAULT_PROTOCOL_PROXY_PORT) + { + return false; + } + matches!(url.host_str(), Some("127.0.0.1" | "localhost" | "::1")) +} + +#[cfg(test)] +mod image_proxy_tests { + use super::is_local_protocol_proxy_base_url; + + #[test] + fn local_protocol_proxy_detection_covers_common_loopback_forms() { + for base_url in [ + "http://127.0.0.1:57321", + "http://127.0.0.1:57321/", + "http://127.0.0.1:57321/v1", + "http://localhost:57321/v1/", + "http://[::1]:57321/v1", + ] { + assert!(is_local_protocol_proxy_base_url(base_url), "{base_url}"); + } + + for base_url in [ + "https://127.0.0.1:57321/v1", + "http://127.0.0.1:57322/v1", + "http://api.example.test:57321/v1", + "not-a-url", + ] { + assert!(!is_local_protocol_proxy_base_url(base_url), "{base_url}"); + } + } +} + pub fn models_url(base_url: &str) -> String { let skip_version_prefix = base_url.trim().ends_with('#'); let mut base = base_url diff --git a/crates/codex-plus-core/tests/protocol_proxy.rs b/crates/codex-plus-core/tests/protocol_proxy.rs index 646a0622b..8e5a13eae 100644 --- a/crates/codex-plus-core/tests/protocol_proxy.rs +++ b/crates/codex-plus-core/tests/protocol_proxy.rs @@ -1,10 +1,12 @@ use codex_plus_core::protocol_proxy::{ ChatSseToResponsesConverter, audio_transcriptions_url, chat_completion_to_response, chat_completion_to_response_with_request, chat_completions_url, chat_sse_to_responses_sse, - chat_sse_to_responses_sse_with_request, is_audio_transcriptions_proxy_path, - is_chat_completions_proxy_path, is_models_proxy_path, is_responses_compact_proxy_path, + chat_sse_to_responses_sse_with_request, image_edits_url, image_generations_url, + is_audio_transcriptions_proxy_path, is_chat_completions_proxy_path, is_image_edits_proxy_path, + is_image_generations_proxy_path, is_models_proxy_path, is_responses_compact_proxy_path, is_responses_proxy_path, models_url, open_audio_transcriptions_proxy_request, - open_chat_completions_proxy_request, open_models_proxy_request, open_responses_proxy_request, + open_chat_completions_proxy_request, open_image_edits_proxy_request, + open_image_generations_proxy_request, open_models_proxy_request, open_responses_proxy_request, open_responses_proxy_request_with_settings, open_responses_proxy_request_with_settings_for_path, responses_compact_url, responses_error_from_upstream, responses_to_chat_completions, @@ -148,6 +150,25 @@ fn proxy_route_matchers_accept_ccswitch_codex_aliases() { ] { assert!(is_audio_transcriptions_proxy_path(path), "{path}"); } + + for path in [ + "/images/generations", + "/v1/images/generations", + "/v1/v1/images/generations", + "/codex/v1/images/generations", + ] { + assert!(is_image_generations_proxy_path(path), "{path}"); + } + for path in [ + "/images/edits", + "/v1/images/edits", + "/v1/v1/images/edits", + "/codex/v1/images/edits", + ] { + assert!(is_image_edits_proxy_path(path), "{path}"); + } + assert!(!is_image_generations_proxy_path("/images/unknown")); + assert!(!is_image_edits_proxy_path("/images/generation")); } #[test] @@ -1730,6 +1751,41 @@ fn audio_transcriptions_url_normalizes_common_base_urls() { ); } +#[test] +fn image_urls_normalize_common_base_urls() { + for base in [ + "https://api.example.test", + "https://api.example.test/", + "https://api.example.test/v1", + "https://api.example.test/v1/", + ] { + assert_eq!( + image_generations_url(base), + "https://api.example.test/v1/images/generations" + ); + assert_eq!( + image_edits_url(base), + "https://api.example.test/v1/images/edits" + ); + } + assert_eq!( + image_generations_url("https://api.example.test/v1/images/generations"), + "https://api.example.test/v1/images/generations" + ); + assert_eq!( + image_edits_url("https://api.example.test/v1/images/edits"), + "https://api.example.test/v1/images/edits" + ); + assert_eq!( + image_generations_url("https://api.example.test/openai"), + "https://api.example.test/openai/images/generations" + ); + assert_eq!( + image_generations_url("https://api.example.test/v1/v1"), + "https://api.example.test/v1/images/generations" + ); +} + #[test] fn models_url_normalizes_common_base_urls() { assert_eq!( @@ -2354,6 +2410,101 @@ async fn audio_transcriptions_proxy_forwards_multipart_body() { assert!(request.contains("abc")); } +#[tokio::test] +async fn image_generations_proxy_forwards_json_and_upstream_error() { + let _lock = settings_path_test_lock().lock().unwrap(); + let temp = tempfile::tempdir().unwrap(); + let _guard = SettingsPathGuard::set(temp.path().join("settings.json")); + let listener = tokio::net::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_async_http_request(&mut stream).await; + let body = br#"{"error":{"message":"rate limited"}}"#; + let response = format!( + "HTTP/1.1 429 Too Many Requests\r\nContent-Type: application/problem+json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ); + stream.write_all(response.as_bytes()).await.unwrap(); + stream.write_all(body).await.unwrap(); + request + }); + write_image_relay_settings(temp.path(), &format!("http://{addr}/v1")); + let body = br#"{"model":"gpt-image-2","prompt":"draw a square"}"#; + let upstream = open_image_generations_proxy_request(body, Some("Image-Client/1.0")) + .await + .unwrap(); + assert_eq!(upstream.status_code, 429); + assert_eq!(upstream.content_type, "application/problem+json"); + assert_eq!( + upstream.response.bytes().await.unwrap().as_ref(), + br#"{"error":{"message":"rate limited"}}"# + ); + let request = server.await.unwrap(); + let header_end = find_http_header_end(&request).unwrap(); + let headers = String::from_utf8_lossy(&request[..header_end]); + assert!(headers.starts_with("POST /v1/images/generations HTTP/1.1")); + assert!( + headers + .to_ascii_lowercase() + .contains("content-type: application/json") + ); + assert!( + headers + .to_ascii_lowercase() + .contains("authorization: bearer ") + ); + assert_eq!(&request[header_end + 4..], body); +} + +#[tokio::test] +async fn image_edits_proxy_preserves_multipart_body_and_content_type() { + let _lock = settings_path_test_lock().lock().unwrap(); + let temp = tempfile::tempdir().unwrap(); + let _guard = SettingsPathGuard::set(temp.path().join("settings.json")); + let listener = tokio::net::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_async_http_request(&mut stream).await; + let body = br#"{"error":{"message":"invalid image"}}"#; + let response = format!( + "HTTP/1.1 400 Bad Request\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ); + stream.write_all(response.as_bytes()).await.unwrap(); + stream.write_all(body).await.unwrap(); + request + }); + write_image_relay_settings(temp.path(), &format!("http://{addr}/v1/")); + let boundary = "codex-image-boundary"; + let mut body = format!( + "--{boundary}\r\nContent-Disposition: form-data; name=\"prompt\"\r\n\r\nmake it blue\r\n--{boundary}\r\nContent-Disposition: form-data; name=\"image\"; filename=\"input.png\"\r\nContent-Type: image/png\r\n\r\n" + ) + .into_bytes(); + body.extend_from_slice(&[0x89, b'P', b'N', b'G', 0x00, 0xff]); + body.extend_from_slice(format!("\r\n--{boundary}--\r\n").as_bytes()); + let content_type = format!("multipart/form-data; boundary={boundary}"); + let upstream = open_image_edits_proxy_request(&body, &content_type, None) + .await + .unwrap(); + assert_eq!(upstream.status_code, 400); + let request = server.await.unwrap(); + let header_end = find_http_header_end(&request).unwrap(); + let headers = String::from_utf8_lossy(&request[..header_end]); + assert!(headers.starts_with("POST /v1/images/edits HTTP/1.1")); + assert!( + headers + .to_ascii_lowercase() + .contains("content-type: multipart/form-data; boundary=codex-image-boundary") + ); + assert_eq!(&request[header_end + 4..], body.as_slice()); +} + #[tokio::test] async fn chat_completions_proxy_uses_configured_user_agent() { let _lock = settings_path_test_lock().lock().unwrap(); @@ -2513,6 +2664,26 @@ fn write_chat_relay_settings(settings_dir: &Path, base_url: &str, user_agent: &s .unwrap(); } +fn write_image_relay_settings(settings_dir: &Path, upstream_base_url: &str) { + let settings = json!({ + "relayProfiles": [{ + "id": "images", + "name": "Images", + "baseUrl": upstream_base_url, + "upstreamBaseUrl": upstream_base_url, + "apiKey": "sk-test", + "protocol": "responses", + "relayMode": "mixedApi" + }], + "activeRelayId": "images" + }); + std::fs::write( + settings_dir.join("settings.json"), + serde_json::to_vec_pretty(&settings).unwrap(), + ) + .unwrap(); +} + fn write_no_auth_relay_settings(settings_dir: &Path, base_url: &str, protocol: &str) { let settings = json!({ "relayProfiles": [{ @@ -2556,6 +2727,41 @@ impl Drop for SettingsPathGuard { } } +fn find_http_header_end(buffer: &[u8]) -> Option { + buffer.windows(4).position(|window| window == b"\r\n\r\n") +} + +async fn read_async_http_request(stream: &mut tokio::net::TcpStream) -> Vec { + let mut request = Vec::new(); + let mut buffer = [0_u8; 4096]; + let mut expected_len = None; + loop { + let read = stream.read(&mut buffer).await.unwrap(); + assert!(read > 0, "upstream request ended before body completed"); + request.extend_from_slice(&buffer[..read]); + if expected_len.is_none() + && let Some(header_end) = find_http_header_end(&request) + { + let headers = String::from_utf8_lossy(&request[..header_end]); + let content_length = headers + .lines() + .find_map(|line| { + line.split_once(':').and_then(|(name, value)| { + name.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse::().ok()) + .flatten() + }) + }) + .unwrap_or(0); + expected_len = Some(header_end + 4 + content_length); + } + if expected_len.is_some_and(|length| request.len() >= length) { + break; + } + } + request +} + struct ChatServer { base_url: String, handle: thread::JoinHandle, From eec02ba8b129f99fb899abe528ba858739d88794 Mon Sep 17 00:00:00 2001 From: Materal Date: Fri, 4 Sep 2026 15:47:22 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20=E5=BB=B6=E9=95=BF=E5=9B=BE=E7=89=87?= =?UTF-8?q?=E8=AF=B7=E6=B1=82=E8=B6=85=E6=97=B6=E5=B9=B6=E5=AE=8C=E5=96=84?= =?UTF-8?q?=E5=9B=9E=E7=8E=AF=E6=A3=80=E6=B5=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/codex-plus-core/src/protocol_proxy.rs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/crates/codex-plus-core/src/protocol_proxy.rs b/crates/codex-plus-core/src/protocol_proxy.rs index ccb1c9af4..bd5d1fcfc 100644 --- a/crates/codex-plus-core/src/protocol_proxy.rs +++ b/crates/codex-plus-core/src/protocol_proxy.rs @@ -17,7 +17,7 @@ pub const NO_AUTH_PROXY_BEARER_TOKEN: &str = "codex-plus-no-auth"; const UPSTREAM_CONNECT_TIMEOUT: Duration = Duration::from_secs(5); const UPSTREAM_HEADER_TIMEOUT: Duration = Duration::from_secs(30); const UPSTREAM_STREAM_HEADER_TIMEOUT: Duration = Duration::from_secs(120); -const UPSTREAM_IMAGE_HEADER_TIMEOUT: Duration = Duration::from_secs(300); +const UPSTREAM_IMAGE_HEADER_TIMEOUT: Duration = Duration::from_secs(600); const THINK_OPEN_TAG: &str = ""; const THINK_CLOSE_TAG: &str = ""; const EXTRA_CHAT_PASSTHROUGH_FIELDS: &[&str] = &[ @@ -1367,12 +1367,22 @@ fn is_local_protocol_proxy_base_url(base_url: &str) -> bool { { return false; } - matches!(url.host_str(), Some("127.0.0.1" | "localhost" | "::1")) + matches!( + url.host_str(), + Some("127.0.0.1" | "localhost" | "::1" | "[::1]") + ) } #[cfg(test)] mod image_proxy_tests { + use super::UPSTREAM_IMAGE_HEADER_TIMEOUT; use super::is_local_protocol_proxy_base_url; + use std::time::Duration; + + #[test] + fn image_requests_allow_ten_minutes_for_response_headers() { + assert_eq!(UPSTREAM_IMAGE_HEADER_TIMEOUT, Duration::from_secs(600)); + } #[test] fn local_protocol_proxy_detection_covers_common_loopback_forms() { From 6583027501c0d9c580598ee851cece208dd1bf58 Mon Sep 17 00:00:00 2001 From: Materal Date: Fri, 4 Sep 2026 16:50:49 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20=E7=9B=B4=E6=8E=A5=E8=AF=BB=E5=8F=96?= =?UTF-8?q?=E5=9B=BE=E7=89=87=E4=B8=8A=E6=B8=B8=E5=93=8D=E5=BA=94=E7=8A=B6?= =?UTF-8?q?=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/codex-plus-core/src/launcher.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/codex-plus-core/src/launcher.rs b/crates/codex-plus-core/src/launcher.rs index dd3fc69f6..7f50ddf4b 100644 --- a/crates/codex-plus-core/src/launcher.rs +++ b/crates/codex-plus-core/src/launcher.rs @@ -1835,8 +1835,8 @@ async fn handle_image_proxy_connection( return Ok(()); } }; - let status = upstream.status(); - let is_success = upstream.is_success(); + let status = upstream.response.status().to_string(); + let is_success = upstream.response.status().is_success(); let content_type = if upstream.content_type.is_empty() { "application/json; charset=utf-8".to_string() } else {