diff --git a/examples/basic_async.py b/examples/basic_async.py new file mode 100644 index 0000000..0df8126 --- /dev/null +++ b/examples/basic_async.py @@ -0,0 +1,30 @@ +import asyncio +import os +from evolution_openai import create_async_client + +key_id = os.environ["KEY_ID"] +secret = os.environ["SECRET"] +project = os.environ["PROJECT"] +url = "https://foundation-models.api.cloud.ru/api/gigacube/openai/v1" + +MODEL: str = "deepseek-ai/DeepSeek-R1-Distill-Llama-70B" +USER_PROMPT: str = "Как написать хороший код? Не более 100 слов" +params = { + "model": MODEL, + "max_tokens": 5000, + "presence_penalty": 0, + "top_p": 0.95, + "temperature": 0.5, + "messages": [ + {"role": "user", "content": USER_PROMPT}, + ], +} + +async def main(): + client = create_async_client(key_id=key_id, secret=secret, base_url=url, project=project) + response = await client.chat.completions.create(**params) + content = response.choices[0].message.content + print(content) + +if __name__ == '__main__': + asyncio.run(main()) \ No newline at end of file diff --git a/examples/basic_sync.py b/examples/basic_sync.py new file mode 100644 index 0000000..b7da762 --- /dev/null +++ b/examples/basic_sync.py @@ -0,0 +1,26 @@ +from evolution_openai import create_client +import os +key_id = os.environ["KEY_ID"] +secret = os.environ["SECRET"] +project = os.environ["PROJECT"] +url = "https://foundation-models.api.cloud.ru/api/gigacube/openai/v1" + + +client = create_client(key_id=key_id, secret=secret, base_url=url, project=project) +MODEL: str = "deepseek-ai/DeepSeek-R1-Distill-Llama-70B" +USER_PROMPT: str = "Как написать хороший код? Не более 100 слов" +params = { + "model": MODEL, + "max_tokens": 5000, + "presence_penalty": 0, + "top_p": 0.95, + "temperature": 0.5, + "messages": [ + {"role": "user", "content": USER_PROMPT}, + ], + } + + +response = client.chat.completions.create(**params) +content = response.choices[0].message.content +print(content) \ No newline at end of file diff --git a/requirements-dev.lock b/requirements-dev.lock index 15a53cf..9bbec20 100644 --- a/requirements-dev.lock +++ b/requirements-dev.lock @@ -26,6 +26,8 @@ certifi==2025.6.15 # via httpcore # via httpx # via requests +cffi==1.17.1 + # via cryptography cfgv==3.4.0 # via pre-commit charset-normalizer==3.4.2 @@ -37,6 +39,8 @@ click-option-group==0.5.7 # via python-semantic-release coverage==7.6.1 # via pytest-cov +cryptography==45.0.4 + # via secretstorage deprecated==1.2.18 # via python-semantic-release dirty-equals==0.9.0 @@ -93,6 +97,9 @@ jaraco-context==6.0.1 # via keyring jaraco-functools==4.1.0 # via keyring +jeepney==0.9.0 + # via keyring + # via secretstorage jinja2==3.1.6 # via myst-parser # via python-semantic-release @@ -135,6 +142,8 @@ platformdirs==4.3.6 pluggy==1.5.0 # via pytest pre-commit==3.5.0 +pycparser==2.22 + # via cffi pydantic==2.10.6 # via openai # via python-semantic-release @@ -189,6 +198,8 @@ rich==14.0.0 # via python-semantic-release # via twine ruff==0.12.0 +secretstorage==3.3.3 + # via keyring shellingham==1.5.4 # via python-semantic-release six==1.17.0 diff --git a/src/evolution_openai/client.py b/src/evolution_openai/client.py index 397c498..dcfabcb 100644 --- a/src/evolution_openai/client.py +++ b/src/evolution_openai/client.py @@ -64,10 +64,10 @@ def __init__( key_id: str, secret: str, base_url: str, + project: str, # Параметры совместимые с OpenAI SDK api_key: Optional[str] = None, # Игнорируется organization: Optional[str] = None, - project: Optional[str] = None, timeout: Union[float, None] = None, max_retries: int = 2, default_headers: Optional[Dict[str, str]] = None, @@ -84,9 +84,11 @@ def __init__( # Сохраняем Cloud.ru credentials self.key_id = key_id self.secret = secret + self.project = project # Инициализируем token manager self.token_manager = EvolutionTokenManager(key_id, secret) + self._need_token_refresh: bool = False # Получаем первоначальный токен initial_token = self.token_manager.get_valid_token() @@ -105,66 +107,41 @@ def __init__( **kwargs, ) - # Переопределяем _client для автоматического обновления токенов - self._patch_client() - - def _patch_client(self) -> None: # type: ignore[reportUnknownMemberType] - """Патчим client для автоматического обновления токенов""" - # В новых версиях используется 'request' - if hasattr(self._client, "request"): # type: ignore[reportUnknownMemberType,reportUnknownArgumentType] - original_request = self._client.request # type: ignore[reportUnknownMemberType,reportUnknownVariableType] - method_name = "request" - else: - logger.warning("Не удалось найти метод request в HTTP клиенте") - return - - def patched_request(*args: Any, **kwargs: Any) -> Any: # type: ignore[reportUnknownMemberType,reportUnknownArgumentType,reportUnknownVariableType,reportUnknownReturnType] - # Обновляем токен перед каждым запросом - current_token = self.token_manager.get_valid_token() - self.api_key = current_token or "" # type: ignore[reportUnknownMemberType] - self._update_auth_headers(current_token or "") - - try: - return original_request(*args, **kwargs) - except Exception as e: - # Если ошибка авторизации, принудительно обновляем токен - if self._is_auth_error(e): - logger.warning( - "Ошибка авторизации, принудительно обновляем токен" - ) - self.token_manager.invalidate_token() - new_token = self.token_manager.get_valid_token() - self.api_key = new_token or "" # type: ignore[reportUnknownMemberType] - # Повторяем запрос с новым токеном - self._update_auth_headers(new_token or "") - return original_request(*args, **kwargs) - else: - raise - - # Устанавливаем патченый метод - setattr(self._client, method_name, patched_request) # type: ignore[reportUnknownMemberType,reportUnknownArgumentType] - - def _update_auth_headers(self, token: str) -> None: - """Обновляет заголовки авторизации""" - auth_header = f"Bearer {token}" - if hasattr(self._client, "_auth_headers"): - self._client._auth_headers["Authorization"] = auth_header # type: ignore[reportAttributeAccessIssue] - elif hasattr(self._client, "default_headers"): - self._client.default_headers["Authorization"] = auth_header # type: ignore[reportAttributeAccessIssue] - - def _is_auth_error(self, error: Exception) -> bool: - """Проверяет, является ли ошибка связанной с авторизацией""" - error_str = str(error).lower() - return any( - keyword in error_str - for keyword in [ - "unauthorized", - "401", - "authentication", - "forbidden", - "403", - ] - ) + @override + def _should_retry(self, response: Any) -> bool: # type: ignore[reportUnknownMemberType] + """Определяет, нужно ли повторять запрос для данного ответа. + + При получении 401 или 403 инициирует обновление токена и позволяет выполнить повтор. + + :param response: Ответ httpx.Response от сервера. + :return: True если нужно сделать retry, иначе — результат родительского метода. + """ + if response.status_code in (401, 403): + self._need_token_refresh = True + return True + return super()._should_retry(response) # type: ignore[reportUnknownMemberType] + + @override + def _prepare_request(self, request: Any) -> None: # type: ignore[reportUnknownMemberType] + """Мутирует объект запроса перед отправкой. + + При необходимости обновляет токен авторизации + и всегда добавляет заголовок x-project-id с текущим проектом. + + :param request: Объект httpx.Request, готовящийся к отправке. + """ + if self._need_token_refresh or not self.is_token_valid: + token = self.refresh_token() + self.api_key = token + request.headers["Authorization"] = f"Bearer {token}" + self._need_token_refresh = False + request.headers["x-project-id"] = self.project + + + @property + def is_token_valid(self) -> bool: + """Возвращает статус валидности токена.""" + return self.token_manager.is_token_valid() @property def current_token(self) -> Optional[str]: @@ -231,10 +208,10 @@ def __init__( key_id: str, secret: str, base_url: str, + project: str, # Параметры совместимые с AsyncOpenAI api_key: Optional[str] = None, organization: Optional[str] = None, - project: Optional[str] = None, timeout: Union[float, None] = None, max_retries: int = 2, default_headers: Optional[Dict[str, str]] = None, @@ -250,10 +227,11 @@ def __init__( # Сохраняем Cloud.ru credentials self.key_id = key_id self.secret = secret + self.project = project # Инициализируем token manager self.token_manager = EvolutionTokenManager(key_id, secret) - + self._need_token_refresh: bool = False # Получаем первоначальный токен initial_token = self.token_manager.get_valid_token() @@ -271,66 +249,41 @@ def __init__( **kwargs, ) - # Патчим async client - self._patch_async_client() - - def _patch_async_client(self) -> None: - """Патчим async client для автоматического обновления токенов""" - # В новых версиях используется 'request' - if hasattr(self._client, "request"): # type: ignore[reportUnknownMemberType,reportUnknownArgumentType] - original_request = self._client.request # type: ignore[reportUnknownMemberType,reportUnknownVariableType] - method_name = "request" - else: - logger.warning( - "Не удалось найти метод request в async HTTP клиенте" - ) - return - - async def patched_request(*args: Any, **kwargs: Any) -> Any: # type: ignore[reportUnknownMemberType,reportUnknownArgumentType,reportUnknownVariableType,reportUnknownReturnType] - # Обновляем токен перед каждым запросом - current_token = self.token_manager.get_valid_token() - self.api_key = current_token or "" # type: ignore[reportUnknownMemberType,reportUnknownVariableType] - self._update_auth_headers(current_token or "") - - try: - return await original_request(*args, **kwargs) - except Exception as e: - if self._is_auth_error(e): - logger.warning( - "Ошибка авторизации, принудительно обновляем токен" - ) - self.token_manager.invalidate_token() - new_token = self.token_manager.get_valid_token() - self.api_key = new_token or "" # type: ignore[reportUnknownMemberType,reportUnknownVariableType] - self._update_auth_headers(new_token or "") - return await original_request(*args, **kwargs) - else: - raise - - # Устанавливаем патченый метод - setattr(self._client, method_name, patched_request) # type: ignore[reportUnknownMemberType,reportUnknownArgumentType] - - def _update_auth_headers(self, token: str) -> None: - """Обновляет заголовки авторизации""" - auth_header = f"Bearer {token}" - if hasattr(self._client, "_auth_headers"): - self._client._auth_headers["Authorization"] = auth_header # type: ignore[reportAttributeAccessIssue] - elif hasattr(self._client, "default_headers"): - self._client.default_headers["Authorization"] = auth_header # type: ignore[reportAttributeAccessIssue] - - def _is_auth_error(self, error: Exception) -> bool: - """Проверяет, является ли ошибка связанной с авторизацией""" - error_str = str(error).lower() - return any( - keyword in error_str - for keyword in [ - "unauthorized", - "401", - "authentication", - "forbidden", - "403", - ] - ) + @override + def _should_retry(self, response: Any) -> bool: # type: ignore[reportUnknownMemberType] + """Определяет, нужно ли повторять запрос для данного ответа. + + При получении 401 или 403 инициирует обновление токена и позволяет выполнить повтор. + + :param response: Ответ httpx.Response от сервера. + :return: True если нужно сделать retry, иначе — результат родительского метода. + """ + if response.status_code in (401, 403): + self._need_token_refresh = True + return True + return super()._should_retry(response) # type: ignore[reportUnknownMemberType] + + @override + async def _prepare_request(self, request: Any) -> None: # type: ignore[reportUnknownMemberType] + """Мутирует объект запроса перед отправкой. + + При необходимости обновляет токен авторизации + и всегда добавляет заголовок x-project-id с текущим проектом. + + :param request: Объект httpx.Request, готовящийся к отправке. + """ + if self._need_token_refresh or not self.is_token_valid: + token = self.refresh_token() + self.api_key = token + request.headers["Authorization"] = f"Bearer {token}" + self._need_token_refresh = False + request.headers["x-project-id"] = self.project + + + @property + def is_token_valid(self) -> bool: + """Возвращает статус валидности токена.""" + return self.token_manager.is_token_valid() @property def current_token(self) -> Optional[str]: