diff --git a/.gitignore b/.gitignore index a585f39..b07181f 100644 --- a/.gitignore +++ b/.gitignore @@ -161,4 +161,6 @@ cython_debug/ .vscode/settings.json ## test files -test.py \ No newline at end of file +tests/gemini.txt +tests/openai.txt +tests/deepl.txt diff --git a/README.md b/README.md index 85f8850..431d7c4 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,7 @@ setuptools_scm>=6.0 tomli -google-generativeai==0.5.0 +google-generativeai==0.5.1 deepl==1.16.1 diff --git a/pyproject.toml b/pyproject.toml index 4cd159b..e535668 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,7 @@ build-backend = "setuptools.build_meta" [project] dependencies = [ - "google-generativeai==0.5.0", + "google-generativeai==0.5.1", "deepl==1.16.1", "openai==1.13.3", "backoff==2.2.1", @@ -18,7 +18,7 @@ dependencies = [ ] name = "easytl" -version = "v0.1.2" +version = "v0.2.0-beta" authors = [ { name="Bikatr7", email="Tetralon07@gmail.com" }, ] diff --git a/src/easytl/__init__.py b/src/easytl/__init__.py index 6eba51e..16e20a2 100644 --- a/src/easytl/__init__.py +++ b/src/easytl/__init__.py @@ -7,8 +7,13 @@ __author__ = "Bikatr7 " from .easytl import EasyTL -from .classes import Language, SplitSentences, Formality, GlossaryInfo, ModelTranslationMessage, SystemTranslationMessage, Message -from .util import MODEL_COSTS, ALLOWED_GEMINI_MODELS, ALLOWED_OPENAI_MODELS + +from .classes import Language, SplitSentences, Formality, GlossaryInfo, TextResult +from .classes import Message, SystemTranslationMessage, ModelTranslationMessage +from .classes import ChatCompletion +from .classes import GenerateContentResponse, AsyncGenerateContentResponse, GenerationConfig + +from .util import MODEL_COSTS, ALLOWED_GEMINI_MODELS, ALLOWED_OPENAI_MODELS, VALID_JSON_OPENAI_MODELS from .exceptions import DeepLException, GoogleAPIError, OpenAIError, EasyTLException, InvalidAPIKeyException, InvalidEasyTLSettings from .exceptions import AuthenticationError, InternalServerError, RateLimitError, APITimeoutError, APIConnectionError, APIStatusError diff --git a/src/easytl/classes.py b/src/easytl/classes.py index 5110a40..a2079c6 100644 --- a/src/easytl/classes.py +++ b/src/easytl/classes.py @@ -5,6 +5,13 @@ ## deepl api data used by deepl_service to type check from deepl.api_data import Language, SplitSentences, Formality, GlossaryInfo, TextResult +## openai api data used by openai_service to type check +from openai.types.chat.chat_completion import ChatCompletion + +## gemini api data used by gemini_service to type check +from google.generativeai import GenerationConfig +from google.generativeai.types import GenerateContentResponse, AsyncGenerateContentResponse + ##-------------------start-of-Message-------------------------------------------------------------------------------------------------------------------------------------------------------------------------- class Message: diff --git a/src/easytl/deepl_service.py b/src/easytl/deepl_service.py index bb63242..4186abb 100644 --- a/src/easytl/deepl_service.py +++ b/src/easytl/deepl_service.py @@ -42,14 +42,14 @@ class DeepLService: ##-------------------start-of-_set_decorator()--------------------------------------------------------------------------------------------------------------------------------------------------------------------------- @staticmethod - def _set_decorator(decorator:typing.Callable) -> None: + def _set_decorator(decorator:typing.Callable | None) -> None: """ - Sets the decorator to use for the Gemini service. Should be a callable that returns a decorator. + Sets the decorator to use for the DeepL service. Should be a callable that returns a decorator or None. Parameters: - decorator (callable) : The decorator to use. + decorator (callable or None) : The decorator to use for the DeepL service. """ @@ -259,7 +259,7 @@ def _get_decorator() -> typing.Union[typing.Callable, None]: """ - Returns the decorator to use for the Gemini service. + Returns the decorator to use for the DeepL service. Returns: decorator (callable) : The decorator to use. diff --git a/src/easytl/easytl.py b/src/easytl/easytl.py index dc13df9..8f2edec 100644 --- a/src/easytl/easytl.py +++ b/src/easytl/easytl.py @@ -12,9 +12,9 @@ ## custom modules from .deepl_service import DeepLService from .gemini_service import GeminiService -from .openai_service import OpenAIService, ChatCompletion +from .openai_service import OpenAIService -from. classes import ModelTranslationMessage, SystemTranslationMessage, TextResult +from. classes import ModelTranslationMessage, SystemTranslationMessage, TextResult, GenerateContentResponse, AsyncGenerateContentResponse, ChatCompletion from .exceptions import DeepLException, GoogleAPIError,OpenAIError, EasyTLException from .util import _convert_to_correct_type, _validate_easytl_translation_settings, _is_iterable_of_strings @@ -142,6 +142,8 @@ def deepl_translate(text:typing.Union[str, typing.Iterable[str]], This function assumes that the API key has already been set. + DeepL has backoff retrying implemented by default. + Parameters: text (string or iterable) : The text to translate. target_lang (string or Language) : The target language to translate to. @@ -170,8 +172,7 @@ def deepl_translate(text:typing.Union[str, typing.Iterable[str]], EasyTL.test_api_key_validity("deepl") - if(decorator != None): - DeepLService._set_decorator(decorator) + DeepLService._set_decorator(decorator) if(override_previous_settings == True): DeepLService._set_attributes(target_lang = target_lang, @@ -245,6 +246,8 @@ async def deepl_translate_async(text:typing.Union[str, typing.Iterable[str]], Will generally be faster for iterables. Order is preserved. This function assumes that the API key has already been set. + + DeepL has backoff retrying implemented by default. Parameters: text (string or iterable) : The text to translate. @@ -271,10 +274,11 @@ async def deepl_translate_async(text:typing.Union[str, typing.Iterable[str]], """ + assert response_type in ["text", "raw"], ValueError("Invalid response type specified. Must be 'text' or 'raw'.") + EasyTL.test_api_key_validity("deepl") - if(decorator != None): - DeepLService._set_decorator(decorator) + DeepLService._set_decorator(decorator) if(override_previous_settings == True): DeepLService._set_attributes(target_lang=target_lang, @@ -325,13 +329,14 @@ def gemini_translate(text:typing.Union[str, typing.Iterable[str]], override_previous_settings:bool = True, decorator:typing.Callable | None = None, logging_directory:str | None = None, + response_type:typing.Literal["text", "raw", "json"] | None = "text", translation_instructions:str | None = None, model:str="gemini-pro", temperature:float=0.5, top_p:float=0.9, top_k:int=40, stop_sequences:typing.List[str] | None=None, - max_output_tokens:int | None=None) -> str | typing.List[str]: + max_output_tokens:int | None=None) -> typing.Union[typing.List[str], str, GenerateContentResponse, typing.List[GenerateContentResponse]]: """ @@ -343,12 +348,15 @@ def gemini_translate(text:typing.Union[str, typing.Iterable[str]], This function is not for use for real-time translation, nor for generating multiple translation candidates. Another function may be implemented for this given demand. + It is not known whether Gemini has backoff retrying implemented. (Their API is a mess.) + Parameters: text (string or iterable) : The text to translate. override_previous_settings (bool) : Whether to override the previous settings that were used during the last call to a Gemini translation function. decorator (callable or None) : The decorator to use when translating. Typically for exponential backoff retrying. logging_directory (string or None) : The directory to log to. If None, no logging is done. This'll append the text result and some function information to a file in the specified directory. File is created if it doesn't exist. - translation_instructions (string or None) : The translation instructions to use. + response_type (literal["text", "raw", "json"]) : The type of response to return. 'text' returns the translated text, 'raw' returns the raw response, a GenerateContentResponse object, 'json' returns a json-parseable string. + translation_instructions (string or None) : The translation instructions to use. If None, the default system message is used. If you plan on using the json response type, you must specify that you want a json output in the instructions. The default system message will ask for json if the response type is json. model (string) : The model to use. temperature (float) : The temperature to use. The higher the temperature, the more creative the output. Lower temperatures are typically better for translation. top_p (float) : The nucleus sampling probability. The higher the value, the more words are considered for the next token. Generally, alter this or temperature, not both. @@ -361,7 +369,7 @@ def gemini_translate(text:typing.Union[str, typing.Iterable[str]], """ - EasyTL.test_api_key_validity("gemini") + assert response_type in ["text", "raw", "json"], ValueError("Invalid response type specified. Must be 'text', 'raw' or 'json'.") _settings = { "gemini_model": "", @@ -372,7 +380,7 @@ def gemini_translate(text:typing.Union[str, typing.Iterable[str]], "gemini_max_output_tokens": "" } - _non_gemini_params = ["text", "override_previous_settings", "decorator", "translation_instructions", "logging_directory"] + _non_gemini_params = ["text", "override_previous_settings", "decorator", "translation_instructions", "logging_directory", "response_type"] _custom_validation_params = ["gemini_stop_sequences"] assert stop_sequences is None or isinstance(stop_sequences, str) or (hasattr(stop_sequences, '__iter__') and all(isinstance(i, str) for i in stop_sequences)), "text must be a string or an iterable of strings." @@ -384,8 +392,11 @@ def gemini_translate(text:typing.Union[str, typing.Iterable[str]], _validate_easytl_translation_settings(_settings, "gemini") - if(decorator != None): - GeminiService._set_decorator(decorator) + EasyTL.test_api_key_validity("gemini") + + GeminiService._set_decorator(decorator) + + GeminiService._set_json_mode(True if response_type == "json" else False) translation_instructions = translation_instructions or GeminiService._system_message @@ -404,25 +415,25 @@ def gemini_translate(text:typing.Union[str, typing.Iterable[str]], if(isinstance(text, str)): _result = GeminiService._translate_text(text) - - if(hasattr(_result, "text")): + + if(response_type == "text" or response_type == "json"): return _result.text else: - raise EasyTLException("Result does not have a 'text' attribute due to an unexpected error.") - + return _result + elif(_is_iterable_of_strings(text)): _results = [GeminiService._translate_text(t) for t in text] - if(all(hasattr(_r, "text") for _r in _results)): + if(response_type == "text" or response_type == "json"): return [_r.text for _r in _results] - else: - raise ValueError("text must be a string or an iterable of strings.") + else: + return _results + + raise ValueError("text must be a string or an iterable of strings.") - raise EasyTLException("Unexpected state reached in gemini_translate.") - ##-------------------start-of-gemini_translate_async()--------------------------------------------------------------------------------------------------------------------------------------------------------------------------- @staticmethod @@ -431,13 +442,14 @@ async def gemini_translate_async(text:typing.Union[str, typing.Iterable[str]], decorator:typing.Callable | None = None, logging_directory:str | None = None, semaphore:int | None = None, + response_type:typing.Literal["text", "raw", "json"] | None = "text", translation_instructions:str | None = None, model:str="gemini-pro", temperature:float=0.5, top_p:float=0.9, top_k:int=40, stop_sequences:typing.List[str] | None=None, - max_output_tokens:int | None=None) -> str | typing.List[str]: + max_output_tokens:int | None=None) -> typing.Union[typing.List[str], str, AsyncGenerateContentResponse, typing.List[AsyncGenerateContentResponse]]: """ @@ -452,13 +464,16 @@ async def gemini_translate_async(text:typing.Union[str, typing.Iterable[str]], This function is not for use for real-time translation, nor for generating multiple translation candidates. Another function may be implemented for this given demand. + It is not known whether Gemini has backoff retrying implemented. (Their API is a mess.) + Parameters: text (string or iterable) : The text to translate. override_previous_settings (bool) : Whether to override the previous settings that were used during the last call to a Gemini translation function. decorator (callable or None) : The decorator to use when translating. Typically for exponential backoff retrying. logging_directory (string or None) : The directory to log to. If None, no logging is done. This'll append the text result and some function information to a file in the specified directory. File is created if it doesn't exist. semaphore (int) : The number of concurrent requests to make. Default is 15 for 1.0 and 2 for 1.5 gemini models. - translation_instructions (string or None) : The translation instructions to use. + response_type (literal["text", "raw", "json"]) : The type of response to return. 'text' returns the translated text, 'raw' returns the raw response, a AsyncGenerateContentResponse object, 'json' returns a json-parseable string. + translation_instructions (string or None) : The translation instructions to use. If None, the default system message is used. If you plan on using the json response type, you must specify that you want a json output in the instructions. The default system message will ask for json if the response type is json. model (string) : The model to use. temperature (float) : The temperature to use. The higher the temperature, the more creative the output. Lower temperatures are typically better for translation. top_p (float) : The nucleus sampling probability. The higher the value, the more words are considered for the next token. Generally, alter this or temperature, not both. @@ -471,8 +486,6 @@ async def gemini_translate_async(text:typing.Union[str, typing.Iterable[str]], """ - EasyTL.test_api_key_validity("gemini") - _settings = { "gemini_model": "", "gemini_temperature": "", @@ -482,7 +495,7 @@ async def gemini_translate_async(text:typing.Union[str, typing.Iterable[str]], "gemini_max_output_tokens": "" } - _non_gemini_params = ["text", "override_previous_settings", "decorator", "translation_instructions", "logging_directory", "semaphore"] + _non_gemini_params = ["text", "override_previous_settings", "decorator", "translation_instructions", "logging_directory", "semaphore", "response_type"] _custom_validation_params = ["gemini_stop_sequences"] assert stop_sequences is None or isinstance(stop_sequences, str) or (hasattr(stop_sequences, '__iter__') and all(isinstance(i, str) for i in stop_sequences)), "stop_sequences must be a string or an iterable of strings." @@ -494,8 +507,11 @@ async def gemini_translate_async(text:typing.Union[str, typing.Iterable[str]], _validate_easytl_translation_settings(_settings, "gemini") - if(decorator != None): - GeminiService._set_decorator(decorator) + EasyTL.test_api_key_validity("gemini") + + GeminiService._set_decorator(decorator) + + GeminiService._set_json_mode(True if response_type == "json" else False) translation_instructions = translation_instructions or GeminiService._system_message @@ -515,25 +531,25 @@ async def gemini_translate_async(text:typing.Union[str, typing.Iterable[str]], if(isinstance(text, str)): _result = await GeminiService._translate_text_async(text) - if(hasattr(_result, "text")): - return _result.text + if(response_type == "text" or response_type == "json"): + return _result.text else: - raise Exception("Unexpected error occurred. Please try again.") + return _result elif(_is_iterable_of_strings(text)): _tasks = [GeminiService._translate_text_async(_t) for _t in text] _results = await asyncio.gather(*_tasks) - if(all(hasattr(_r, "text") for _r in _results)): - return [_r.text for _r in _results] + if(response_type == "text" or response_type == "json"): + return [_r.text for _r in _results] - else: - raise ValueError("text must be a string or an iterable of strings.") + else: + return _results + + raise ValueError("text must be a string or an iterable of strings.") - raise Exception("Unexpected state reached in gemini_translate_async.") - ##-------------------start-of-openai_translate()--------------------------------------------------------------------------------------------------------------------------------------------------------------------------- @staticmethod @@ -541,6 +557,7 @@ def openai_translate(text:typing.Union[str, typing.Iterable[str], ModelTranslati override_previous_settings:bool = True, decorator:typing.Callable | None = None, logging_directory:str | None = None, + response_type:typing.Literal["text", "raw", "json"] | None = "text", translation_instructions:str | SystemTranslationMessage | None = None, model:str="gpt-4", temperature:float=0.3, @@ -549,7 +566,7 @@ def openai_translate(text:typing.Union[str, typing.Iterable[str], ModelTranslati max_tokens:int | None=None, presence_penalty:float=0.0, frequency_penalty:float=0.0 - ) -> str | typing.List[str]: + ) -> typing.Union[typing.List[str], str, typing.List[ChatCompletion], ChatCompletion]: """ @@ -561,12 +578,15 @@ def openai_translate(text:typing.Union[str, typing.Iterable[str], ModelTranslati This function is not for use for real-time translation, nor for generating multiple translation candidates. Another function may be implemented for this given demand. + OpenAI has it's backoff retrying disabled by EasyTL. + Parameters: text (string or iterable) : The text to translate. override_previous_settings (bool) : Whether to override the previous settings that were used during the last call to an OpenAI translation function. decorator (callable or None) : The decorator to use when translating. Typically for exponential backoff retrying. logging_directory (string or None) : The directory to log to. If None, no logging is done. This'll append the text result and some function information to a file in the specified directory. File is created if it doesn't exist. - translation_instructions (string or SystemTranslationMessage or None) : The translation instructions to use. + response_type (literal["text", "raw", "json"]) : The type of response to return. 'text' returns the translated text, 'raw' returns the raw response, a ChatCompletion object, 'json' returns a json-parseable string. + translation_instructions (string or SystemTranslationMessage or None) : The translation instructions to use. If None, the default system message is used. If you plan on using the json response type, you must specify that you want a json output in the instructions. The default system message will ask for json if the response type is json. model (string) : The model to use. temperature (float) : The temperature to use. The higher the temperature, the more creative the output. Lower temperatures are typically better for translation. top_p (float) : The nucleus sampling probability. The higher the value, the more words are considered for the next token. Generally, alter this or temperature, not both. @@ -580,8 +600,6 @@ def openai_translate(text:typing.Union[str, typing.Iterable[str], ModelTranslati """ - EasyTL.test_api_key_validity("openai") - _settings = { "openai_model": "", "openai_temperature": "", @@ -592,7 +610,7 @@ def openai_translate(text:typing.Union[str, typing.Iterable[str], ModelTranslati "openai_frequency_penalty": "" } - _non_openai_params = ["text", "override_previous_settings", "decorator", "translation_instructions", "logging_directory"] + _non_openai_params = ["text", "override_previous_settings", "decorator", "translation_instructions", "logging_directory", "response_type"] _custom_validation_params = ["openai_stop"] assert stop is None or isinstance(stop, str) or (hasattr(stop, '__iter__') and all(isinstance(i, str) for i in stop)), "stop must be a string or an iterable of strings." @@ -602,10 +620,13 @@ def openai_translate(text:typing.Union[str, typing.Iterable[str], ModelTranslati if(param_name in locals() and _key not in _non_openai_params and _key not in _custom_validation_params): _settings[_key] = _convert_to_correct_type(_key, locals()[param_name]) + EasyTL.test_api_key_validity("openai") + _validate_easytl_translation_settings(_settings, "openai") - if(decorator != None): - OpenAIService._set_decorator(decorator) + OpenAIService._set_decorator(decorator) + + OpenAIService._set_json_mode(True if response_type == "json" else False) if(override_previous_settings == True): OpenAIService._set_attributes(model=model, @@ -628,10 +649,11 @@ def openai_translate(text:typing.Union[str, typing.Iterable[str], ModelTranslati _result = OpenAIService._translate_text(_translation_instructions, _text) - translation = _result.choices[0].message.content + if(response_type == "text" or response_type == "json"): + translation = _result.choices[0].message.content - if(translation is None): - raise EasyTLException("Unexpected error occurred. Please try again.") + else: + translation = _result translations.append(translation) @@ -646,6 +668,7 @@ async def openai_translate_async(text:typing.Union[str, typing.Iterable[str], Mo decorator:typing.Callable | None = None, semaphore:int | None = None, logging_directory:str | None = None, + response_type:typing.Literal["text", "raw", "json"] | None = "text", translation_instructions:str | SystemTranslationMessage | None = None, model:str="gpt-4", temperature:float=0.3, @@ -654,7 +677,7 @@ async def openai_translate_async(text:typing.Union[str, typing.Iterable[str], Mo max_tokens:int | None=None, presence_penalty:float=0.0, frequency_penalty:float=0.0 - ) -> str | typing.List[str]: + ) -> typing.Union[typing.List[str], str, typing.List[ChatCompletion], ChatCompletion]: """ @@ -667,12 +690,16 @@ async def openai_translate_async(text:typing.Union[str, typing.Iterable[str], Mo This function is not for use for real-time translation, nor for generating multiple translation candidates. Another function may be implemented for this given demand. + OpenAI has it's backoff retrying disabled by EasyTL. + Parameters: text (string or iterable) : The text to translate. override_previous_settings (bool) : Whether to override the previous settings that were used during the last call to an OpenAI translation function. decorator (callable or None) : The decorator to use when translating. Typically for exponential backoff retrying. semaphore (int) : The number of concurrent requests to make. Default is 5. - translation_instructions (string or SystemTranslationMessage or None) : The translation instructions to use. + logging_directory (string or None) : The directory to log to. If None, no logging is done. This'll append the text result and some function information to a file in the specified directory. File is created if it doesn't exist. + response_type (literal["text", "raw", "json"]) : The type of response to return. 'text' returns the translated text, 'raw' returns the raw response, a ChatCompletion object, 'json' returns a json-parseable string. + translation_instructions (string or SystemTranslationMessage or None) : The translation instructions to use. If None, the default system message is used. If you plan on using the json response type, you must specify that you want a json output in the instructions. The default system message will ask for json if the response type is json. model (string) : The model to use. temperature (float) : The temperature to use. The higher the temperature, the more creative the output. Lower temperatures are typically better for translation. top_p (float) : The nucleus sampling probability. The higher the value, the more words are considered for the next token. Generally, alter this or temperature, not both. @@ -686,8 +713,6 @@ async def openai_translate_async(text:typing.Union[str, typing.Iterable[str], Mo """ - EasyTL.test_api_key_validity("openai") - _settings = { "openai_model": "", "openai_temperature": "", @@ -698,7 +723,7 @@ async def openai_translate_async(text:typing.Union[str, typing.Iterable[str], Mo "openai_frequency_penalty": "" } - _non_openai_params = ["text", "override_previous_settings", "decorator", "translation_instructions", "logging_directory", "semaphore"] + _non_openai_params = ["text", "override_previous_settings", "decorator", "translation_instructions", "logging_directory", "semaphore", "response_type"] _custom_validation_params = ["openai_stop"] assert stop is None or isinstance(stop, str) or (hasattr(stop, '__iter__') and all(isinstance(i, str) for i in stop)), "stop must be a string or an iterable of strings." @@ -710,8 +735,11 @@ async def openai_translate_async(text:typing.Union[str, typing.Iterable[str], Mo _validate_easytl_translation_settings(_settings, "openai") - if(decorator != None): - OpenAIService._set_decorator(decorator) + EasyTL.test_api_key_validity("openai") + + OpenAIService._set_decorator(decorator) + + OpenAIService._set_json_mode(True if response_type == "json" else False) if(override_previous_settings == True): OpenAIService._set_attributes(model=model, @@ -737,18 +765,25 @@ async def openai_translate_async(text:typing.Union[str, typing.Iterable[str], Mo _results = await asyncio.gather(*_translation_tasks) _results:typing.List[ChatCompletion] = _results + + if(response_type == "text" or response_type == "json"): + _translations = [result.choices[0].message.content for result in _results if result.choices[0].message.content is not None] - _translations = [result.choices[0].message.content for result in _results if result.choices[0].message.content is not None] + elif(response_type == "raw"): + _translations = _results # If the original input was a single text (not an iterable of texts), return a single translation instead of a list return _translations if isinstance(text, typing.Iterable) and not isinstance(text, str) else _translations[0] - + ##-------------------start-of-translate()--------------------------------------------------------------------------------------------------------------------------------------------------------------------------- @staticmethod def translate(text:str | typing.Iterable[str], service:typing.Optional[typing.Literal["deepl", "openai", "gemini"]] = "deepl", - **kwargs) -> typing.Union[typing.List[str], str, typing.List[TextResult], TextResult]: + **kwargs) -> typing.Union[typing.List[str], str, + typing.List[TextResult], TextResult, + typing.List[GenerateContentResponse], GenerateContentResponse, + typing.List[ChatCompletion], ChatCompletion]: """ @@ -787,7 +822,11 @@ def translate(text:str | typing.Iterable[str], @staticmethod async def translate_async(text:str | typing.Iterable[str], service:typing.Optional[typing.Literal["deepl", "openai", "gemini"]] = "deepl", - **kwargs) -> typing.Union[typing.List[str], str, typing.List[TextResult], TextResult]: + **kwargs) -> typing.Union[typing.List[str], str, + typing.List[TextResult], TextResult, + typing.List[GenerateContentResponse], GenerateContentResponse, + typing.List[ChatCompletion], ChatCompletion, + AsyncGenerateContentResponse, typing.List[AsyncGenerateContentResponse]]: """ diff --git a/src/easytl/gemini_service.py b/src/easytl/gemini_service.py index 9d68bd4..7b00c97 100644 --- a/src/easytl/gemini_service.py +++ b/src/easytl/gemini_service.py @@ -6,16 +6,14 @@ import typing import asyncio -## third party libraries -from google.generativeai import GenerationConfig -from google.generativeai.types import GenerateContentResponse, AsyncGenerateContentResponse - import google.generativeai as genai ## custom modules from .util import _estimate_cost, _convert_iterable_to_str, _is_iterable_of_strings from .decorators import _async_logging_decorator, _sync_logging_decorator +from .classes import GenerationConfig, GenerateContentResponse, AsyncGenerateContentResponse + class GeminiService: _default_translation_instructions:str = "Please translate the following text into English." @@ -42,7 +40,7 @@ class GeminiService: _log_directory:str | None = None - ## I don't plan to allow users to change these settings, as I believe that translations should be as accurate as possible, avoiding any censorship or filtering of content. + ## I don't plan to easily allowing users to change these settings, as I believe that translations should be as accurate as possible, avoiding any censorship or filtering of content. _safety_settings = [ { "category": "HARM_CATEGORY_DANGEROUS", @@ -66,6 +64,8 @@ class GeminiService: }, ] + _json_mode:bool = False + ##-------------------start-of-_set_api_key()--------------------------------------------------------------------------------------------------------------------------------------------------------------------------- @staticmethod @@ -85,19 +85,35 @@ def _set_api_key(api_key:str) -> None: ##-------------------start-of-_set_decorator()--------------------------------------------------------------------------------------------------------------------------------------------------------------------------- @staticmethod - def _set_decorator(decorator:typing.Callable) -> None: + def _set_decorator(decorator:typing.Callable | None) -> None: """ - Sets the decorator to use for the Gemini service. Should be a callable that returns a decorator. + Sets the decorator to use for the Gemini service. Should be a callable that returns a decorator or None. Parameters: - decorator (callable) : The decorator to use. + decorator (callable | None) : The decorator to use. """ GeminiService._decorator_to_use = decorator +##-------------------start-of-_set_json_mode()--------------------------------------------------------------------------------------------------------------------------------------------------------------------------- + + @staticmethod + def _set_json_mode(json_mode:bool) -> None: + + """ + + Sets the JSON mode for the Gemini service. + + Parameters: + json_mode (bool) : True if the service should return JSON responses, False if it should return text responses. + + """ + + GeminiService._json_mode = json_mode + ##-------------------start-of-_set_attributes()--------------------------------------------------------------------------------------------------------------------------------------------------------------------------- @staticmethod @@ -135,9 +151,15 @@ def _set_attributes(model:str="gemini-pro", GeminiService._semaphore_value = semaphore else: - GeminiService._semaphore_value = 15 if GeminiService._model != "gemini--1.5-pro-latest" else 2 + GeminiService._semaphore_value = 15 if GeminiService._model != "gemini-1.5-pro-latest" else 2 GeminiService._log_directory = logging_directory + + if(GeminiService._json_mode and GeminiService._model != "gemini-1.5-pro-latest"): + GeminiService._default_translation_instructions = "Please translate the following text into English. Make sure to return the translated text in JSON format." + + else: + GeminiService._default_translation_instructions = "Please translate the following text into English." ##-------------------start-of-_redefine_client()--------------------------------------------------------------------------------------------------------------------------------------------------------------------------- @@ -150,25 +172,34 @@ def _redefine_client() -> None: """ - ## as of now, the only model that allows for system instructions is gemini--1.5-pro-latest - if(GeminiService._model == "gemini--1.5-pro-latest"): + response_mime_type = "text/plain" - GeminiService._client = genai.GenerativeModel(model_name=GeminiService._model, - safety_settings=GeminiService._safety_settings, - system_instruction=GeminiService._system_message, - ) - else: - GeminiService._client = genai.GenerativeModel(model_name=GeminiService._model, - safety_settings=GeminiService._safety_settings) + if(GeminiService._json_mode): + response_mime_type = "application/json" + gen_model_params = { + "model_name": GeminiService._model, + "safety_settings": GeminiService._safety_settings + } + + ## gemini 1.5 is the only model that supports json responses and system instructions + if(GeminiService._model == "gemini-1.5-pro-latest"): + gen_model_params["system_instruction"] = GeminiService._system_message + else: + response_mime_type = "text/plain" + + GeminiService._client = genai.GenerativeModel(**gen_model_params) + + GeminiService._generation_config = GenerationConfig( + candidate_count=GeminiService._candidate_count, + stop_sequences=GeminiService._stop_sequences, + max_output_tokens=GeminiService._max_output_tokens, + temperature=GeminiService._temperature, + top_p=GeminiService._top_p, + top_k=GeminiService._top_k, + response_mime_type=response_mime_type + ) - GeminiService._generation_config = GenerationConfig(candidate_count=GeminiService._candidate_count, - stop_sequences=GeminiService._stop_sequences, - max_output_tokens=GeminiService._max_output_tokens, - temperature=GeminiService._temperature, - top_p=GeminiService._top_p, - top_k=GeminiService._top_k) - GeminiService._semaphore = asyncio.Semaphore(GeminiService._semaphore_value) ##-------------------start-of-_redefine_client_decorator()--------------------------------------------------------------------------------------------------------------------------------------------------------------------------- diff --git a/src/easytl/openai_service.py b/src/easytl/openai_service.py index 5e9a7af..0b05c0d 100644 --- a/src/easytl/openai_service.py +++ b/src/easytl/openai_service.py @@ -8,11 +8,10 @@ ## third-party libraries from openai import AsyncOpenAI, OpenAI -from openai.types.chat.chat_completion import ChatCompletion ## custom modules -from .classes import SystemTranslationMessage, ModelTranslationMessage -from .util import _convert_iterable_to_str, _estimate_cost, _is_iterable_of_strings +from .classes import SystemTranslationMessage, ModelTranslationMessage, ChatCompletion +from .util import _convert_iterable_to_str, _estimate_cost, _is_iterable_of_strings, VALID_JSON_OPENAI_MODELS from .decorators import _async_logging_decorator, _sync_logging_decorator class OpenAIService: @@ -41,6 +40,8 @@ class OpenAIService: _decorator_to_use:typing.Union[typing.Callable, None] = None + _json_mode:bool = False + _log_directory:str | None = None ##-------------------start-of-set_api_key()--------------------------------------------------------------------------------------------------------------------------------------------------------------------------- @@ -63,19 +64,35 @@ def _set_api_key(api_key:str) -> None: ##-------------------start-of-set_decorator()--------------------------------------------------------------------------------------------------------------------------------------------------------------------------- @staticmethod - def _set_decorator(decorator:typing.Callable) -> None: + def _set_decorator(decorator:typing.Callable | None) -> None: """ - Sets the decorator to use for the OpenAI service. Should be a callable that returns a decorator. + Sets the decorator to use for the OpenAI service. Should be a callable that returns a decorator or None. Parameters: - decorator (callable) : The decorator to use. + decorator (callable | None) : The decorator to use. """ OpenAIService._decorator_to_use = decorator +##-------------------start-of-set_json_mode()--------------------------------------------------------------------------------------------------------------------------------------------------------------------------- + + @staticmethod + def _set_json_mode(json_mode:bool) -> None: + + """ + + Sets the JSON mode for the OpenAI service. + + Parameters: + json_mode (bool) : True if the JSON mode is to be used, False otherwise. + + """ + + OpenAIService._json_mode = json_mode + ##-------------------start-of-set_attributes()--------------------------------------------------------------------------------------------------------------------------------------------------------------------------- @staticmethod @@ -116,6 +133,12 @@ def _set_attributes(model:str = _default_model, OpenAIService._log_directory = logging_directory + if(OpenAIService._json_mode and OpenAIService._model in VALID_JSON_OPENAI_MODELS): + OpenAIService._default_translation_instructions = SystemTranslationMessage("Please translate the following text into English. Make sure to return the translated text in JSON format.") + + else: + OpenAIService._default_translation_instructions = SystemTranslationMessage("Please translate the following text into English.") + ##-------------------start-of-_build_translation_batches()--------------------------------------------------------------------------------------------------------------------------------------------------------------------------- @staticmethod @@ -236,8 +259,11 @@ def __translate_text(instructions:SystemTranslationMessage, prompt:ModelTranslat """ + response_format = "json_object" if OpenAIService._json_mode and OpenAIService._model in VALID_JSON_OPENAI_MODELS else "text" + response = OpenAIService._sync_client.chat.completions.create( - messages=[ + response_format={ "type": response_format }, + messages=[ instructions.to_dict(), prompt.to_dict() ], # type: ignore @@ -275,9 +301,12 @@ async def __translate_text_async(instruction:SystemTranslationMessage, prompt:Mo """ + response_format = "json_object" if OpenAIService._json_mode and OpenAIService._model in VALID_JSON_OPENAI_MODELS else "text" + async with OpenAIService._semaphore: response = await OpenAIService._async_client.chat.completions.create( + response_format={ "type": response_format }, messages=[ instruction.to_dict(), prompt.to_dict() diff --git a/src/easytl/util.py b/src/easytl/util.py index b315b7f..d88f8c9 100644 --- a/src/easytl/util.py +++ b/src/easytl/util.py @@ -398,7 +398,7 @@ def _estimate_cost(text:str | typing.Iterable, model:str, price_case:int | None raise Exception("An unknown error occurred while calculating the minimum cost of translation.") ##------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- -## Costs & Models are determined and updated manually, listed in USD. Updated by Bikatr7 as of 2024-04-09 +## Costs & Models are determined and updated manually, listed in USD. Updated by Bikatr7 as of 2024-04-18 ## https://platform.openai.com/docs/models/overview ALLOWED_OPENAI_MODELS = [ "gpt-3.5-turbo", @@ -423,7 +423,14 @@ def _estimate_cost(text:str | typing.Iterable, model:str, price_case:int | None "gpt-4-1106-vision-preview", ] -## Costs & Models are determined and updated manually, listed in USD. Updated by Bikatr7 as of 2024-04-09 +VALID_JSON_OPENAI_MODELS = [ + "gpt-3.5-turbo-0125", + "gpt-4-turbo", + "gpt-4-turbo-preview", + "gpt-4-turbo-2024-04-09", +] + +## Costs & Models are determined and updated manually, listed in USD. Updated by Bikatr7 as of 2024-04-18 ## https://ai.google.dev/models/gemini ALLOWED_GEMINI_MODELS = [ "gemini-1.0-pro-001", @@ -439,7 +446,7 @@ def _estimate_cost(text:str | typing.Iterable, model:str, price_case:int | None ## "gemini-ultra" ] -## Costs & Models are determined and updated manually, listed in USD. Updated by Bikatr7 as of 2024-04-09 +## Costs & Models are determined and updated manually, listed in USD. Updated by Bikatr7 as of 2024-04-18 MODEL_COSTS = { # Grouping GPT-3.5 models together "gpt-3.5-turbo-0125": {"price_case": 7, "_input_cost": 0.0005, "_output_cost": 0.0015}, diff --git a/src/easytl/version.py b/src/easytl/version.py index b50f147..388adf5 100644 --- a/src/easytl/version.py +++ b/src/easytl/version.py @@ -2,4 +2,4 @@ ## Use of this source code is governed by an GNU Lesser General Public License v2.1 ## license that can be found in the LICENSE file. -VERSION = "0.1.2" \ No newline at end of file +VERSION = "0.2.0-beta" \ No newline at end of file diff --git a/tests/issue_template.py b/tests/issue_template.py new file mode 100644 index 0000000..e0372b8 --- /dev/null +++ b/tests/issue_template.py @@ -0,0 +1,70 @@ +## built-in libraries +import typing + +## third party libraries +from google.generativeai import GenerationConfig +from google.generativeai.types import GenerateContentResponse, AsyncGenerateContentResponse + +import google.generativeai as genai + + +## dummy values from my production code +_default_translation_instructions:str = "Format the response as JSON parseable string. It should have 2 keys, one for input titled input, and one called output, which is the translation." +_default_model:str = "gemini-1.5-pro-latest" + +_system_message = _default_translation_instructions + +_model:str = _default_model +_temperature:float = 0.5 +_top_p:float = 0.9 +_top_k:int = 40 +_candidate_count:int = 1 +_stream:bool = False +_stop_sequences:typing.List[str] | None = None +_max_output_tokens:int | None = None + +_client:genai.GenerativeModel +_generation_config:GenerationConfig + +_decorator_to_use:typing.Union[typing.Callable, None] = None + +_safety_settings = [ + { + "category": "HARM_CATEGORY_DANGEROUS", + "threshold": "BLOCK_NONE", + }, + { + "category": "HARM_CATEGORY_HARASSMENT", + "threshold": "BLOCK_NONE", + }, + { + "category": "HARM_CATEGORY_HATE_SPEECH", + "threshold": "BLOCK_NONE", + }, + { + "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", + "threshold": "BLOCK_NONE", + }, + { + "category": "HARM_CATEGORY_DANGEROUS_CONTENT", + "threshold": "BLOCK_NONE", + }, +] + +genai.configure(api_key="API_KEY") + +_client = genai.GenerativeModel(model_name=_model, + safety_settings=_safety_settings, + system_instruction=_system_message) + +_generation_config = GenerationConfig(candidate_count=_candidate_count, + stop_sequences=_stop_sequences, + max_output_tokens=_max_output_tokens, + temperature=_temperature, + top_p=_top_p, + top_k=_top_k, + response_mime_type="application/json") + +print(_client.generate_content( + "Hello, world!",generation_config=_generation_config +).text) \ No newline at end of file diff --git a/tests/passing.py b/tests/passing.py new file mode 100644 index 0000000..220606a --- /dev/null +++ b/tests/passing.py @@ -0,0 +1,103 @@ +from easytl import EasyTL + +import asyncio + + +def read_api_key(filename): + with open(filename, 'r') as file: + return file.read().strip() + +async def main(): + + deepl_api_key = read_api_key('tests/deepl.txt') + gemini_api_key = read_api_key('tests/gemini.txt') + openai_api_key = read_api_key('tests/openai.txt') + + EasyTL.set_api_key("deepl", deepl_api_key) + EasyTL.set_api_key("gemini", gemini_api_key) + EasyTL.set_api_key("openai", openai_api_key) + + logging_directory = "C:/Users/Tetra/Desktop/" + + print("Deepl ------------------------------------------------") + + print("Text response") + + print(EasyTL.deepl_translate("Hello, world!", target_lang="DE", logging_directory=logging_directory)) + print(await EasyTL.deepl_translate_async("Hello, world!", target_lang="DE", logging_directory=logging_directory)) + + print(EasyTL.deepl_translate("Hello, world!", target_lang="DE", response_type="raw", logging_directory=logging_directory).text) # type: ignore + result = await EasyTL.deepl_translate_async("Hello, world!", target_lang="DE", response_type="raw", logging_directory=logging_directory) + + print(result.text) # type: ignore + + print("Raw response") + + results = EasyTL.deepl_translate(text=["Hello, world!", "Goodbye, world!"], target_lang="DE", response_type="raw", logging_directory=logging_directory) + async_results = await EasyTL.deepl_translate_async(text=["Hello, world!", "Goodbye, world!"], target_lang="DE", response_type="raw", logging_directory=logging_directory) + + for result in results: # type: ignore + print(result.text) # type: ignore + + for result in async_results: # type: ignore + print(result.text) + + print("Gemini ------------------------------------------------") + + print("Text response") + + print(EasyTL.gemini_translate("Hello, world!", translation_instructions="Translate this to German.", logging_directory=logging_directory)) + print(await EasyTL.gemini_translate_async("Hello, world!", translation_instructions="Translate this to German.", logging_directory=logging_directory)) + + print(EasyTL.gemini_translate("Hello, world!", translation_instructions="Translate this to German.", response_type="raw", logging_directory=logging_directory).text) # type: ignore + result = await EasyTL.gemini_translate_async("Hello, world!", translation_instructions="Translate this to German.", response_type="raw", logging_directory=logging_directory) + + print(result.text) # type: ignore + + print("Raw response") + + results = EasyTL.gemini_translate(text=["Hello, world!", "Goodbye, world!"], translation_instructions="Translate this to German.", response_type="raw", logging_directory=logging_directory) + async_results = await EasyTL.gemini_translate_async(text=["Hello, world!", "Goodbye, world!"], translation_instructions="Translate this to German.", response_type="raw", logging_directory=logging_directory) + + for result in results: # type: ignore + print(result.text) # type: ignore + + for result in async_results: # type: ignore + print(result.text) + + print("JSON response") + + print(EasyTL.gemini_translate("Hello, world!", model="gemini-1.5-pro-latest", translation_instructions="Translate this to German. Format the response as JSON.", response_type="json", logging_directory=logging_directory)) + print(await EasyTL.gemini_translate_async("Hello, world!", model="gemini-1.5-pro-latest", translation_instructions="Format the response as JSON parseable string. It should have 2 keys, one for input titled input, and one called output, which is the translation.", response_type="json", logging_directory=logging_directory)) + + + print("OpenAI ------------------------------------------------") + + print("Text response") + + print(EasyTL.openai_translate("Hello, world!", model="gpt-3.5-turbo", translation_instructions="Translate this to German.", logging_directory=logging_directory)) + print(await EasyTL.openai_translate_async("Hello, world!", model="gpt-3.5-turbo", translation_instructions="Translate this to German.", logging_directory=logging_directory)) + + print(EasyTL.openai_translate("Hello, world!", model="gpt-3.5-turbo", translation_instructions="Translate this to German.", response_type="raw", logging_directory=logging_directory).choices[0].message.content) # type: ignore + result = await EasyTL.openai_translate_async("Hello, world!", model="gpt-3.5-turbo", translation_instructions="Translate this to German.", response_type="raw", logging_directory=logging_directory) + + print(result.choices[0].message.content) # type: ignore + + print("Raw response") + + results = EasyTL.openai_translate(text=["Hello, world!", "Goodbye, world!"], model="gpt-3.5-turbo", translation_instructions="Translate this to German.", response_type="raw", logging_directory=logging_directory) + async_results = await EasyTL.openai_translate_async(text=["Hello, world!", "Goodbye, world!"], model="gpt-3.5-turbo", translation_instructions="Translate this to German.", response_type="raw", logging_directory=logging_directory) + + for result in results: # type: ignore + print(result.choices[0].message.content) # type: ignore + + for result in async_results: # type: ignore + print(result.choices[0].message.content ) # type: ignore + + print("JSON response") + + print(EasyTL.openai_translate("Hello, world!", model="gpt-3.5-turbo-0125", translation_instructions="Translate this to German. Format the response as JSON parseable string.", response_type="json", logging_directory=logging_directory)) + print(await EasyTL.openai_translate_async("Hello, world!", model="gpt-3.5-turbo-0125", translation_instructions="Translate this to German. Format the response as JSON parseable string. It should have 2 keys, one for input titled input, and one called output, which is the translation.", response_type="json", logging_directory=logging_directory)) + +if(__name__ == "__main__"): + asyncio.run(main()) \ No newline at end of file