first vommit
This commit is contained in:
commit
a04e68bc95
4926 files changed
+764440
No files matched your search
@@ -0,0 +1,17 @@
|
||||
# Copyright 2025 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
|
||||
|
||||
"""Tests for the Google GenAI SDK."""
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,543 @@
|
||||
# Copyright 2025 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
|
||||
"""Tests for async stream."""
|
||||
|
||||
import asyncio
|
||||
from typing import List
|
||||
from unittest import mock
|
||||
from unittest.mock import AsyncMock
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import patch
|
||||
import pytest
|
||||
|
||||
|
||||
try:
|
||||
import aiohttp
|
||||
|
||||
AIOHTTP_NOT_INSTALLED = False
|
||||
except ImportError:
|
||||
AIOHTTP_NOT_INSTALLED = True
|
||||
aiohttp = mock.MagicMock()
|
||||
|
||||
import httpx
|
||||
|
||||
from ... import _api_client as api_client
|
||||
from ... import Client
|
||||
from ... import errors
|
||||
from ... import types
|
||||
|
||||
|
||||
EVENT_STREAM_DATA_WITH_ERROR = [
|
||||
b'{"candidates":[{"content":{"parts":[{"text":"test"}],"role":"model"}}]}',
|
||||
b'\n',
|
||||
b'{"error":{"code":500,"message":"Error","status":"INTERNAL"}}',
|
||||
]
|
||||
|
||||
|
||||
class MockHTTPXResponse(httpx.Response):
|
||||
"""Mock httpx.Response class for testing."""
|
||||
|
||||
def __init__(self, lines: List[str]):
|
||||
self.aiter_lines = MagicMock()
|
||||
self.aiter_lines.return_value.__aiter__ = MagicMock(
|
||||
return_value=self._async_line_iterator(lines)
|
||||
)
|
||||
self.aclose = AsyncMock()
|
||||
|
||||
async def _async_line_iterator(self, lines: List[str]):
|
||||
for line in lines:
|
||||
yield line
|
||||
|
||||
|
||||
class MockAIOHTTPResponse(aiohttp.ClientResponse):
|
||||
|
||||
def __init__(self, lines: List[str]):
|
||||
self.content = MagicMock()
|
||||
self.content.readline = AsyncMock()
|
||||
# Simulate reading lines, each ending with newline bytes for readline behavior
|
||||
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, **kwargs) -> bytes:
|
||||
if self._read_pos >= len(self._read_data):
|
||||
return b"" # End of stream
|
||||
|
||||
newline_pos = self._read_data.find(b"\n", self._read_pos)
|
||||
if newline_pos == -1: # Should not happen with the appended '\n'
|
||||
line = self._read_data[self._read_pos :]
|
||||
self._read_pos = len(self._read_data)
|
||||
return line
|
||||
else:
|
||||
line = self._read_data[self._read_pos : newline_pos + 1]
|
||||
self._read_pos = newline_pos + 1
|
||||
return line
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def responses() -> api_client.HttpResponse:
|
||||
return api_client.HttpResponse(headers={})
|
||||
|
||||
|
||||
requires_aiohttp = pytest.mark.skipif(
|
||||
AIOHTTP_NOT_INSTALLED, reason="aiohttp is not installed, skipping test."
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_has_aiohttp():
|
||||
yield
|
||||
api_client.has_aiohttp = False
|
||||
|
||||
|
||||
def test_invalid_response_stream_type(responses: api_client.HttpResponse):
|
||||
"""Tests that an invalid response stream type raises an error."""
|
||||
api_client.has_aiohttp = False
|
||||
with pytest.raises(
|
||||
TypeError,
|
||||
match=(
|
||||
"Expected self.response_stream to be an httpx.Response or"
|
||||
" aiohttp.ClientResponse object"
|
||||
),
|
||||
):
|
||||
|
||||
async def run():
|
||||
async for _ in responses._aiter_response_stream():
|
||||
pass
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_httpx_simple_lines(responses: api_client.HttpResponse):
|
||||
lines = ["hello", "world", "testing"]
|
||||
mock_response = MockHTTPXResponse(lines)
|
||||
responses.response_stream = mock_response
|
||||
|
||||
results = [line async for line in responses._aiter_response_stream()]
|
||||
|
||||
assert results == lines
|
||||
mock_response.aiter_lines.assert_called_once()
|
||||
mock_response.aclose.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_httpx_data_prefix(responses: api_client.HttpResponse):
|
||||
lines = ["data: { 'message': 'hello' }", "", "data: { 'status': 'ok' }"]
|
||||
mock_response = MockHTTPXResponse(lines)
|
||||
responses.response_stream = mock_response
|
||||
|
||||
results = [line async for line in responses._aiter_response_stream()]
|
||||
|
||||
assert results == ["{ 'message': 'hello' }", "{ 'status': 'ok' }"]
|
||||
mock_response.aiter_lines.assert_called_once()
|
||||
mock_response.aclose.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_httpx_multiline_data(responses: api_client.HttpResponse) -> None:
|
||||
lines = [
|
||||
"data: {",
|
||||
"data: 'message': 'hello'",
|
||||
"data: }",
|
||||
"",
|
||||
]
|
||||
mock_response = MockHTTPXResponse(lines)
|
||||
responses.response_stream = mock_response
|
||||
|
||||
results = [line async for line in responses._aiter_response_stream()]
|
||||
|
||||
assert results == ["{\n 'message': 'hello'\n}"]
|
||||
mock_response.aiter_lines.assert_called_once()
|
||||
mock_response.aclose.assert_called_once()
|
||||
|
||||
|
||||
def test_httpx_multiline_data_sync(responses: api_client.HttpResponse) -> None:
|
||||
lines = [
|
||||
"data: {",
|
||||
"data: 'message': 'hello'",
|
||||
"data: }",
|
||||
"",
|
||||
]
|
||||
mock_response = MagicMock(spec=httpx.Response)
|
||||
mock_response.iter_lines.return_value = iter(lines)
|
||||
responses.response_stream = mock_response
|
||||
|
||||
results = list(responses._iter_response_stream())
|
||||
|
||||
assert results == ["{\n 'message': 'hello'\n}"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_httpx_multiple_json_chunk(responses: api_client.HttpResponse):
|
||||
lines = [
|
||||
'{ "id": 1 }',
|
||||
"",
|
||||
'data: { "id": 2 }',
|
||||
"",
|
||||
'data: { "id": 3 }',
|
||||
]
|
||||
mock_response = MockHTTPXResponse(lines)
|
||||
responses.response_stream = mock_response
|
||||
|
||||
results = [line async for line in responses._aiter_response_stream()]
|
||||
|
||||
assert results == ['{ "id": 1 }', '{ "id": 2 }', '{ "id": 3 }']
|
||||
mock_response.aiter_lines.assert_called_once()
|
||||
mock_response.aclose.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_httpx_incomplete_json_at_end(responses: api_client.HttpResponse):
|
||||
lines = ['{ "partial": "data"'] # Missing closing brace
|
||||
mock_response = MockHTTPXResponse(lines)
|
||||
responses.response_stream = mock_response
|
||||
|
||||
results = [line async for line in responses._aiter_response_stream()]
|
||||
|
||||
# The remaining chunk is yielded
|
||||
assert results == ['{ "partial": "data"']
|
||||
mock_response.aiter_lines.assert_called_once()
|
||||
mock_response.aclose.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_httpx_empty_stream(responses: api_client.HttpResponse):
|
||||
lines: List[str] = []
|
||||
mock_response = MockHTTPXResponse(lines)
|
||||
responses.response_stream = mock_response
|
||||
|
||||
results = [line async for line in responses._aiter_response_stream()]
|
||||
|
||||
assert results == []
|
||||
mock_response.aiter_lines.assert_called_once()
|
||||
mock_response.aclose.assert_called_once()
|
||||
|
||||
|
||||
# Async aiohttp
|
||||
@requires_aiohttp
|
||||
@pytest.mark.asyncio
|
||||
async def test_aiohttp_simple_lines(responses: api_client.HttpResponse):
|
||||
api_client.has_aiohttp = True # Force aiohttp
|
||||
lines = ["hello", "world", "testing"]
|
||||
# Use the mock class that pretends to be aiohttp.ClientResponse
|
||||
mock_response = MockAIOHTTPResponse(lines)
|
||||
responses.response_stream = mock_response
|
||||
|
||||
results = [line async for line in responses._aiter_response_stream()]
|
||||
|
||||
assert results == lines
|
||||
mock_response.content.readline.assert_called()
|
||||
mock_response.release.assert_called_once()
|
||||
|
||||
|
||||
@requires_aiohttp
|
||||
@pytest.mark.asyncio
|
||||
async def test_aiohttp_data_prefix(responses: api_client.HttpResponse):
|
||||
api_client.has_aiohttp = True # Force aiohttp
|
||||
lines = ["data: { 'message': 'hello' }", "", "data: { 'status': 'ok' }"]
|
||||
# Use the mock class that pretends to be aiohttp.ClientResponse
|
||||
mock_response = MockAIOHTTPResponse(lines)
|
||||
responses.response_stream = mock_response
|
||||
|
||||
results = [line async for line in responses._aiter_response_stream()]
|
||||
|
||||
assert results == ["{ 'message': 'hello' }", "{ 'status': 'ok' }"]
|
||||
mock_response.content.readline.assert_called()
|
||||
mock_response.release.assert_called_once()
|
||||
|
||||
|
||||
@requires_aiohttp
|
||||
@pytest.mark.asyncio
|
||||
async def test_aiohttp_multiple_json_chunks(responses: api_client.HttpResponse):
|
||||
api_client.has_aiohttp = True # Force aiohttp
|
||||
lines = [
|
||||
'{ "id": 1 }',
|
||||
"", # empty line to check robustness
|
||||
'data: { "id": 2 }',
|
||||
"",
|
||||
'data: { "id": 3 }',
|
||||
]
|
||||
# Use the mock class that pretends to be aiohttp.ClientResponse
|
||||
mock_response = MockAIOHTTPResponse(lines)
|
||||
responses.response_stream = mock_response
|
||||
|
||||
results = [line async for line in responses._aiter_response_stream()]
|
||||
|
||||
assert results == ['{ "id": 1 }', '{ "id": 2 }', '{ "id": 3 }']
|
||||
mock_response.content.readline.assert_called()
|
||||
mock_response.release.assert_called_once()
|
||||
|
||||
|
||||
@requires_aiohttp
|
||||
@pytest.mark.asyncio
|
||||
async def test_aiohttp_incomplete_json_at_end(
|
||||
responses: api_client.HttpResponse,
|
||||
):
|
||||
api_client.has_aiohttp = True # Force aiohttp
|
||||
lines = ['{ "partial": "data"'] # Missing closing brace
|
||||
# Use the mock class that pretends to be aiohttp.ClientResponse
|
||||
mock_response = MockAIOHTTPResponse(lines)
|
||||
responses.response_stream = mock_response
|
||||
|
||||
results = [line async for line in responses._aiter_response_stream()]
|
||||
|
||||
assert results == ['{ "partial": "data"']
|
||||
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()
|
||||
|
||||
|
||||
def mock_response(chunks):
|
||||
mock_stream = MagicMock(spec=httpx.SyncByteStream)
|
||||
mock_stream.__iter__.return_value = chunks
|
||||
return httpx.Response(
|
||||
status_code=200,
|
||||
stream=mock_stream,
|
||||
)
|
||||
|
||||
|
||||
@patch('httpx.Client.send')
|
||||
def test_error_event_in_streamed_responses_bad_json(mock_send_method):
|
||||
with_bad_json = [
|
||||
b'{"candidates":[{"content":{"parts":[{"text":"test"}],"role":"model"}}]}',
|
||||
b'\n',
|
||||
b'{"error": bad_json}',
|
||||
]
|
||||
mock_send_method.return_value = mock_response(with_bad_json)
|
||||
|
||||
client = api_client.BaseApiClient(api_key='test_api_key')
|
||||
stream = client.request_streamed('POST', 'models/gemini-2.5-flash', {})
|
||||
|
||||
chunk = next(stream)
|
||||
assert chunk == types.HttpResponse(
|
||||
headers={},
|
||||
body=(
|
||||
'{"candidates": [{"content": {"parts": [{"text": "test"}], "role":'
|
||||
' "model"}}]}'
|
||||
),
|
||||
)
|
||||
|
||||
with pytest.raises(errors.UnknownApiResponseError):
|
||||
next(stream)
|
||||
|
||||
|
||||
@patch('httpx.Client.send')
|
||||
def test_error_event_in_streamed_responses(mock_send_method):
|
||||
mock_send_method.return_value = mock_response(EVENT_STREAM_DATA_WITH_ERROR)
|
||||
|
||||
client = api_client.BaseApiClient(api_key='test_api_key')
|
||||
stream = client.request_streamed('POST', 'models/gemini-2.5-flash', {})
|
||||
|
||||
chunk = next(stream)
|
||||
assert chunk == types.HttpResponse(
|
||||
body=(
|
||||
'{"candidates": [{"content": {"parts": [{"text": "test"}], "role":'
|
||||
' "model"}}]}'
|
||||
),
|
||||
headers={},
|
||||
)
|
||||
|
||||
with pytest.raises(errors.ServerError):
|
||||
next(stream)
|
||||
|
||||
|
||||
@patch('httpx.Client.send')
|
||||
def test_error_event_in_generate_content_stream(mock_send_method):
|
||||
mock_send_method.return_value = mock_response(EVENT_STREAM_DATA_WITH_ERROR)
|
||||
|
||||
client = Client(api_key='test_api_key')
|
||||
generated_response = client.models.generate_content_stream(
|
||||
model='gemini-2.5-flash',
|
||||
contents='Tell me a story in 300 words.',
|
||||
)
|
||||
|
||||
chunk = next(generated_response)
|
||||
assert chunk == types.GenerateContentResponse(
|
||||
candidates=[
|
||||
types.Candidate(
|
||||
content=types.Content(
|
||||
parts=[
|
||||
types.Part(
|
||||
text='test'
|
||||
),
|
||||
],
|
||||
role='model'
|
||||
)
|
||||
),
|
||||
],
|
||||
sdk_http_response=types.HttpResponse(headers={})
|
||||
)
|
||||
|
||||
with pytest.raises(errors.ServerError):
|
||||
next(generated_response)
|
||||
|
||||
|
||||
async def _async_httpx_response(_):
|
||||
mock_stream = MagicMock(spec=httpx.AsyncByteStream)
|
||||
mock_stream.__aiter__.return_value = EVENT_STREAM_DATA_WITH_ERROR
|
||||
mock_stream.aclose = AsyncMock()
|
||||
return httpx.Response(
|
||||
status_code=200,
|
||||
stream=mock_stream,
|
||||
)
|
||||
|
||||
|
||||
@patch('httpx.AsyncBaseTransport')
|
||||
@pytest.mark.asyncio
|
||||
async def test_error_event_in_streamed_responses_async(mock_transport):
|
||||
client = api_client.BaseApiClient(
|
||||
api_key='test_api_key',
|
||||
http_options=types.HttpOptions(
|
||||
async_client_args={'transport': mock_transport}
|
||||
),
|
||||
)
|
||||
mock_transport.handle_async_request = _async_httpx_response
|
||||
mock_transport.aclose = AsyncMock()
|
||||
|
||||
resp = await client.async_request_streamed(
|
||||
'POST', 'models/gemini-2.5-flash', {'key': 'value'}
|
||||
)
|
||||
|
||||
chunk = await anext(resp)
|
||||
assert chunk == types.HttpResponse(
|
||||
headers={},
|
||||
body=(
|
||||
'{"candidates": [{"content": {"parts": [{"text": "test"}], "role":'
|
||||
' "model"}}]}'
|
||||
),
|
||||
)
|
||||
|
||||
with pytest.raises(errors.ServerError):
|
||||
await anext(resp)
|
||||
|
||||
|
||||
@patch('httpx.AsyncBaseTransport')
|
||||
@pytest.mark.asyncio
|
||||
async def test_error_event_in_generate_content_stream_async(mock_transport):
|
||||
client = Client(
|
||||
api_key='test_api_key',
|
||||
http_options=types.HttpOptions(
|
||||
async_client_args={'transport': mock_transport}
|
||||
),
|
||||
)
|
||||
mock_transport.handle_async_request = _async_httpx_response
|
||||
mock_transport.aclose = AsyncMock()
|
||||
|
||||
generated_response = await client.aio.models.generate_content_stream(
|
||||
model='gemini-2.5-flash',
|
||||
contents='Tell me a story in 300 words.',
|
||||
)
|
||||
|
||||
chunk = await anext(generated_response)
|
||||
assert chunk == types.GenerateContentResponse(
|
||||
candidates=[
|
||||
types.Candidate(
|
||||
content=types.Content(
|
||||
parts=[
|
||||
types.Part(
|
||||
text='test'
|
||||
),
|
||||
],
|
||||
role='model'
|
||||
)
|
||||
),
|
||||
],
|
||||
sdk_http_response=types.HttpResponse(headers={})
|
||||
)
|
||||
|
||||
with pytest.raises(errors.ServerError):
|
||||
await anext(generated_response)
|
||||
@@ -0,0 +1,222 @@
|
||||
# Copyright 2025 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
|
||||
"""Tests for closing the clients and context managers."""
|
||||
import asyncio
|
||||
from unittest import mock
|
||||
|
||||
from google.oauth2 import credentials
|
||||
import pytest
|
||||
try:
|
||||
import aiohttp
|
||||
AIOHTTP_NOT_INSTALLED = False
|
||||
except ImportError:
|
||||
AIOHTTP_NOT_INSTALLED = True
|
||||
aiohttp = mock.MagicMock()
|
||||
|
||||
|
||||
from ... import _api_client as api_client
|
||||
from ... import Client
|
||||
|
||||
|
||||
requires_aiohttp = pytest.mark.skipif(
|
||||
AIOHTTP_NOT_INSTALLED, reason='aiohttp is not installed, skipping test.'
|
||||
)
|
||||
|
||||
|
||||
def test_close_httpx_client():
|
||||
"""Tests that the httpx client is closed when the client is closed."""
|
||||
api_client.has_aiohttp = False
|
||||
client = Client(
|
||||
vertexai=True,
|
||||
project='test_project',
|
||||
location='global',
|
||||
http_options=api_client.HttpOptions(client_args={'max_redirects': 10}),
|
||||
)
|
||||
client.close()
|
||||
assert client._api_client._httpx_client.is_closed
|
||||
|
||||
|
||||
def test_httpx_client_context_manager():
|
||||
"""Tests that the httpx client is closed when the client is closed."""
|
||||
api_client.has_aiohttp = False
|
||||
with Client(
|
||||
vertexai=True,
|
||||
project='test_project',
|
||||
location='global',
|
||||
http_options=api_client.HttpOptions(client_args={'max_redirects': 10}),
|
||||
) as client:
|
||||
pass
|
||||
assert not client._api_client._httpx_client.is_closed
|
||||
|
||||
assert client._api_client._httpx_client.is_closed
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_aclose_httpx_client():
|
||||
"""Tests that the httpx async client is closed when the client is closed."""
|
||||
api_client.has_aiohttp = False
|
||||
async_client = Client(
|
||||
vertexai=True,
|
||||
project='test_project',
|
||||
location='global',
|
||||
).aio
|
||||
await async_client.aclose()
|
||||
assert async_client._api_client._async_httpx_client.is_closed
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_httpx_client_context_manager():
|
||||
"""Tests that the httpx async client is closed when the client is closed."""
|
||||
api_client.has_aiohttp = False
|
||||
async with Client(
|
||||
vertexai=True,
|
||||
project='test_project',
|
||||
location='global',
|
||||
).aio as async_client:
|
||||
pass
|
||||
assert not async_client._api_client._async_httpx_client.is_closed
|
||||
|
||||
assert async_client._api_client._async_httpx_client.is_closed
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_request():
|
||||
mock_aiohttp_response = mock.Mock(spec=aiohttp.ClientSession.request)
|
||||
mock_aiohttp_response.return_value = mock_aiohttp_response
|
||||
yield mock_aiohttp_response
|
||||
|
||||
|
||||
def _patch_auth_default():
|
||||
return mock.patch(
|
||||
'google.auth.default',
|
||||
return_value=(credentials.Credentials('magic_token'), 'test_project'),
|
||||
autospec=True,
|
||||
)
|
||||
|
||||
|
||||
async def _aiohttp_async_response(status: int):
|
||||
"""Has to return a coroutine hence async."""
|
||||
response = mock.Mock(spec=aiohttp.ClientResponse)
|
||||
response.status = status
|
||||
response.headers = {'status-code': str(status)}
|
||||
response.json.return_value = {}
|
||||
response.text.return_value = 'test'
|
||||
return response
|
||||
|
||||
|
||||
@requires_aiohttp
|
||||
@mock.patch.object(aiohttp.ClientSession, 'request', autospec=True)
|
||||
def test_aclose_aiohttp_session(mock_request):
|
||||
"""Tests that the aiohttp session is closed when the client is closed."""
|
||||
api_client.has_aiohttp = True
|
||||
async def run():
|
||||
mock_request.side_effect = (
|
||||
aiohttp.ClientConnectorError(
|
||||
connection_key=aiohttp.client_reqrep.ConnectionKey(
|
||||
'localhost', 80, False, True, None, None, None
|
||||
),
|
||||
os_error=OSError,
|
||||
),
|
||||
_aiohttp_async_response(200),
|
||||
)
|
||||
with _patch_auth_default():
|
||||
async_client = Client(
|
||||
vertexai=True,
|
||||
project='test_project',
|
||||
location='global',
|
||||
http_options=api_client.HttpOptions(
|
||||
async_client_args={'trust_env': False}
|
||||
),
|
||||
).aio
|
||||
# aiohttp session is created in the first request instead of client
|
||||
# initialization.
|
||||
_ = await async_client._api_client._async_request_once(
|
||||
api_client.HttpRequest(
|
||||
method='GET',
|
||||
url='https://example.com',
|
||||
headers={},
|
||||
data=None,
|
||||
timeout=None,
|
||||
)
|
||||
)
|
||||
assert async_client._api_client._aiohttp_session is not None
|
||||
if hasattr(async_client._api_client._aiohttp_session, 'closed'):
|
||||
assert not async_client._api_client._aiohttp_session.closed
|
||||
# Close the client and check that the session is closed.
|
||||
await async_client.aclose()
|
||||
if hasattr(async_client._api_client._aiohttp_session, 'closed'):
|
||||
assert async_client._api_client._aiohttp_session.closed
|
||||
else:
|
||||
from google.auth.aio.transport.sessions import AsyncAuthorizedSession
|
||||
|
||||
if isinstance(
|
||||
async_client._api_client._aiohttp_session, AsyncAuthorizedSession
|
||||
):
|
||||
assert async_client._api_client._aiohttp_session._auth_request._closed
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
@requires_aiohttp
|
||||
@mock.patch.object(aiohttp.ClientSession, 'request', autospec=True)
|
||||
def test_aiohttp_session_context_manager(mock_request):
|
||||
"""Tests that the aiohttp session is closed when the client is closed."""
|
||||
api_client.has_aiohttp = True
|
||||
async def run():
|
||||
mock_request.side_effect = (
|
||||
aiohttp.ClientConnectorError(
|
||||
connection_key=aiohttp.client_reqrep.ConnectionKey(
|
||||
'localhost', 80, False, True, None, None, None
|
||||
),
|
||||
os_error=OSError,
|
||||
),
|
||||
_aiohttp_async_response(200),
|
||||
)
|
||||
with _patch_auth_default():
|
||||
async with Client(
|
||||
vertexai=True,
|
||||
project='test_project',
|
||||
location='global',
|
||||
http_options=api_client.HttpOptions(
|
||||
async_client_args={'trust_env': False}
|
||||
),
|
||||
).aio as async_client:
|
||||
# aiohttp session is created in the first request instead of client
|
||||
# initialization.
|
||||
_ = await async_client._api_client._async_request_once(
|
||||
api_client.HttpRequest(
|
||||
method='GET',
|
||||
url='https://example.com',
|
||||
headers={},
|
||||
data=None,
|
||||
timeout=None,
|
||||
)
|
||||
)
|
||||
assert async_client._api_client._aiohttp_session is not None
|
||||
if hasattr(async_client._api_client._aiohttp_session, 'closed'):
|
||||
assert not async_client._api_client._aiohttp_session.closed
|
||||
|
||||
if hasattr(async_client._api_client._aiohttp_session, 'closed'):
|
||||
assert async_client._api_client._aiohttp_session.closed
|
||||
else:
|
||||
from google.auth.aio.transport.sessions import AsyncAuthorizedSession
|
||||
|
||||
if isinstance(
|
||||
async_client._api_client._aiohttp_session, AsyncAuthorizedSession
|
||||
):
|
||||
assert async_client._api_client._aiohttp_session._auth_request._closed
|
||||
|
||||
asyncio.run(run())
|
||||
+1926
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,221 @@
|
||||
# Copyright 2025 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
|
||||
|
||||
"""Tests for client behavior when issuing requests."""
|
||||
|
||||
from ... import _api_client as api_client
|
||||
from ... import Client
|
||||
from ... import types
|
||||
|
||||
|
||||
def build_test_client(monkeypatch):
|
||||
monkeypatch.setenv('GOOGLE_API_KEY', 'google_api_key')
|
||||
return Client()
|
||||
|
||||
|
||||
def test_join_url_path_base_url_with_trailing_slash_and_path_with_leading_slash():
|
||||
base_url = 'https://fake-url.com/some_path/'
|
||||
path = '/v1beta/models'
|
||||
assert (
|
||||
api_client.join_url_path(base_url, path)
|
||||
== 'https://fake-url.com/some_path/v1beta/models'
|
||||
)
|
||||
|
||||
|
||||
def test_join_url_path_with_base_url_with_trailing_slash_and_path_without_leading_slash():
|
||||
base_url = 'https://fake-url.com/some_path/'
|
||||
path = 'v1beta/models'
|
||||
assert (
|
||||
api_client.join_url_path(base_url, path)
|
||||
== 'https://fake-url.com/some_path/v1beta/models'
|
||||
)
|
||||
|
||||
|
||||
def test_join_url_path_with_base_url_without_trailing_slash_and_path_with_leading_slash():
|
||||
base_url = 'https://fake-url.com/some_path'
|
||||
path = '/v1beta/models'
|
||||
assert (
|
||||
api_client.join_url_path(base_url, path)
|
||||
== 'https://fake-url.com/some_path/v1beta/models'
|
||||
)
|
||||
|
||||
|
||||
def test_join_url_path_with_base_url_without_trailing_slash_and_path_without_leading_slash():
|
||||
base_url = 'https://fake-url.com/some_path'
|
||||
path = 'v1beta/models'
|
||||
assert (
|
||||
api_client.join_url_path(base_url, path)
|
||||
== 'https://fake-url.com/some_path/v1beta/models'
|
||||
)
|
||||
|
||||
|
||||
def test_join_url_path_base_url_without_path_with_trailing_slash():
|
||||
base_url = 'https://fake-url.com/'
|
||||
path = 'v1beta/models'
|
||||
assert (
|
||||
api_client.join_url_path(base_url, path)
|
||||
== 'https://fake-url.com/v1beta/models'
|
||||
)
|
||||
|
||||
|
||||
def test_join_url_path_base_url_without_path_without_trailing_slash():
|
||||
base_url = 'https://fake-url.com'
|
||||
path = 'v1beta/models'
|
||||
assert (
|
||||
api_client.join_url_path(base_url, path)
|
||||
== 'https://fake-url.com/v1beta/models'
|
||||
)
|
||||
|
||||
|
||||
def test_build_request_sets_library_version_headers(monkeypatch):
|
||||
request_client = build_test_client(monkeypatch).models._api_client
|
||||
request = request_client._build_request('GET', 'test/path', {'key': 'value'})
|
||||
assert 'google-genai-sdk/' in request.headers['user-agent']
|
||||
assert 'gl-python/' in request.headers['user-agent']
|
||||
assert 'google-genai-sdk/' in request.headers['x-goog-api-client']
|
||||
assert 'gl-python/' in request.headers['x-goog-api-client']
|
||||
|
||||
|
||||
def test_build_request_appends_to_user_agent_headers(monkeypatch):
|
||||
request_client = build_test_client(monkeypatch).models._api_client
|
||||
request = request_client._build_request(
|
||||
'GET',
|
||||
'test/path',
|
||||
{'key': 'value'},
|
||||
types.HttpOptionsDict(
|
||||
base_url='test/url',
|
||||
api_version='1',
|
||||
headers={'user-agent': 'test-user-agent'},
|
||||
),
|
||||
)
|
||||
assert 'test-user-agent' in request.headers['user-agent']
|
||||
assert 'google-genai-sdk/' in request.headers['user-agent']
|
||||
assert 'gl-python/' in request.headers['user-agent']
|
||||
assert 'google-genai-sdk/' in request.headers['x-goog-api-client']
|
||||
|
||||
|
||||
def test_build_request_appends_to_goog_api_client_headers(monkeypatch):
|
||||
request_client = build_test_client(monkeypatch).models._api_client
|
||||
request = request_client._build_request(
|
||||
'GET',
|
||||
'test/path',
|
||||
{'key': 'value'},
|
||||
types.HttpOptionsDict(
|
||||
base_url='test/url',
|
||||
api_version='1',
|
||||
headers={'x-goog-api-client': 'test-goog-api-client'},
|
||||
),
|
||||
)
|
||||
assert 'google-genai-sdk/' in request.headers['user-agent']
|
||||
assert 'test-goog-api-client' in request.headers['x-goog-api-client']
|
||||
assert 'google-genai-sdk/' in request.headers['x-goog-api-client']
|
||||
assert 'gl-python/' in request.headers['x-goog-api-client']
|
||||
|
||||
|
||||
def test_build_request_keeps_sdk_version_headers(monkeypatch):
|
||||
headers_to_inject = {}
|
||||
api_client.append_library_version_headers(headers_to_inject)
|
||||
assert 'google-genai-sdk/' in headers_to_inject['user-agent']
|
||||
request_client = build_test_client(monkeypatch).models._api_client
|
||||
request = request_client._build_request(
|
||||
'GET',
|
||||
'test/path',
|
||||
{'key': 'value'},
|
||||
types.HttpOptionsDict(
|
||||
base_url='test/url',
|
||||
api_version='1',
|
||||
headers=headers_to_inject,
|
||||
),
|
||||
)
|
||||
assert 'google-genai-sdk/' in request.headers['user-agent']
|
||||
assert 'gl-python/' in request.headers['x-goog-api-client']
|
||||
assert 'google-genai-sdk/' in request.headers['x-goog-api-client']
|
||||
assert 'gl-python/' in request.headers['x-goog-api-client']
|
||||
|
||||
|
||||
def test_build_request_with_resource_scope(monkeypatch):
|
||||
monkeypatch.delenv('GOOGLE_API_KEY', raising=False)
|
||||
monkeypatch.delenv('GEMINI_API_KEY', raising=False)
|
||||
monkeypatch.delenv('GOOGLE_CLOUD_PROJECT', raising=False)
|
||||
monkeypatch.delenv('GOOGLE_CLOUD_LOCATION', raising=False)
|
||||
|
||||
client = Client(
|
||||
vertexai=True,
|
||||
http_options=types.HttpOptionsDict(
|
||||
base_url='https://custom-base-url.com',
|
||||
base_url_resource_scope=types.ResourceScope.COLLECTION,
|
||||
),
|
||||
)
|
||||
|
||||
request = client.models._api_client._build_request(
|
||||
'post',
|
||||
'publishers/google/models/gemini-3-pro-preview',
|
||||
{'key': 'value'},
|
||||
)
|
||||
assert request.url == 'https://custom-base-url.com/publishers/google/models/gemini-3-pro-preview'
|
||||
|
||||
|
||||
def test_build_request_with_resource_scope_with_project_and_location(
|
||||
monkeypatch,
|
||||
):
|
||||
monkeypatch.delenv('GOOGLE_API_KEY', raising=False)
|
||||
monkeypatch.delenv('GEMINI_API_KEY', raising=False)
|
||||
monkeypatch.delenv('GOOGLE_CLOUD_PROJECT', raising=False)
|
||||
monkeypatch.delenv('GOOGLE_CLOUD_LOCATION', raising=False)
|
||||
|
||||
client = Client(
|
||||
vertexai=True,
|
||||
project='test-project',
|
||||
location='test-location',
|
||||
http_options=types.HttpOptionsDict(
|
||||
base_url='https://custom-base-url.com',
|
||||
base_url_resource_scope=types.ResourceScope.COLLECTION,
|
||||
),
|
||||
)
|
||||
|
||||
request = client.models._api_client._build_request(
|
||||
'post',
|
||||
'publishers/google/models/gemini-3-pro-preview',
|
||||
{'key': 'value'},
|
||||
)
|
||||
assert request.url == 'https://custom-base-url.com/publishers/google/models/gemini-3-pro-preview'
|
||||
|
||||
|
||||
|
||||
def build_test_client_no_env_vars(monkeypatch):
|
||||
monkeypatch.delenv('GOOGLE_API_KEY', raising=False)
|
||||
monkeypatch.delenv('GEMINI_API_KEY', raising=False)
|
||||
monkeypatch.delenv('GOOGLE_CLOUD_PROJECT', raising=False)
|
||||
monkeypatch.delenv('GOOGLE_CLOUD_LOCATION', raising=False)
|
||||
return Client(
|
||||
vertexai=True,
|
||||
http_options=types.HttpOptionsDict(
|
||||
base_url='https://custom-base-url.com',
|
||||
headers={'Authorization': 'Bearer fake_access_token'},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def test_build_request_with_custom_base_url_no_env_vars(monkeypatch):
|
||||
request_client = (
|
||||
build_test_client_no_env_vars(monkeypatch).models._api_client
|
||||
)
|
||||
request = request_client._build_request(
|
||||
'GET',
|
||||
'test/path',
|
||||
{'key': 'value'},
|
||||
)
|
||||
assert request.url == 'https://custom-base-url.com'
|
||||
@@ -0,0 +1,104 @@
|
||||
# Copyright 2025 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
|
||||
"""Tests for custom clients."""
|
||||
import asyncio
|
||||
from unittest import mock
|
||||
|
||||
from google.oauth2 import credentials
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from ... import _api_client as api_client
|
||||
from ... import Client
|
||||
|
||||
|
||||
try:
|
||||
import aiohttp
|
||||
|
||||
AIOHTTP_NOT_INSTALLED = False
|
||||
except ImportError:
|
||||
AIOHTTP_NOT_INSTALLED = True
|
||||
aiohttp = mock.MagicMock()
|
||||
|
||||
requires_aiohttp = pytest.mark.skipif(
|
||||
AIOHTTP_NOT_INSTALLED, reason='aiohttp is not installed, skipping test.'
|
||||
)
|
||||
|
||||
|
||||
# Httpx
|
||||
def test_constructor_with_httpx_clients():
|
||||
mldev_http_options = {
|
||||
'httpx_client': httpx.Client(trust_env=False),
|
||||
'httpx_async_client': httpx.AsyncClient(trust_env=False),
|
||||
}
|
||||
vertexai_http_options = {
|
||||
'httpx_client': httpx.Client(trust_env=False),
|
||||
'httpx_async_client': httpx.AsyncClient(trust_env=False),
|
||||
}
|
||||
|
||||
# Even if aiohttp is installed, expect it to be disabled when httpx clients
|
||||
# are provided.
|
||||
api_client.has_aiohttp = True
|
||||
|
||||
mldev_client = Client(
|
||||
api_key='google_api_key', http_options=mldev_http_options
|
||||
)
|
||||
assert not mldev_client.models._api_client._httpx_client.trust_env
|
||||
assert not mldev_client.models._api_client._async_httpx_client.trust_env
|
||||
# Expect aiohttp to be disabled when httpx clients are provided, regardless of
|
||||
# whether aiohttp is installed.
|
||||
assert not mldev_client.models._api_client._use_aiohttp()
|
||||
|
||||
vertexai_client = Client(
|
||||
vertexai=True,
|
||||
project='fake_project_id',
|
||||
location='fake-location',
|
||||
http_options=vertexai_http_options,
|
||||
)
|
||||
assert not vertexai_client.models._api_client._httpx_client.trust_env
|
||||
assert not vertexai_client.models._api_client._async_httpx_client.trust_env
|
||||
# Expect aiohttp to be disabled when httpx clients are provided, regardless of
|
||||
# whether aiohttp is installed.
|
||||
assert not mldev_client.models._api_client._use_aiohttp()
|
||||
|
||||
|
||||
# Aiohttp
|
||||
@requires_aiohttp
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.skipif(
|
||||
AIOHTTP_NOT_INSTALLED, reason='aiohttp is not installed, skipping test.'
|
||||
)
|
||||
async def test_constructor_with_aiohttp_clients():
|
||||
api_client.has_aiohttp = True
|
||||
mldev_http_options = {
|
||||
'aiohttp_client': aiohttp.ClientSession(trust_env=False),
|
||||
}
|
||||
vertexai_http_options = {
|
||||
'aiohttp_client': aiohttp.ClientSession(trust_env=False),
|
||||
}
|
||||
mldev_client = Client(
|
||||
api_key='google_api_key', http_options=mldev_http_options
|
||||
)
|
||||
assert not mldev_client.models._api_client._aiohttp_session.trust_env
|
||||
|
||||
vertexai_client = Client(
|
||||
vertexai=True,
|
||||
project='fake_project_id',
|
||||
location='fake-location',
|
||||
http_options=vertexai_http_options,
|
||||
)
|
||||
assert not vertexai_client.models._api_client._aiohttp_session.trust_env
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
# Copyright 2025 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
|
||||
|
||||
"""Tests for client behavior when issuing requests."""
|
||||
|
||||
import pytest
|
||||
|
||||
from ... import _api_client
|
||||
from ... import types
|
||||
|
||||
|
||||
def test_patch_http_options_with_copies_all_fields():
|
||||
patch_options = types.HttpOptions(
|
||||
base_url='https://fake-url.com/',
|
||||
api_version='v1',
|
||||
headers={'X-Custom-Header': 'custom_value'},
|
||||
timeout=10000,
|
||||
client_args={'http2': True},
|
||||
async_client_args={'http1': True},
|
||||
extra_body={'key': 'value'},
|
||||
retry_options=types.HttpRetryOptions(attempts=10),
|
||||
base_url_resource_scope=types.ResourceScope.COLLECTION,
|
||||
)
|
||||
options = types.HttpOptions()
|
||||
patched = _api_client.patch_http_options(options, patch_options)
|
||||
http_options_keys = types.HttpOptions.model_fields.keys()
|
||||
|
||||
for key in http_options_keys:
|
||||
assert hasattr(patched, key)
|
||||
if key not in [
|
||||
'httpx_client',
|
||||
'httpx_async_client',
|
||||
'aiohttp_client',
|
||||
]:
|
||||
assert getattr(patched, key) is not None
|
||||
assert patched.base_url == 'https://fake-url.com/'
|
||||
assert patched.api_version == 'v1'
|
||||
assert patched.headers['X-Custom-Header'] == 'custom_value'
|
||||
assert patched.timeout == 10000
|
||||
assert patched.retry_options.attempts == 10
|
||||
assert patched.client_args['http2']
|
||||
assert patched.async_client_args['http1']
|
||||
|
||||
|
||||
def test_patch_http_options_merges_headers():
|
||||
original_options = types.HttpOptions(
|
||||
headers={
|
||||
'X-Custom-Header': 'different_value',
|
||||
'X-different-header': 'different_value',
|
||||
}
|
||||
)
|
||||
patch_options = types.HttpOptions(
|
||||
base_url='https://fake-url.com/',
|
||||
api_version='v1',
|
||||
headers={'X-Custom-Header': 'custom_value'},
|
||||
timeout=10000,
|
||||
)
|
||||
patched = _api_client.patch_http_options(original_options, patch_options)
|
||||
# If the header is present in both the original and patch options, the patch
|
||||
# options value should be used
|
||||
assert patched.headers['X-Custom-Header'] == 'custom_value'
|
||||
assert patched.headers['X-different-header'] == 'different_value'
|
||||
assert patched.base_url == 'https://fake-url.com/'
|
||||
assert patched.api_version == 'v1'
|
||||
assert patched.timeout == 10000
|
||||
|
||||
|
||||
def test_patch_http_options_appends_version_headers():
|
||||
original_options = types.HttpOptions(
|
||||
headers={
|
||||
'X-Custom-Header': 'different_value',
|
||||
'X-different-header': 'different_value',
|
||||
}
|
||||
)
|
||||
patch_options = types.HttpOptions(
|
||||
base_url='https://fake-url.com/',
|
||||
api_version='v1',
|
||||
headers={'X-Custom-Header': 'custom_value'},
|
||||
timeout=10000,
|
||||
)
|
||||
patched = _api_client.patch_http_options(original_options, patch_options)
|
||||
assert 'user-agent' in patched.headers
|
||||
assert 'x-goog-api-client' in patched.headers
|
||||
|
||||
|
||||
def test_setting_timeout_populates_server_timeout_header():
|
||||
api_client = _api_client.BaseApiClient(
|
||||
vertexai=False,
|
||||
api_key='test_api_key',
|
||||
http_options=types.HttpOptions(timeout=10000),
|
||||
)
|
||||
request = api_client._build_request(
|
||||
http_method='POST',
|
||||
path='sample/path',
|
||||
request_dict={},
|
||||
)
|
||||
assert 'X-Server-Timeout' in request.headers
|
||||
assert request.headers['X-Server-Timeout'] == '10'
|
||||
|
||||
|
||||
def test_timeout_rounded_to_nearest_second():
|
||||
api_client = _api_client.BaseApiClient(
|
||||
vertexai=False,
|
||||
api_key='test_api_key',
|
||||
)
|
||||
http_options = types.HttpOptions(timeout=7300)
|
||||
request = api_client._build_request(
|
||||
http_method='POST',
|
||||
path='sample/path',
|
||||
request_dict={},
|
||||
http_options=http_options,
|
||||
)
|
||||
assert request.headers['X-Server-Timeout'] == '8'
|
||||
|
||||
|
||||
def test_server_timeout_not_overwritten():
|
||||
api_client = _api_client.BaseApiClient(
|
||||
vertexai=False,
|
||||
api_key='test_api_key',
|
||||
)
|
||||
http_options = types.HttpOptions(
|
||||
headers={'X-Server-Timeout': '3'},
|
||||
timeout=11000)
|
||||
request = api_client._build_request(
|
||||
http_method='POST',
|
||||
path='sample/path',
|
||||
request_dict={},
|
||||
http_options=http_options,
|
||||
)
|
||||
assert request.headers['X-Server-Timeout'] == '3'
|
||||
|
||||
|
||||
def test_server_timeout_not_set_by_default():
|
||||
api_client = _api_client.BaseApiClient(
|
||||
vertexai=False,
|
||||
api_key='test_api_key',
|
||||
)
|
||||
request = api_client._build_request(
|
||||
http_method='POST',
|
||||
path='sample/path',
|
||||
request_dict={},
|
||||
)
|
||||
assert not 'X-Server-Timeout' in request.headers
|
||||
|
||||
|
||||
def test_resource_scope_without_base_url_raises_error():
|
||||
with pytest.raises(ValueError):
|
||||
_api_client.BaseApiClient(
|
||||
vertexai=True,
|
||||
http_options=types.HttpOptions(
|
||||
base_url_resource_scope=types.ResourceScope.COLLECTION,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def test_base_url_resource_scope_not_set_by_default():
|
||||
api_client = _api_client.BaseApiClient(
|
||||
vertexai=True,
|
||||
http_options=types.HttpOptions(
|
||||
base_url='https://fake-url.com/',
|
||||
),
|
||||
)
|
||||
|
||||
assert api_client._http_options.base_url_resource_scope is None
|
||||
|
||||
|
||||
def test_retry_options_not_set_by_default():
|
||||
options = types.HttpOptions()
|
||||
assert options.retry_options is None
|
||||
+168
@@ -0,0 +1,168 @@
|
||||
# Copyright 2025 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
|
||||
|
||||
"""Tests replay client correctly compares requests ignoring key casing."""
|
||||
|
||||
from ... import _replay_api_client
|
||||
from ... import types
|
||||
|
||||
|
||||
def test_equal_objects_with_same_casing_returns_true():
|
||||
obj1 = [{
|
||||
'contents': [{'parts': [{'text': 'What is your name?'}], 'role': 'user'}],
|
||||
'generationConfig': {'topK': 2.0, 'maxOutputTokens': 3},
|
||||
}]
|
||||
obj2 = [{
|
||||
'contents': [{'parts': [{'text': 'What is your name?'}], 'role': 'user'}],
|
||||
'generationConfig': {'topK': 2.0, 'maxOutputTokens': 3},
|
||||
}]
|
||||
|
||||
assert _replay_api_client._equals_ignore_key_case(obj1, obj2)
|
||||
|
||||
|
||||
def test_equal_objects_with_different_casing_returns_true():
|
||||
obj1 = [{
|
||||
'contents': [{'parts': [{'text': 'What is your name?'}], 'role': 'user'}],
|
||||
'generationConfig': {'topK': 2.0, 'maxOutputTokens': 3},
|
||||
}]
|
||||
obj2 = [{
|
||||
'contents': [{'parts': [{'text': 'What is your name?'}], 'role': 'user'}],
|
||||
'generation_config': {'topK': 2.0, 'max_output_tokens': 3},
|
||||
}]
|
||||
|
||||
assert _replay_api_client._equals_ignore_key_case(obj1, obj2)
|
||||
|
||||
|
||||
def test_equal_objects_with_different_nested_casing_returns_true():
|
||||
obj1 = [{
|
||||
'contents': [{'parts': [{'text': 'What is your name?'}], 'role': 'user'}],
|
||||
'generationConfig': {'topK': 2.0, 'maxOutputTokens': 3},
|
||||
}]
|
||||
obj2 = [{
|
||||
'contents': [{'parts': [{'text': 'What is your name?'}], 'role': 'user'}],
|
||||
'generationConfig': {'top_k': 2.0, 'maxOutputTokens': 3},
|
||||
}]
|
||||
|
||||
assert _replay_api_client._equals_ignore_key_case(obj1, obj2)
|
||||
|
||||
def test_equal_int_float_values_returns_true():
|
||||
obj1 = [{
|
||||
'contents': [{'parts': [{'text': 'What is your name?'}], 'role': 'user'}],
|
||||
'generationConfig': {'topK': 2.0, 'maxOutputTokens': 3},
|
||||
}]
|
||||
obj2 = [{
|
||||
'contents': [{'parts': [{'text': 'What is your name?'}], 'role': 'user'}],
|
||||
'generation_config': {'topK': 2, 'max_output_tokens': 3},
|
||||
}]
|
||||
|
||||
assert _replay_api_client._equals_ignore_key_case(obj1, obj2)
|
||||
|
||||
|
||||
def test_equal_enum_string_values_returns_true():
|
||||
obj1 = [{
|
||||
'contents': [{'parts': [{'text': 'What is your name?'}], 'role': 'user'}],
|
||||
'generationConfig': {
|
||||
'topK': 2.0,
|
||||
'maxOutputTokens': 3,
|
||||
'type': types.Type.STRING,
|
||||
},
|
||||
}]
|
||||
obj2 = [{
|
||||
'contents': [{'parts': [{'text': 'What is your name?'}], 'role': 'user'}],
|
||||
'generation_config': {
|
||||
'topK': 2,
|
||||
'max_output_tokens': 3,
|
||||
'type': 'STRING',
|
||||
},
|
||||
}]
|
||||
|
||||
assert _replay_api_client._equals_ignore_key_case(obj1, obj2)
|
||||
|
||||
|
||||
def test_equal_enum_values_returns_true():
|
||||
obj1 = [{
|
||||
'contents': [{'parts': [{'text': 'What is your name?'}], 'role': 'user'}],
|
||||
'generationConfig': {
|
||||
'topK': 2.0,
|
||||
'maxOutputTokens': 3,
|
||||
'type': types.Type.STRING,
|
||||
},
|
||||
}]
|
||||
obj2 = [{
|
||||
'contents': [{'parts': [{'text': 'What is your name?'}], 'role': 'user'}],
|
||||
'generation_config': {
|
||||
'topK': 2,
|
||||
'max_output_tokens': 3,
|
||||
'type': types.Type.STRING,
|
||||
},
|
||||
}]
|
||||
|
||||
assert _replay_api_client._equals_ignore_key_case(obj1, obj2)
|
||||
|
||||
|
||||
def test_different_number_value_returns_false():
|
||||
obj1 = [{
|
||||
'contents': [{'parts': [{'text': 'What is your name?'}], 'role': 'user'}],
|
||||
'generationConfig': {'topK': 3, 'maxOutputTokens': 3},
|
||||
}]
|
||||
obj2 = [{
|
||||
'contents': [{'parts': [{'text': 'What is your name?'}], 'role': 'user'}],
|
||||
'generation_config': {'topK': 2.0, 'max_output_tokens': 3},
|
||||
}]
|
||||
|
||||
assert not _replay_api_client._equals_ignore_key_case(
|
||||
obj1, obj2
|
||||
)
|
||||
|
||||
|
||||
def test_different_string_value_returns_false():
|
||||
obj1 = [{
|
||||
'contents': [{'parts': [{'text': 'What is your name?'}], 'role': 'user'}],
|
||||
'generationConfig': {'topK': 3, 'maxOutputTokens': 3},
|
||||
}]
|
||||
obj2 = [{
|
||||
'contents': [
|
||||
{'parts': [{'text': 'What is your name?'}], 'role': 'model'}
|
||||
],
|
||||
'generation_config': {'topK': 3, 'max_output_tokens': 3},
|
||||
}]
|
||||
|
||||
assert not _replay_api_client._equals_ignore_key_case(
|
||||
obj1, obj2
|
||||
)
|
||||
|
||||
|
||||
def test_different_enum_values_returns_false():
|
||||
obj1 = [{
|
||||
'contents': [{'parts': [{'text': 'What is your name?'}], 'role': 'user'}],
|
||||
'generationConfig': {
|
||||
'topK': 2.0,
|
||||
'maxOutputTokens': 3,
|
||||
'type': types.Type.STRING,
|
||||
},
|
||||
}]
|
||||
obj2 = [{
|
||||
'contents': [{'parts': [{'text': 'What is your name?'}], 'role': 'user'}],
|
||||
'generation_config': {
|
||||
'topK': 2,
|
||||
'max_output_tokens': 3,
|
||||
'type': types.Type.OBJECT,
|
||||
},
|
||||
}]
|
||||
|
||||
assert not _replay_api_client._equals_ignore_key_case(
|
||||
obj1, obj2
|
||||
)
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,197 @@
|
||||
# Copyright 2025 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
|
||||
"""Tests for error handling in the client api client layer for uploads."""
|
||||
|
||||
import io
|
||||
import json
|
||||
from unittest import mock
|
||||
import pytest
|
||||
|
||||
import httpx
|
||||
|
||||
from ... import _api_client as api_client
|
||||
from ... import errors
|
||||
from ... import types
|
||||
|
||||
try:
|
||||
import aiohttp
|
||||
AIOHTTP_NOT_INSTALLED = False
|
||||
except ImportError:
|
||||
AIOHTTP_NOT_INSTALLED = True
|
||||
|
||||
def _httpx_response(code: int, headers: dict = None, content: bytes = b''):
|
||||
headers = headers or {}
|
||||
headers['status-code'] = str(code)
|
||||
return httpx.Response(
|
||||
status_code=code,
|
||||
headers=headers,
|
||||
content=content,
|
||||
)
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
return api_client.BaseApiClient(vertexai=False, api_key='test_api_key')
|
||||
|
||||
|
||||
def test_upload_url_rewrite(client: api_client.BaseApiClient):
|
||||
mock_httpx_client = mock.MagicMock(spec=httpx.Client)
|
||||
mock_httpx_client.request.side_effect = [
|
||||
_httpx_response(
|
||||
200, headers={"X-Goog-Upload-Status": "final"}
|
||||
), # Upload request succeeding
|
||||
]
|
||||
client._httpx_client = mock_httpx_client
|
||||
|
||||
http_options = types.HttpOptions(base_url="https://my-proxy.company.com")
|
||||
|
||||
with io.BytesIO(b"test") as f:
|
||||
client._upload_fd(
|
||||
f,
|
||||
"https://generativelanguage.googleapis.com/upload/v1beta/files?uploadType=resumable",
|
||||
4,
|
||||
http_options=http_options,
|
||||
)
|
||||
|
||||
assert mock_httpx_client.request.call_count == 1
|
||||
call_args = mock_httpx_client.request.call_args[1]
|
||||
assert (
|
||||
call_args["url"]
|
||||
== "https://my-proxy.company.com/upload/v1beta/files?uploadType=resumable"
|
||||
)
|
||||
|
||||
|
||||
def test_upload_fd_error(client: api_client.BaseApiClient):
|
||||
error_content = json.dumps({
|
||||
"error": {
|
||||
"code": 400,
|
||||
"message": "Unsupported MIME type: bad/mime_type",
|
||||
"status": "INVALID_ARGUMENT"
|
||||
}
|
||||
}).encode('utf-8')
|
||||
|
||||
mock_httpx_client = mock.MagicMock(spec=httpx.Client)
|
||||
mock_httpx_client.request.side_effect = [
|
||||
_httpx_response(200, headers={"X-Goog-Upload-URL": "http://fake/upload"}), # Initial request to get upload URL
|
||||
_httpx_response(400, content=error_content, headers={"X-Goog-Upload-Status": "final"}), # Upload request failing
|
||||
]
|
||||
client._httpx_client = mock_httpx_client
|
||||
|
||||
with pytest.raises(errors.APIError, match='Unsupported MIME type: bad/mime_type'), io.BytesIO(b"test") as f:
|
||||
client._upload_fd(
|
||||
f, # Pass file object directly
|
||||
"http://fake/upload",
|
||||
4,
|
||||
http_options=types.HttpOptions(),
|
||||
)
|
||||
|
||||
assert mock_httpx_client.request.call_count == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_upload_url_rewrite_httpx(client: api_client.BaseApiClient):
|
||||
mock_async_httpx_client = mock.MagicMock(spec=httpx.AsyncClient)
|
||||
mock_async_httpx_client.request = mock.AsyncMock(
|
||||
side_effect=[
|
||||
_httpx_response(
|
||||
200, headers={"X-Goog-Upload-Status": "final"}
|
||||
), # Upload request
|
||||
]
|
||||
)
|
||||
client._async_httpx_client = mock_async_httpx_client
|
||||
|
||||
http_options = types.HttpOptions(base_url="https://my-proxy.company.com")
|
||||
|
||||
with mock.patch.object(
|
||||
client, "_use_aiohttp", return_value=False
|
||||
), io.BytesIO(b"test") as f:
|
||||
await client._async_upload_fd(
|
||||
f,
|
||||
"https://generativelanguage.googleapis.com/upload/v1beta/files?uploadType=resumable",
|
||||
4,
|
||||
http_options=http_options,
|
||||
)
|
||||
|
||||
assert mock_async_httpx_client.request.call_count == 1
|
||||
call_args = mock_async_httpx_client.request.call_args[1]
|
||||
assert (
|
||||
call_args["url"]
|
||||
== "https://my-proxy.company.com/upload/v1beta/files?uploadType=resumable"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_upload_fd_error_httpx(client: api_client.BaseApiClient):
|
||||
error_content = json.dumps({
|
||||
"error": {
|
||||
"code": 400,
|
||||
"message": "Unsupported MIME type: bad/mime_type",
|
||||
"status": "INVALID_ARGUMENT"
|
||||
}
|
||||
}).encode('utf-8')
|
||||
|
||||
# Mock for httpx.AsyncClient
|
||||
mock_async_httpx_client = mock.MagicMock(spec=httpx.AsyncClient)
|
||||
mock_async_httpx_client.request = mock.AsyncMock(side_effect=[
|
||||
_httpx_response(200, headers={"X-Goog-Upload-URL": "http://fake/upload"}), # Initial request
|
||||
_httpx_response(400, content=error_content, headers={"X-Goog-Upload-Status": "final"}), # Upload request
|
||||
])
|
||||
client._async_httpx_client = mock_async_httpx_client
|
||||
|
||||
# Patch the _use_aiohttp method to control which client is used
|
||||
with mock.patch.object(client, '_use_aiohttp', return_value=False), \
|
||||
pytest.raises(errors.APIError, match='Unsupported MIME type: bad/mime_type'), \
|
||||
io.BytesIO(b"test") as f:
|
||||
await client._async_upload_fd(
|
||||
f, # Pass file object directly
|
||||
"http://fake/upload",
|
||||
4,
|
||||
http_options=types.HttpOptions(),
|
||||
|
||||
)
|
||||
assert mock_async_httpx_client.request.call_count == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.skipif(
|
||||
AIOHTTP_NOT_INSTALLED, reason="aiohttp is not installed, skipping test."
|
||||
)
|
||||
async def test_async_upload_fd_error_aiohttp(client: api_client.BaseApiClient):
|
||||
error_content = json.dumps({
|
||||
"error": {
|
||||
"code": 400,
|
||||
"message": "Unsupported MIME type: bad/mime_type",
|
||||
"status": "INVALID_ARGUMENT"
|
||||
}
|
||||
}).encode('utf-8')
|
||||
|
||||
# Mock for aiohttp.ClientSession
|
||||
mock_aiohttp_session = mock.MagicMock()
|
||||
mock_aiohttp_session.request = mock.AsyncMock(side_effect=[
|
||||
_httpx_response(200, headers={"X-Goog-Upload-URL": "http://fake/upload"}), # Initial request
|
||||
_httpx_response(400, content=error_content, headers={"X-Goog-Upload-Status": "final"}), # Upload request
|
||||
])
|
||||
|
||||
with mock.patch.object(client, '_use_aiohttp', return_value=True), \
|
||||
mock.patch.object(client, '_get_aiohttp_session', return_value=mock_aiohttp_session), \
|
||||
pytest.raises(errors.APIError, match='Unsupported MIME type: bad/mime_type'), \
|
||||
io.BytesIO(b"test") as f:
|
||||
await client._async_upload_fd(
|
||||
f, # Pass file object directly
|
||||
"http://fake/upload",
|
||||
4,
|
||||
http_options=types.HttpOptions(),
|
||||
)
|
||||
assert mock_aiohttp_session.request.call_count == 2
|
||||
|
||||
Reference in new issue
Block a user