diff --git a/README.md b/README.md index 67242ee..796c93f 100644 --- a/README.md +++ b/README.md @@ -131,6 +131,14 @@ response = client.models.generate_content( response.text ``` +#### Manually declare and invoke a function for function calling + +If you don't want to use the automatic function support, you can manually +declare the function and invoke it. + +The following example shows how to declare a function and pass it as a tool. +Then you will receive a function call part in the response. + ``` python function = dict( name="get_current_weather", @@ -159,15 +167,24 @@ response = client.models.generate_content( response.candidates[0].content.parts[0].function_call ``` +After you receive the function call part from model, you can invoke the function +and get the function response. And then you can pass the function response to +the model. +The following example shows how to do it for a simple function invocation. + ``` python function_call_part = response.candidates[0].content.parts[0] -function_response = get_current_weather(**function_call_part.function_call.args) +try: + function_result = get_current_weather(**function_call_part.function_call.args) + function_response = {'result': function_result} +except Exception as e: # instead of raising the exception, you can let the model handle it + function_response = {'error': str(e)} function_response_part = types.Part.from_function_response( name=function_call_part.function_call.name, - response={'result': function_response} + response=function_response, ) response = client.models.generate_content( @@ -245,6 +262,8 @@ print(response.text) ### Streaming +#### Streaming for text content + ``` python for chunk in client.models.generate_content_stream( model='gemini-2.0-flash-exp', contents='Tell me a story in 300 words.' @@ -252,6 +271,47 @@ for chunk in client.models.generate_content_stream( print(chunk.text) ``` +#### Streaming for image content + +If your image is stored in Google Cloud Storage, you can use the `from_uri` +class method to create a Part object. + +``` python +for chunk in client.models.generate_content_stream( + model='gemini-1.5-flash', + contents=[ + 'What is this image about?', + types.Part.from_uri( + file_uri='gs://generativeai-downloads/images/scones.jpg', + mime_type='image/jpeg' + ) + ], +): + print(chunk.text) +``` + +If your image is stored in your local file system, you can read it in as bytes +data and use the `from_bytes` class method to create a Part object. + +``` python +YOUR_IMAGE_PATH = 'your_image_path' +YOUR_IMAGE_MIME_TYPE = 'your_image_mime_type' +with open(YOUR_IMAGE_PATH, 'rb') as f: + image_bytes = f.read() + +for chunk in client.models.generate_content_stream( + model='gemini-1.5-flash', + contents=[ + 'What is this image about?', + types.Part.from_bytes( + data=image_bytes, + mime_type=YOUR_IMAGE_MIME_TYPE + ) + ], +): + print(chunk.text) +``` + ### Async `client.aio` exposes all the analogous `async` methods that are diff --git a/google/genai/__init__.py b/google/genai/__init__.py index 40ae2dd..cc37069 100644 --- a/google/genai/__init__.py +++ b/google/genai/__init__.py @@ -17,6 +17,6 @@ from .client import Client -__version__ = '0.2.1' +__version__ = '0.2.2' __all__ = ['Client'] diff --git a/google/genai/_api_client.py b/google/genai/_api_client.py index 060e748..9333a54 100644 --- a/google/genai/_api_client.py +++ b/google/genai/_api_client.py @@ -51,7 +51,7 @@ class HttpOptions(TypedDict): def _append_library_version_headers(headers: dict[str, str]) -> None: """Appends the telemetry header to the headers dict.""" # TODO: Automate revisions to the SDK library version. - library_label = f'google-genai-sdk/0.2.1' + library_label = f'google-genai-sdk/0.2.2' language_label = 'gl-python/' + sys.version.split()[0] version_header_value = f'{library_label} {language_label}' if ( diff --git a/google/genai/errors.py b/google/genai/errors.py index 106310b..db18f0f 100644 --- a/google/genai/errors.py +++ b/google/genai/errors.py @@ -29,29 +29,49 @@ class APIError(Exception): code: int response: requests.Response - message: str = '' - status: str = 'UNKNOWN' - details: Optional[Any] = None + status: Optional[str] = None + message: Optional[str] = None + response: Optional[Any] = None def __init__( self, code: int, response: Union[requests.Response, 'ReplayResponse'] ): - self.code = code self.response = response if isinstance(response, requests.Response): try: - raw_error = response.json().get('error', {}) + # do not do any extra muanipulation on the response. + # return the raw response json as is. + response_json = response.json() except requests.exceptions.JSONDecodeError: - raw_error = {'message': response.text, 'status': response.reason} + response_json = { + 'message': response.text, + 'status': response.reason, + } else: - raw_error = response.body_segments[0].get('error', {}) + response_json = response.body_segments[0].get('error', {}) + + self.details = response_json + self.message = self._get_message(response_json) + self.status = self._get_status(response_json) + self.code = code if code else self._get_code(response_json) + + super().__init__(f'{self.code} {self.status}. {self.details}') + + def _get_status(self, response_json): + return response_json.get( + 'status', response_json.get('error', {}).get('status', None) + ) - self.message = raw_error.get('message', '') - self.status = raw_error.get('status', 'UNKNOWN') - self.details = raw_error.get('details', None) + def _get_message(self, response_json): + return response_json.get( + 'message', response_json.get('error', {}).get('message', None) + ) - super().__init__(f'{self.code} {self.status}. {self.message}') + def _get_code(self, response_json): + return response_json.get( + 'code', response_json.get('error', {}).get('code', None) + ) def _to_replay_record(self): """Returns a dictionary representation of the error for replay recording. diff --git a/google/genai/tests/errors/__init__.py b/google/genai/tests/errors/__init__.py new file mode 100644 index 0000000..38d793e --- /dev/null +++ b/google/genai/tests/errors/__init__.py @@ -0,0 +1 @@ +"""Unit Tests for the error modules.""" \ No newline at end of file diff --git a/google/genai/tests/errors/test_api_error.py b/google/genai/tests/errors/test_api_error.py new file mode 100644 index 0000000..7d3ea09 --- /dev/null +++ b/google/genai/tests/errors/test_api_error.py @@ -0,0 +1,332 @@ +# Copyright 2024 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. +# + + +"""Unit Tests for the APIError class. + +End to end tests should be in models/test_generate_content.py. +""" + + +from typing import cast + +import requests + +from ... import errors + + +def test_constructor_code_none_error_in_json_code_in_error(): + class FakeResponse(requests.Response): + + def json(self): + return { + 'error': { + 'code': 400, + 'message': 'error message', + 'status': 'INVALID_ARGUMENT', + } + } + + actual_error = errors.APIError( + None, + FakeResponse(), + ) + + assert actual_error.code == 400 + assert actual_error.message == 'error message' + assert actual_error.status == 'INVALID_ARGUMENT' + assert actual_error.details == { + 'error': { + 'code': 400, + 'message': 'error message', + 'status': 'INVALID_ARGUMENT', + } + } + + +def test_constructor_code_none_error_in_json_code_outside_error(): + class FakeResponse(requests.Response): + + def json(self): + return { + 'code': 400, + 'error': { + 'code': ( + 500 + ), # differentiate from the code in the outer level for test purpose. + 'message': 'error message', + 'status': 'INVALID_ARGUMENT', + }, + } + + actual_error = errors.APIError( + None, + FakeResponse(), + ) + + assert actual_error.code == 400 + assert actual_error.message == 'error message' + assert actual_error.status == 'INVALID_ARGUMENT' + assert actual_error.details == { + 'code': 400, + 'error': { + 'code': 500, + 'message': 'error message', + 'status': 'INVALID_ARGUMENT', + }, + } + + +def test_constructor_code_not_present(): + class FakeResponse(requests.Response): + + def json(self): + return { + 'error': { + 'message': 'error message', + 'status': 'INVALID_ARGUMENT', + } + } + + actual_error = errors.APIError( + None, + FakeResponse(), + ) + + assert actual_error.code is None + assert actual_error.message == 'error message' + assert actual_error.status == 'INVALID_ARGUMENT' + assert actual_error.details == { + 'error': { + 'message': 'error message', + 'status': 'INVALID_ARGUMENT', + } + } + + +def test_constructor_code_exist_error_in_json(): + class FakeResponse(requests.Response): + + def json(self): + return { + 'error': { + 'code': 400, + 'message': 'error message', + 'status': 'INVALID_ARGUMENT', + } + } + + actual_error = errors.APIError( + 400, + FakeResponse(), + ) + + assert actual_error.code == 400 + assert actual_error.message == 'error message' + assert actual_error.status == 'INVALID_ARGUMENT' + assert actual_error.details == { + 'error': { + 'code': 400, + 'message': 'error message', + 'status': 'INVALID_ARGUMENT', + } + } + + +def test_constructor_error_not_in_json(): + class FakeResponse(requests.Response): + + def json(self): + return { + 'message': 'error message', + 'status': 'INVALID_ARGUMENT', + 'code': 400, + } + + actual_error = errors.APIError( + 400, + FakeResponse(), + ) + + assert actual_error.code == 400 + assert actual_error.message == 'error message' + assert actual_error.status == 'INVALID_ARGUMENT' + assert actual_error.details == { + 'message': 'error message', + 'status': 'INVALID_ARGUMENT', + 'code': 400, + } + + +def test_constructor_error_in_json_status_outside_error(): + class FakeResponse(requests.Response): + + def json(self): + return { + 'status': 'OUTTER_INVALID_ARGUMENT_STATUS', + 'error': { + 'code': 400, + 'message': 'error message', + 'status': 'INNER_INVALID_ARGUMENT_STATUS', + }, + } + + actual_error = errors.APIError( + 400, + FakeResponse(), + ) + + assert actual_error.code == 400 + assert actual_error.message == 'error message' + assert actual_error.status == 'OUTTER_INVALID_ARGUMENT_STATUS' + assert actual_error.details == { + 'status': 'OUTTER_INVALID_ARGUMENT_STATUS', + 'error': { + 'code': 400, + 'message': 'error message', + 'status': 'INNER_INVALID_ARGUMENT_STATUS', + }, + } + + +def test_constructor_status_not_present(): + class FakeResponse(requests.Response): + + def json(self): + return { + 'error': { + 'code': 400, + 'message': 'error message', + } + } + + actual_error = errors.APIError( + 400, + FakeResponse(), + ) + + assert actual_error.code == 400 + assert actual_error.message == 'error message' + assert actual_error.status == None + assert actual_error.details == { + 'error': { + 'code': 400, + 'message': 'error message', + } + } + + +def test_constructor_error_in_json_message_outside_error(): + class FakeResponse(requests.Response): + + def json(self): + return { + 'message': 'OUTTER_ERROR_MESSAGE', + 'error': { + 'code': 400, + 'message': 'INNER_ERROR_MESSAGE', + 'status': 'INVALID_ARGUMENT', + }, + } + + actual_error = errors.APIError( + 400, + FakeResponse(), + ) + + assert actual_error.code == 400 + assert actual_error.message == 'OUTTER_ERROR_MESSAGE' + assert actual_error.status == 'INVALID_ARGUMENT' + assert actual_error.details == { + 'message': 'OUTTER_ERROR_MESSAGE', + 'error': { + 'code': 400, + 'message': 'INNER_ERROR_MESSAGE', + 'status': 'INVALID_ARGUMENT', + }, + } + + +def test_constructor_message_not_present(): + class FakeResponse(requests.Response): + + def json(self): + return { + 'error': { + 'code': 400, + 'status': 'INVALID_ARGUMENT', + } + } + + actual_error = errors.APIError( + 400, + FakeResponse(), + ) + + assert actual_error.code == 400 + assert actual_error.message is None + assert actual_error.status == 'INVALID_ARGUMENT' + assert actual_error.details == { + 'error': { + 'code': 400, + 'status': 'INVALID_ARGUMENT', + } + } + + +def test_constructor_code_exist_json_decoder_error(): + class FakeResponse(requests.Response): + + def json(self): + raise requests.exceptions.JSONDecodeError( + 'json decode error', 'json string', 10 + ) + + actual_error = errors.APIError( + 400, + FakeResponse(), + ) + assert actual_error.code == 400 + assert ( + actual_error.message == '' + ) # response.text defaults to '' in requests.Response. + assert actual_error.status is None + assert actual_error.details == { + 'message': '', + 'status': None, + } + + +def test_constructor_code_none_json_decoder_error(): + class FakeResponse(requests.Response): + + def json(self): + raise requests.exceptions.JSONDecodeError( + 'json decode error', 'json string', 10 + ) + + actual_error = errors.APIError( + None, + FakeResponse(), + ) + assert actual_error.code is None + assert ( + actual_error.message == '' + ) # response.text defaults to '' in requests.Response. + assert actual_error.status is None + assert actual_error.details == { + 'message': '', + 'status': None, + } diff --git a/google/genai/tests/live/test_live.py b/google/genai/tests/live/test_live.py index d0cc538..c67fb3d 100644 --- a/google/genai/tests/live/test_live.py +++ b/google/genai/tests/live/test_live.py @@ -99,6 +99,15 @@ def test_vertex_from_env(monkeypatch): assert isinstance(client.aio.live.api_client, api_client.ApiClient) +def test_websocket_base_url(): + base_url = 'https://test.com' + api_client = gl_client.ApiClient( + api_key = 'google_api_key', + http_options={'base_url': base_url}, + ) + assert api_client._websocket_base_url() == 'wss://test.com' + + @pytest.mark.parametrize('vertexai', [True, False]) @pytest.mark.asyncio async def test_async_session_send_text( diff --git a/google/genai/tests/models/test_generate_content.py b/google/genai/tests/models/test_generate_content.py index 7b6c5ce..2fe1637 100644 --- a/google/genai/tests/models/test_generate_content.py +++ b/google/genai/tests/models/test_generate_content.py @@ -456,3 +456,33 @@ def test_invalid_input_for_simple_parameter(client): contents='What is your name?', ) assert 'model' in str(e) + + +def test_catch_stack_trace_in_error_handling(client): + try: + client.models.generate_content( + model='gemini-1.5-flash', + contents='What is your name?', + config={ + 'response_modalities': ['AUDIO'] + }, + ) + except errors.ClientError as e: + # Note that the stack trace is truncated in replay file, therefore this is + # the best we can do in testing error handling. In api mode, the stack trace + # is: + # { + # 'error': { + # 'code': 400, + # 'message': 'Multi-modal output is not supported.', + # 'status': 'INVALID_ARGUMENT', + # 'details': [{ + # '@type': 'type.googleapis.com/google.rpc.DebugInfo', + # 'detail': '[ORIGINAL ERROR] generic::invalid_argument: ' + # 'Multi-modal output is not supported. ' + # '[google.rpc.error_details_ext] ' + # '{ message: "Multi-modal output is not supported." }' + # }] + # } + # } + assert e.details == {'code': 400, 'message': '', 'status': 'UNKNOWN'} diff --git a/google/genai/tests/models/test_generate_content_media_resolution.py b/google/genai/tests/models/test_generate_content_media_resolution.py index 81cb722..c4d47f7 100644 --- a/google/genai/tests/models/test_generate_content_media_resolution.py +++ b/google/genai/tests/models/test_generate_content_media_resolution.py @@ -17,46 +17,57 @@ from ... import types from .. import pytest_helper -# TODO(b/383526328) -# TODO(b/383524789) -# test_table: list[pytest_helper.TestTableItem] = [ -# pytest_helper.TestTableItem( -# name='test_video_audio_uri_with_media_resolution', -# parameters=types._GenerateContentParameters( -# model='gemini-2.0-flash-exp', -# contents=[ -# """ -# Is the audio related to the video? -# If so, how? -# What are the common themes? -# What are the different emphases? -# """, -# types.Part.from_uri( -# 'gs://cloud-samples-data/generative-ai/video/pixel8.mp4', -# 'video/mp4', -# ), -# types.Part.from_uri( -# 'gs://cloud-samples-data/generative-ai/audio/pixel.mp3', -# 'audio/mpeg', -# ), -# ], -# config={ -# 'system_instruction': ( -# 'you are a helpful assistant for people with visual and hearing' -# ' disabilities.'), -# 'media_resolution': 'MEDIA_RESOLUTION_LOW', -# }, -# ), -# exception_if_mldev='not supported', -# has_union=True, -# )] +test_table: list[pytest_helper.TestTableItem] = [ + pytest_helper.TestTableItem( + name='test_video_audio_uri_with_media_resolution', + parameters=types._GenerateContentParameters( + model='gemini-2.0-flash-exp', + contents=[ + types.Content( + role='user', + parts=[types.Part.from_text( + 'Is the audio related to the video? ' + 'If so, how? ' + 'What are the common themes? ' + 'What are the different emphases?' + )], + ), + types.Content( + role='user', + parts=[types.Part.from_uri( + 'gs://cloud-samples-data/generative-ai/video/pixel8.mp4', + 'video/mp4', + )], + ), + types.Content( + role='user', + parts=[types.Part.from_uri( + 'gs://cloud-samples-data/generative-ai/audio/pixel.mp3', + 'audio/mpeg', + )], + ), + ], + config={ + 'system_instruction': types.Content( + role='user', + parts=[types.Part.from_text( + 'you are a helpful assistant for people with visual ' + 'and hearing disabilities.' + )], + ), + 'media_resolution': 'MEDIA_RESOLUTION_LOW', + }, + ), + exception_if_mldev='not supported', + ) +] pytestmark = pytest_helper.setup( file=__file__, globals_for_file=globals(), test_method='models.generate_content', - #test_table=test_table, + test_table=test_table, ) diff --git a/google/genai/types.py b/google/genai/types.py index 3d3dce5..3e5345e 100644 --- a/google/genai/types.py +++ b/google/genai/types.py @@ -1280,7 +1280,7 @@ class AutomaticFunctionCallingConfig(_common.BaseModel): """, ) maximum_remote_calls: Optional[int] = Field( - default=None, + default=10, description="""If automatic function calling is enabled, maximum number of remote calls for automatic function calling. This number should be a positive integer. diff --git a/pyproject.toml b/pyproject.toml index 6dd82a3..57ff9d4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ requires = ["setuptools", "wheel"] [project] name = "google-genai" -version = "0.2.1" +version = "0.2.2" description = "GenAI Python SDK" readme = "README.md" license = {text = "Apache-2.0"}