Skip to content

Commit f894862

Browse files
authored
Support removing keys from HTTP request bodies (#661)
## Summary Add the ability to omit keys from the request body by setting them to `null` in `--backend-kwargs`. ## Details This PR makes it so any value in the request body that is `None` will be omitted from the body before being sent. ## Test Plan `--backend-kwargs '{"extras":"body":{"stream_options":{"continuous_usage_stats": None}}}'` ## Related Issues <!-- Link any relevant issues that this PR addresses. --> - Resolves #603 --- - [x] "I certify that all code in this PR is my own, except as noted below." ## Use of AI - [x] Includes AI-assisted code completion - [ ] Includes code generated by an AI application - [x] Includes AI-generated tests (NOTE: AI written tests should have a docstring that includes `## WRITTEN BY AI ##`)
2 parents 999b41f + a18bf00 commit f894862

File tree

6 files changed

+661
-2
lines changed

6 files changed

+661
-2
lines changed

src/guidellm/backends/openai/http.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
GenerationResponse,
2727
RequestInfo,
2828
)
29+
from guidellm.utils.dict import deep_filter
2930

3031
__all__ = [
3132
"OpenAIHTTPBackend",
@@ -390,6 +391,8 @@ async def resolve( # type: ignore[override, misc]
390391
if arguments.files
391392
else None
392393
)
394+
# Omit `None` from output JSON
395+
deep_filter(arguments.body or {}, lambda _, v: v is not None)
393396
request_json = arguments.body if not request_files else None
394397
request_data = arguments.body if request_files else None
395398

src/guidellm/schemas/request.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from pydantic import Field, computed_field
1616

1717
from guidellm.schemas.base import StandardBaseDict, StandardBaseModel
18+
from guidellm.utils.dict import deep_update
1819

1920
__all__ = [
2021
"GenerationRequest",
@@ -82,7 +83,8 @@ def model_combine(
8283
for combine in ("headers", "params", "body", "files"):
8384
if (val := additional_dict.get(combine)) is not None:
8485
current = getattr(self, combine, None) or {}
85-
setattr(self, combine, {**current, **val})
86+
deep_update(current, val)
87+
setattr(self, combine, current)
8688

8789
return self
8890

src/guidellm/utils/dict.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,11 @@
1+
"""Utility functions for working with dictionaries."""
2+
3+
from __future__ import annotations
4+
5+
from collections.abc import Callable, Hashable
6+
from typing import Any
7+
8+
19
def recursive_key_update(d, key_update_func):
210
if not isinstance(d, dict) and not isinstance(d, list):
311
return d
@@ -21,3 +29,31 @@ def recursive_key_update(d, key_update_func):
2129
for _, value in d.items():
2230
recursive_key_update(value, key_update_func)
2331
return d
32+
33+
34+
def deep_update(dict1: dict, dict2: dict) -> None:
35+
"""
36+
Update dict1 with values from dict2 recursively.
37+
38+
Modifies dict1 in-place. Does not handle circular references.
39+
Does not copy values. Does not merge lists.
40+
"""
41+
for key, val in dict2.items():
42+
if isinstance(val, dict) and key in dict1 and isinstance(dict1[key], dict):
43+
deep_update(dict1[key], val)
44+
else:
45+
dict1[key] = val
46+
47+
48+
def deep_filter(d: dict, predicate: Callable[[Hashable, Any], bool]) -> None:
49+
"""
50+
Recursively filters a dictionary based on a predicate function.
51+
52+
Modifies the input dictionary in-place. Does not handle circular references.
53+
Does not copy values. Does not filter lists.
54+
"""
55+
for key, value in list(d.items()):
56+
if isinstance(value, dict):
57+
deep_filter(value, predicate)
58+
elif not predicate(key, value):
59+
d.pop(key)

tests/unit/backends/openai/test_http.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -718,3 +718,83 @@ async def test_resolve_stream(
718718
with handler_patch:
719719
async for _response, _info in backend.resolve(request, request_info):
720720
pass
721+
722+
@pytest.mark.smoke
723+
@pytest.mark.asyncio
724+
@async_timeout(10.0)
725+
async def test_resolve_filters_none_from_request_body(
726+
self,
727+
httpx_mock: HTTPXMock,
728+
mock_request_handler,
729+
):
730+
"""
731+
Test that None values are filtered from request body.
732+
733+
This is a simple integration test confirming the backend works with
734+
None filtering. Deep testing of None filtering is covered by test_dict.py.
735+
736+
### WRITTEN BY AI ###
737+
"""
738+
# Track the actual request body sent
739+
sent_body = None
740+
741+
def capture_request(request: httpx.Request):
742+
nonlocal sent_body
743+
sent_body = json.loads(request.content)
744+
return httpx.Response(
745+
status_code=200,
746+
json={"choices": [{"message": {"content": "Response"}}]},
747+
)
748+
749+
httpx_mock.add_callback(capture_request, url="http://test/v1/chat/completions")
750+
751+
backend = OpenAIHTTPBackend(
752+
target="http://test",
753+
model="test-model",
754+
request_format="chat_completions",
755+
)
756+
await backend.process_startup()
757+
758+
request = GenerationRequest()
759+
request_info = RequestInfo(
760+
request_id="test-id",
761+
status="pending",
762+
scheduler_node_id=1,
763+
scheduler_process_id=1,
764+
scheduler_start_time=123.0,
765+
timings=RequestTimings(),
766+
)
767+
768+
# Configure mock handler to return body with None values
769+
mock_handler, handler_patch = mock_request_handler
770+
mock_handler.format.return_value = GenerationRequestArguments(
771+
body={
772+
"model": "gpt-4",
773+
"messages": [{"role": "user", "content": "test"}],
774+
"temperature": 0.7,
775+
"max_tokens": None, # Should be filtered out
776+
"top_p": None, # Should be filtered out
777+
"stream": None, # Should be filtered out
778+
}
779+
)
780+
mock_handler.compile_non_streaming.return_value = GenerationResponse(
781+
request_id="test-id", request_args="test args"
782+
)
783+
784+
with handler_patch:
785+
responses = []
786+
async for response, info in backend.resolve(request, request_info):
787+
responses.append((response, info))
788+
789+
# Verify the backend processed the request successfully
790+
assert len(responses) == 1
791+
assert responses[0][0].request_id == "test-id"
792+
793+
# Verify that None values were filtered out from the sent body
794+
assert sent_body is not None
795+
assert "model" in sent_body
796+
assert "messages" in sent_body
797+
assert "temperature" in sent_body
798+
assert "max_tokens" not in sent_body # None value filtered
799+
assert "top_p" not in sent_body # None value filtered
800+
assert "stream" not in sent_body # None value filtered

tests/unit/schemas/test_request.py

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,133 @@ def test_marshalling(self, valid_instances):
235235
for key, expected_value in constructor_args.items():
236236
assert getattr(reconstructed, key) == expected_value
237237

238+
@pytest.mark.regression
239+
def test_model_combine_deep_merge_nested_dicts(self):
240+
"""
241+
Test that nested dicts in headers and body are merged, not replaced.
242+
243+
This is the PRIMARY REGRESSION TEST for the shallow merge bug fix.
244+
Before the fix, merging nested dicts would completely replace them.
245+
After the fix using deep_update(), nested values are properly merged.
246+
247+
### WRITTEN BY AI ###
248+
"""
249+
base = GenerationRequestArguments(
250+
headers={"Authorization": "Bearer token1"},
251+
body={"model": "gpt-4", "parameters": {"temperature": 0.5, "top_p": 0.9}},
252+
)
253+
additional = GenerationRequestArguments(
254+
headers={"Content-Type": "application/json"},
255+
body={"parameters": {"temperature": 0.7, "max_tokens": 100}},
256+
)
257+
258+
result = base.model_combine(additional)
259+
260+
# Headers should be merged (both keys preserved)
261+
assert result.headers == {
262+
"Authorization": "Bearer token1",
263+
"Content-Type": "application/json",
264+
}
265+
266+
# Body should be merged, with nested parameters also merged
267+
assert result.body == {
268+
"model": "gpt-4",
269+
"parameters": {
270+
"temperature": 0.7, # Overwritten
271+
"top_p": 0.9, # Preserved from base
272+
"max_tokens": 100, # Added from additional
273+
},
274+
}
275+
276+
@pytest.mark.regression
277+
def test_model_combine_deep_merge_params(self):
278+
"""
279+
Test deep merge for params field.
280+
281+
### WRITTEN BY AI ###
282+
"""
283+
base = GenerationRequestArguments(
284+
params={"page": 1, "filters": {"type": "active", "status": "open"}},
285+
)
286+
additional = GenerationRequestArguments(
287+
params={"limit": 10, "filters": {"type": "archived"}},
288+
)
289+
290+
result = base.model_combine(additional)
291+
292+
assert result.params == {
293+
"page": 1,
294+
"limit": 10,
295+
"filters": {
296+
"type": "archived", # Overwritten
297+
"status": "open", # Preserved
298+
},
299+
}
300+
301+
@pytest.mark.regression
302+
def test_model_combine_deep_merge_files(self):
303+
"""
304+
Test deep merge for files field.
305+
306+
### WRITTEN BY AI ###
307+
"""
308+
base = GenerationRequestArguments(
309+
files={"file1": "data1", "config": {"format": "json", "encoding": "utf-8"}},
310+
)
311+
additional = GenerationRequestArguments(
312+
files={"file2": "data2", "config": {"compress": True}},
313+
)
314+
315+
result = base.model_combine(additional)
316+
317+
assert result.files == {
318+
"file1": "data1",
319+
"file2": "data2",
320+
"config": {
321+
"format": "json", # Preserved
322+
"encoding": "utf-8", # Preserved
323+
"compress": True, # Added
324+
},
325+
}
326+
327+
@pytest.mark.regression
328+
def test_model_combine_multiple_levels_nesting(self):
329+
"""
330+
Test deep merge with 3+ levels of nesting.
331+
332+
### WRITTEN BY AI ###
333+
"""
334+
base = GenerationRequestArguments(
335+
body={
336+
"level1": {
337+
"level2": {
338+
"level3": {"a": 1, "b": 2},
339+
"other": "value",
340+
},
341+
},
342+
},
343+
)
344+
additional = GenerationRequestArguments(
345+
body={
346+
"level1": {
347+
"level2": {
348+
"level3": {"b": 99, "c": 3},
349+
},
350+
},
351+
},
352+
)
353+
354+
result = base.model_combine(additional)
355+
356+
assert result.body == {
357+
"level1": {
358+
"level2": {
359+
"level3": {"a": 1, "b": 99, "c": 3},
360+
"other": "value",
361+
},
362+
},
363+
}
364+
238365

239366
class TestUsageMetrics:
240367
"""Test cases for UsageMetrics model."""

0 commit comments

Comments
 (0)