From e6014961af353bd8368af5ef674923eb35e652e5 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 4 Nov 2026 18:52:42 +0000 Subject: [PATCH] test(gateway): clarify hash compatibility and relocate forwarding docs --- .../{ => crates/gateway-forward}/FORWARDING.md | 16 +++++++++------- .../crates/gateway-forward/tests/exchange.rs | 3 +++ .../crates/gateway-forward/tests/websocket.rs | 4 ++-- 3 files changed, 14 insertions(+), 9 deletions(-) rename litellm-rust/{ => crates/gateway-forward}/FORWARDING.md (80%) diff --git a/litellm-rust/FORWARDING.md b/litellm-rust/crates/gateway-forward/FORWARDING.md similarity index 80% rename from litellm-rust/FORWARDING.md rename to litellm-rust/crates/gateway-forward/FORWARDING.md index 58ac3d6300b..9a310157ca9 100644 --- a/litellm-rust/FORWARDING.md +++ b/litellm-rust/crates/gateway-forward/FORWARDING.md @@ -1,6 +1,6 @@ # Python forwarding compatibility -The opt-in forwarding gateway serves HTTP/1.1 and WebSockets in front of Python. Python still owns every route and policy. Its protocol contract and environment settings are in [gateway-forward/AGENTS.md](crates/gateway-forward/AGENTS.md) +The opt-in forwarding gateway serves HTTP/1.1 and WebSockets in front of Python. Python still owns every route and policy. Its protocol contract and environment settings are in [AGENTS.md](AGENTS.md) ## Known differences from uvicorn with h11 @@ -8,12 +8,14 @@ These cases remain executable as ignored socket tests. They are not passing pari | Input | Direct Python | Through Rust | Regression test | |---|---|---|---| -| Literal `#` in a request path or query | Keeps every byte | `http::Uri` truncates at `#`, potentially changing the route or query. Must be fixed before shipping | [forwards_an_unescaped_hash_in_the_target](crates/gateway-forward/tests/exchange.rs) | -| Header after a removed hop-by-hop field | Keeps the remaining header order | `HeaderMap::remove` can move a later header | [dropping_a_hop_by_hop_header_keeps_the_others_in_order](crates/gateway-forward/tests/exchange.rs) | -| Repeated header names interleaved with other names | Keeps the interleaving | Groups values by name, keeping their order within that name | [interleaved_repeated_headers_reach_python_interleaved](crates/gateway-forward/tests/exchange.rs) | -| Obsolete folded header, such as `x-a: b\r\n c` | Unfolds to `b c` | Rejects with 400 | [a_folded_header_reaches_python_unfolded](crates/gateway-forward/tests/exchange.rs) | -| Identical Content-Length list, such as `7, 7` | Accepts length 7 | Rejects with 400 | [a_content_length_list_of_one_value_reaches_python](crates/gateway-forward/tests/framing.rs) | -| HTTP/1.0 request with chunked transfer coding | Decodes the body | Rejects with 400 | [an_http10_chunked_upload_reaches_python](crates/gateway-forward/tests/framing.rs) | +| Literal `#` in a request path or query | Keeps every byte | `http::Uri` truncates at `#`, potentially changing the route or query. Must be fixed before shipping | [forwards_an_unescaped_hash_in_the_target](tests/exchange.rs) | +| Header after a removed hop-by-hop field | Keeps the remaining header order | `HeaderMap::remove` can move a later header | [dropping_a_hop_by_hop_header_keeps_the_others_in_order](tests/exchange.rs) | +| Repeated header names interleaved with other names | Keeps the interleaving | Groups values by name, keeping their order within that name | [interleaved_repeated_headers_reach_python_interleaved](tests/exchange.rs) | +| Obsolete folded header, such as `x-a: b\r\n c` | Unfolds to `b c` | Rejects with 400 | [a_folded_header_reaches_python_unfolded](tests/exchange.rs) | +| Identical Content-Length list, such as `7, 7` | Accepts length 7 | Rejects with 400 | [a_content_length_list_of_one_value_reaches_python](tests/framing.rs) | +| HTTP/1.0 request with chunked transfer coding | Decodes the body | Rejects with 400 | [an_http10_chunked_upload_reaches_python](tests/framing.rs) | + +The literal `#` issue does not affect browser hash routing, since browsers never send the fragment to the server. A hash encoded as `%23` is forwarded unchanged Clients can avoid the parser rejections by sending unfolded headers, one Content-Length value, and HTTP/1.1 for chunked uploads. Integrations must not depend on header ordering across different field names diff --git a/litellm-rust/crates/gateway-forward/tests/exchange.rs b/litellm-rust/crates/gateway-forward/tests/exchange.rs index fdc20def97b..ab866d3f998 100644 --- a/litellm-rust/crates/gateway-forward/tests/exchange.rs +++ b/litellm-rust/crates/gateway-forward/tests/exchange.rs @@ -201,6 +201,9 @@ async fn forwards_every_method(#[case] method: &str) { #[rstest] #[case::double_slash("//v1//models")] #[case::encoded_slash("/v1/files/a%2Fb/content")] +#[case::encoded_hash_in_query("/v1/files?purpose=batch%23retry&limit=1")] +#[case::encoded_hash_in_path("/v1/files/report%231.pdf/content")] +#[case::encoded_hash_before_query("/gemini/v1beta/files/a%23b?key=k")] #[case::encoded_query("/v1/models?q=%E2%9C%93&empty=&flag")] #[case::utf8_bytes_escaped("/mcp/%F0%9F%A6%80")] #[case::lowercase_encoded_slash("/v1/files/a%2fb/content")] diff --git a/litellm-rust/crates/gateway-forward/tests/websocket.rs b/litellm-rust/crates/gateway-forward/tests/websocket.rs index 3cda99fd6a5..ff64ddf38d3 100644 --- a/litellm-rust/crates/gateway-forward/tests/websocket.rs +++ b/litellm-rust/crates/gateway-forward/tests/websocket.rs @@ -250,7 +250,7 @@ async fn handshake_headers_cross_unchanged_both_ways() { &gateway, handshake( target, - "sec-websocket-protocol: realtime, openai-insecure-api-key.sk-1234, openai-beta.realtime-v1\r\nsec-websocket-protocol: second-line\r\nsec-websocket-extensions: permessage-deflate; client_max_window_bits\r\ncookie: token=abc\r\n", + "sec-websocket-protocol: realtime, openai-insecure-api-key.sk-websocket-test, openai-beta.realtime-v1\r\nsec-websocket-protocol: second-line\r\nsec-websocket-extensions: permessage-deflate; client_max_window_bits\r\ncookie: token=abc\r\n", ), ) .await; @@ -260,7 +260,7 @@ async fn handshake_headers_cross_unchanged_both_ways() { assert_eq!( values(&handshake, "sec-websocket-protocol"), [ - "realtime, openai-insecure-api-key.sk-1234, openai-beta.realtime-v1", + "realtime, openai-insecure-api-key.sk-websocket-test, openai-beta.realtime-v1", "second-line" ] );