diff --git a/backend/crates/gateway-api/Cargo.toml b/backend/crates/gateway-api/Cargo.toml index 1b77e5779..76348eb3d 100644 --- a/backend/crates/gateway-api/Cargo.toml +++ b/backend/crates/gateway-api/Cargo.toml @@ -15,8 +15,8 @@ path = "tests/main.rs" axum = { version = "0.8.9", default-features = false, features = ["json", "query", "ws"] } base64 = "0.23.0" bytes = "1.11.1" -flate2 = "1.1.9" chrono = { version = "0.4.45", features = ["serde"] } +flate2 = "1.1.9" futures = "0.3.32" gateway-admin = { path = "../gateway-admin" } gateway-core = { path = "../gateway-core" } diff --git a/backend/crates/gateway-api/src/openai/responses/error.rs b/backend/crates/gateway-api/src/openai/responses/error.rs index feadc1cd8..a6998519b 100644 --- a/backend/crates/gateway-api/src/openai/responses/error.rs +++ b/backend/crates/gateway-api/src/openai/responses/error.rs @@ -40,9 +40,6 @@ pub enum RequestDecodeError { /// 请求不是合法 JSON。 #[error("request body must be valid JSON")] MalformedJson, - /// `Content-Encoding` 指示的压缩正文无法解压。 - #[error("request body could not be decompressed")] - MalformedContentEncoding, /// 请求体解压后超过允许上限。 #[error("decompressed request body is too large")] DecompressedBodyTooLarge, @@ -106,7 +103,7 @@ impl RequestDecodeError { #[must_use] pub fn protocol_body(&self) -> ProtocolErrorBody { let (code, message, param) = match self { - Self::MalformedJson | Self::MalformedContentEncoding => ( + Self::MalformedJson => ( "invalid_json", "Request body must be valid JSON.".to_owned(), None, diff --git a/backend/crates/gateway-api/src/openai/responses/request.rs b/backend/crates/gateway-api/src/openai/responses/request.rs index 53ec3f0c8..1f5b32847 100644 --- a/backend/crates/gateway-api/src/openai/responses/request.rs +++ b/backend/crates/gateway-api/src/openai/responses/request.rs @@ -1,6 +1,6 @@ //! OpenAI Responses JSON 到 Core 路由事实与不透明 wire payload 的单一解码边界。 -use std::{fmt, net::IpAddr}; +use std::{borrow::Cow, fmt, net::IpAddr}; use axum::http::HeaderMap; use base64::{Engine as _, engine::general_purpose::STANDARD}; @@ -323,7 +323,7 @@ impl fmt::Debug for DecodedResponsesRequest { /// /// # Errors /// -/// JSON 非法、顶层不是 object,或网关必须解释的路由字段无效时返回安全错误。 +/// 编码不支持、压缩正文损坏或超限、JSON 非法,或路由字段无效时返回安全错误。 pub fn decode_request_with_headers( body: &[u8], headers: &HeaderMap, @@ -337,36 +337,40 @@ const MAX_DECOMPRESSED_REQUEST_BYTES: usize = 64 * 1024 * 1024; /// 按 `Content-Encoding` 解压下游请求体。 /// -/// 官方 Codex 客户端对较大的 `/v1/responses` 请求体默认 zstd 压缩,亦可能使用 -/// gzip;未压缩与 `identity` 原样透传。解压结果超过 -/// [`MAX_DECOMPRESSED_REQUEST_BYTES`] 时拒绝,避免解压炸弹。 -fn decompress_request_body( - body: &[u8], +/// 未压缩与 `identity` 借用原始正文;压缩正文在读取过程中限制展开大小, +/// 避免先完整分配再检查。只接受单一编码,重复头和叠加编码不能只解释第一项。 +fn decompress_request_body<'a>( + body: &'a [u8], headers: &HeaderMap, -) -> Result, RequestDecodeError> { - let Some(value) = headers.get(axum::http::header::CONTENT_ENCODING) else { - return Ok(body.to_vec()); - }; - let encoding = value - .to_str() - .map_err(|_| RequestDecodeError::MalformedJson)?; +) -> Result, RequestDecodeError> { + let encoding = headers + .get_all(axum::http::header::CONTENT_ENCODING) + .iter() + .map(|value| { + value + .to_str() + .map_err(|_| RequestDecodeError::MalformedJson) + }) + .collect::, _>>()? + .join(","); match encoding.trim().to_ascii_lowercase().as_str() { - "" | "identity" => Ok(body.to_vec()), + "" | "identity" => Ok(Cow::Borrowed(body)), "gzip" => { - let decoder = flate2::read::GzDecoder::new(body); - read_bounded(decoder) + let decoder = flate2::read::MultiGzDecoder::new(body); + read_bounded(decoder).map(Cow::Owned) } "deflate" => { let decoder = flate2::read::ZlibDecoder::new(body); - read_bounded(decoder) + read_bounded(decoder).map(Cow::Owned) } "zstd" => { - let decoded = zstd::stream::decode_all(std::io::Cursor::new(body)) + let mut decoder = zstd::stream::read::Decoder::with_buffer(body) .map_err(|_| RequestDecodeError::MalformedJson)?; - if decoded.len() > MAX_DECOMPRESSED_REQUEST_BYTES { - return Err(RequestDecodeError::DecompressedBodyTooLarge); - } - Ok(decoded) + // 输出有界之外,也限制压缩帧声明的回溯窗口,防止解码器内部过量分配。 + decoder + .window_log_max(MAX_DECOMPRESSED_REQUEST_BYTES.ilog2()) + .map_err(|_| RequestDecodeError::MalformedJson)?; + read_bounded(decoder).map(Cow::Owned) } other => Err(RequestDecodeError::UnsupportedContentEncoding { encoding: other.to_owned(), @@ -375,17 +379,31 @@ fn decompress_request_body( } fn read_bounded(mut reader: R) -> Result, RequestDecodeError> { - use std::io::Read; let mut decoded = Vec::new(); - reader - .by_ref() - .take((MAX_DECOMPRESSED_REQUEST_BYTES + 1) as u64) - .read_to_end(&mut decoded) - .map_err(|_| RequestDecodeError::MalformedJson)?; - if decoded.len() > MAX_DECOMPRESSED_REQUEST_BYTES { - return Err(RequestDecodeError::DecompressedBodyTooLarge); + let mut chunk = [0; 8192]; + loop { + let remaining = MAX_DECOMPRESSED_REQUEST_BYTES - decoded.len(); + let read_limit = chunk.len().min(remaining + 1); + let read = reader + .read(&mut chunk[..read_limit]) + .map_err(|_| RequestDecodeError::MalformedJson)?; + if read == 0 { + return Ok(decoded); + } + if read > remaining { + return Err(RequestDecodeError::DecompressedBodyTooLarge); + } + // 默认 Vec 扩容和 read_to_end 的 EOF 探测可能突破输出上限; + // 容量增长也受相同边界约束,越界探测只使用栈上分块缓冲区。 + let required = decoded.len() + read; + if required > decoded.capacity() { + let capacity = (decoded.capacity() * 2) + .max(required) + .min(MAX_DECOMPRESSED_REQUEST_BYTES); + decoded.reserve_exact(capacity - decoded.len()); + } + decoded.extend_from_slice(&chunk[..read]); } - Ok(decoded) } pub(super) fn decode_request_inner( diff --git a/backend/crates/gateway-api/tests/architecture.rs b/backend/crates/gateway-api/tests/architecture.rs index 6a549410d..3a2927e49 100644 --- a/backend/crates/gateway-api/tests/architecture.rs +++ b/backend/crates/gateway-api/tests/architecture.rs @@ -112,6 +112,7 @@ fn test_tree_should_match_frozen_rust_mirror() { "tests/openai/endpoint.rs", "tests/openai/responses/http.rs", "tests/openai/responses/mod.rs", + "tests/openai/responses/request.rs", "tests/openai/responses/websocket/connection.rs", "tests/openai/responses/websocket/mod.rs", "tests/openai/responses/websocket/protocol.rs", diff --git a/backend/crates/gateway-api/tests/openai/responses/http.rs b/backend/crates/gateway-api/tests/openai/responses/http.rs index 775cef024..83ac98023 100644 --- a/backend/crates/gateway-api/tests/openai/responses/http.rs +++ b/backend/crates/gateway-api/tests/openai/responses/http.rs @@ -1,4 +1,5 @@ use std::collections::VecDeque; +use std::io::Read; use std::net::{IpAddr, SocketAddr}; use std::sync::{ Arc, Mutex, @@ -11,7 +12,7 @@ use axum::{ extract::connect_info::ConnectInfo, http::{ HeaderMap, HeaderValue, Request, StatusCode, - header::{AUTHORIZATION, CONTENT_TYPE}, + header::{AUTHORIZATION, CONTENT_ENCODING, CONTENT_LENGTH, CONTENT_TYPE}, }, }; use bytes::Bytes; @@ -451,9 +452,26 @@ async fn captured_client_context( async fn captured_http_request( provider_name: &str, body: Value, - mut headers: HeaderMap, + headers: HeaderMap, peer_address: Option, ) -> CapturedClientContext { + let (response, observed) = http_request_with_body( + provider_name, + body.to_string().into(), + headers, + peer_address, + ) + .await; + assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); + observed.expect("captured request context") +} + +async fn http_request_with_body( + provider_name: &str, + body: Bytes, + mut headers: HeaderMap, + peer_address: Option, +) -> (axum::response::Response, Option) { headers.insert( AUTHORIZATION, "Bearer sk_context_test".parse().expect("authorization"), @@ -464,7 +482,7 @@ async fn captured_http_request( client: authenticated_client_for_provider("sk_context_test", provider_name), }); let mut request = Request::post("/v1/responses") - .body(Body::from(body.to_string())) + .body(Body::from(body)) .expect("context request"); *request.headers_mut() = headers; if let Some(peer_address) = peer_address { @@ -475,12 +493,75 @@ async fn captured_http_request( .oneshot(request) .await .expect("context response"); - assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); - observed - .lock() - .expect("context capture lock") - .clone() - .expect("captured request context") + let observed = observed.lock().expect("context capture lock").clone(); + (response, observed) +} + +#[tokio::test] +async fn compressed_http_requests_should_preserve_execution_context_without_transport_headers() { + let body = json!({ + "model": "model-a", "input": "hello", "previous_response_id": "resp_previous", + "prompt_cache_key": "cache-key", "client_metadata": {"source": "test"} + }); + let mut headers = HeaderMap::new(); + headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); + headers.insert("session_id", HeaderValue::from_static("session-test")); + let expected = captured_http_request("openai", body.clone(), headers.clone(), None).await; + for encoding in ["gzip", "deflate", "zstd"] { + let compressed = super::request::encode_body(encoding, body.to_string().as_bytes()); + let mut headers = headers.clone(); + headers.insert(CONTENT_ENCODING, HeaderValue::from_static(encoding)); + headers.insert( + CONTENT_LENGTH, + HeaderValue::from_str(&compressed.len().to_string()).expect("compressed length"), + ); + let (response, observed) = + http_request_with_body("openai", compressed.into(), headers, None).await; + // 捕获执行器在解码成功后主动返回 500;正文和业务头应与未压缩请求一致, + // 压缩编码及长度不能泄漏到已解压正文的上游协议上下文。 + assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); + assert_eq!(observed.as_ref(), Some(&expected), "{encoding}"); + } +} + +#[tokio::test] +async fn invalid_compressed_http_requests_should_fail_before_execution() { + for (encoding, body, code, message) in [ + ( + "gzip", + Bytes::from_static(b"broken gzip"), + "invalid_json", + "Request body must be valid JSON.", + ), + ( + "br", + Bytes::from_static(b"{}"), + "unsupported_content_encoding", + "Content-Encoding `br` is not supported.", + ), + ( + "zstd", + super::request::encode_body("zstd", std::io::repeat(0).take(64 * 1024 * 1024 + 1)) + .into(), + "request_too_large", + "Decompressed request body exceeds the allowed size.", + ), + ] { + let headers = HeaderMap::from_iter([ + (CONTENT_ENCODING, HeaderValue::from_static(encoding)), + (CONTENT_TYPE, HeaderValue::from_static("application/json")), + ]); + let (response, observed) = http_request_with_body("openai", body, headers, None).await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{encoding}"); + assert!(observed.is_none(), "invalid body must not start execution"); + let body = to_bytes(response.into_body(), 4096) + .await + .expect("error body"); + assert_eq!( + serde_json::from_slice::(&body).expect("error JSON"), + json!({"error": {"type": "invalid_request_error", "code": code, "message": message}}) + ); + } } async fn captured_http_compaction(provider_name: &str) -> CapturedClientContext { diff --git a/backend/crates/gateway-api/tests/openai/responses/mod.rs b/backend/crates/gateway-api/tests/openai/responses/mod.rs index 69483a661..95e9895b0 100644 --- a/backend/crates/gateway-api/tests/openai/responses/mod.rs +++ b/backend/crates/gateway-api/tests/openai/responses/mod.rs @@ -1,4 +1,5 @@ mod http; +mod request; mod websocket; use axum::http::{HeaderMap, HeaderValue}; @@ -388,7 +389,6 @@ fn decoder_should_exclude_downstream_transport_headers_from_opaque_context() { "x-forwarded-for", "x-forwarded-prefix", "accept-encoding", - "content-encoding", ] { headers.insert(name, HeaderValue::from_static("downstream-only")); } @@ -397,6 +397,8 @@ fn decoder_should_exclude_downstream_transport_headers_from_opaque_context() { HeaderValue::from_static(r#"{"scheme":"https"}"#), ); headers.insert("accept-encoding", HeaderValue::from_static("br, gzip")); + // 正文未压缩;入口现在消费 Content-Encoding,但仍不得向上游透传。 + headers.insert("content-encoding", HeaderValue::from_static("identity")); headers.insert("x-openai-future-mode", HeaderValue::from_static("keep")); let decoded = @@ -715,89 +717,3 @@ fn transparent_encoder_should_only_require_wire_terminal_for_buffered_conversion ResponseEncodeError::MissingWireTerminal ); } - -fn gzip_bytes(data: &[u8]) -> Vec { - use std::io::Write as _; - let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); - encoder.write_all(data).expect("gzip encode"); - encoder.finish().expect("gzip finish") -} - -fn zstd_bytes(data: &[u8]) -> Vec { - zstd::stream::encode_all(std::io::Cursor::new(data), 3).expect("zstd encode") -} - -fn content_encoding_headers(encoding: &str) -> HeaderMap { - let mut headers = HeaderMap::new(); - headers.insert( - axum::http::header::CONTENT_ENCODING, - HeaderValue::from_str(encoding).expect("valid header value"), - ); - headers -} - -#[test] -fn http_decode_accepts_gzip_request_body() { - let body = json!({"model": "gpt-test", "input": []}).to_string(); - let decoded = decode_request_with_headers( - gzip_bytes(body.as_bytes()).as_slice(), - &content_encoding_headers("gzip"), - ) - .expect("gzip body should decode"); - assert_eq!(decoded.metadata().requested_model().to_string(), "gpt-test"); -} - -#[test] -fn http_decode_accepts_zstd_request_body() { - let body = json!({"model": "gpt-test", "input": []}).to_string(); - let decoded = decode_request_with_headers( - zstd_bytes(body.as_bytes()).as_slice(), - &content_encoding_headers("zstd"), - ) - .expect("zstd body should decode"); - assert_eq!(decoded.metadata().requested_model().to_string(), "gpt-test"); -} - -#[test] -fn http_decode_treats_identity_encoding_as_plain_json() { - let body = json!({"model": "gpt-test", "input": []}).to_string(); - let decoded = - decode_request_with_headers(body.as_bytes(), &content_encoding_headers("identity")) - .expect("identity body should decode"); - assert_eq!(decoded.metadata().requested_model().to_string(), "gpt-test"); -} - -#[test] -fn http_decode_rejects_unsupported_content_encoding() { - let body = json!({"model": "gpt-test", "input": []}).to_string(); - let error = decode_request_with_headers(body.as_bytes(), &content_encoding_headers("br")) - .expect_err("unsupported encoding must fail"); - assert_eq!( - error, - RequestDecodeError::UnsupportedContentEncoding { - encoding: "br".to_owned() - } - ); -} - -#[test] -fn http_decode_rejects_corrupted_compressed_body() { - let error = decode_request_with_headers( - b"not-a-valid-gzip-stream", - &content_encoding_headers("gzip"), - ) - .expect_err("corrupted gzip body must fail"); - // 损坏的压缩载荷统一按 invalid_json 返回,不区分内部解压细节。 - assert_eq!(error, RequestDecodeError::MalformedJson); -} - -#[test] -fn http_decode_rejects_decompression_bomb() { - // 高压缩比载荷:解压后超过 64 MiB 上限时必须拒绝。 - let bomb = vec![0u8; 128 * 1024 * 1024]; - let compressed = zstd_bytes(&bomb); - let error = - decode_request_with_headers(compressed.as_slice(), &content_encoding_headers("zstd")) - .expect_err("decompression bomb must fail"); - assert_eq!(error, RequestDecodeError::DecompressedBodyTooLarge); -} diff --git a/backend/crates/gateway-api/tests/openai/responses/request.rs b/backend/crates/gateway-api/tests/openai/responses/request.rs new file mode 100644 index 000000000..f0ba6480d --- /dev/null +++ b/backend/crates/gateway-api/tests/openai/responses/request.rs @@ -0,0 +1,174 @@ +use std::io::{Read, Write, copy, repeat}; + +use axum::http::{HeaderMap, HeaderValue, header::CONTENT_ENCODING}; +use gateway_api::openai::responses::{RequestDecodeError, decode_request_with_headers}; +use serde_json::json; + +use super::openai_wire_body; + +const ENCODINGS: [&str; 3] = ["gzip", "deflate", "zstd"]; +const REQUEST: &[u8] = br#"{"model":"gpt-test","input":"hello","future_field":{"value":1}}"#; +const DECOMPRESSED_LIMIT: u64 = 64 * 1024 * 1024; + +pub(super) fn encode_body(encoding: &str, mut data: impl Read) -> Vec { + match encoding { + "gzip" => { + let mut encoder = + flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast()); + copy(&mut data, &mut encoder).expect("gzip encode"); + encoder.finish().expect("gzip finish") + } + "deflate" => { + let mut encoder = + flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::fast()); + copy(&mut data, &mut encoder).expect("deflate encode"); + encoder.finish().expect("deflate finish") + } + "zstd" => zstd::stream::encode_all(data, 3).expect("zstd encode"), + _ => panic!("unsupported fixture encoding"), + } +} + +fn headers(encoding: &str) -> HeaderMap { + HeaderMap::from_iter([( + CONTENT_ENCODING, + HeaderValue::from_str(encoding).expect("encoding header"), + )]) +} + +#[test] +fn http_decode_should_preserve_compressed_request_fields() { + let expected = serde_json::from_slice::(REQUEST).expect("fixture JSON"); + for encoding in ENCODINGS { + let compressed = encode_body(encoding, REQUEST); + let decoded = decode_request_with_headers(&compressed, &headers(encoding)).expect(encoding); + assert_eq!( + openai_wire_body(&decoded), + expected.as_object().expect("object") + ); + } +} + +#[test] +fn http_decode_should_accept_plain_identity_and_case_insensitive_encodings() { + for request_headers in [HeaderMap::new(), headers(""), headers(" identity ")] { + assert!(decode_request_with_headers(REQUEST, &request_headers).is_ok()); + } + let compressed = encode_body("gzip", REQUEST); + assert!(decode_request_with_headers(&compressed, &headers(" GZip ")).is_ok()); +} + +#[test] +fn http_decode_should_reject_unknown_and_stacked_encodings() { + let mut repeated = headers("identity"); + repeated.append(CONTENT_ENCODING, HeaderValue::from_static("gzip")); + for request_headers in [headers("br"), headers("gzip, zstd"), repeated] { + let error = decode_request_with_headers(REQUEST, &request_headers) + .expect_err("unsupported encoding"); + assert!(matches!( + error, + RequestDecodeError::UnsupportedContentEncoding { .. } + )); + assert_eq!( + error.protocol_body().into_value()["error"]["code"], + "unsupported_content_encoding" + ); + } +} + +#[test] +fn http_decode_should_reject_corrupted_and_truncated_compressed_bodies() { + for encoding in ENCODINGS { + let mut truncated = encode_body(encoding, REQUEST); + truncated.truncate(truncated.len() - 4); + for body in [b"not-a-compressed-stream".as_slice(), truncated.as_slice()] { + let error = decode_request_with_headers(body, &headers(encoding)).expect_err(encoding); + assert_eq!( + error.protocol_body().into_value(), + json!({"error": { + "type": "invalid_request_error", "code": "invalid_json", + "message": "Request body must be valid JSON." + }}) + ); + } + } +} + +#[test] +fn http_decode_should_read_all_gzip_members_and_zstd_frames() { + for encoding in ["gzip", "zstd"] { + let mut compressed = encode_body(encoding, &REQUEST[..12]); + compressed.extend(encode_body(encoding, &REQUEST[12..])); + let decoded = decode_request_with_headers(&compressed, &headers(encoding)).expect(encoding); + assert_eq!(openai_wire_body(&decoded)["input"], "hello"); + } +} + +#[test] +fn http_decode_should_not_ignore_invalid_trailing_members() { + for encoding in ["gzip", "zstd"] { + let mut compressed = encode_body(encoding, REQUEST); + compressed.extend(encode_body(encoding, b"invalid trailing JSON".as_slice())); + assert_eq!( + decode_request_with_headers(&compressed, &headers(encoding)).expect_err(encoding), + RequestDecodeError::MalformedJson + ); + } +} + +#[test] +fn http_decode_should_reject_zstd_frames_with_an_excessive_window() { + let mut encoder = zstd::stream::write::Encoder::new(Vec::new(), 3).expect("zstd encoder"); + encoder.window_log(27).expect("128 MiB window"); + encoder.write_all(REQUEST).expect("write JSON"); + // 提前输出帧头,避免编码器根据最终小正文缩减窗口。 + encoder.flush().expect("flush frame header"); + let compressed = encoder.finish().expect("finish frame"); + assert_eq!( + zstd::stream::decode_all(compressed.as_slice()).expect("valid zstd frame"), + REQUEST + ); + assert_eq!( + decode_request_with_headers(&compressed, &headers("zstd")).expect_err("oversized window"), + RequestDecodeError::MalformedJson + ); +} + +#[test] +fn http_decode_should_accept_the_exact_decompressed_limit() { + let padding = repeat(b' ').take(DECOMPRESSED_LIMIT - REQUEST.len() as u64); + let compressed = encode_body("zstd", REQUEST.chain(padding)); + let decoded = decode_request_with_headers(&compressed, &headers("zstd")).expect("exact limit"); + assert_eq!(decoded.metadata().requested_model(), "gpt-test"); +} + +#[test] +fn http_decode_should_reject_bodies_above_the_decompressed_limit() { + for encoding in ENCODINGS { + let compressed = encode_body(encoding, repeat(0).take(DECOMPRESSED_LIMIT + 1)); + let error = + decode_request_with_headers(&compressed, &headers(encoding)).expect_err(encoding); + assert_eq!( + error, + RequestDecodeError::DecompressedBodyTooLarge, + "{encoding}" + ); + } +} + +#[test] +fn http_decode_should_stop_at_limit_before_reading_invalid_trailing_frames() { + for encoding in ["zstd", "gzip"] { + // 尾部故意损坏:若先完整解压再检查长度,就会错误地返回 invalid_json, + // 这个断言验证越界时已经停止读取,而不只是最终返回了某个超限错误。 + let mut compressed = encode_body(encoding, repeat(0).take(DECOMPRESSED_LIMIT + 1)); + compressed.extend_from_slice(b"invalid trailing frame"); + let error = + decode_request_with_headers(&compressed, &headers(encoding)).expect_err(encoding); + assert_eq!( + error, + RequestDecodeError::DecompressedBodyTooLarge, + "{encoding}" + ); + } +} diff --git a/docs/api.md b/docs/api.md index 74ceee5d9..980426ab5 100644 --- a/docs/api.md +++ b/docs/api.md @@ -113,8 +113,8 @@ body 不进入这个通用信封。稳定业务码如下: ## 3. OpenAI 数据面与模型目录 -Responses、Images 和 standalone Search HTTP body、WebSocket message 和 frame 不设置网关私有长度上限; -协议可接受性由上游决定。 +除下述 Responses 入站解压保护外,Responses、Images 和 standalone Search HTTP body、 +WebSocket message 和 frame 不设置网关私有长度上限;协议可接受性由上游决定。 | 方法 | 路由 | 说明 | | --- | --- | --- | @@ -129,6 +129,15 @@ Responses、Images 和 standalone Search HTTP body、WebSocket message 和 frame Codex 的 review 等子代理请求仍使用 `/v1/responses`,并通过 `x-openai-subagent` 请求头携带子代理类型; 网关不提供独立的子代理请求路径。 +`POST /v1/responses` 在鉴权后按 `Content-Encoding` 解压,再解析 JSON;支持单一 `gzip`、 +`deflate`(zlib 封装)和 `zstd`,缺省、空值或 `identity` 直接使用原始正文。gzip 多成员与 zstd +多帧连续解码,整体展开结果最多 64 MiB,超限在继续展开前返回 `400 request_too_large`;zstd +回溯窗口同样最多 64 MiB,不能满足该限制的帧按解码失败处理。这个限制保护入站解压资源,不是 +模型上下文或 Token 上限,也不新增未压缩正文的长度限制。 +不支持的编码、逗号分隔的叠加编码和重复 `Content-Encoding` 头返回 +`400 unsupported_content_encoding`;压缩正文损坏、截断或解压后不是合法 JSON 返回 +`400 invalid_json`。本地错误不包含原始正文或解压库细节。WebSocket 文本帧不经过这条解压路径。 + Responses 不透传下游的逐跳头、反代元数据(如 `cf-*`、`x-forwarded-*`、`forwarded`、`via`、 `cdn-loop`)以及 `Accept-Encoding` / `Content-Encoding`。链路元数据和编解码能力 由各段传输层独立管理;其余业务扩展头继续透传,不使用固定业务头白名单。