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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ Or run from source with the same login and environment variable:
cargo run --locked --release -- --listen 127.0.0.1:8080
```

The request size limit defaults to 64 MiB. Override it with `CODEX_GATEWAY_MAX_REQUEST_BYTES=67108864`
(pass it with Docker `-e`) or add `--max-request-bytes 67108864` to the executable
arguments. See [request limits](docs/common.md).

## Use

Generate text with the official OpenAI Python SDK:
Expand Down
11 changes: 9 additions & 2 deletions docs/common.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,20 @@ Use `Content-Type: application/json` unless an endpoint documents multipart or W

| Limit | Default |
| --- | --- |
| Request body / WebSocket message | 16 MiB |
| Decoded native zstd request | 16 MiB |
| Request body / WebSocket message and frame | 64 MiB; `--max-request-bytes` |
| Decoded native zstd request | Same as `--max-request-bytes` |
| Buffered tool response / HTTP Responses event stream | 64 MiB |
| Upstream model catalog | 16 MiB |
| Concurrent generation/native requests or WebSocket connections | 4; `--max-concurrency` |
| Request deadline / streaming idle timeout | 300 seconds; `--timeout-seconds` |

Set `CODEX_GATEWAY_MAX_REQUEST_BYTES` or `--max-request-bytes` to a positive
byte count (the CLI flag takes precedence). For example, `67108864` allows
64 MiB HTTP bodies, decoded zstd bodies, and WebSocket messages/frames.
Multipart overhead counts toward the HTTP limit. Restart to apply changes.
The upstream model catalog and buffered response limits are independent;
upstream service limits still apply.

Models and health checks do not occupy generation slots. Images, standalone search, uploads and native routes share slots with Responses. Signed transfers have separate limits. WebSocket slots last for the connection lifetime.

## Startup login
Expand Down
42 changes: 42 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,18 @@ struct Args {
max_concurrency: u16,
#[arg(long, default_value_t=300, value_parser=clap::value_parser!(u64).range(1..))]
timeout_seconds: u64,
/// Maximum HTTP body, decoded zstd body, and WebSocket message/frame size in bytes.
#[arg(long, env = "CODEX_GATEWAY_MAX_REQUEST_BYTES", default_value_t = 64 * 1024 * 1024, value_parser = parse_request_bytes)]
max_request_bytes: usize,
}
fn parse_request_bytes(value: &str) -> Result<usize, String> {
let bytes = value.parse::<usize>().map_err(|e| e.to_string())?;
if bytes == 0 || bytes > isize::MAX as usize {
return Err("must be between 1 and isize::MAX bytes".into());
}
Ok(bytes)
}

#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt()
Expand Down Expand Up @@ -68,6 +79,7 @@ async fn main() -> anyhow::Result<()> {
args.max_concurrency,
))),
timeout,
max_request_bytes: args.max_request_bytes,
transfers,
};
let listener = tokio::net::TcpListener::bind(args.listen).await?;
Expand All @@ -90,3 +102,33 @@ async fn main() -> anyhow::Result<()> {
}
Ok(())
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn request_limit_defaults_to_64_mib() {
use clap::CommandFactory;
let command = Args::command();
let argument = command
.get_arguments()
.find(|arg| arg.get_id() == "max_request_bytes")
.unwrap();
assert_eq!(
argument.get_default_values(),
&[std::ffi::OsString::from("67108864")]
);
}

#[test]
fn request_limit_cli_validation() {
for value in ["1", "16777216", "67108864"] {
let args = Args::try_parse_from(["gateway", "--max-request-bytes", value]).unwrap();
assert_eq!(args.max_request_bytes, value.parse::<usize>().unwrap());
}
for value in ["0", "-1", "invalid", "18446744073709551615"] {
assert!(Args::try_parse_from(["gateway", "--max-request-bytes", value]).is_err());
}
}
}
4 changes: 3 additions & 1 deletion src/native.rs
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,9 @@ pub async fn handle(
use axum::extract::FromRequestParts;
let ws = WebSocketUpgrade::from_request_parts(&mut parts, &())
.await
.map_err(|e| GatewayError::invalid(e.to_string()))?;
.map_err(|e| GatewayError::invalid(e.to_string()))?
.max_message_size(gateway.max_request_bytes)
.max_frame_size(gateway.max_request_bytes);
for name in [
"sec-websocket-key",
"sec-websocket-version",
Expand Down
4 changes: 2 additions & 2 deletions src/realtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -172,8 +172,8 @@ pub async fn upgrade(
.map(str::to_owned);
let mut response = ws
.protocols(protocols)
.max_message_size(16 * 1024 * 1024)
.max_frame_size(16 * 1024 * 1024)
.max_message_size(gateway.max_request_bytes)
.max_frame_size(gateway.max_request_bytes)
.on_upgrade(move |client| async move {
let _permit = permit;
crate::websocket::relay(client, upstream, idle, false, &[], "").await;
Expand Down
10 changes: 6 additions & 4 deletions src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ pub struct Gateway {
pub key: Option<String>,
pub concurrency: Arc<Semaphore>,
pub timeout: Duration,
pub max_request_bytes: usize,
pub transfers: Option<crate::transfers::Transfers>,
pub telemetry: Option<Arc<crate::telemetry::Telemetry>>,
}
Expand Down Expand Up @@ -71,7 +72,7 @@ pub fn router(gateway: Gateway) -> Router {
.route("/v1/live/sessions/{session_id}/attach", get(crate::realtime::upgrade))
.route("/transfers/{handle}", axum::routing::any(transfer))
.fallback(crate::native::handle)
.layer(DefaultBodyLimit::max(16 * 1024 * 1024))
.layer(DefaultBodyLimit::max(gateway.max_request_bytes))
.with_state(Arc::new(gateway))
}
async fn transfer(
Expand Down Expand Up @@ -274,6 +275,7 @@ async fn native_create(
body: Bytes,
) -> Result<Response, GatewayError> {
authorize(&gateway, &headers)?;
let max_request_bytes = gateway.max_request_bytes;
let body = match headers
.get("content-encoding")
.and_then(|v| v.to_str().ok())
Expand All @@ -285,15 +287,15 @@ async fn native_create(
.map_err(|_| GatewayError::invalid("invalid zstd request"))?;
let mut decoded = Vec::new();
decoder
.take(16 * 1024 * 1024 + 1)
.take(max_request_bytes as u64 + 1)
.read_to_end(&mut decoded)
.map_err(|_| GatewayError::invalid("invalid zstd request"))?;
if decoded.len() > 16 * 1024 * 1024 {
if decoded.len() > max_request_bytes {
return Err(GatewayError {
upstream_response: None,
status: StatusCode::PAYLOAD_TOO_LARGE,
code: "invalid_request_error",
message: "decoded body exceeds 16 MiB".into(),
message: format!("decoded body exceeds {max_request_bytes} bytes"),
});
}
Ok(decoded)
Expand Down
4 changes: 2 additions & 2 deletions src/websocket.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,8 @@ pub async fn upgrade(
.map(str::to_owned)
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
let mut response = ws
.max_message_size(16 * 1024 * 1024)
.max_frame_size(16 * 1024 * 1024)
.max_message_size(gateway.max_request_bytes)
.max_frame_size(gateway.max_request_bytes)
Comment on lines +56 to +57

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Apply the configured limit to native WebSockets

When a client uses a fallback WebSocket route such as /codex/guardian, /codex/guardian-classifier, or /backend-api/wham/remote/control/server, the connection is upgraded in native::handle, whose two ws.protocols(...).on_upgrade(...) branches never call max_message_size or max_frame_size. Consequently, --max-request-bytes only affects the Responses and Realtime WebSocket handlers: a smaller configured limit is not enforced on these native sockets, while a larger value cannot override Axum's defaults. Apply the same limits to the upgrade paths in src/native.rs.

Useful? React with 👍 / 👎.

.on_upgrade(move |client| async move {
let _permit = permit;
relay_with_telemetry(
Expand Down
86 changes: 86 additions & 0 deletions tests/cases/native.rs
Original file line number Diff line number Diff line change
Expand Up @@ -563,3 +563,89 @@ async fn audio_prefix_preserves_oauth_requests_and_upstream_errors() {
assert_eq!(response.status(), StatusCode::NOT_FOUND);
assert_eq!(h.fake.received.lock().unwrap().len(), 2);
}

#[tokio::test]
async fn native_websockets_enforce_configured_message_and_frame_limits() {
use futures::{SinkExt, StreamExt};
use tokio_tungstenite::tungstenite::protocol::frame::{
Frame,
coding::{Data, OpCode},
};
use tokio_tungstenite::tungstenite::{Message, client::IntoClientRequest};
for (path, delegated) in [
("/codex/guardian", false),
("/codex/guardian-classifier", false),
("/backend-api/wham/remote/control/server", true),
] {
for fragmented in [false, true] {
let h = Harness::with_request_limit(
vec![],
StatusCode::OK,
false,
Duration::from_secs(5),
CodexAuth::from_api_key("upstream-secret"),
false,
1024,
)
.await;
let mut request = format!("{}{path}", h.url.replace("http:", "ws:"))
.into_client_request()
.unwrap();
request.headers_mut().insert(
"authorization",
if delegated {
"Bearer remote-token"
} else {
"Bearer client-key"
}
.parse()
.unwrap(),
);
if delegated {
request.headers_mut().insert(
"x-codex-gateway-authorization",
"Bearer client-key".parse().unwrap(),
);
}
let (mut ws, _) = tokio_tungstenite::connect_async(request).await.unwrap();
// An exact-limit binary message must make the complete round trip.
ws.send(Message::Binary(vec![7; 1024].into()))
.await
.unwrap();
let reply = tokio::time::timeout(Duration::from_secs(2), ws.next())
.await
.unwrap()
.unwrap()
.unwrap();
assert_eq!(reply, Message::Binary(vec![7; 1024].into()), "{path}");
if fragmented {
// Each frame fits, but the assembled message exceeds the limit.
ws.send(Message::Frame(Frame::message(
vec![7; 512],
OpCode::Data(Data::Binary),
false,
)))
.await
.unwrap();
ws.send(Message::Frame(Frame::message(
vec![7; 513],
OpCode::Data(Data::Continue),
true,
)))
.await
.unwrap();
} else {
ws.send(Message::Binary(vec![7; 1025].into()))
.await
.unwrap();
}
let reply = tokio::time::timeout(Duration::from_secs(2), ws.next())
.await
.unwrap();
assert!(
matches!(reply, None | Some(Err(_)) | Some(Ok(Message::Close(_)))),
"{path} fragmented={fragmented}: {reply:?}"
);
}
}
}
1 change: 1 addition & 0 deletions tests/catalog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ async fn startup_catalog_exposes_new_models_and_uses_their_capabilities() {
);
}
let app = router(Gateway {
max_request_bytes: 16 * 1024 * 1024,
telemetry: None,
backend,
models,
Expand Down
Loading
Loading