88If no route is specified, falls through to vLLM's built-in /invocations
99handler (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
1721Usage: vllm serve --omni --middleware omni_sagemaker_serve.SageMakerRouteMiddleware
1822
2529no-op and SageMaker /invocations will return 404 for non-default routes.
2630"""
2731
32+ import json
2833import logging
2934import 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
3339logger = 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
3646def _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 \n Content-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+
45118class 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