Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 0 additions & 14 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,19 +1,5 @@
# Changelog

## [2.3.0](https://github.com/googleapis/python-genai/compare/v2.2.0...v2.3.0) (2026-05-15)


### Features

* Add content union to UserInputStep ([a5059a8](https://github.com/googleapis/python-genai/commit/a5059a82dc596f9555dd3221aa6e7414d50df24a))
* Interaction.{output_text,output_image,output_audio,output_video} ([975d16a](https://github.com/googleapis/python-genai/commit/975d16a3cea49282137dd2a901b219820a641b64))
* Migrate Agent Engines, Evaluation, Prompt Management, and Skill features to agentplatform ([abb1099](https://github.com/googleapis/python-genai/commit/abb1099fab3fc227acf53f3cdcd51a87679a51fe))


### Documentation

* Refresh generated docs for 2.2 ([2ce0298](https://github.com/googleapis/python-genai/commit/2ce02983753b1a0b4f7f5068b17996081e378b09))

## [2.2.0](https://github.com/googleapis/python-genai/compare/v2.1.0...v2.2.0) (2026-05-12)


Expand Down
4 changes: 3 additions & 1 deletion google/genai/_api_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -447,7 +447,9 @@ async def _aiter_response_stream(self) -> AsyncIterator[str]:
try:
while True:
# Read a line from the stream. This returns bytes.
line_bytes = await self.response_stream.content.readline()
line_bytes = await self.response_stream.content.readline(
max_line_length=READ_BUFFER_SIZE
)
if not line_bytes:
break
# Decode the bytes and remove trailing whitespace and newlines.
Expand Down
91 changes: 86 additions & 5 deletions google/genai/tests/client/test_async_stream.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ def __init__(self, lines: List[str]):
self.content.readline.side_effect = self._async_read_line
self.release = MagicMock()

async def _async_read_line(self) -> bytes:
async def _async_read_line(self, **kwargs) -> bytes:
if self._read_pos >= len(self._read_data):
return b"" # End of stream

Expand Down Expand Up @@ -240,7 +240,7 @@ async def test_aiohttp_simple_lines(responses: api_client.HttpResponse):
results = [line async for line in responses._aiter_response_stream()]

assert results == lines
mock_response.content.readline.assert_any_call()
mock_response.content.readline.assert_called()
mock_response.release.assert_called_once()


Expand All @@ -256,7 +256,7 @@ async def test_aiohttp_data_prefix(responses: api_client.HttpResponse):
results = [line async for line in responses._aiter_response_stream()]

assert results == ["{ 'message': 'hello' }", "{ 'status': 'ok' }"]
mock_response.content.readline.assert_any_call()
mock_response.content.readline.assert_called()
mock_response.release.assert_called_once()


Expand All @@ -278,7 +278,7 @@ async def test_aiohttp_multiple_json_chunks(responses: api_client.HttpResponse):
results = [line async for line in responses._aiter_response_stream()]

assert results == ['{ "id": 1 }', '{ "id": 2 }', '{ "id": 3 }']
mock_response.content.readline.assert_any_call()
mock_response.content.readline.assert_called()
mock_response.release.assert_called_once()


Expand All @@ -296,7 +296,88 @@ async def test_aiohttp_incomplete_json_at_end(
results = [line async for line in responses._aiter_response_stream()]

assert results == ['{ "partial": "data"']
mock_response.content.readline.assert_any_call()
mock_response.content.readline.assert_called()
mock_response.release.assert_called_once()


class MockAIOHTTPResponseWithLineLimits(aiohttp.ClientResponse):
"""Mock that enforces aiohttp's real readline limits.

Real aiohttp StreamReader raises LineTooLong when a line exceeds
`_high_water` (= limit * 2) bytes. The default limit is 2**16, so lines
over 131072 bytes fail unless `max_line_length` is explicitly passed.
"""

DEFAULT_HIGH_WATER = 2**16 * 2 # 131072, same as aiohttp default

def __init__(self, lines: List[str]):
self.content = MagicMock()
self.content.readline = AsyncMock()
self._read_data = b"\n".join(line.encode("utf-8") for line in lines) + b"\n"
self._read_pos = 0
self.content.readline.side_effect = self._async_read_line
self.release = MagicMock()

async def _async_read_line(
self, *, max_line_length=None
) -> bytes:
if self._read_pos >= len(self._read_data):
return b""

newline_pos = self._read_data.find(b"\n", self._read_pos)
if newline_pos == -1:
line = self._read_data[self._read_pos:]
self._read_pos = len(self._read_data)
else:
line = self._read_data[self._read_pos:newline_pos + 1]
self._read_pos = newline_pos + 1

# Enforce limit like real aiohttp StreamReader.readuntil does
limit = max_line_length or self.DEFAULT_HIGH_WATER
if len(line) > limit:
from aiohttp.http_exceptions import LineTooLong
raise LineTooLong(line[:100] + b"...", limit)

return line


@requires_aiohttp
@pytest.mark.asyncio
async def test_aiohttp_large_sse_line_with_thought_signature(
responses: api_client.HttpResponse,
):
"""Verifies large SSE lines (e.g. thoughtSignature) don't hit LineTooLong.

aiohttp's StreamReader.readline() enforces a maximum line length based on
the session's read_bufsize (default: 2**16), which gives a _high_water limit
of 131072 bytes. Thinking models can return a thoughtSignature field large
enough to push a single SSE data: line past this limit, causing LineTooLong.

The fix passes max_line_length=READ_BUFFER_SIZE (4MB) explicitly on the
readline() call in _aiter_response_stream(), overriding the limit at the
call site regardless of how the underlying aiohttp session was configured.

This test verifies the fix by using a mock that enforces the real aiohttp
readline limit and confirms a 150KB line is streamed successfully.
"""
api_client.has_aiohttp = True

# Build a single SSE line larger than aiohttp's default limit (131072)
large_thought_sig = "A" * 150_000 # > 131072 bytes
large_sse_payload = (
f'{{"candidates": [{{"content": {{"parts": [{{"text": "",'
f'"thoughtSignature": "{large_thought_sig}"}}]}}}}]}}'
)
lines = [f"data: {large_sse_payload}", ""]

mock_response = MockAIOHTTPResponseWithLineLimits(lines)
responses.response_stream = mock_response

results = [line async for line in responses._aiter_response_stream()]

assert len(results) == 1
assert "thoughtSignature" in results[0]
assert large_thought_sig in results[0]
mock_response.release.assert_called_once()


Expand Down
2 changes: 1 addition & 1 deletion google/genai/version.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,4 @@
# limitations under the License.
#

__version__ = '2.3.0' # x-release-please-version
__version__ = '2.2.0' # x-release-please-version
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ requires = ["setuptools", "wheel", "twine>=6.1.0", "packaging>=24.2", "pkginfo>=

[project]
name = "google-genai"
version = "2.3.0"
version = "2.2.0"
description = "GenAI Python SDK"
readme = "README.md"
license = "Apache-2.0"
Expand Down
Loading