Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,13 @@ etcd:

proxy:
addr: "0.0.0.0:3000"
request_body_limit_bytes: 10485760 # 10 MiB
# Cap on inbound request bodies (JSON, multipart, passthrough, MCP,
# A2A). 0 — the default — disables the cap: providers accept larger
# requests than any fixed gateway default (Anthropic takes 32 MB), so
# a gateway-side cap rejects requests the upstream would have served.
# Set a value to bound per-request memory; over-limit requests get a
# 413 in the caller's error envelope.
# request_body_limit_bytes: 0
# tls:
# cert_file: "/etc/aisix/tls/proxy.crt"
# key_file: "/etc/aisix/tls/proxy.key"
Expand Down
4 changes: 3 additions & 1 deletion config.managed.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,9 @@ etcd:

proxy:
addr: "0.0.0.0:3000"
request_body_limit_bytes: 10485760
# 0 = no request-body cap (the default); set a value to bound
# per-request memory.
# request_body_limit_bytes: 0

admin:
# Bind to an unbindable port so even if managed mode somehow
Expand Down
14 changes: 12 additions & 2 deletions crates/aisix-core/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,14 @@ impl EtcdConfig {
#[serde(deny_unknown_fields)]
pub struct ProxyConfig {
pub addr: String,
/// Cap on inbound request bodies across the whole proxy surface
/// (JSON, multipart, passthrough, MCP, A2A). `0` — the default —
/// disables the cap, matching the reference LLM proxy's
/// out-of-box behaviour: providers accept larger requests than any
/// fixed gateway default (Anthropic takes 32 MB), so a gateway-side
/// cap rejects requests the upstream would have served. Set a value
/// to bound per-request memory; over-limit requests get a 413 in
/// the caller's error envelope.
#[serde(default = "ProxyConfig::default_body_limit")]
pub request_body_limit_bytes: usize,
#[serde(default)]
Expand All @@ -389,7 +397,7 @@ pub struct ProxyConfig {

impl ProxyConfig {
const fn default_body_limit() -> usize {
10 * 1024 * 1024
0
}
}

Expand Down Expand Up @@ -1082,7 +1090,9 @@ admin:
);
let cfg = Config::load_from_path(Some(f.path())).unwrap();
assert_eq!(cfg.etcd.endpoints, vec!["http://127.0.0.1:2379"]);
assert_eq!(cfg.proxy.request_body_limit_bytes, 10 * 1024 * 1024);
// `0` = no request-body cap, the out-of-box behaviour of the
// reference LLM proxy.
assert_eq!(cfg.proxy.request_body_limit_bytes, 0);
assert!(cfg.observability.metrics.prometheus.enabled);
// The dedicated metrics listener defaults to 0.0.0.0:9090 in
// every mode — no admin-listener fallback to fall out of sync with.
Expand Down
44 changes: 43 additions & 1 deletion crates/aisix-proxy/src/a2a.rs
Original file line number Diff line number Diff line change
Expand Up @@ -128,8 +128,21 @@ async fn dispatch(
let upstream = upstream_from_a2a_agent(&entry.value);

let (_parts, body) = request.into_parts();
let bytes = match to_bytes(body, state.request_body_limit_bytes).await {
let bytes = match to_bytes(
body,
crate::error::body_read_cap(state.request_body_limit_bytes),
)
.await
{
Ok(bytes) => bytes,
// Cap hit → 413 in the standard envelope, matching the
// Content-Length middleware's answer on this route.
Err(err) if crate::error::is_length_limit_error(&err) => {
return crate::error::ProxyError::RequestTooLarge {
limit_bytes: state.request_body_limit_bytes,
}
.into_response();
}
Err(_) => return (StatusCode::BAD_REQUEST, "invalid request body").into_response(),
};
let value: serde_json::Value = match serde_json::from_slice(&bytes) {
Expand Down Expand Up @@ -411,6 +424,35 @@ mod tests {
b.body(Body::from(body.to_string())).unwrap()
}

#[tokio::test]
async fn chunked_oversized_body_returns_enveloped_413() {
// Same contract as /mcp: a chunked body over the cap surfaces as
// the enveloped 413 from the handler's capped read, not the old
// bare 400.
let app = router_with(snapshot_with(
"http://127.0.0.1:1/a2a",
true,
serde_json::json!(["invoice"]),
));
let chunk = vec![b'a'; 200 * 1024];
let stream =
futures::stream::iter((0..10).map(move |_| Ok::<_, std::io::Error>(chunk.clone())));
let req = HttpRequest::post("/a2a/invoice")
.header("host", "a2a.aisix.example.com")
.header("content-type", "application/json")
.header("authorization", format!("Bearer {TOKEN}"))
.body(Body::from_stream(stream))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::PAYLOAD_TOO_LARGE);
let body = axum::body::to_bytes(resp.into_body(), 64 * 1024)
.await
.expect("read body");
let v: serde_json::Value =
serde_json::from_slice(&body).expect("413 must carry the JSON envelope");
assert_eq!(v["error"]["type"], "invalid_request_error");
}

#[tokio::test]
async fn endpoint_denies_key_without_allowed_agents_403() {
// Unreachable upstream on purpose: the ACL must reject BEFORE any
Expand Down
37 changes: 27 additions & 10 deletions crates/aisix-proxy/src/audio.rs
Original file line number Diff line number Diff line change
Expand Up @@ -282,8 +282,20 @@ pub async fn speech(
State(state): State<ProxyState>,
auth: AuthenticatedKey,
client: ClientContext,
Json(body): Json<Value>,
// Result-wrapped so an extractor-layer 413 maps to the OpenAI
// envelope — see completions.rs.
body: Result<Json<Value>, axum::extract::rejection::JsonRejection>,
) -> Response {
let Json(body) = match body {
Ok(json) => json,
Err(rej) => {
return crate::error::proxy_error_from_json_rejection(
rej,
state.request_body_limit_bytes,
)
.into_response();
}
};
let started = Instant::now();
let request_id = client.request_id.clone();
let api_key_id = auth.entry.id.clone();
Expand Down Expand Up @@ -407,18 +419,23 @@ async fn multipart_dispatch(
// outgoing reqwest multipart.
let mut fields: Vec<(String, Option<String>, Option<String>, Bytes)> = Vec::new();

while let Some(field) = multipart
.next_field()
.await
.map_err(|e| ProxyError::InvalidRequest(format!("multipart read error: {e}")))?
{
while let Some(field) = multipart.next_field().await.map_err(|e| {
crate::error::proxy_error_from_multipart(
e,
state.request_body_limit_bytes,
"multipart read error",
)
})? {
let name = field.name().unwrap_or("").to_string();
let file_name = field.file_name().map(|s| s.to_string());
let content_type = field.content_type().map(|s| s.to_string());
let data = field
.bytes()
.await
.map_err(|e| ProxyError::InvalidRequest(format!("multipart field read error: {e}")))?;
let data = field.bytes().await.map_err(|e| {
crate::error::proxy_error_from_multipart(
e,
state.request_body_limit_bytes,
"multipart field read error",
)
})?;
fields.push((name, file_name, content_type, data));
}

Expand Down
16 changes: 15 additions & 1 deletion crates/aisix-proxy/src/completions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -86,8 +86,22 @@ pub async fn completions(
State(state): State<ProxyState>,
auth: AuthenticatedKey,
client: ClientContext,
Json(body): Json<Value>,
// Result-wrapped so an extractor-layer 413 (chunked body over the
// cap) maps to the OpenAI envelope instead of axum's stock
// text/plain rejection — same discriminate-then-map pattern as
// chat.rs / messages.rs.
body: Result<Json<Value>, axum::extract::rejection::JsonRejection>,
) -> Response {
let Json(body) = match body {
Ok(json) => json,
Err(rej) => {
return crate::error::proxy_error_from_json_rejection(
rej,
state.request_body_limit_bytes,
)
.into_response();
}
};
let started = Instant::now();
let request_id = client.request_id.clone();
let api_key_id = auth.entry.id.clone();
Expand Down
59 changes: 59 additions & 0 deletions crates/aisix-proxy/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -617,6 +617,65 @@ pub(crate) fn proxy_error_from_json_rejection(
}
}

/// [`proxy_error_from_json_rejection`]'s sibling for handlers that take
/// the raw `Bytes` extractor (batches / fine-tuning): same 413-vs-400
/// discrimination, no JSON layer.
pub(crate) fn proxy_error_from_bytes_rejection(
rej: axum::extract::rejection::BytesRejection,
limit_bytes: usize,
) -> ProxyError {
if rej.status() == StatusCode::PAYLOAD_TOO_LARGE {
ProxyError::RequestTooLarge { limit_bytes }
} else {
ProxyError::InvalidRequest("failed to read request body".into())
}
}

/// Map a multipart read failure, preserving axum's 413 discrimination:
/// an over-cap stream or part is a real `RequestTooLarge` (axum's
/// `MultipartError::status()` already classifies it 413); everything
/// else stays the 400 the call site describes via `context`. Without
/// this, an over-limit chunked upload surfaced as a generic 400
/// `invalid_request_error` instead of `request_too_large`.
pub(crate) fn proxy_error_from_multipart(
err: axum::extract::multipart::MultipartError,
limit_bytes: usize,
context: &str,
) -> ProxyError {
if err.status() == StatusCode::PAYLOAD_TOO_LARGE {
ProxyError::RequestTooLarge { limit_bytes }
} else {
ProxyError::InvalidRequest(format!("{context}: {err}"))
}
}

/// Cap for manual `axum::body::to_bytes` reads: the configured
/// `request_body_limit_bytes` with the `0` = "no cap" sentinel widened to
/// `usize::MAX`, mirroring what `DefaultBodyLimit::disable()` does on the
/// extractor path.
pub(crate) fn body_read_cap(limit_bytes: usize) -> usize {
if limit_bytes == 0 {
usize::MAX
} else {
limit_bytes
}
}

/// Whether a manual body read failed because it hit the length cap
/// (→ 413) rather than a transport fault (→ 400). `axum::body::to_bytes`
/// folds both into one opaque `axum::Error`; the cap case carries
/// `http_body_util::LengthLimitError` in its source chain.
pub(crate) fn is_length_limit_error(err: &axum::Error) -> bool {
let mut source: Option<&(dyn std::error::Error + 'static)> = Some(err);
while let Some(e) = source {
if e.is::<http_body_util::LengthLimitError>() {
return true;
}
source = e.source();
}
false
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down
14 changes: 13 additions & 1 deletion crates/aisix-proxy/src/images.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,20 @@ pub async fn image_generations(
State(state): State<ProxyState>,
auth: AuthenticatedKey,
client: ClientContext,
Json(body): Json<Value>,
// Result-wrapped so an extractor-layer 413 maps to the OpenAI
// envelope — see completions.rs.
body: Result<Json<Value>, axum::extract::rejection::JsonRejection>,
) -> Response {
let Json(body) = match body {
Ok(json) => json,
Err(rej) => {
return crate::error::proxy_error_from_json_rejection(
rej,
state.request_body_limit_bytes,
)
.into_response();
}
};
let started = Instant::now();
let request_id = client.request_id.clone();
let api_key_id = auth.entry.id.clone();
Expand Down
60 changes: 50 additions & 10 deletions crates/aisix-proxy/src/jobs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -790,15 +790,21 @@ pub(crate) async fn create_file(
let mut form_model: Option<String> = None;
let mut file_bytes: Option<Bytes> = None;

while let Some(field) = multipart
.next_field()
.await
.map_err(|e| ProxyError::InvalidRequest(format!("malformed multipart body: {e}")))?
{
while let Some(field) = multipart.next_field().await.map_err(|e| {
crate::error::proxy_error_from_multipart(
e,
state.request_body_limit_bytes,
"malformed multipart body",
)
})? {
let name = field.name().unwrap_or_default().to_string();
if name == "model" {
let v = field.text().await.map_err(|e| {
ProxyError::InvalidRequest(format!("malformed multipart field: {e}"))
crate::error::proxy_error_from_multipart(
e,
state.request_body_limit_bytes,
"malformed multipart field",
)
})?;
if !v.trim().is_empty() {
form_model = Some(v.trim().to_string());
Expand All @@ -809,7 +815,11 @@ pub(crate) async fn create_file(
let file_name = field.file_name().unwrap_or("file").to_string();
let content_type = field.content_type().map(str::to_string);
let bytes = field.bytes().await.map_err(|e| {
ProxyError::InvalidRequest(format!("failed to read file field: {e}"))
crate::error::proxy_error_from_multipart(
e,
state.request_body_limit_bytes,
"failed to read file field",
)
})?;
let mut part = reqwest::multipart::Part::bytes(bytes.to_vec()).file_name(file_name);
if let Some(ct) = content_type {
Expand All @@ -822,7 +832,11 @@ pub(crate) async fn create_file(
continue;
}
let v = field.text().await.map_err(|e| {
ProxyError::InvalidRequest(format!("malformed multipart field: {e}"))
crate::error::proxy_error_from_multipart(
e,
state.request_body_limit_bytes,
"malformed multipart field",
)
})?;
form = form.text(name, v);
}
Expand Down Expand Up @@ -1008,8 +1022,21 @@ pub(crate) async fn create_batch(
client: ClientContext,
Query(params): Query<HashMap<String, String>>,
headers: HeaderMap,
body: Bytes,
// Result-wrapped so an extractor-layer 413 (chunked body over the
// cap) maps to the OpenAI envelope instead of axum's stock
// text/plain rejection — see completions.rs.
body: Result<Bytes, axum::extract::rejection::BytesRejection>,
) -> Response {
let body = match body {
Ok(bytes) => bytes,
Err(rej) => {
return crate::error::proxy_error_from_bytes_rejection(
rej,
state.request_body_limit_bytes,
)
.into_response();
}
};
let started = Instant::now();
let request_id = client.request_id.clone();
let mut monitor_hits: Vec<aisix_core::GuardrailMonitorHit> = Vec::new();
Expand Down Expand Up @@ -1235,8 +1262,21 @@ pub(crate) async fn create_ft_job(
client: ClientContext,
Query(params): Query<HashMap<String, String>>,
headers: HeaderMap,
body: Bytes,
// Result-wrapped so an extractor-layer 413 (chunked body over the
// cap) maps to the OpenAI envelope instead of axum's stock
// text/plain rejection — see completions.rs.
body: Result<Bytes, axum::extract::rejection::BytesRejection>,
) -> Response {
let body = match body {
Ok(bytes) => bytes,
Err(rej) => {
return crate::error::proxy_error_from_bytes_rejection(
rej,
state.request_body_limit_bytes,
)
.into_response();
}
};
let started = Instant::now();
let request_id = client.request_id.clone();
let mut monitor_hits: Vec<aisix_core::GuardrailMonitorHit> = Vec::new();
Expand Down
Loading
Loading