fix(settings): make Responses decompression limit configurable (#141)

Persist the output limit in runtime settings and use the authenticated snapshot for each compressed Responses request. Expose the setting in MiB and preserve bounded decoding and the independent zstd window limit.

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Zyy
2026-09-17 15:29:14 +08:00
committed by GitHub
co-authored by Zyy Codex
parent 0f2e0b18a9
commit 06e94ef69b
29 changed files with 425 additions and 52 deletions
@@ -28,6 +28,7 @@ pub struct RuntimeSettings {
pub max_waiting_per_key: u32,
pub max_waiting_per_account: u32,
pub concurrency_wait_timeout_seconds: u32,
pub responses_max_decompressed_body_bytes: u64,
pub rotation_strategy: RotationStrategy,
pub min_codex_desktop_version: Option<String>,
pub min_codex_cli_version: Option<String>,
@@ -57,6 +58,7 @@ pub struct ReplaceRuntimeSettings {
pub max_waiting_per_key: u32,
pub max_waiting_per_account: u32,
pub concurrency_wait_timeout_seconds: u32,
pub responses_max_decompressed_body_bytes: u64,
pub rotation_strategy: RotationStrategy,
pub min_codex_desktop_version: Option<String>,
pub min_codex_cli_version: Option<String>,
@@ -115,6 +115,8 @@ impl SettingsService for DefaultSettingsService {
fn validate_settings(command: &ReplaceRuntimeSettings) -> Result<(), AdminError> {
let valid = command.request_location.validate().is_ok()
&& command.responses_max_decompressed_body_bytes > 0
&& isize::try_from(command.responses_max_decompressed_body_bytes).is_ok()
&& command.refresh_margin_seconds > 0
&& command.refresh_concurrency > 0
&& command.max_concurrent_per_account > 0
@@ -984,6 +984,7 @@ impl SettingsStore for StaticSettingsStore {
max_waiting_per_key: 0,
max_waiting_per_account: 0,
concurrency_wait_timeout_seconds: 30,
responses_max_decompressed_body_bytes: 64 * 1024 * 1024,
rotation_strategy: RotationStrategy::Smart,
min_codex_desktop_version: None,
min_codex_cli_version: None,
@@ -36,6 +36,7 @@ fn runtime_settings(enabled: bool, probe_enabled: bool, adaptive: bool) -> Runti
max_waiting_per_key: 0,
max_waiting_per_account: 0,
concurrency_wait_timeout_seconds: 30,
responses_max_decompressed_body_bytes: 64 * 1024 * 1024,
rotation_strategy: gateway_admin::model::settings::RotationStrategy::Smart,
min_codex_desktop_version: None,
min_codex_cli_version: None,
@@ -764,6 +764,7 @@ impl SettingsStore for FixtureSettingsStore {
max_waiting_per_key: 0,
max_waiting_per_account: 0,
concurrency_wait_timeout_seconds: 30,
responses_max_decompressed_body_bytes: 64 * 1024 * 1024,
rotation_strategy: RotationStrategy::Smart,
min_codex_desktop_version: None,
min_codex_cli_version: None,
@@ -71,6 +71,7 @@ async fn settings_should_reject_zero_refresh_margin_before_store_call() {
max_waiting_per_key: 0,
max_waiting_per_account: 0,
concurrency_wait_timeout_seconds: 30,
responses_max_decompressed_body_bytes: 64 * 1024 * 1024,
rotation_strategy: RotationStrategy::Smart,
min_codex_desktop_version: None,
min_codex_cli_version: None,
@@ -44,6 +44,7 @@ pub struct RuntimeSettingsView {
pub max_waiting_per_key: u32,
pub max_waiting_per_account: u32,
pub concurrency_wait_timeout_seconds: u32,
pub responses_max_decompressed_body_bytes: u64,
pub rotation_strategy: String,
pub min_codex_desktop_version: Option<String>,
pub min_codex_cli_version: Option<String>,
@@ -74,6 +75,7 @@ pub struct UpdateRuntimeSettingsRequest {
pub max_waiting_per_key: u32,
pub max_waiting_per_account: u32,
pub concurrency_wait_timeout_seconds: u32,
pub responses_max_decompressed_body_bytes: u64,
pub rotation_strategy: String,
pub min_codex_desktop_version: Option<String>,
pub min_codex_cli_version: Option<String>,
@@ -104,6 +106,13 @@ impl UpdateRuntimeSettingsRequest {
return Err(WireValidationError::new(field));
}
}
if self.responses_max_decompressed_body_bytes == 0
|| isize::try_from(self.responses_max_decompressed_body_bytes).is_err()
{
return Err(WireValidationError::new(
"responsesMaxDecompressedBodyBytes",
));
}
if !(1..=120).contains(&self.concurrency_wait_timeout_seconds) {
return Err(WireValidationError::new("concurrencyWaitTimeoutSeconds"));
}
@@ -181,6 +190,7 @@ impl UpdateRuntimeSettingsRequest {
max_waiting_per_key: self.max_waiting_per_key,
max_waiting_per_account: self.max_waiting_per_account,
concurrency_wait_timeout_seconds: self.concurrency_wait_timeout_seconds,
responses_max_decompressed_body_bytes: self.responses_max_decompressed_body_bytes,
rotation_strategy: RotationStrategy::parse(&self.rotation_strategy)
.ok_or_else(|| WireValidationError::new("rotationStrategy"))?,
min_codex_desktop_version: self.min_codex_desktop_version,
@@ -216,6 +226,7 @@ impl From<RuntimeSettings> for RuntimeSettingsView {
max_waiting_per_key: settings.max_waiting_per_key,
max_waiting_per_account: settings.max_waiting_per_account,
concurrency_wait_timeout_seconds: settings.concurrency_wait_timeout_seconds,
responses_max_decompressed_body_bytes: settings.responses_max_decompressed_body_bytes,
rotation_strategy: settings.rotation_strategy.as_str().to_owned(),
min_codex_desktop_version: settings.min_codex_desktop_version,
min_codex_cli_version: settings.min_codex_cli_version,
@@ -42,7 +42,10 @@ pub enum RequestDecodeError {
MalformedJson,
/// 请求体解压后超过允许上限。
#[error("decompressed request body is too large")]
DecompressedBodyTooLarge,
DecompressedBodyTooLarge {
/// 当前请求快照中的解压输出上限。
limit_bytes: usize,
},
/// 请求使用了网关不支持的 `Content-Encoding`。
#[error("request content-encoding `{encoding}` is not supported")]
UnsupportedContentEncoding {
@@ -108,9 +111,11 @@ impl RequestDecodeError {
"Request body must be valid JSON.".to_owned(),
None,
),
Self::DecompressedBodyTooLarge => (
Self::DecompressedBodyTooLarge { limit_bytes } => (
"request_too_large",
"Decompressed request body exceeds the allowed size.".to_owned(),
format!(
"Decompressed request body exceeds the allowed size ({limit_bytes} bytes)."
),
None,
),
Self::UnsupportedContentEncoding { encoding } => (
@@ -52,7 +52,11 @@ pub(crate) async fn responses(
Ok(client) => client,
Err(error) => return client_access_error_response(error),
};
let decoded = match decode_request_with_headers(&body, &headers) {
let decoded = match decode_request_with_headers(
&body,
&headers,
client.snapshot().responses_max_decompressed_body_bytes(),
) {
Ok(decoded) => decoded,
Err(error) => {
return protocol_error_response(StatusCode::BAD_REQUEST, error.protocol_body());
@@ -327,13 +327,14 @@ impl fmt::Debug for DecodedResponsesRequest {
pub fn decode_request_with_headers(
body: &[u8],
headers: &HeaderMap,
max_decompressed_bytes: usize,
) -> Result<DecodedResponsesRequest, RequestDecodeError> {
let body = decompress_request_body(body, headers)?;
let body = decompress_request_body(body, headers, max_decompressed_bytes)?;
decode_request_inner(&body, &OpenAiRequestHeaders::from_headers(headers))
}
/// 下游请求体解压后的最大字节数;防御解压炸弹。
const MAX_DECOMPRESSED_REQUEST_BYTES: usize = 64 * 1024 * 1024;
/// zstd 回溯窗口独立于输出上限,调整设置不能放大解码器内部窗口分配。
const MAX_ZSTD_WINDOW_LOG: u32 = 26;
/// 按 `Content-Encoding` 解压下游请求体。
///
@@ -342,6 +343,7 @@ const MAX_DECOMPRESSED_REQUEST_BYTES: usize = 64 * 1024 * 1024;
fn decompress_request_body<'a>(
body: &'a [u8],
headers: &HeaderMap,
max_decompressed_bytes: usize,
) -> Result<Cow<'a, [u8]>, RequestDecodeError> {
let encoding = headers
.get_all(axum::http::header::CONTENT_ENCODING)
@@ -357,20 +359,20 @@ fn decompress_request_body<'a>(
"" | "identity" => Ok(Cow::Borrowed(body)),
"gzip" => {
let decoder = flate2::read::MultiGzDecoder::new(body);
read_bounded(decoder).map(Cow::Owned)
read_bounded(decoder, max_decompressed_bytes).map(Cow::Owned)
}
"deflate" => {
let decoder = flate2::read::ZlibDecoder::new(body);
read_bounded(decoder).map(Cow::Owned)
read_bounded(decoder, max_decompressed_bytes).map(Cow::Owned)
}
"zstd" => {
let mut decoder = zstd::stream::read::Decoder::with_buffer(body)
.map_err(|_| RequestDecodeError::MalformedJson)?;
// 输出有界之外,也限制压缩帧声明的回溯窗口,防止解码器内部过量分配。
decoder
.window_log_max(MAX_DECOMPRESSED_REQUEST_BYTES.ilog2())
.window_log_max(MAX_ZSTD_WINDOW_LOG)
.map_err(|_| RequestDecodeError::MalformedJson)?;
read_bounded(decoder).map(Cow::Owned)
read_bounded(decoder, max_decompressed_bytes).map(Cow::Owned)
}
other => Err(RequestDecodeError::UnsupportedContentEncoding {
encoding: other.to_owned(),
@@ -378,12 +380,15 @@ fn decompress_request_body<'a>(
}
}
fn read_bounded<R: std::io::Read>(mut reader: R) -> Result<Vec<u8>, RequestDecodeError> {
fn read_bounded<R: std::io::Read>(
mut reader: R,
max_decompressed_bytes: usize,
) -> Result<Vec<u8>, RequestDecodeError> {
let mut decoded = Vec::new();
let mut chunk = [0; 8192];
loop {
let remaining = MAX_DECOMPRESSED_REQUEST_BYTES - decoded.len();
let read_limit = chunk.len().min(remaining + 1);
let remaining = max_decompressed_bytes - decoded.len();
let read_limit = chunk.len().min(remaining.saturating_add(1));
let read = reader
.read(&mut chunk[..read_limit])
.map_err(|_| RequestDecodeError::MalformedJson)?;
@@ -391,15 +396,19 @@ fn read_bounded<R: std::io::Read>(mut reader: R) -> Result<Vec<u8>, RequestDecod
return Ok(decoded);
}
if read > remaining {
return Err(RequestDecodeError::DecompressedBodyTooLarge);
return Err(RequestDecodeError::DecompressedBodyTooLarge {
limit_bytes: max_decompressed_bytes,
});
}
// 默认 Vec 扩容和 read_to_end 的 EOF 探测可能突破输出上限;
// 容量增长也受相同边界约束,越界探测只使用栈上分块缓冲区。
let required = decoded.len() + read;
if required > decoded.capacity() {
let capacity = (decoded.capacity() * 2)
let capacity = decoded
.capacity()
.saturating_mul(2)
.max(required)
.min(MAX_DECOMPRESSED_REQUEST_BYTES);
.min(max_decompressed_bytes);
decoded.reserve_exact(capacity - decoded.len());
}
decoded.extend_from_slice(&chunk[..read]);
@@ -413,6 +413,7 @@ impl SettingsStore for MemorySettingsStore {
max_waiting_per_key: command.max_waiting_per_key,
max_waiting_per_account: command.max_waiting_per_account,
concurrency_wait_timeout_seconds: command.concurrency_wait_timeout_seconds,
responses_max_decompressed_body_bytes: command.responses_max_decompressed_body_bytes,
rotation_strategy: command.rotation_strategy,
min_codex_desktop_version: command.min_codex_desktop_version,
min_codex_cli_version: command.min_codex_cli_version,
@@ -1281,6 +1282,7 @@ fn test_runtime_settings() -> RuntimeSettings {
max_waiting_per_key: 0,
max_waiting_per_account: 0,
concurrency_wait_timeout_seconds: 30,
responses_max_decompressed_body_bytes: 64 * 1024 * 1024,
rotation_strategy: RotationStrategy::Smart,
min_codex_desktop_version: None,
min_codex_cli_version: None,
@@ -50,6 +50,7 @@ fn update_body() -> Value {
"maxWaitingPerKey": 0,
"maxWaitingPerAccount": 0,
"concurrencyWaitTimeoutSeconds": 30,
"responsesMaxDecompressedBodyBytes": 67108864,
"rotationStrategy": "round_robin",
"minCodexDesktopVersion": "26.825.6671",
"minCodexCliVersion": "0.40.0",
@@ -121,6 +122,7 @@ fn settings_response_should_cover_the_full_runtime_settings_contract() {
max_waiting_per_key: 0,
max_waiting_per_account: 0,
concurrency_wait_timeout_seconds: 30,
responses_max_decompressed_body_bytes: 64 * 1024 * 1024,
rotation_strategy: RotationStrategy::RoundRobin,
min_codex_desktop_version: Some("26.825.6671".to_owned()),
min_codex_cli_version: Some("0.40.0".to_owned()),
@@ -157,6 +159,7 @@ fn settings_response_should_cover_the_full_runtime_settings_contract() {
"maxWaitingPerKey": 0,
"maxWaitingPerAccount": 0,
"concurrencyWaitTimeoutSeconds": 30,
"responsesMaxDecompressedBodyBytes": 67108864,
"rotationStrategy": "round_robin",
"minCodexDesktopVersion": "26.825.6671",
"minCodexCliVersion": "0.40.0",
@@ -217,6 +220,7 @@ fn settings_request_and_response_fields_should_stay_in_lockstep() {
max_waiting_per_key: 0,
max_waiting_per_account: 0,
concurrency_wait_timeout_seconds: 30,
responses_max_decompressed_body_bytes: 64 * 1024 * 1024,
rotation_strategy: RotationStrategy::parse(&request.rotation_strategy)
.expect("fixture rotation strategy"),
min_codex_desktop_version: request.min_codex_desktop_version,
@@ -575,3 +579,24 @@ async fn request_location_should_reject_invalid_or_missing_fields_without_replac
original
);
}
#[test]
fn decompression_setting_should_reject_invalid_values() {
let field = "responsesMaxDecompressedBodyBytes";
for invalid in [
json!(0),
json!(-1),
json!(1.5),
json!(u64::MAX),
Value::Null,
] {
let mut body = update_body();
body[field] = invalid;
if let Ok(request) = serde_json::from_value::<UpdateRuntimeSettingsRequest>(body) {
assert_eq!(request.validate().unwrap_err().field(), field);
}
}
let mut body = update_body();
body.as_object_mut().unwrap().remove(field);
assert!(serde_json::from_value::<UpdateRuntimeSettingsRequest>(body).is_err());
}
+11 -1
View File
@@ -135,8 +135,18 @@ pub(super) fn authenticated_client_for_provider(
plaintext: &str,
provider_name: &str,
) -> AuthenticatedClient {
authenticated_client_for_provider_with_limit(plaintext, provider_name, 64 * 1024 * 1024)
}
pub(super) fn authenticated_client_for_provider_with_limit(
plaintext: &str,
provider_name: &str,
bytes: usize,
) -> AuthenticatedClient {
let snapshot = snapshot(plaintext, provider_name)
.with_responses_max_decompressed_body_bytes(std::num::NonZeroUsize::new(bytes).unwrap());
let source = DefaultExecutionService::new(
RuntimeSnapshotHandle::new(snapshot(plaintext, provider_name)),
RuntimeSnapshotHandle::new(snapshot),
Arc::new(UnusedExecutionStore),
ProviderRegistry::default(),
Arc::new(UnusedAdmissions),
@@ -548,7 +548,7 @@ async fn invalid_compressed_http_requests_should_fail_before_execution() {
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.",
"Decompressed request body exceeds the allowed size (67108864 bytes).",
),
] {
let headers = HeaderMap::from_iter([
@@ -2288,3 +2288,50 @@ async fn local_execution_failure_uses_the_existing_model_id() {
}
}
}
#[tokio::test]
async fn compressed_http_request_above_default_limit_should_reach_execution_after_increase() {
let body = json!({"model": "model-a", "input": "x".repeat(64 * 1024 * 1024)}).to_string();
let compressed = super::request::encode_body("zstd", body.as_bytes());
for limit in [64 * 1024 * 1024, 128 * 1024 * 1024] {
let observed = Arc::new(Mutex::new(None));
let execution = Arc::new(ContextCaptureExecution {
observed: Arc::clone(&observed),
client: crate::openai::authenticated_client_for_provider_with_limit(
"sk_context_test",
"openai",
limit,
),
});
let response = api_router(execution)
.await
.oneshot(
Request::post("/v1/responses")
.header(AUTHORIZATION, "Bearer sk_context_test")
.header(CONTENT_ENCODING, "zstd")
.body(Body::from(compressed.clone()))
.unwrap(),
)
.await
.unwrap();
if limit == 64 * 1024 * 1024 {
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
assert!(observed.lock().unwrap().is_none());
let error: Value =
serde_json::from_slice(&to_bytes(response.into_body(), usize::MAX).await.unwrap())
.unwrap();
assert_eq!(error["error"]["code"], "request_too_large");
} else {
// 捕获执行服务刻意返回 500;断言完整输入到达执行层,而不是只验证路由状态码。
let captured = observed
.lock()
.unwrap()
.take()
.expect("request reached execution");
assert_eq!(
captured.input.unwrap().as_str().unwrap().len(),
64 * 1024 * 1024
);
}
}
}
@@ -19,7 +19,7 @@ use gateway_protocol::openai::sse::parse_sse_events;
use serde_json::{Value, json};
fn decode_request(body: &[u8]) -> Result<DecodedResponsesRequest, RequestDecodeError> {
decode_request_with_headers(body, &HeaderMap::new())
decode_request_with_headers(body, &HeaderMap::new(), 64 * 1024 * 1024)
}
fn decode_response_create(
@@ -205,8 +205,9 @@ fn decoder_should_preserve_connection_metadata_outside_the_openai_wire_body() {
}
});
let decoded = decode_request_with_headers(body.to_string().as_bytes(), &headers)
.expect("request should decode");
let decoded =
decode_request_with_headers(body.to_string().as_bytes(), &headers, 64 * 1024 * 1024)
.expect("request should decode");
assert_eq!(Value::Object(openai_wire_body(&decoded).clone()), body);
assert_eq!(
@@ -317,9 +318,12 @@ fn decoder_should_preserve_ordinary_request_headers_as_opaque_multivalues() {
HeaderValue::from_static("client-is-update"),
);
let decoded =
decode_request_with_headers(br#"{"model":"smart-code","input":"hello"}"#, &headers)
.expect("opaque request headers should not affect body decoding");
let decoded = decode_request_with_headers(
br#"{"model":"smart-code","input":"hello"}"#,
&headers,
64 * 1024 * 1024,
)
.expect("opaque request headers should not affect body decoding");
let entries = openai_protocol_context(&decoded)
.get("opaque_request_headers")
.and_then(Value::as_array)
@@ -420,9 +424,12 @@ fn decoder_should_strip_http_transport_but_leave_source_headers_for_provider() {
headers.insert("content-encoding", HeaderValue::from_static("identity"));
headers.insert("x-openai-future-mode", HeaderValue::from_static("keep"));
let decoded =
decode_request_with_headers(br#"{"model":"smart-code","input":"hello"}"#, &headers)
.expect("decode request behind a reverse proxy");
let decoded = decode_request_with_headers(
br#"{"model":"smart-code","input":"hello"}"#,
&headers,
64 * 1024 * 1024,
)
.expect("decode request behind a reverse proxy");
assert_eq!(
openai_protocol_context(&decoded).get("opaque_request_headers"),
@@ -482,7 +489,8 @@ fn downstream_client_headers_should_remain_opaque_without_losing_session_semanti
frame["type"] = json!("response.create");
let opening = OpenAiRequestHeaders::from_headers(&headers);
for decoded in [
decode_request_with_headers(body.to_string().as_bytes(), &headers).unwrap(),
decode_request_with_headers(body.to_string().as_bytes(), &headers, 64 * 1024 * 1024)
.unwrap(),
decode_response_create_with_context(&frame.to_string(), &opening).unwrap(),
decode_response_create_with_context(&frame.to_string(), &opening).unwrap(),
] {
@@ -565,7 +573,7 @@ fn decoder_should_preserve_unknown_arbitrary_precision_numbers() {
let number = "12345678901234567890123456789012345678901234567890";
let body = format!(r#"{{"model":"smart-code","input":"hello","future_number":{number}}}"#);
let decoded = decode_request_with_headers(body.as_bytes(), &HeaderMap::new())
let decoded = decode_request_with_headers(body.as_bytes(), &HeaderMap::new(), 64 * 1024 * 1024)
.expect("opaque numeric field should decode");
let encoded = serde_json::to_string(openai_wire_body(&decoded)).expect("encode request body");
@@ -41,7 +41,9 @@ fn http_decode_should_preserve_compressed_request_fields() {
let expected = serde_json::from_slice::<serde_json::Value>(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);
let decoded =
decode_request_with_headers(&compressed, &headers(encoding), 64 * 1024 * 1024)
.expect(encoding);
assert_eq!(
openai_wire_body(&decoded),
expected.as_object().expect("object")
@@ -52,10 +54,10 @@ fn http_decode_should_preserve_compressed_request_fields() {
#[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());
assert!(decode_request_with_headers(REQUEST, &request_headers, 64 * 1024 * 1024).is_ok());
}
let compressed = encode_body("gzip", REQUEST);
assert!(decode_request_with_headers(&compressed, &headers(" GZip ")).is_ok());
assert!(decode_request_with_headers(&compressed, &headers(" GZip "), 64 * 1024 * 1024).is_ok());
}
#[test]
@@ -63,7 +65,7 @@ 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)
let error = decode_request_with_headers(REQUEST, &request_headers, 64 * 1024 * 1024)
.expect_err("unsupported encoding");
assert!(matches!(
error,
@@ -82,7 +84,8 @@ fn http_decode_should_reject_corrupted_and_truncated_compressed_bodies() {
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);
let error = decode_request_with_headers(body, &headers(encoding), 64 * 1024 * 1024)
.expect_err(encoding);
assert_eq!(
error.protocol_body().into_value(),
json!({"error": {
@@ -99,7 +102,9 @@ 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);
let decoded =
decode_request_with_headers(&compressed, &headers(encoding), 64 * 1024 * 1024)
.expect(encoding);
assert_eq!(openai_wire_body(&decoded)["input"], "hello");
}
}
@@ -110,7 +115,8 @@ fn http_decode_should_not_ignore_invalid_trailing_members() {
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),
decode_request_with_headers(&compressed, &headers(encoding), 64 * 1024 * 1024)
.expect_err(encoding),
RequestDecodeError::MalformedJson
);
}
@@ -129,7 +135,8 @@ fn http_decode_should_reject_zstd_frames_with_an_excessive_window() {
REQUEST
);
assert_eq!(
decode_request_with_headers(&compressed, &headers("zstd")).expect_err("oversized window"),
decode_request_with_headers(&compressed, &headers("zstd"), 128 * 1024 * 1024)
.expect_err("oversized window"),
RequestDecodeError::MalformedJson
);
}
@@ -138,7 +145,8 @@ fn http_decode_should_reject_zstd_frames_with_an_excessive_window() {
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");
let decoded = decode_request_with_headers(&compressed, &headers("zstd"), 64 * 1024 * 1024)
.expect("exact limit");
assert_eq!(decoded.metadata().requested_model(), "gpt-test");
}
@@ -146,11 +154,13 @@ fn http_decode_should_accept_the_exact_decompressed_limit() {
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);
let error = decode_request_with_headers(&compressed, &headers(encoding), 64 * 1024 * 1024)
.expect_err(encoding);
assert_eq!(
error,
RequestDecodeError::DecompressedBodyTooLarge,
RequestDecodeError::DecompressedBodyTooLarge {
limit_bytes: 64 * 1024 * 1024
},
"{encoding}"
);
}
@@ -163,12 +173,54 @@ fn http_decode_should_stop_at_limit_before_reading_invalid_trailing_frames() {
// 这个断言验证越界时已经停止读取,而不只是最终返回了某个超限错误。
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);
let error = decode_request_with_headers(&compressed, &headers(encoding), 64 * 1024 * 1024)
.expect_err(encoding);
assert_eq!(
error,
RequestDecodeError::DecompressedBodyTooLarge,
RequestDecodeError::DecompressedBodyTooLarge {
limit_bytes: 64 * 1024 * 1024
},
"{encoding}"
);
}
}
#[test]
fn http_decode_should_apply_configured_limits_to_every_encoding_and_frame() {
for encoding in ENCODINGS {
let compressed = encode_body(encoding, REQUEST);
assert!(
decode_request_with_headers(&compressed, &headers(encoding), REQUEST.len()).is_ok()
);
let error = decode_request_with_headers(&compressed, &headers(encoding), REQUEST.len() - 1)
.unwrap_err();
assert_eq!(
error,
RequestDecodeError::DecompressedBodyTooLarge {
limit_bytes: REQUEST.len() - 1
}
);
assert!(
error
.protocol_body()
.error
.message
.contains(&(REQUEST.len() - 1).to_string())
);
}
for encoding in ["gzip", "zstd"] {
let split = REQUEST.len() / 2;
let mut compressed = encode_body(encoding, &REQUEST[..split]);
compressed.extend(encode_body(encoding, &REQUEST[split..]));
assert!(
decode_request_with_headers(&compressed, &headers(encoding), REQUEST.len()).is_ok()
);
assert!(matches!(
decode_request_with_headers(&compressed, &headers(encoding), REQUEST.len() - 1),
Err(RequestDecodeError::DecompressedBodyTooLarge { .. })
));
}
for request_headers in [HeaderMap::new(), headers("identity")] {
assert!(decode_request_with_headers(REQUEST, &request_headers, 1).is_ok());
}
}
@@ -34,6 +34,7 @@ pub struct SnapshotSettingsFacts {
max_waiting_per_key: u32,
max_waiting_per_account: u32,
concurrency_wait_timeout_seconds: u32,
responses_max_decompressed_body_bytes: u64,
request_interval_ms: u64,
rotation_strategy: String,
model_mappings: BTreeMap<String, String>,
@@ -42,6 +43,12 @@ pub struct SnapshotSettingsFacts {
}
impl SnapshotSettingsFacts {
#[must_use]
pub const fn with_responses_max_decompressed_body_bytes(mut self, bytes: u64) -> Self {
self.responses_max_decompressed_body_bytes = bytes;
self
}
#[must_use]
pub fn with_request_location(
mut self,
@@ -82,6 +89,7 @@ impl SnapshotSettingsFacts {
max_waiting_per_key: 0,
max_waiting_per_account: 0,
concurrency_wait_timeout_seconds: 30,
responses_max_decompressed_body_bytes: 64 * 1024 * 1024,
request_interval_ms,
rotation_strategy: rotation_strategy.into(),
model_mappings,
@@ -391,6 +399,12 @@ async fn compile_runtime_snapshot(
{
return Err(RuntimeSnapshotCompileError::InvalidData);
}
let decompressed_body_limit =
isize::try_from(facts.settings.responses_max_decompressed_body_bytes)
.ok()
.and_then(|bytes| usize::try_from(bytes).ok())
.and_then(std::num::NonZeroUsize::new)
.ok_or(RuntimeSnapshotCompileError::InvalidData)?;
let queue_timeout =
Duration::from_secs(u64::from(facts.settings.concurrency_wait_timeout_seconds));
let client_queue_policy = ConcurrencyQueuePolicy {
@@ -492,6 +506,7 @@ async fn compile_runtime_snapshot(
.map(|snapshot| {
snapshot
.with_request_location(request_location)
.with_responses_max_decompressed_body_bytes(decompressed_body_limit)
.with_client_queue_policy(client_queue_policy)
.with_model_mappings(model_mappings)
.with_account_directory(account_directory)
@@ -503,6 +518,7 @@ async fn compile_runtime_snapshot(
/// 数据面使用的不可变配置快照。
#[derive(Debug, Clone)]
pub struct RuntimeSnapshot {
responses_max_decompressed_body_bytes: std::num::NonZeroUsize,
request_location: Option<crate::account::RequestLocation>,
revision: ConfigRevision,
client_queue_policy: ConcurrencyQueuePolicy,
@@ -520,6 +536,20 @@ pub struct RuntimeSnapshot {
}
impl RuntimeSnapshot {
#[must_use]
pub const fn responses_max_decompressed_body_bytes(&self) -> usize {
self.responses_max_decompressed_body_bytes.get()
}
#[must_use]
pub const fn with_responses_max_decompressed_body_bytes(
mut self,
bytes: std::num::NonZeroUsize,
) -> Self {
self.responses_max_decompressed_body_bytes = bytes;
self
}
#[must_use]
pub fn with_request_location(
mut self,
@@ -608,6 +638,8 @@ impl RuntimeSnapshot {
client_policy_map.retain(|_, policy| policy.enabled());
Ok(Self {
responses_max_decompressed_body_bytes: std::num::NonZeroUsize::new(64 * 1024 * 1024)
.expect("positive default limit"),
request_location: None,
revision,
account_selection_policy,
@@ -407,3 +407,49 @@ fn global_request_location_should_be_frozen_when_snapshot_is_published() {
"Asia/Tokyo"
);
}
#[test]
fn decompression_setting_should_validate_and_remain_frozen_across_publication() {
use gateway_core::runtime::RuntimeSnapshotHandle;
let compile = |version, bytes| {
let facts = SnapshotFacts::new(
revision(version),
revision(version),
SnapshotSettingsFacts::new(3, 0, "smart", BTreeMap::new(), None, None)
.with_responses_max_decompressed_body_bytes(bytes),
Vec::new(),
Vec::new(),
Vec::new(),
Vec::new(),
);
block_on(compiler(Arc::new(TestSnapshotStore::new(Ok(facts)))).compile())
};
for invalid in [0, u64::MAX] {
assert!(matches!(
compile(1, invalid),
Err(RuntimeSnapshotCompileError::InvalidData)
));
}
let handle = RuntimeSnapshotHandle::new(compile(1, 64 * 1024 * 1024).unwrap());
let frozen = handle.acquire().unwrap();
handle.publish(compile(2, 128 * 1024 * 1024).unwrap());
assert_eq!(
handle
.acquire()
.unwrap()
.responses_max_decompressed_body_bytes(),
128 * 1024 * 1024
);
assert_eq!(
frozen.responses_max_decompressed_body_bytes(),
64 * 1024 * 1024
);
handle.publish(compile(3, 1024 * 1024).unwrap());
assert_eq!(
handle
.acquire()
.unwrap()
.responses_max_decompressed_body_bytes(),
1024 * 1024
);
}
@@ -49,6 +49,8 @@ impl SettingsStore for AdminSettingsStoreAdapter {
max_waiting_per_key: command.max_waiting_per_key,
max_waiting_per_account: command.max_waiting_per_account,
concurrency_wait_timeout_seconds: command.concurrency_wait_timeout_seconds,
responses_max_decompressed_body_bytes: command
.responses_max_decompressed_body_bytes,
rotation_strategy: command.rotation_strategy.as_str().to_owned(),
model_mappings: store_model_mappings(command.model_mappings),
min_codex_desktop_version: command.min_codex_desktop_version,
@@ -81,6 +83,7 @@ impl SettingsStore for AdminSettingsStoreAdapter {
"max_waiting_per_key".to_owned(),
"max_waiting_per_account".to_owned(),
"concurrency_wait_timeout_seconds".to_owned(),
"responses_max_decompressed_body_bytes".to_owned(),
"rotation_strategy".to_owned(),
"min_codex_desktop_version".to_owned(),
"min_codex_cli_version".to_owned(),
@@ -190,6 +193,7 @@ pub(crate) fn admin_runtime_settings(
max_waiting_per_key: settings.max_waiting_per_key,
max_waiting_per_account: settings.max_waiting_per_account,
concurrency_wait_timeout_seconds: settings.concurrency_wait_timeout_seconds,
responses_max_decompressed_body_bytes: settings.responses_max_decompressed_body_bytes,
rotation_strategy,
min_codex_desktop_version: settings.min_codex_desktop_version,
min_codex_cli_version: settings.min_codex_cli_version,
@@ -28,6 +28,7 @@ pub struct RuntimeSettings {
pub max_waiting_per_key: u32,
pub max_waiting_per_account: u32,
pub concurrency_wait_timeout_seconds: u32,
pub responses_max_decompressed_body_bytes: u64,
pub rotation_strategy: String,
pub request_location_enabled: bool,
pub request_location: gateway_core::account::RequestLocation,
@@ -115,6 +116,7 @@ pub struct RuntimeSettingsUpdate {
pub max_waiting_per_key: u32,
pub max_waiting_per_account: u32,
pub concurrency_wait_timeout_seconds: u32,
pub responses_max_decompressed_body_bytes: u64,
pub rotation_strategy: String,
pub request_location_enabled: bool,
pub request_location: gateway_core::account::RequestLocation,
@@ -152,6 +154,8 @@ impl fmt::Debug for RuntimeSettingsUpdate {
impl RuntimeSettingsUpdate {
pub fn validate(&self) -> StoreResult<()> {
if self.request_location.validate().is_err()
|| self.responses_max_decompressed_body_bytes == 0
|| isize::try_from(self.responses_max_decompressed_body_bytes).is_err()
|| self.refresh_margin_seconds == 0
|| self.refresh_concurrency == 0
|| self.max_concurrent_per_account == 0
@@ -229,7 +233,7 @@ pub(crate) async fn load_runtime_settings_from_pool(pool: &PgPool) -> StoreResul
refresh_concurrency, max_concurrent_per_account, request_interval_ms,
rotation_strategy, model_mappings_json, usage_retention_days, ops_event_retention_days,
audit_retention_days, min_codex_desktop_version,
min_codex_cli_version, updated_at, max_waiting_per_key, max_waiting_per_account, concurrency_wait_timeout_seconds,
min_codex_cli_version, updated_at, responses_max_decompressed_body_bytes, max_waiting_per_key, max_waiting_per_account, concurrency_wait_timeout_seconds,
account_auto_freeze_enabled, account_auto_freeze_threshold,
account_auto_freeze_window_seconds, account_auto_freeze_duration_seconds,
account_auto_freeze_probe_enabled, account_auto_freeze_probe_model,
@@ -291,7 +295,7 @@ pub(crate) async fn load_runtime_settings_in_transaction(
refresh_concurrency, max_concurrent_per_account, request_interval_ms,
rotation_strategy, model_mappings_json, usage_retention_days, ops_event_retention_days,
audit_retention_days, min_codex_desktop_version,
min_codex_cli_version, updated_at, max_waiting_per_key, max_waiting_per_account, concurrency_wait_timeout_seconds,
min_codex_cli_version, updated_at, responses_max_decompressed_body_bytes, max_waiting_per_key, max_waiting_per_account, concurrency_wait_timeout_seconds,
account_auto_freeze_enabled, account_auto_freeze_threshold,
account_auto_freeze_window_seconds, account_auto_freeze_duration_seconds,
account_auto_freeze_probe_enabled, account_auto_freeze_probe_model,
@@ -342,6 +346,7 @@ pub(crate) async fn update_runtime_settings_in_transaction(
account_auto_freeze_adaptive_concurrency = $22,
request_location_json = $23,
request_location_enabled = $24,
responses_max_decompressed_body_bytes = $25,
updated_at = now()
where id = 1
returning config_revision",
@@ -379,6 +384,10 @@ pub(crate) async fn update_runtime_settings_in_transaction(
.map_err(|_| invalid_location())?,
))
.bind(update.request_location_enabled)
.bind(
i64::try_from(update.responses_max_decompressed_body_bytes)
.map_err(|_| invalid_numeric())?,
)
.fetch_optional(&mut **transaction)
.await
.map_err(|_| postgres_unavailable("update runtime settings in transaction"))?
@@ -447,6 +456,7 @@ struct RuntimeSettingsRow {
max_waiting_per_key: i64,
max_waiting_per_account: i64,
concurrency_wait_timeout_seconds: i64,
responses_max_decompressed_body_bytes: i64,
account_auto_freeze_enabled: bool,
account_auto_freeze_threshold: i64,
account_auto_freeze_window_seconds: i64,
@@ -481,6 +491,7 @@ fn runtime_settings_from_row(row: RuntimeSettingsRow) -> StoreResult<RuntimeSett
max_waiting_per_key: to_u32(row.max_waiting_per_key)?,
max_waiting_per_account: to_u32(row.max_waiting_per_account)?,
concurrency_wait_timeout_seconds: to_u32(row.concurrency_wait_timeout_seconds)?,
responses_max_decompressed_body_bytes: to_u64(row.responses_max_decompressed_body_bytes)?,
account_auto_freeze_enabled: row.account_auto_freeze_enabled,
account_auto_freeze_threshold: to_u32(row.account_auto_freeze_threshold)?,
account_auto_freeze_window_seconds: to_u64(row.account_auto_freeze_window_seconds)?,
@@ -29,6 +29,7 @@ pub struct SnapshotRuntimeSettings {
pub max_waiting_per_key: u32,
pub max_waiting_per_account: u32,
pub concurrency_wait_timeout_seconds: u32,
pub responses_max_decompressed_body_bytes: u64,
pub rotation_strategy: String,
pub model_mappings: BTreeMap<String, String>,
pub min_codex_desktop_version: Option<String>,
@@ -154,6 +155,9 @@ impl SnapshotStorePort for PgRuntimeSnapshotRepository {
data.settings.min_codex_desktop_version,
data.settings.min_codex_cli_version,
)
.with_responses_max_decompressed_body_bytes(
data.settings.responses_max_decompressed_body_bytes,
)
.with_request_location(
data.settings.request_location,
data.settings.request_location_enabled,
@@ -249,12 +253,13 @@ async fn load_settings(
i64, i64, i64,
sqlx::types::Json<gateway_core::account::RequestLocation>,
bool,
i64,
),
>(
"select config_revision, refresh_margin_seconds, refresh_concurrency,
max_concurrent_per_account, request_interval_ms, rotation_strategy,
model_mappings_json, min_codex_desktop_version,
min_codex_cli_version, max_waiting_per_key, max_waiting_per_account, concurrency_wait_timeout_seconds, request_location_json, request_location_enabled
min_codex_cli_version, max_waiting_per_key, max_waiting_per_account, concurrency_wait_timeout_seconds, request_location_json, request_location_enabled, responses_max_decompressed_body_bytes
from runtime_settings where id = 1",
)
.fetch_optional(&mut **transaction)
@@ -267,6 +272,7 @@ async fn load_settings(
Ok((
revision_from_i64(row.0)?,
SnapshotRuntimeSettings {
responses_max_decompressed_body_bytes: to_u64(row.14)?,
request_location_enabled: row.13,
request_location: row.12.0,
refresh_margin_seconds: to_u64(row.1)?,
@@ -19,6 +19,7 @@ fn settings_with_margin(refresh_margin_seconds: u64) -> RuntimeSettingsUpdate {
max_waiting_per_key: 0,
max_waiting_per_account: 0,
concurrency_wait_timeout_seconds: 30,
responses_max_decompressed_body_bytes: 64 * 1024 * 1024,
rotation_strategy: "smart".to_owned(),
model_mappings: BTreeMap::from([
("gpt-5.4".to_owned(), "gpt-5.5".to_owned()),
@@ -380,3 +381,51 @@ async fn auto_freeze_defaults_off_and_explicit_opt_in_round_trips() {
);
database.close().await;
}
#[tokio::test]
async fn decompression_setting_should_persist_and_reach_snapshot_facts() {
use gateway_store::postgres::{PgRuntimeSnapshotRepository, RuntimeSnapshotRepository};
let Some(database) = TestDatabase::create("decompression_settings").await else {
return;
};
let repository = PgRuntimeSettingsRepository::new(database.pool.clone());
let before = repository.load_runtime_settings().await.unwrap();
assert_eq!(
before.responses_max_decompressed_body_bytes,
64 * 1024 * 1024
);
let mut update = settings_with_margin(3600);
update.responses_max_decompressed_body_bytes = 128 * 1024 * 1024;
repository.update_runtime_settings(update).await.unwrap();
let reloaded = PgRuntimeSettingsRepository::new(database.pool.clone())
.load_runtime_settings()
.await
.unwrap();
assert_eq!(
reloaded.responses_max_decompressed_body_bytes,
128 * 1024 * 1024
);
let snapshot = PgRuntimeSnapshotRepository::new(database.pool.clone())
.load_runtime_snapshot()
.await
.unwrap();
assert_eq!(
snapshot.settings.responses_max_decompressed_body_bytes,
reloaded.responses_max_decompressed_body_bytes
);
assert!(snapshot.config_revision > before.config_revision);
for invalid in [0, u64::MAX] {
let mut update = settings_with_margin(3600);
update.responses_max_decompressed_body_bytes = invalid;
assert!(repository.update_runtime_settings(update).await.is_err());
assert_eq!(
repository
.load_runtime_settings()
.await
.unwrap()
.config_revision,
reloaded.config_revision
);
}
database.close().await;
}
+1
View File
@@ -10,3 +10,4 @@ b7d3df7b48d835a00a3b74cbefc958a039d38fea5529cdad04bfbc703dca72aa 0001_initial.s
3751a802e80b5d538abc5619a2397aa4bf13f52076068a489892f664907adf49 0010_account_auto_freeze.sql
ae7f7acb3014f0a60d979dd9c04b3065322b206e42eb6256505baf78caf9dd22 0011_proxy_request_location.sql
b20e425c0b062b72d35fdebe294e023eb17ceb44ac93083ff3d48400715bf430 0012_upstream_response_model.sql
6e23fceef1096b50c1af3584cb1687eded2ad37e31e65391397ec984bf78c7e3 0013_responses_decompression_limit.sql
@@ -0,0 +1,4 @@
-- 解压输出上限属于运行设置,升级后沿用原有 64 MiB 默认值。
alter table runtime_settings
add column responses_max_decompressed_body_bytes bigint not null default 67108864
check (responses_max_decompressed_body_bytes > 0);
+9 -2
View File
@@ -162,8 +162,9 @@ Codex 的 review 等子代理请求仍使用 `/v1/responses`,并通过 `x-open
`POST /v1/responses` 在鉴权后按 `Content-Encoding` 解压,再解析 JSON;支持单一 `gzip`、
`deflate`(zlib 封装)和 `zstd`,缺省、空值或 `identity` 直接使用原始正文。gzip 多成员与 zstd
多帧连续解码,整体展开结果最多 64 MiB,超限在继续展开前返回 `400 request_too_large`;zstd
回溯窗口同样最多 64 MiB,不能满足该限制的帧按解码失败处理。这个限制保护入站解压资源,不是
多帧连续解码,整体展开结果受运行设置 `responsesMaxDecompressedBodyBytes` 约束(默认 64 MiB),
超限在继续展开前返回 `400 request_too_large`,错误信息包含当前请求的上限字节数。zstd
回溯窗口独立固定为最多 64 MiB,不能满足该限制的帧按解码失败处理。这个限制保护入站解压资源,不是
模型上下文或 Token 上限;未压缩正文不受此长度限制。
不支持的编码、逗号分隔的叠加编码和重复 `Content-Encoding` 头返回
`400 unsupported_content_encoding`;压缩正文损坏、截断或解压后不是合法 JSON 返回
@@ -943,6 +944,7 @@ maxConcurrentPerAccount
maxWaitingPerKey
maxWaitingPerAccount
concurrencyWaitTimeoutSeconds
responsesMaxDecompressedBodyBytes
requestIntervalMs
rotationStrategy
minCodexDesktopVersion
@@ -974,6 +976,11 @@ accountAutoFreezeAdaptiveConcurrency
切换账号或内部重试不重新计时,等待同时计入请求总超时。该时限不用于中断已开始的上游生成。
设置更新请求须包含这三个字段,新请求使用更新后的快照。
`responsesMaxDecompressedBodyBytes` 是压缩 Responses HTTP 请求的解压输出上限,单位字节,默认
67108864(64 MiB)。必须为正整数,且可表示为进程平台的 `isize`;管理端以整数 MiB 编辑。
保存并发布成功后,新请求使用新值;已鉴权请求沿用原快照,无需重启。调高上限会增加大请求的内存占用,
它不代表整个进程的内存预算。
`rotationStrategy` 可取 `smart`、`quota_reset_priority`、`round_robin`、`sticky`。
两个 `minCodex*Version` 字段为 `string | null`,只设置最低版本,不存在最大版本字段。
+1
View File
@@ -15,6 +15,7 @@ export interface RuntimeSettings {
maxWaitingPerKey: number
maxWaitingPerAccount: number
concurrencyWaitTimeoutSeconds: number
responsesMaxDecompressedBodyBytes: number
rotationStrategy: RotationStrategy
minCodexDesktopVersion: string | null
minCodexCliVersion: string | null
@@ -13,6 +13,7 @@ const requestIntervalMs = defineModel<string>('requestIntervalMs', { required: t
const maxWaitingPerKey = defineModel<string>('maxWaitingPerKey', { required: true })
const maxWaitingPerAccount = defineModel<string>('maxWaitingPerAccount', { required: true })
const concurrencyWaitTimeoutSeconds = defineModel<string>('concurrencyWaitTimeoutSeconds', { required: true })
const responsesMaxDecompressedBodyMiB = defineModel<string>('responsesMaxDecompressedBodyMiB', { required: true })
</script>
<template>
@@ -80,6 +81,22 @@ const concurrencyWaitTimeoutSeconds = defineModel<string>('concurrencyWaitTimeou
</template>
</BaseInput>
</BaseFormItem>
<BaseFormItem
label="Responses 解压上限"
description="默认 64 MiB,保存后对新请求生效;调高可接收更大的图片和历史上下文,也会增加内存占用"
>
<BaseInput
v-model="responsesMaxDecompressedBodyMiB"
aria-label="Responses 解压上限"
type="number"
min="1"
step="1"
>
<template #suffix>
MiB
</template>
</BaseInput>
</BaseFormItem>
<div class="col-span-full grid gap-4 pt-4 sm:grid-cols-2">
<BaseFormItem label="每个密钥最大排队数" description="所有密钥使用此上限,各自独立计数;0 表示不排队">
<BaseInput v-model="maxWaitingPerKey" aria-label="每个密钥最大排队数" type="number" min="0" max="1000" step="1" />
@@ -11,6 +11,8 @@ import { normalizeRequestLocation, requestLocationError } from '@/utils/request-
type RotationStrategy = (typeof rotationOptions)[number]['value']
const MIB = 1024 * 1024
export function useSettingsForm() {
const loading = shallowRef(true)
const saveAction = useAsyncAction()
@@ -28,6 +30,7 @@ export function useSettingsForm() {
maxWaitingPerKey: null as number | null,
maxWaitingPerAccount: null as number | null,
concurrencyWaitTimeoutSeconds: null as number | null,
responsesMaxDecompressedBodyMiB: null as number | null,
rotationStrategy: '' as RotationStrategy | '',
minCodexDesktopVersion: '',
@@ -45,7 +48,7 @@ export function useSettingsForm() {
accountAutoFreezeAdaptiveConcurrency: true,
})
function numericModel(key: 'refreshMarginSeconds' | 'refreshConcurrency' | 'maxConcurrentPerAccount' | 'requestIntervalMs' | 'maxWaitingPerKey' | 'maxWaitingPerAccount' | 'concurrencyWaitTimeoutSeconds' | 'accountAutoFreezeThreshold' | 'accountAutoFreezeWindowSeconds' | 'accountAutoFreezeDurationSeconds') {
function numericModel(key: 'refreshMarginSeconds' | 'refreshConcurrency' | 'maxConcurrentPerAccount' | 'requestIntervalMs' | 'maxWaitingPerKey' | 'maxWaitingPerAccount' | 'concurrencyWaitTimeoutSeconds' | 'responsesMaxDecompressedBodyMiB' | 'accountAutoFreezeThreshold' | 'accountAutoFreezeWindowSeconds' | 'accountAutoFreezeDurationSeconds') {
return computed({
get: () => (form[key] === null ? '' : String(form[key])),
set: (value: string) => {
@@ -65,6 +68,7 @@ export function useSettingsForm() {
const requestIntervalMsValue = numericModel('requestIntervalMs')
const maxWaitingPerKeyValue = numericModel('maxWaitingPerKey')
const maxWaitingPerAccountValue = numericModel('maxWaitingPerAccount')
const responsesMaxDecompressedBodyMiBValue = numericModel('responsesMaxDecompressedBodyMiB')
const concurrencyWaitTimeoutSecondsValue = numericModel('concurrencyWaitTimeoutSeconds')
const accountAutoFreezeThresholdValue = numericModel('accountAutoFreezeThreshold')
const accountAutoFreezeWindowSecondsValue = numericModel('accountAutoFreezeWindowSeconds')
@@ -89,6 +93,7 @@ export function useSettingsForm() {
form.maxWaitingPerKey = data.maxWaitingPerKey
form.maxWaitingPerAccount = data.maxWaitingPerAccount
form.concurrencyWaitTimeoutSeconds = data.concurrencyWaitTimeoutSeconds
form.responsesMaxDecompressedBodyMiB = data.responsesMaxDecompressedBodyBytes / MIB
form.rotationStrategy = data.rotationStrategy
form.minCodexDesktopVersion = data.minCodexDesktopVersion ?? ''
@@ -158,11 +163,16 @@ export function useSettingsForm() {
async function saveSettings() {
if (saving.value || loading.value || !savedRequestLocation.value)
return
const { refreshMarginSeconds, refreshConcurrency, maxConcurrentPerAccount, requestIntervalMs, rotationStrategy, maxWaitingPerKey, maxWaitingPerAccount, concurrencyWaitTimeoutSeconds, accountAutoFreezeThreshold, accountAutoFreezeWindowSeconds, accountAutoFreezeDurationSeconds } = form
const { refreshMarginSeconds, refreshConcurrency, maxConcurrentPerAccount, requestIntervalMs, rotationStrategy, maxWaitingPerKey, maxWaitingPerAccount, concurrencyWaitTimeoutSeconds, responsesMaxDecompressedBodyMiB, accountAutoFreezeThreshold, accountAutoFreezeWindowSeconds, accountAutoFreezeDurationSeconds } = form
if (refreshMarginSeconds === null || refreshConcurrency === null || maxConcurrentPerAccount === null || requestIntervalMs === null || !rotationStrategy || maxWaitingPerKey === null || maxWaitingPerAccount === null || concurrencyWaitTimeoutSeconds === null) {
toast.warning('请完整填写运行参数和调度策略')
return
}
if (responsesMaxDecompressedBodyMiB === null || !Number.isInteger(responsesMaxDecompressedBodyMiB) || responsesMaxDecompressedBodyMiB < 1
|| !Number.isSafeInteger(responsesMaxDecompressedBodyMiB * MIB)) {
toast.warning('Responses 解压上限应为有效的正整数(MiB)')
return
}
if (![maxWaitingPerKey, maxWaitingPerAccount].every(value => Number.isInteger(value) && value >= 0 && value <= 1000)
|| !Number.isInteger(concurrencyWaitTimeoutSeconds) || concurrencyWaitTimeoutSeconds < 1 || concurrencyWaitTimeoutSeconds > 120) {
toast.warning('最大排队数应为 0~1000 的整数,最长排队时间应为 1~120 秒的整数')
@@ -208,6 +218,7 @@ export function useSettingsForm() {
maxWaitingPerKey,
maxWaitingPerAccount,
concurrencyWaitTimeoutSeconds,
responsesMaxDecompressedBodyBytes: responsesMaxDecompressedBodyMiB * MIB,
rotationStrategy,
minCodexDesktopVersion: form.minCodexDesktopVersion.trim() || null,
minCodexCliVersion: form.minCodexCliVersion.trim() || null,
@@ -248,6 +259,7 @@ export function useSettingsForm() {
maxWaitingPerKeyValue,
maxWaitingPerAccountValue,
concurrencyWaitTimeoutSecondsValue,
responsesMaxDecompressedBodyMiBValue,
accountAutoFreezeThresholdValue,
accountAutoFreezeWindowSecondsValue,
accountAutoFreezeDurationSecondsValue,
+2
View File
@@ -54,6 +54,7 @@ const {
maxWaitingPerKeyValue,
maxWaitingPerAccountValue,
concurrencyWaitTimeoutSecondsValue,
responsesMaxDecompressedBodyMiBValue,
accountAutoFreezeThresholdValue,
accountAutoFreezeWindowSecondsValue,
accountAutoFreezeDurationSecondsValue,
@@ -139,6 +140,7 @@ watch(
v-model:max-waiting-per-key="maxWaitingPerKeyValue"
v-model:max-waiting-per-account="maxWaitingPerAccountValue"
v-model:concurrency-wait-timeout-seconds="concurrencyWaitTimeoutSecondsValue"
v-model:responses-max-decompressed-body-mi-b="responsesMaxDecompressedBodyMiBValue"
/>
<RequestLocationCard v-model="form.requestLocation" v-model:enabled="form.requestLocationEnabled" :disabled="loading || saving" />