first vommit

This commit is contained in:
Till-Immer committed 2026-07-04 14:31:50 +02:00
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."""
@@ -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())
@@ -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
@@ -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