Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions examples/basic_async.py
Original file line number Diff line number Diff line change
@@ -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())
26 changes: 26 additions & 0 deletions examples/basic_sync.py
Original file line number Diff line number Diff line change
@@ -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)
11 changes: 11 additions & 0 deletions requirements-dev.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
199 changes: 76 additions & 123 deletions src/evolution_openai/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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()
Expand All @@ -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]:
Expand Down Expand Up @@ -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,
Expand All @@ -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()

Expand All @@ -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]:
Expand Down
Loading