Skip to content

Commit 7c64ace

Browse files
author
Andrey Shlyapin
committed
fix: correct x-project-id header handling and change mutate methods
1 parent 5676871 commit 7c64ace

4 files changed

Lines changed: 143 additions & 123 deletions

File tree

examples/basic_async.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import asyncio
2+
import os
3+
from evolution_openai import create_async_client
4+
5+
key_id = os.environ["KEY_ID"]
6+
secret = os.environ["SECRET"]
7+
project = os.environ["PROJECT"]
8+
url = "https://foundation-models.api.cloud.ru/api/gigacube/openai/v1"
9+
10+
MODEL: str = "deepseek-ai/DeepSeek-R1-Distill-Llama-70B"
11+
USER_PROMPT: str = "Как написать хороший код? Не более 100 слов"
12+
params = {
13+
"model": MODEL,
14+
"max_tokens": 5000,
15+
"presence_penalty": 0,
16+
"top_p": 0.95,
17+
"temperature": 0.5,
18+
"messages": [
19+
{"role": "user", "content": USER_PROMPT},
20+
],
21+
}
22+
23+
async def main():
24+
client = create_async_client(key_id=key_id, secret=secret, base_url=url, project=project)
25+
response = await client.chat.completions.create(**params)
26+
content = response.choices[0].message.content
27+
print(content)
28+
29+
if __name__ == '__main__':
30+
asyncio.run(main())

examples/basic_sync.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
from evolution_openai import create_client
2+
import os
3+
key_id = os.environ["KEY_ID"]
4+
secret = os.environ["SECRET"]
5+
project = os.environ["PROJECT"]
6+
url = "https://foundation-models.api.cloud.ru/api/gigacube/openai/v1"
7+
8+
9+
client = create_client(key_id=key_id, secret=secret, base_url=url, project=project)
10+
MODEL: str = "deepseek-ai/DeepSeek-R1-Distill-Llama-70B"
11+
USER_PROMPT: str = "Как написать хороший код? Не более 100 слов"
12+
params = {
13+
"model": MODEL,
14+
"max_tokens": 5000,
15+
"presence_penalty": 0,
16+
"top_p": 0.95,
17+
"temperature": 0.5,
18+
"messages": [
19+
{"role": "user", "content": USER_PROMPT},
20+
],
21+
}
22+
23+
24+
response = client.chat.completions.create(**params)
25+
content = response.choices[0].message.content
26+
print(content)

requirements-dev.lock

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ certifi==2025.6.15
2626
# via httpcore
2727
# via httpx
2828
# via requests
29+
cffi==1.17.1
30+
# via cryptography
2931
cfgv==3.4.0
3032
# via pre-commit
3133
charset-normalizer==3.4.2
@@ -37,6 +39,8 @@ click-option-group==0.5.7
3739
# via python-semantic-release
3840
coverage==7.6.1
3941
# via pytest-cov
42+
cryptography==45.0.4
43+
# via secretstorage
4044
deprecated==1.2.18
4145
# via python-semantic-release
4246
dirty-equals==0.9.0
@@ -93,6 +97,9 @@ jaraco-context==6.0.1
9397
# via keyring
9498
jaraco-functools==4.1.0
9599
# via keyring
100+
jeepney==0.9.0
101+
# via keyring
102+
# via secretstorage
96103
jinja2==3.1.6
97104
# via myst-parser
98105
# via python-semantic-release
@@ -135,6 +142,8 @@ platformdirs==4.3.6
135142
pluggy==1.5.0
136143
# via pytest
137144
pre-commit==3.5.0
145+
pycparser==2.22
146+
# via cffi
138147
pydantic==2.10.6
139148
# via openai
140149
# via python-semantic-release
@@ -189,6 +198,8 @@ rich==14.0.0
189198
# via python-semantic-release
190199
# via twine
191200
ruff==0.12.0
201+
secretstorage==3.3.3
202+
# via keyring
192203
shellingham==1.5.4
193204
# via python-semantic-release
194205
six==1.17.0

src/evolution_openai/client.py

Lines changed: 76 additions & 123 deletions
Original file line numberDiff line numberDiff line change
@@ -64,10 +64,10 @@ def __init__(
6464
key_id: str,
6565
secret: str,
6666
base_url: str,
67+
project: str,
6768
# Параметры совместимые с OpenAI SDK
6869
api_key: Optional[str] = None, # Игнорируется
6970
organization: Optional[str] = None,
70-
project: Optional[str] = None,
7171
timeout: Union[float, None] = None,
7272
max_retries: int = 2,
7373
default_headers: Optional[Dict[str, str]] = None,
@@ -84,9 +84,11 @@ def __init__(
8484
# Сохраняем Cloud.ru credentials
8585
self.key_id = key_id
8686
self.secret = secret
87+
self.project = project
8788

8889
# Инициализируем token manager
8990
self.token_manager = EvolutionTokenManager(key_id, secret)
91+
self._need_token_refresh: bool = False
9092

9193
# Получаем первоначальный токен
9294
initial_token = self.token_manager.get_valid_token()
@@ -105,66 +107,41 @@ def __init__(
105107
**kwargs,
106108
)
107109

108-
# Переопределяем _client для автоматического обновления токенов
109-
self._patch_client()
110-
111-
def _patch_client(self) -> None: # type: ignore[reportUnknownMemberType]
112-
"""Патчим client для автоматического обновления токенов"""
113-
# В новых версиях используется 'request'
114-
if hasattr(self._client, "request"): # type: ignore[reportUnknownMemberType,reportUnknownArgumentType]
115-
original_request = self._client.request # type: ignore[reportUnknownMemberType,reportUnknownVariableType]
116-
method_name = "request"
117-
else:
118-
logger.warning("Не удалось найти метод request в HTTP клиенте")
119-
return
120-
121-
def patched_request(*args: Any, **kwargs: Any) -> Any: # type: ignore[reportUnknownMemberType,reportUnknownArgumentType,reportUnknownVariableType,reportUnknownReturnType]
122-
# Обновляем токен перед каждым запросом
123-
current_token = self.token_manager.get_valid_token()
124-
self.api_key = current_token or "" # type: ignore[reportUnknownMemberType]
125-
self._update_auth_headers(current_token or "")
126-
127-
try:
128-
return original_request(*args, **kwargs)
129-
except Exception as e:
130-
# Если ошибка авторизации, принудительно обновляем токен
131-
if self._is_auth_error(e):
132-
logger.warning(
133-
"Ошибка авторизации, принудительно обновляем токен"
134-
)
135-
self.token_manager.invalidate_token()
136-
new_token = self.token_manager.get_valid_token()
137-
self.api_key = new_token or "" # type: ignore[reportUnknownMemberType]
138-
# Повторяем запрос с новым токеном
139-
self._update_auth_headers(new_token or "")
140-
return original_request(*args, **kwargs)
141-
else:
142-
raise
143-
144-
# Устанавливаем патченый метод
145-
setattr(self._client, method_name, patched_request) # type: ignore[reportUnknownMemberType,reportUnknownArgumentType]
146-
147-
def _update_auth_headers(self, token: str) -> None:
148-
"""Обновляет заголовки авторизации"""
149-
auth_header = f"Bearer {token}"
150-
if hasattr(self._client, "_auth_headers"):
151-
self._client._auth_headers["Authorization"] = auth_header # type: ignore[reportAttributeAccessIssue]
152-
elif hasattr(self._client, "default_headers"):
153-
self._client.default_headers["Authorization"] = auth_header # type: ignore[reportAttributeAccessIssue]
154-
155-
def _is_auth_error(self, error: Exception) -> bool:
156-
"""Проверяет, является ли ошибка связанной с авторизацией"""
157-
error_str = str(error).lower()
158-
return any(
159-
keyword in error_str
160-
for keyword in [
161-
"unauthorized",
162-
"401",
163-
"authentication",
164-
"forbidden",
165-
"403",
166-
]
167-
)
110+
@override
111+
def _should_retry(self, response: Any) -> bool: # type: ignore[reportUnknownMemberType]
112+
"""Определяет, нужно ли повторять запрос для данного ответа.
113+
114+
При получении 401 или 403 инициирует обновление токена и позволяет выполнить повтор.
115+
116+
:param response: Ответ httpx.Response от сервера.
117+
:return: True если нужно сделать retry, иначе — результат родительского метода.
118+
"""
119+
if response.status_code in (401, 403):
120+
self._need_token_refresh = True
121+
return True
122+
return super()._should_retry(response) # type: ignore[reportUnknownMemberType]
123+
124+
@override
125+
def _prepare_request(self, request: Any) -> None: # type: ignore[reportUnknownMemberType]
126+
"""Мутирует объект запроса перед отправкой.
127+
128+
При необходимости обновляет токен авторизации
129+
и всегда добавляет заголовок x-project-id с текущим проектом.
130+
131+
:param request: Объект httpx.Request, готовящийся к отправке.
132+
"""
133+
if self._need_token_refresh or not self.is_token_valid:
134+
token = self.refresh_token()
135+
self.api_key = token
136+
request.headers["Authorization"] = f"Bearer {token}"
137+
self._need_token_refresh = False
138+
request.headers["x-project-id"] = self.project
139+
140+
141+
@property
142+
def is_token_valid(self) -> bool:
143+
"""Возвращает статус валидности токена."""
144+
return self.token_manager.is_token_valid()
168145

169146
@property
170147
def current_token(self) -> Optional[str]:
@@ -231,10 +208,10 @@ def __init__(
231208
key_id: str,
232209
secret: str,
233210
base_url: str,
211+
project: str,
234212
# Параметры совместимые с AsyncOpenAI
235213
api_key: Optional[str] = None,
236214
organization: Optional[str] = None,
237-
project: Optional[str] = None,
238215
timeout: Union[float, None] = None,
239216
max_retries: int = 2,
240217
default_headers: Optional[Dict[str, str]] = None,
@@ -250,10 +227,11 @@ def __init__(
250227
# Сохраняем Cloud.ru credentials
251228
self.key_id = key_id
252229
self.secret = secret
230+
self.project = project
253231

254232
# Инициализируем token manager
255233
self.token_manager = EvolutionTokenManager(key_id, secret)
256-
234+
self._need_token_refresh: bool = False
257235
# Получаем первоначальный токен
258236
initial_token = self.token_manager.get_valid_token()
259237

@@ -271,66 +249,41 @@ def __init__(
271249
**kwargs,
272250
)
273251

274-
# Патчим async client
275-
self._patch_async_client()
276-
277-
def _patch_async_client(self) -> None:
278-
"""Патчим async client для автоматического обновления токенов"""
279-
# В новых версиях используется 'request'
280-
if hasattr(self._client, "request"): # type: ignore[reportUnknownMemberType,reportUnknownArgumentType]
281-
original_request = self._client.request # type: ignore[reportUnknownMemberType,reportUnknownVariableType]
282-
method_name = "request"
283-
else:
284-
logger.warning(
285-
"Не удалось найти метод request в async HTTP клиенте"
286-
)
287-
return
288-
289-
async def patched_request(*args: Any, **kwargs: Any) -> Any: # type: ignore[reportUnknownMemberType,reportUnknownArgumentType,reportUnknownVariableType,reportUnknownReturnType]
290-
# Обновляем токен перед каждым запросом
291-
current_token = self.token_manager.get_valid_token()
292-
self.api_key = current_token or "" # type: ignore[reportUnknownMemberType,reportUnknownVariableType]
293-
self._update_auth_headers(current_token or "")
294-
295-
try:
296-
return await original_request(*args, **kwargs)
297-
except Exception as e:
298-
if self._is_auth_error(e):
299-
logger.warning(
300-
"Ошибка авторизации, принудительно обновляем токен"
301-
)
302-
self.token_manager.invalidate_token()
303-
new_token = self.token_manager.get_valid_token()
304-
self.api_key = new_token or "" # type: ignore[reportUnknownMemberType,reportUnknownVariableType]
305-
self._update_auth_headers(new_token or "")
306-
return await original_request(*args, **kwargs)
307-
else:
308-
raise
309-
310-
# Устанавливаем патченый метод
311-
setattr(self._client, method_name, patched_request) # type: ignore[reportUnknownMemberType,reportUnknownArgumentType]
312-
313-
def _update_auth_headers(self, token: str) -> None:
314-
"""Обновляет заголовки авторизации"""
315-
auth_header = f"Bearer {token}"
316-
if hasattr(self._client, "_auth_headers"):
317-
self._client._auth_headers["Authorization"] = auth_header # type: ignore[reportAttributeAccessIssue]
318-
elif hasattr(self._client, "default_headers"):
319-
self._client.default_headers["Authorization"] = auth_header # type: ignore[reportAttributeAccessIssue]
320-
321-
def _is_auth_error(self, error: Exception) -> bool:
322-
"""Проверяет, является ли ошибка связанной с авторизацией"""
323-
error_str = str(error).lower()
324-
return any(
325-
keyword in error_str
326-
for keyword in [
327-
"unauthorized",
328-
"401",
329-
"authentication",
330-
"forbidden",
331-
"403",
332-
]
333-
)
252+
@override
253+
def _should_retry(self, response: Any) -> bool: # type: ignore[reportUnknownMemberType]
254+
"""Определяет, нужно ли повторять запрос для данного ответа.
255+
256+
При получении 401 или 403 инициирует обновление токена и позволяет выполнить повтор.
257+
258+
:param response: Ответ httpx.Response от сервера.
259+
:return: True если нужно сделать retry, иначе — результат родительского метода.
260+
"""
261+
if response.status_code in (401, 403):
262+
self._need_token_refresh = True
263+
return True
264+
return super()._should_retry(response) # type: ignore[reportUnknownMemberType]
265+
266+
@override
267+
async def _prepare_request(self, request: Any) -> None: # type: ignore[reportUnknownMemberType]
268+
"""Мутирует объект запроса перед отправкой.
269+
270+
При необходимости обновляет токен авторизации
271+
и всегда добавляет заголовок x-project-id с текущим проектом.
272+
273+
:param request: Объект httpx.Request, готовящийся к отправке.
274+
"""
275+
if self._need_token_refresh or not self.is_token_valid:
276+
token = self.refresh_token()
277+
self.api_key = token
278+
request.headers["Authorization"] = f"Bearer {token}"
279+
self._need_token_refresh = False
280+
request.headers["x-project-id"] = self.project
281+
282+
283+
@property
284+
def is_token_valid(self) -> bool:
285+
"""Возвращает статус валидности токена."""
286+
return self.token_manager.is_token_valid()
334287

335288
@property
336289
def current_token(self) -> Optional[str]:

0 commit comments

Comments
 (0)