Skip to content

Commit 748f15f

Browse files
feat(vllm-omni): accept application/json on video routes
Restore JSON->multipart conversion in the SageMaker routing middleware for the form-data video routes (/v1/videos, /v1/videos/sync). These upstream handlers only accept multipart/form-data, so a SageMaker caller sending application/json with route=/v1/videos/sync got a 400. The middleware now converts a JSON object body into multipart/form-data when the resolved route is one of FORM_DATA_ROUTES, so JSON callers work without changing the proven multipart path (byte-for-byte passthrough). Stays on vllm-omni 0.21.0rc1 (no framework bump): the 0.22.0 release ships a TrackingArgumentParser that crashes on argparse append actions (our --middleware flag), so the version bump is deferred until the upstream fix is released. Bumps dlc_minor_version 3 -> 4 for the middleware change.
1 parent bb35081 commit 748f15f

6 files changed

Lines changed: 428 additions & 9 deletions

File tree

.github/config/image/vllm-omni/ec2-amzn2023.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ build:
2525
torch_cuda_arch_list: "7.5;8.0;8.6;8.9;9.0;10.0;11.0;12.0+PTX"
2626
install_kv_connectors: "true"
2727
dlc_major_version: "1"
28-
dlc_minor_version: "3"
28+
dlc_minor_version: "4"
2929
use_sccache: "true"
3030

3131
release:

.github/config/image/vllm-omni/sagemaker-amzn2023.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ build:
2626
torch_cuda_arch_list: "7.5;8.0;8.6;8.9;9.0;10.0;11.0;12.0+PTX"
2727
install_kv_connectors: "true"
2828
dlc_major_version: "1"
29-
dlc_minor_version: "3"
29+
dlc_minor_version: "4"
3030
use_sccache: "true"
3131

3232
release:

.github/config/model-tests/vllm-omni-model-tests.yml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,21 @@ smoke-test:
104104
test_request: 'prompt=a dog running on a beach&num_frames=17&num_inference_steps=4&size=480x320&seed=42'
105105
validate: "binary_size_gt:1000"
106106

107+
# Sync route via application/json: the SageMaker routing middleware
108+
# (omni_sagemaker_serve.py) converts the JSON object into a
109+
# multipart/form-data body before handing it to the upstream
110+
# /v1/videos/sync handler, which only accepts form data. Exercises the
111+
# JSON content-type path so SageMaker callers can send JSON instead of
112+
# building a multipart body by hand.
113+
- name: "wan2.1-t2v-1.3b-sync-json"
114+
s3_model: "wan2.1-t2v-1.3b.tar.gz"
115+
fleet: "x86-g6exl-runner"
116+
extra_args: ""
117+
route: "/v1/videos/sync"
118+
content_type: "application/json"
119+
test_request: '{"prompt": "a dog running on a beach", "num_frames": 17, "num_inference_steps": 4, "size": "480x320", "seed": 42}'
120+
validate: "binary_size_gt:1000"
121+
107122
# Wan2.1-VACE: unified video creation/editing pipeline (WanVACEPipeline,
108123
# added in vllm-omni #1885). Distinct from WanPipeline T2V — accepts
109124
# text + optional video/mask/reference image. 1.3B variant fits L40S.

.github/workflows/vllm-omni.pr.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
name: "PR - vLLM-Omni"
22

33
on:
4+
workflow_dispatch:
45
pull_request:
56
branches: [main]
67
types: [opened, reopened, synchronize]

scripts/docker/vllm/omni_sagemaker_serve.py

Lines changed: 130 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,15 @@
88
If no route is specified, falls through to vLLM's built-in /invocations
99
handler (chat/completion/embed).
1010
11-
Clients targeting routes that require multipart/form-data (e.g. /v1/videos)
12-
must send the request with ContentType="multipart/form-data; boundary=..."
13-
and a pre-built multipart body. SageMaker InvokeEndpoint accepts arbitrary
14-
ContentType values and forwards them to the model server unchanged, so no
15-
in-middleware conversion is needed.
11+
Some routes (e.g. /v1/videos, /v1/videos/sync) are implemented upstream as
12+
multipart/form-data handlers. Clients may target them either with a
13+
pre-built multipart/form-data body, or — for convenience over SageMaker
14+
InvokeEndpoint — with a plain application/json object. When the resolved
15+
route is one of FORM_DATA_ROUTES and the request arrives as application/json,
16+
this middleware transparently converts the JSON object into a
17+
multipart/form-data body (each top-level key becomes a form field) so the
18+
upstream handler receives the encoding it expects. multipart/form-data bodies
19+
are passed through unchanged.
1620
1721
Usage: vllm serve --omni --middleware omni_sagemaker_serve.SageMakerRouteMiddleware
1822
@@ -25,13 +29,19 @@
2529
no-op and SageMaker /invocations will return 404 for non-default routes.
2630
"""
2731

32+
import json
2833
import logging
2934
import re
35+
import uuid
3036

31-
from starlette.types import ASGIApp, Receive, Scope, Send
37+
from starlette.types import ASGIApp, Message, Receive, Scope, Send
3238

3339
logger = logging.getLogger("omni_sagemaker")
3440

41+
# Routes whose upstream handlers expect multipart/form-data. A JSON body
42+
# targeting one of these is converted to multipart before it reaches vLLM.
43+
FORM_DATA_ROUTES = frozenset({"/v1/videos", "/v1/videos/sync"})
44+
3545

3646
def _parse_route(headers: list[tuple[bytes, bytes]]) -> str | None:
3747
"""Extract route=<path> from SageMaker custom attributes header."""
@@ -42,11 +52,79 @@ def _parse_route(headers: list[tuple[bytes, bytes]]) -> str | None:
4252
return None
4353

4454

55+
def _content_type(headers: list[tuple[bytes, bytes]]) -> str:
56+
"""Return the bare content-type (no parameters), lowercased."""
57+
for key, value in headers:
58+
if key.lower() == b"content-type":
59+
return value.decode().split(";", 1)[0].strip().lower()
60+
return ""
61+
62+
63+
def _field_value(value: object) -> str:
64+
"""Render a non-null JSON value as a multipart form field string.
65+
66+
Scalars map to their natural string form; bool maps to "true"/"false";
67+
nested objects/arrays are re-encoded as JSON. None is handled by the
68+
caller (_build_multipart drops null keys) and never reaches here.
69+
"""
70+
if isinstance(value, str):
71+
return value
72+
if isinstance(value, bool):
73+
return "true" if value else "false"
74+
if isinstance(value, (int, float)):
75+
return str(value)
76+
return json.dumps(value)
77+
78+
79+
def _escape_field_name(name: str) -> str:
80+
"""Escape a form-field name for the Content-Disposition header.
81+
82+
RFC 7578 requires CR/LF and double-quote to be escaped inside name="...".
83+
Without this a key containing " or a newline would corrupt the header.
84+
"""
85+
return name.replace("\\", "\\\\").replace('"', '\\"').replace("\r", "%0D").replace("\n", "%0A")
86+
87+
88+
def _build_multipart(fields: dict, boundary: str) -> bytes:
89+
# JSON null has no faithful form-data representation: a form field is
90+
# always a present string. Emitting the literal "null" would make the
91+
# upstream handler see a present-but-bogus value instead of an absent one,
92+
# bypassing its default-on-missing logic. Dropping the key reproduces what
93+
# a caller omitting the field would send, which is the closest match.
94+
parts = [
95+
f'--{boundary}\r\nContent-Disposition: form-data; name="{_escape_field_name(k)}"'
96+
f"\r\n\r\n{_field_value(v)}\r\n"
97+
for k, v in fields.items()
98+
if v is not None
99+
]
100+
parts.append(f"--{boundary}--\r\n")
101+
return "".join(parts).encode()
102+
103+
104+
def _replay(body: bytes) -> Receive:
105+
"""Build a receive callable that yields `body` as a single http.request."""
106+
sent = False
107+
108+
async def receive() -> Message:
109+
nonlocal sent
110+
if not sent:
111+
sent = True
112+
return {"type": "http.request", "body": body, "more_body": False}
113+
return {"type": "http.disconnect"}
114+
115+
return receive
116+
117+
45118
class SageMakerRouteMiddleware:
46119
"""ASGI middleware that reroutes /invocations based on CustomAttributes.
47120
48121
Explicit route via header -> rewrites path to that endpoint.
49122
No route specified -> falls through to vLLM's built-in /invocations handler.
123+
124+
When the resolved route expects multipart/form-data (FORM_DATA_ROUTES) but
125+
the body arrives as application/json, the JSON object is converted to a
126+
multipart/form-data body so the upstream handler receives its expected
127+
encoding.
50128
"""
51129

52130
def __init__(self, app: ASGIApp) -> None:
@@ -61,4 +139,50 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
61139
scope["path"] = route
62140
scope["raw_path"] = route.encode()
63141

142+
if (
143+
route in FORM_DATA_ROUTES
144+
and _content_type(scope.get("headers", [])) == "application/json"
145+
):
146+
scope, receive = await self._json_to_multipart(scope, receive)
147+
64148
await self.app(scope, receive, send)
149+
150+
async def _json_to_multipart(self, scope: Scope, receive: Receive) -> tuple[Scope, Receive]:
151+
"""Drain the JSON body, convert it to multipart, and return a new
152+
(scope, receive) carrying the rewritten content-type and body.
153+
154+
On any failure (non-object JSON, parse error) the original body is
155+
replayed untouched so the upstream handler produces its normal error
156+
response rather than this middleware masking it.
157+
"""
158+
body = b""
159+
more_body = True
160+
while more_body:
161+
message = await receive()
162+
body += message.get("body", b"")
163+
more_body = message.get("more_body", False)
164+
165+
try:
166+
parsed = json.loads(body)
167+
if not isinstance(parsed, dict):
168+
raise ValueError("JSON body for a form route must be an object")
169+
except (ValueError, json.JSONDecodeError) as exc:
170+
logger.warning("Could not convert JSON body to multipart: %s", exc)
171+
return scope, _replay(body)
172+
173+
boundary = uuid.uuid4().hex
174+
new_body = _build_multipart(parsed, boundary)
175+
content_type = f"multipart/form-data; boundary={boundary}".encode()
176+
177+
headers = [
178+
(k, v)
179+
for k, v in scope["headers"]
180+
if k.lower() not in (b"content-type", b"content-length")
181+
]
182+
headers.append((b"content-type", content_type))
183+
headers.append((b"content-length", str(len(new_body)).encode()))
184+
scope = dict(scope)
185+
scope["headers"] = headers
186+
187+
logger.info("Converted application/json body to multipart/form-data")
188+
return scope, _replay(new_body)

0 commit comments

Comments
 (0)