Skip to content

Commit 5e65b33

Browse files
committed
US-021: Add request body size pre-flight check for OpenAI-compatible provider
1 parent 87b982e commit 5e65b33

2 files changed

Lines changed: 171 additions & 5 deletions

File tree

rust/crates/api/src/error.rs

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,11 @@ pub enum ApiError {
6363
attempt: u32,
6464
base_delay: Duration,
6565
},
66+
RequestBodySizeExceeded {
67+
estimated_bytes: usize,
68+
max_bytes: usize,
69+
provider: &'static str,
70+
},
6671
}
6772

6873
impl ApiError {
@@ -129,7 +134,8 @@ impl ApiError {
129134
| Self::Io(_)
130135
| Self::Json { .. }
131136
| Self::InvalidSseFrame(_)
132-
| Self::BackoffOverflow { .. } => false,
137+
| Self::BackoffOverflow { .. }
138+
| Self::RequestBodySizeExceeded { .. } => false,
133139
}
134140
}
135141

@@ -147,7 +153,8 @@ impl ApiError {
147153
| Self::Io(_)
148154
| Self::Json { .. }
149155
| Self::InvalidSseFrame(_)
150-
| Self::BackoffOverflow { .. } => None,
156+
| Self::BackoffOverflow { .. }
157+
| Self::RequestBodySizeExceeded { .. } => None,
151158
}
152159
}
153160

@@ -172,6 +179,7 @@ impl ApiError {
172179
"provider_transport"
173180
}
174181
Self::InvalidApiKeyEnv(_) | Self::Io(_) | Self::Json { .. } => "runtime_io",
182+
Self::RequestBodySizeExceeded { .. } => "request_size",
175183
}
176184
}
177185

@@ -194,7 +202,8 @@ impl ApiError {
194202
| Self::Io(_)
195203
| Self::Json { .. }
196204
| Self::InvalidSseFrame(_)
197-
| Self::BackoffOverflow { .. } => false,
205+
| Self::BackoffOverflow { .. }
206+
| Self::RequestBodySizeExceeded { .. } => false,
198207
}
199208
}
200209

@@ -223,7 +232,8 @@ impl ApiError {
223232
| Self::Io(_)
224233
| Self::Json { .. }
225234
| Self::InvalidSseFrame(_)
226-
| Self::BackoffOverflow { .. } => false,
235+
| Self::BackoffOverflow { .. }
236+
| Self::RequestBodySizeExceeded { .. } => false,
227237
}
228238
}
229239
}
@@ -324,6 +334,16 @@ impl Display for ApiError {
324334
f,
325335
"retry backoff overflowed on attempt {attempt} with base delay {base_delay:?}"
326336
),
337+
Self::RequestBodySizeExceeded {
338+
estimated_bytes,
339+
max_bytes,
340+
provider,
341+
} => write!(
342+
f,
343+
"request body size ({} bytes) exceeds {provider} limit ({} bytes); reduce prompt length or context before retrying",
344+
estimated_bytes,
345+
max_bytes
346+
),
327347
}
328348
}
329349
}

rust/crates/api/src/providers/openai_compat.rs

Lines changed: 147 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,12 +31,22 @@ pub struct OpenAiCompatConfig {
3131
pub api_key_env: &'static str,
3232
pub base_url_env: &'static str,
3333
pub default_base_url: &'static str,
34+
/// Maximum request body size in bytes. Provider-specific limits:
35+
/// - DashScope: 6MB (6_291_456 bytes) - observed in dogfood testing
36+
/// - OpenAI: 100MB (104_857_600 bytes)
37+
/// - xAI: 50MB (52_428_800 bytes)
38+
pub max_request_body_bytes: usize,
3439
}
3540

3641
const XAI_ENV_VARS: &[&str] = &["XAI_API_KEY"];
3742
const OPENAI_ENV_VARS: &[&str] = &["OPENAI_API_KEY"];
3843
const DASHSCOPE_ENV_VARS: &[&str] = &["DASHSCOPE_API_KEY"];
3944

45+
// Provider-specific request body size limits in bytes
46+
const XAI_MAX_REQUEST_BODY_BYTES: usize = 52_428_800; // 50MB
47+
const OPENAI_MAX_REQUEST_BODY_BYTES: usize = 104_857_600; // 100MB
48+
const DASHSCOPE_MAX_REQUEST_BODY_BYTES: usize = 6_291_456; // 6MB (observed limit in dogfood)
49+
4050
impl OpenAiCompatConfig {
4151
#[must_use]
4252
pub const fn xai() -> Self {
@@ -45,6 +55,7 @@ impl OpenAiCompatConfig {
4555
api_key_env: "XAI_API_KEY",
4656
base_url_env: "XAI_BASE_URL",
4757
default_base_url: DEFAULT_XAI_BASE_URL,
58+
max_request_body_bytes: XAI_MAX_REQUEST_BODY_BYTES,
4859
}
4960
}
5061

@@ -55,6 +66,7 @@ impl OpenAiCompatConfig {
5566
api_key_env: "OPENAI_API_KEY",
5667
base_url_env: "OPENAI_BASE_URL",
5768
default_base_url: DEFAULT_OPENAI_BASE_URL,
69+
max_request_body_bytes: OPENAI_MAX_REQUEST_BODY_BYTES,
5870
}
5971
}
6072

@@ -69,6 +81,7 @@ impl OpenAiCompatConfig {
6981
api_key_env: "DASHSCOPE_API_KEY",
7082
base_url_env: "DASHSCOPE_BASE_URL",
7183
default_base_url: DEFAULT_DASHSCOPE_BASE_URL,
84+
max_request_body_bytes: DASHSCOPE_MAX_REQUEST_BODY_BYTES,
7285
}
7386
}
7487

@@ -249,6 +262,9 @@ impl OpenAiCompatClient {
249262
&self,
250263
request: &MessageRequest,
251264
) -> Result<reqwest::Response, ApiError> {
265+
// Pre-flight check: verify request body size against provider limits
266+
check_request_body_size(request, self.config())?;
267+
252268
let request_url = chat_completions_endpoint(&self.base_url);
253269
self.http
254270
.post(&request_url)
@@ -791,9 +807,41 @@ fn strip_routing_prefix(model: &str) -> &str {
791807
}
792808
}
793809

810+
/// Estimate the serialized JSON size of a request payload in bytes.
811+
/// This is a pre-flight check to avoid hitting provider-specific size limits.
812+
pub fn estimate_request_body_size(request: &MessageRequest, config: OpenAiCompatConfig) -> usize {
813+
let payload = build_chat_completion_request(request, config);
814+
// serde_json::to_vec gives us the exact byte size of the serialized JSON
815+
serde_json::to_vec(&payload).map_or(0, |v| v.len())
816+
}
817+
818+
/// Pre-flight check for request body size against provider limits.
819+
/// Returns Ok(()) if the request is within limits, or an error with
820+
/// a clear message about the size limit being exceeded.
821+
pub fn check_request_body_size(
822+
request: &MessageRequest,
823+
config: OpenAiCompatConfig,
824+
) -> Result<(), ApiError> {
825+
let estimated_bytes = estimate_request_body_size(request, config);
826+
let max_bytes = config.max_request_body_bytes;
827+
828+
if estimated_bytes > max_bytes {
829+
Err(ApiError::RequestBodySizeExceeded {
830+
estimated_bytes,
831+
max_bytes,
832+
provider: config.provider_name,
833+
})
834+
} else {
835+
Ok(())
836+
}
837+
}
838+
794839
/// Builds a chat completion request payload from a `MessageRequest`.
795840
/// Public for benchmarking purposes.
796-
pub fn build_chat_completion_request(request: &MessageRequest, config: OpenAiCompatConfig) -> Value {
841+
pub fn build_chat_completion_request(
842+
request: &MessageRequest,
843+
config: OpenAiCompatConfig,
844+
) -> Value {
797845
let mut messages = Vec::new();
798846
if let Some(system) = request.system.as_ref().filter(|value| !value.is_empty()) {
799847
messages.push(json!({
@@ -2031,4 +2079,102 @@ mod tests {
20312079
assert_eq!(tool_msg_gpt["content"], json!("file contents"));
20322080
assert_eq!(tool_msg_kimi["content"], json!("file contents"));
20332081
}
2082+
2083+
// ============================================================================
2084+
// US-021: Request body size pre-flight check tests
2085+
// ============================================================================
2086+
2087+
#[test]
2088+
fn estimate_request_body_size_returns_reasonable_estimate() {
2089+
let request = MessageRequest {
2090+
model: "gpt-4o".to_string(),
2091+
max_tokens: 100,
2092+
messages: vec![InputMessage::user_text("Hello world".to_string())],
2093+
stream: false,
2094+
..Default::default()
2095+
};
2096+
2097+
let size = super::estimate_request_body_size(&request, OpenAiCompatConfig::openai());
2098+
// Should be non-zero and reasonable for a small request
2099+
assert!(size > 0, "estimated size should be positive");
2100+
assert!(size < 10_000, "small request should be under 10KB");
2101+
}
2102+
2103+
#[test]
2104+
fn check_request_body_size_passes_for_small_requests() {
2105+
let request = MessageRequest {
2106+
model: "gpt-4o".to_string(),
2107+
max_tokens: 100,
2108+
messages: vec![InputMessage::user_text("Hello".to_string())],
2109+
stream: false,
2110+
..Default::default()
2111+
};
2112+
2113+
// Should pass for all providers with a small request
2114+
assert!(super::check_request_body_size(&request, OpenAiCompatConfig::openai()).is_ok());
2115+
assert!(super::check_request_body_size(&request, OpenAiCompatConfig::xai()).is_ok());
2116+
assert!(super::check_request_body_size(&request, OpenAiCompatConfig::dashscope()).is_ok());
2117+
}
2118+
2119+
#[test]
2120+
fn check_request_body_size_fails_for_dashscope_when_exceeds_6mb() {
2121+
// Create a request that exceeds DashScope's 6MB limit
2122+
let large_content = "x".repeat(7_000_000); // 7MB of content
2123+
let request = MessageRequest {
2124+
model: "qwen-plus".to_string(),
2125+
max_tokens: 100,
2126+
messages: vec![InputMessage::user_text(large_content)],
2127+
stream: false,
2128+
..Default::default()
2129+
};
2130+
2131+
let result = super::check_request_body_size(&request, OpenAiCompatConfig::dashscope());
2132+
assert!(result.is_err(), "should fail for 7MB request to DashScope");
2133+
2134+
let err = result.unwrap_err();
2135+
match err {
2136+
crate::error::ApiError::RequestBodySizeExceeded {
2137+
estimated_bytes,
2138+
max_bytes,
2139+
provider,
2140+
} => {
2141+
assert_eq!(provider, "DashScope");
2142+
assert_eq!(max_bytes, 6_291_456); // 6MB limit
2143+
assert!(estimated_bytes > max_bytes);
2144+
}
2145+
_ => panic!("expected RequestBodySizeExceeded error, got {:?}", err),
2146+
}
2147+
}
2148+
2149+
#[test]
2150+
fn check_request_body_size_allows_large_requests_for_openai() {
2151+
// Create a request that exceeds DashScope's limit but is under OpenAI's 100MB limit
2152+
let large_content = "x".repeat(10_000_000); // 10MB of content
2153+
let request = MessageRequest {
2154+
model: "gpt-4o".to_string(),
2155+
max_tokens: 100,
2156+
messages: vec![InputMessage::user_text(large_content)],
2157+
stream: false,
2158+
..Default::default()
2159+
};
2160+
2161+
// Should pass for OpenAI (100MB limit)
2162+
assert!(
2163+
super::check_request_body_size(&request, OpenAiCompatConfig::openai()).is_ok(),
2164+
"10MB request should pass for OpenAI's 100MB limit"
2165+
);
2166+
2167+
// Should fail for DashScope (6MB limit)
2168+
assert!(
2169+
super::check_request_body_size(&request, OpenAiCompatConfig::dashscope()).is_err(),
2170+
"10MB request should fail for DashScope's 6MB limit"
2171+
);
2172+
}
2173+
2174+
#[test]
2175+
fn provider_specific_size_limits_are_correct() {
2176+
assert_eq!(OpenAiCompatConfig::dashscope().max_request_body_bytes, 6_291_456); // 6MB
2177+
assert_eq!(OpenAiCompatConfig::openai().max_request_body_bytes, 104_857_600); // 100MB
2178+
assert_eq!(OpenAiCompatConfig::xai().max_request_body_bytes, 52_428_800); // 50MB
2179+
}
20342180
}

0 commit comments

Comments
 (0)