Skip to content

Commit c8a7b6f

Browse files
vertex-sdk-botcopybara-github
authored andcommitted
fix: parse Server-Sent Events in Agent Engine streaming responses
Agent Engine streaming responses are newline-delimited JSON. A response may instead arrive as Server-Sent Events, with each JSON object wrapped in a `data:` frame. The streaming readers now remove that framing before parsing, so both shapes yield the same parsed objects rather than raw `data: {...}` strings, and the `HttpBody` reader parses a `text/event-stream` response rather than yielding the unparsed message. Newline-delimited JSON responses are unaffected. PiperOrigin-RevId: 959194089
1 parent 2a18bff commit c8a7b6f

5 files changed

Lines changed: 218 additions & 3 deletions

File tree

agentplatform/_genai/_agent_engines_utils.py

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1888,6 +1888,39 @@ async def _method(self, **kwargs) -> Any: # type: ignore[no-untyped-def]
18881888
return _method # type: ignore[return-value]
18891889

18901890

1891+
_SSE_DATA_PREFIX = "data:"
1892+
_STREAMABLE_CONTENT_TYPES = ("application/json", "text/event-stream")
1893+
1894+
1895+
def _strip_sse_framing(line: str) -> str:
1896+
"""Returns the payload of a Server-Sent Events `data:` line.
1897+
1898+
Streaming responses are newline-delimited JSON. A response may instead
1899+
arrive as Server-Sent Events, in which case each JSON object is wrapped in a
1900+
`data:` frame; removing that framing here lets both shapes be parsed the
1901+
same way
1902+
(https://github.com/googleapis/python-aiplatform/issues/5586).
1903+
1904+
A serialized JSON value never begins with `data:` -- it begins with `{`,
1905+
`[`, `"`, a digit, or one of `true`/`false`/`null` -- so stripping the
1906+
prefix unconditionally cannot corrupt a newline-delimited JSON response. A
1907+
chunk whose value is the string `data: hello` is serialized as
1908+
`"data: hello"`, with the quote first.
1909+
1910+
Args:
1911+
line: A single line of the response body.
1912+
1913+
Returns:
1914+
The line with any SSE `data:` framing removed.
1915+
"""
1916+
line = line.rstrip("\r")
1917+
if not line.startswith(_SSE_DATA_PREFIX):
1918+
return line
1919+
# The single space after the colon is optional per the SSE specification.
1920+
payload = line[len(_SSE_DATA_PREFIX) :]
1921+
return payload[1:] if payload.startswith(" ") else payload
1922+
1923+
18911924
def _yield_parsed_json(http_response: google_genai_types.HttpResponse) -> Iterator[Any]:
18921925
"""Converts the body of the HTTP Response message to JSON format.
18931926
@@ -1904,6 +1937,9 @@ def _yield_parsed_json(http_response: google_genai_types.HttpResponse) -> Iterat
19041937

19051938
# Handle the case of multiple dictionaries delimited by newlines.
19061939
for line in http_response.body.split("\n"):
1940+
# Strip before the emptiness check so the blank line that terminates an
1941+
# SSE frame, and a `data:` line with an empty payload, are both skipped.
1942+
line = _strip_sse_framing(line)
19071943
if line:
19081944
try:
19091945
line = json.loads(line)
@@ -1931,7 +1967,11 @@ def _yield_parsed_json_from_httpbody(body: httpbody_pb2.HttpBody) -> Iterator[An
19311967
content_type = getattr(body, "content_type", None)
19321968
data = getattr(body, "data", None)
19331969

1934-
if content_type is None or data is None or "application/json" not in content_type:
1970+
if (
1971+
content_type is None
1972+
or data is None
1973+
or not any(t in content_type for t in _STREAMABLE_CONTENT_TYPES)
1974+
):
19351975
yield body
19361976
return
19371977

@@ -1948,6 +1988,9 @@ def _yield_parsed_json_from_httpbody(body: httpbody_pb2.HttpBody) -> Iterator[An
19481988

19491989
# Handle the case of multiple dictionaries delimited by newlines.
19501990
for line in utf8_data.split("\n"):
1991+
# Strip before the emptiness check so the blank line that terminates an
1992+
# SSE frame, and a `data:` line with an empty payload, are both skipped.
1993+
line = _strip_sse_framing(line)
19511994
if line:
19521995
try:
19531996
line = json.loads(line)

tests/unit/agentplatform/genai/test_agent_engines.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1805,6 +1805,60 @@ def test_yield_parsed_json_from_httpbody(self, obj, expected):
18051805
got = list(_agent_engines_utils._yield_parsed_json_from_httpbody(obj))
18061806
assert got == expected
18071807

1808+
1809+
# pytest does not allow absl.testing.parameterized.named_parameters.
1810+
@pytest.mark.parametrize(
1811+
"obj, expected",
1812+
[
1813+
(
1814+
# "sse_single_event",
1815+
genai_types.HttpResponse(body='data: {"a": 1}\n\n'),
1816+
[{"a": 1}],
1817+
),
1818+
(
1819+
# "sse_multiple_events",
1820+
genai_types.HttpResponse(
1821+
body='data: {"a": 1}\n\ndata: {"a": 2}\n\n'
1822+
),
1823+
[{"a": 1}, {"a": 2}],
1824+
),
1825+
(
1826+
# "sse_no_space_after_colon",
1827+
genai_types.HttpResponse(body='data:{"a": 1}\n\n'),
1828+
[{"a": 1}],
1829+
),
1830+
(
1831+
# "sse_crlf_line_endings",
1832+
genai_types.HttpResponse(body='data: {"a": 1}\r\n\r\n'),
1833+
[{"a": 1}],
1834+
),
1835+
(
1836+
# "sse_empty_data_line_is_skipped",
1837+
genai_types.HttpResponse(body='data:\n\ndata: {"a": 1}\n\n'),
1838+
[{"a": 1}],
1839+
),
1840+
(
1841+
# "json_string_value_beginning_with_data_is_untouched",
1842+
genai_types.HttpResponse(body='"data: hello"'),
1843+
["data: hello"],
1844+
),
1845+
],
1846+
)
1847+
def test_to_parsed_json_server_sent_events(self, obj, expected):
1848+
"""An SSE-framed response is parsed into the same objects as NDJSON."""
1849+
assert list(_agent_engines_utils._yield_parsed_json(obj)) == expected
1850+
1851+
def test_yield_parsed_json_from_httpbody_event_stream(self):
1852+
"""The gRPC path parses SSE instead of yielding the raw proto."""
1853+
body = httpbody_pb2.HttpBody(
1854+
content_type="text/event-stream",
1855+
data=b'data: {"a": 1}\n\ndata: {"a": 2}\n\n',
1856+
)
1857+
assert list(_agent_engines_utils._yield_parsed_json_from_httpbody(body)) == [
1858+
{"a": 1},
1859+
{"a": 2},
1860+
]
1861+
18081862
def test_yield_parsed_json_from_httpbody_non_json_content_type(self):
18091863
body = httpbody_pb2.HttpBody(content_type="text/plain", data=b"hello")
18101864
assert list(_agent_engines_utils._yield_parsed_json_from_httpbody(body)) == [

vertexai/_genai/_agent_engines_utils.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2007,6 +2007,39 @@ async def _method(self, **kwargs) -> Any: # type: ignore[no-untyped-def]
20072007
_wrap_a2a_operation = _wrap_a2a_operation_v03
20082008

20092009

2010+
_SSE_DATA_PREFIX = "data:"
2011+
_STREAMABLE_CONTENT_TYPES = ("application/json", "text/event-stream")
2012+
2013+
2014+
def _strip_sse_framing(line: str) -> str:
2015+
"""Returns the payload of a Server-Sent Events `data:` line.
2016+
2017+
Streaming responses are newline-delimited JSON. A response may instead
2018+
arrive as Server-Sent Events, in which case each JSON object is wrapped in a
2019+
`data:` frame; removing that framing here lets both shapes be parsed the
2020+
same way
2021+
(https://github.com/googleapis/python-aiplatform/issues/5586).
2022+
2023+
A serialized JSON value never begins with `data:` -- it begins with `{`,
2024+
`[`, `"`, a digit, or one of `true`/`false`/`null` -- so stripping the
2025+
prefix unconditionally cannot corrupt a newline-delimited JSON response. A
2026+
chunk whose value is the string `data: hello` is serialized as
2027+
`"data: hello"`, with the quote first.
2028+
2029+
Args:
2030+
line: A single line of the response body.
2031+
2032+
Returns:
2033+
The line with any SSE `data:` framing removed.
2034+
"""
2035+
line = line.rstrip("\r")
2036+
if not line.startswith(_SSE_DATA_PREFIX):
2037+
return line
2038+
# The single space after the colon is optional per the SSE specification.
2039+
payload = line[len(_SSE_DATA_PREFIX) :]
2040+
return payload[1:] if payload.startswith(" ") else payload
2041+
2042+
20102043
def _yield_parsed_json(http_response: google_genai_types.HttpResponse) -> Iterator[Any]:
20112044
"""Converts the body of the HTTP Response message to JSON format.
20122045
@@ -2023,6 +2056,9 @@ def _yield_parsed_json(http_response: google_genai_types.HttpResponse) -> Iterat
20232056

20242057
# Handle the case of multiple dictionaries delimited by newlines.
20252058
for line in http_response.body.split("\n"):
2059+
# Strip before the emptiness check so the blank line that terminates an
2060+
# SSE frame, and a `data:` line with an empty payload, are both skipped.
2061+
line = _strip_sse_framing(line)
20262062
if line:
20272063
try:
20282064
line = json.loads(line)

vertexai/agent_engines/_utils.py

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,40 @@ def to_json_serializable_autogen_object(
305305
return _autogen_run_response_protocol_to_dict(obj)
306306

307307

308+
309+
_SSE_DATA_PREFIX = "data:"
310+
_STREAMABLE_CONTENT_TYPES = ("application/json", "text/event-stream")
311+
312+
313+
def _strip_sse_framing(line: str) -> str:
314+
"""Returns the payload of a Server-Sent Events `data:` line.
315+
316+
Streaming responses are newline-delimited JSON. A response may instead
317+
arrive as Server-Sent Events, in which case each JSON object is wrapped in a
318+
`data:` frame; removing that framing here lets both shapes be parsed the
319+
same way
320+
(https://github.com/googleapis/python-aiplatform/issues/5586).
321+
322+
A serialized JSON value never begins with `data:` -- it begins with `{`,
323+
`[`, `"`, a digit, or one of `true`/`false`/`null` -- so stripping the
324+
prefix unconditionally cannot corrupt a newline-delimited JSON response. A
325+
chunk whose value is the string `data: hello` is serialized as
326+
`"data: hello"`, with the quote first.
327+
328+
Args:
329+
line: A single line of the response body.
330+
331+
Returns:
332+
The line with any SSE `data:` framing removed.
333+
"""
334+
line = line.rstrip("\r")
335+
if not line.startswith(_SSE_DATA_PREFIX):
336+
return line
337+
# The single space after the colon is optional per the SSE specification.
338+
payload = line[len(_SSE_DATA_PREFIX) :]
339+
return payload[1:] if payload.startswith(" ") else payload
340+
341+
308342
def yield_parsed_json(body: httpbody_pb2.HttpBody) -> Iterable[Any]:
309343
"""Converts the contents of the httpbody message to JSON format.
310344
@@ -318,7 +352,11 @@ def yield_parsed_json(body: httpbody_pb2.HttpBody) -> Iterable[Any]:
318352
content_type = getattr(body, "content_type", None)
319353
data = getattr(body, "data", None)
320354

321-
if content_type is None or data is None or "application/json" not in content_type:
355+
if (
356+
content_type is None
357+
or data is None
358+
or not any(t in content_type for t in _STREAMABLE_CONTENT_TYPES)
359+
):
322360
yield body
323361
return
324362

@@ -335,6 +373,9 @@ def yield_parsed_json(body: httpbody_pb2.HttpBody) -> Iterable[Any]:
335373

336374
# Handle the case of multiple dictionaries delimited by newlines.
337375
for line in utf8_data.split("\n"):
376+
# Strip before the emptiness check so the blank line that terminates an
377+
# SSE frame, and a `data:` line with an empty payload, are both skipped.
378+
line = _strip_sse_framing(line)
338379
if line:
339380
try:
340381
line = json.loads(line)

vertexai/reasoning_engines/_utils.py

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,40 @@ def to_json_serializable_llama_index_object(
162162
return str(obj)
163163

164164

165+
166+
_SSE_DATA_PREFIX = "data:"
167+
_STREAMABLE_CONTENT_TYPES = ("application/json", "text/event-stream")
168+
169+
170+
def _strip_sse_framing(line: str) -> str:
171+
"""Returns the payload of a Server-Sent Events `data:` line.
172+
173+
Streaming responses are newline-delimited JSON. A response may instead
174+
arrive as Server-Sent Events, in which case each JSON object is wrapped in a
175+
`data:` frame; removing that framing here lets both shapes be parsed the
176+
same way
177+
(https://github.com/googleapis/python-aiplatform/issues/5586).
178+
179+
A serialized JSON value never begins with `data:` -- it begins with `{`,
180+
`[`, `"`, a digit, or one of `true`/`false`/`null` -- so stripping the
181+
prefix unconditionally cannot corrupt a newline-delimited JSON response. A
182+
chunk whose value is the string `data: hello` is serialized as
183+
`"data: hello"`, with the quote first.
184+
185+
Args:
186+
line: A single line of the response body.
187+
188+
Returns:
189+
The line with any SSE `data:` framing removed.
190+
"""
191+
line = line.rstrip("\r")
192+
if not line.startswith(_SSE_DATA_PREFIX):
193+
return line
194+
# The single space after the colon is optional per the SSE specification.
195+
payload = line[len(_SSE_DATA_PREFIX) :]
196+
return payload[1:] if payload.startswith(" ") else payload
197+
198+
165199
def yield_parsed_json(body: httpbody_pb2.HttpBody) -> Iterable[Any]:
166200
"""Converts the contents of the httpbody message to JSON format.
167201
@@ -175,7 +209,11 @@ def yield_parsed_json(body: httpbody_pb2.HttpBody) -> Iterable[Any]:
175209
content_type = getattr(body, "content_type", None)
176210
data = getattr(body, "data", None)
177211

178-
if content_type is None or data is None or "application/json" not in content_type:
212+
if (
213+
content_type is None
214+
or data is None
215+
or not any(t in content_type for t in _STREAMABLE_CONTENT_TYPES)
216+
):
179217
yield body
180218
return
181219

@@ -192,6 +230,9 @@ def yield_parsed_json(body: httpbody_pb2.HttpBody) -> Iterable[Any]:
192230

193231
# Handle the case of multiple dictionaries delimited by newlines.
194232
for line in utf8_data.split("\n"):
233+
# Strip before the emptiness check so the blank line that terminates an
234+
# SSE frame, and a `data:` line with an empty payload, are both skipped.
235+
line = _strip_sse_framing(line)
195236
if line:
196237
try:
197238
line = json.loads(line)

0 commit comments

Comments
 (0)