diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a14bfc8..682809c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,7 +63,7 @@ jobs: path: dist/ test: - timeout-minutes: 15 + timeout-minutes: 20 name: test runs-on: ubuntu-latest needs: [lint, build] @@ -153,7 +153,7 @@ jobs: coverage-file: coverage.json test-examples: - timeout-minutes: 15 + timeout-minutes: 20 name: test-examples runs-on: ubuntu-latest needs: [test] @@ -198,4 +198,12 @@ jobs: EVOLUTION_KEY_ID: ${{ secrets.EVOLUTION_KEY_ID }} EVOLUTION_SECRET: ${{ secrets.EVOLUTION_SECRET }} EVOLUTION_BASE_URL: ${{ secrets.EVOLUTION_BASE_URL }} - run: make run-tokens \ No newline at end of file + run: make run-tokens + + - name: Run foundation models examples + env: + EVOLUTION_KEY_ID: ${{ secrets.EVOLUTION_KEY_ID }} + EVOLUTION_SECRET: ${{ secrets.EVOLUTION_SECRET }} + EVOLUTION_BASE_URL: ${{ secrets.EVOLUTION_BASE_URL }} + EVOLUTION_PROJECT_ID: ${{ secrets.EVOLUTION_PROJECT_ID }} + run: make run-foundation-models \ No newline at end of file diff --git a/Makefile b/Makefile index 9a0fb69..6c869c0 100644 --- a/Makefile +++ b/Makefile @@ -19,11 +19,12 @@ help: @echo " type-check Run type checking (pyright + mypy)" @echo "" @echo "Examples:" - @echo " run-examples Run basic usage examples" - @echo " run-all-examples Run all examples" - @echo " run-streaming Run streaming examples" - @echo " run-async Run async examples" - @echo " run-tokens Run token management examples" + @echo " run-examples Run basic usage examples" + @echo " run-all-examples Run all examples" + @echo " run-streaming Run streaming examples" + @echo " run-async Run async examples" + @echo " run-tokens Run token management examples" + @echo " run-foundation-models Run foundation models examples" @echo "" @echo "Build:" @echo " clean Clean build artifacts" @@ -58,6 +59,9 @@ shell: test: rye run pytest tests/ -v --cov=evolution_openai --cov-report=html --cov-report=term --cov-report=xml:coverage.xml --cov-report=json:coverage.json +test-foundation-models: + rye run pytest tests/test_foundation_models_*.py -v + # Code quality lint: rye run ruff check . @@ -209,6 +213,10 @@ run-tokens: @if [ -f .env ]; then echo "Загружение переменных окружения из файла .env..."; export $$(grep -v '^#' .env | xargs); fi; \ rye run python examples/token_management.py +run-foundation-models: + @if [ -f .env ]; then echo "Загружение переменных окружения из файла .env..."; export $$(grep -v '^#' .env | xargs); fi; \ + rye run python examples/foundation_models_example.py + # Package info info: @echo "Package: evolution-openai" diff --git a/README.md b/README.md index f235d53..7a36f1b 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,9 @@ - ✅ **Retry логика** при ошибках авторизации - ✅ **Поддержка .env файлов** для управления конфигурацией - ✅ **Интеграционные тесты** с реальным API +- ✅ **Evolution Foundation Models** поддержка с `project_id` +- ✅ **Готовые примеры** для Foundation Models +- ✅ **Передовые AI модели** включая DeepSeek-R1, Qwen2.5 и другие ## 📦 Установка @@ -37,24 +40,40 @@ client = OpenAI(api_key="sk-...") # ✅ СТАЛО (Evolution OpenAI) from evolution_openai import OpenAI +# Для обычного использования client = OpenAI( - key_id="your_key_id", secret="your_secret", base_url="https://your-model-endpoint.cloud.ru/v1" + key_id="your_key_id", + secret="your_secret", + base_url="https://your-model-endpoint.cloud.ru/v1" +) + +# Для Evolution Foundation Models +client = OpenAI( + key_id="your_key_id", + secret="your_secret", + base_url="https://foundation-models.api.cloud.ru/api/gigacube/openai/v1", + project_id="your_project_id" # Для Evolution Foundation Models ) # Все остальное работает ТОЧНО ТАК ЖЕ! response = client.chat.completions.create( - model="default", messages=[{"role": "user", "content": "Hello!"}] + model="default", # или "deepseek-ai/DeepSeek-R1-Distill-Llama-70B" для Foundation Models + messages=[{"role": "user", "content": "Hello!"}] ) ``` ### Основное использование +#### Обычное использование + ```python from evolution_openai import OpenAI -# Инициализация client +# Инициализация client для обычного использования client = OpenAI( - key_id="your_key_id", secret="your_secret", base_url="https://your-model-endpoint.cloud.ru/v1" + key_id="your_key_id", + secret="your_secret", + base_url="https://your-model-endpoint.cloud.ru/v1" ) # Chat Completions @@ -70,12 +89,54 @@ response = client.chat.completions.create( print(response.choices[0].message.content) ``` +#### 🚀 Evolution Foundation Models + +Библиотека полностью поддерживает **Evolution Foundation Models** - платформу для работы с передовыми AI моделями на Cloud.ru. Ключевые возможности: + +- **Автоматическое управление Project ID** - добавляет заголовок `x-project-id` автоматически +- **Передовые модели** - DeepSeek-R1, Qwen2.5, RefalMachine/RuadaptQwen2.5-7B-Lite-Beta +- **Специальный endpoint** - `https://foundation-models.api.cloud.ru/api/gigacube/openai/v1` +- **Полная совместимость** с OpenAI SDK - все методы работают идентично + +```python +from evolution_openai import OpenAI + +# Инициализация для Evolution Foundation Models +client = OpenAI( + key_id="your_key_id", + secret="your_secret", + base_url="https://foundation-models.api.cloud.ru/api/gigacube/openai/v1", + project_id="your_project_id" # Автоматически добавляется в заголовки +) + +# Использование Foundation Models +response = client.chat.completions.create( + model="deepseek-ai/DeepSeek-R1-Distill-Llama-70B", + messages=[ + {"role": "system", "content": "You are a helpful assistant."}, + {"role": "user", "content": "What is artificial intelligence?"}, + ], + max_tokens=150 +) + +print(response.choices[0].message.content) +``` + ### Streaming ```python -# Streaming responses +# Для обычного использования +stream = client.chat.completions.create( + model="default", + messages=[{"role": "user", "content": "Tell me a story"}], + stream=True +) + +# Для Foundation Models stream = client.chat.completions.create( - model="default", messages=[{"role": "user", "content": "Tell me a story"}], stream=True + model="deepseek-ai/DeepSeek-R1-Distill-Llama-70B", + messages=[{"role": "user", "content": "Tell me a story"}], + stream=True ) for chunk in stream: @@ -91,14 +152,24 @@ from evolution_openai import AsyncOpenAI async def main(): + # Для обычного использования client = AsyncOpenAI( key_id="your_key_id", secret="your_secret", base_url="https://your-model-endpoint.cloud.ru/v1", ) + # Для Foundation Models + client = AsyncOpenAI( + key_id="your_key_id", + secret="your_secret", + base_url="https://foundation-models.api.cloud.ru/api/gigacube/openai/v1", + project_id="your_project_id", # Опционально для Foundation Models + ) + response = await client.chat.completions.create( - model="default", messages=[{"role": "user", "content": "Async hello!"}] + model="deepseek-ai/DeepSeek-R1-Distill-Llama-70B", # или "default" для обычного использования + messages=[{"role": "user", "content": "Async hello!"}] ) print(response.choices[0].message.content) @@ -118,6 +189,8 @@ asyncio.run(main()) cp env.example .env ``` +#### Для обычного использования: + ```bash # .env файл EVOLUTION_KEY_ID=your_key_id_here @@ -128,6 +201,19 @@ ENABLE_INTEGRATION_TESTS=false LOG_LEVEL=INFO ``` +#### Для Evolution Foundation Models: + +```bash +# .env файл для Foundation Models +EVOLUTION_KEY_ID=your_key_id_here +EVOLUTION_SECRET=your_secret_here +EVOLUTION_BASE_URL=https://foundation-models.api.cloud.ru/api/gigacube/openai/v1 +EVOLUTION_PROJECT_ID=your_project_id_here # Обязательно для Foundation Models +EVOLUTION_TOKEN_URL=https://iam.api.cloud.ru/api/v1/auth/token +ENABLE_INTEGRATION_TESTS=false +LOG_LEVEL=INFO +``` + ```python import os from evolution_openai import OpenAI @@ -140,6 +226,7 @@ client = OpenAI( key_id=os.getenv("EVOLUTION_KEY_ID"), secret=os.getenv("EVOLUTION_SECRET"), base_url=os.getenv("EVOLUTION_BASE_URL"), + project_id=os.getenv("EVOLUTION_PROJECT_ID"), # Опционально для Foundation Models ) ``` @@ -180,29 +267,11 @@ with client: response = client.chat.completions.create(...) ``` -## 🔍 Управление токенами - -```python -# Получить информацию о токене -token_info = client.get_token_info() -print(token_info) -# { -# "has_token": true, -# "expires_at": "2024-01-01T12:00:00", -# "is_valid": true, -# "buffer_seconds": 30 -# } - -# Принудительно обновить токен -new_token = client.refresh_token() - -# Получить текущий токен -current_token = client.current_token -``` ## 📚 Документация - [API Documentation](https://cloud-ru-tech.github.io/evolution-openai-python) +- [Evolution Foundation Models Guide](https://cloud-ru-tech.github.io/evolution-openai-python/foundation_models) - [Migration Guide](https://cloud-ru-tech.github.io/evolution-openai-python/migration) - [Examples](examples/) - [Changelog](CHANGELOG.md) diff --git a/docs/conf.py b/docs/conf.py index 61a5402..cbde85f 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -13,8 +13,8 @@ sys.path.insert(0, os.path.abspath("..")) project = "Evolution OpenAI" -copyright = "2024, Evolution OpenAI Team" -author = "Evolution OpenAI Team" +copyright = "2024, Evolution ML Inference Team" +author = "Evolution ML Inference Team" release = "1.0.0" # -- General configuration --------------------------------------------------- diff --git a/docs/foundation_models.rst b/docs/foundation_models.rst new file mode 100644 index 0000000..3cc1565 --- /dev/null +++ b/docs/foundation_models.rst @@ -0,0 +1,483 @@ +Evolution Foundation Models +============================ + +Evolution Foundation Models - это специальная платформа для работы с передовыми моделями искусственного интеллекта на основе Cloud.ru. Библиотека **evolution-openai** предоставляет полную поддержку для работы с Evolution Foundation Models через знакомый OpenAI-совместимый API. + +Особенности Foundation Models +------------------------------ + +✅ **Передовые модели AI** - Доступ к последним моделям ИИ включая DeepSeek-R1, Qwen2.5 и другие + +✅ **Автоматическое управление Project ID** - Библиотека автоматически добавляет заголовок ``x-project-id`` + +✅ **Полная совместимость с OpenAI SDK** - Все методы работают идентично + +✅ **Поддержка streaming** - Потоковая обработка ответов + +✅ **Async/await поддержка** - Асинхронные операции + +✅ **Автоматическое управление токенами** - Встроенная авторизация Cloud.ru + +Быстрый старт +------------- + +Базовая настройка +~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + from evolution_openai import OpenAI + + client = OpenAI( + key_id="your_key_id", + secret="your_secret", + base_url="https://foundation-models.api.cloud.ru/api/gigacube/openai/v1", + project_id="your_project_id" # Обязательно для Foundation Models + ) + + response = client.chat.completions.create( + model="RefalMachine/RuadaptQwen2.5-7B-Lite-Beta", + messages=[ + {"role": "system", "content": "Ты полезный помощник."}, + {"role": "user", "content": "Расскажи о возможностях ИИ"} + ], + max_tokens=100 + ) + + print(response.choices[0].message.content) + +Переменные окружения +~~~~~~~~~~~~~~~~~~~~ + +Рекомендуется использовать файл ``.env`` для хранения конфигурации: + +.. code-block:: bash + + # .env файл для Foundation Models + EVOLUTION_KEY_ID=your_key_id_here + EVOLUTION_SECRET=your_secret_here + EVOLUTION_BASE_URL=https://foundation-models.api.cloud.ru/api/gigacube/openai/v1 + EVOLUTION_PROJECT_ID=your_project_id_here + EVOLUTION_FOUNDATION_MODELS_URL=https://foundation-models.api.cloud.ru/api/gigacube/openai/v1 + +Загрузка из переменных окружения: + +.. code-block:: python + + import os + from evolution_openai import OpenAI + from dotenv import load_dotenv + + load_dotenv() + + client = OpenAI( + key_id=os.getenv("EVOLUTION_KEY_ID"), + secret=os.getenv("EVOLUTION_SECRET"), + base_url=os.getenv("EVOLUTION_FOUNDATION_MODELS_URL"), + project_id=os.getenv("EVOLUTION_PROJECT_ID"), + ) + +Доступные модели +---------------- + +Evolution Foundation Models предоставляет доступ к различным моделям: + +**RefalMachine/RuadaptQwen2.5-7B-Lite-Beta** (рекомендуется) + Адаптированная для русского языка модель на основе Qwen2.5-7B + +**deepseek-ai/DeepSeek-R1-Distill-Llama-70B** + Модель на основе DeepSeek-R1 с дистилляцией + +**Другие модели** + Список доступных моделей может обновляться - обратитесь к документации Cloud.ru + +Параметры конфигурации +---------------------- + +Project ID +~~~~~~~~~~ + +``project_id`` - обязательный параметр для Foundation Models: + +.. code-block:: python + + client = OpenAI( + key_id="your_key_id", + secret="your_secret", + base_url="https://foundation-models.api.cloud.ru/api/gigacube/openai/v1", + project_id="your_project_id" # Автоматически добавляется в заголовки + ) + +Timeout и повторы +~~~~~~~~~~~~~~~~~ + +Foundation Models могут требовать больше времени для обработки: + +.. code-block:: python + + client = OpenAI( + key_id="your_key_id", + secret="your_secret", + base_url="https://foundation-models.api.cloud.ru/api/gigacube/openai/v1", + project_id="your_project_id", + timeout=60.0, # Увеличенный timeout + max_retries=3, # Количество повторов + ) + +Примеры использования +--------------------- + +Базовый пример +~~~~~~~~~~~~~~ + +.. code-block:: python + + from evolution_openai import OpenAI + + client = OpenAI( + key_id="your_key_id", + secret="your_secret", + base_url="https://foundation-models.api.cloud.ru/api/gigacube/openai/v1", + project_id="your_project_id" + ) + + response = client.chat.completions.create( + model="RefalMachine/RuadaptQwen2.5-7B-Lite-Beta", + messages=[ + {"role": "system", "content": "Ты полезный помощник."}, + {"role": "user", "content": "Объясни машинное обучение простыми словами"} + ], + max_tokens=200, + temperature=0.7 + ) + + print(f"Ответ: {response.choices[0].message.content}") + print(f"Модель: {response.model}") + print(f"Токенов использовано: {response.usage.total_tokens}") + +Streaming ответы +~~~~~~~~~~~~~~~~ + +.. code-block:: python + + stream = client.chat.completions.create( + model="RefalMachine/RuadaptQwen2.5-7B-Lite-Beta", + messages=[ + {"role": "user", "content": "Напиши короткое стихотворение про технологии"} + ], + stream=True, + max_tokens=100, + temperature=0.8 + ) + + print("Генерация стихотворения:") + for chunk in stream: + if chunk.choices[0].delta.content: + print(chunk.choices[0].delta.content, end="", flush=True) + +Асинхронное использование +~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + import asyncio + from evolution_openai import AsyncOpenAI + + async def main(): + async with AsyncOpenAI( + key_id="your_key_id", + secret="your_secret", + base_url="https://foundation-models.api.cloud.ru/api/gigacube/openai/v1", + project_id="your_project_id" + ) as client: + response = await client.chat.completions.create( + model="RefalMachine/RuadaptQwen2.5-7B-Lite-Beta", + messages=[ + {"role": "user", "content": "Что такое квантовые вычисления?"} + ], + max_tokens=150 + ) + + print(response.choices[0].message.content) + + asyncio.run(main()) + +Параллельные запросы +~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + import asyncio + from evolution_openai import AsyncOpenAI + + async def parallel_requests(): + async with AsyncOpenAI( + key_id="your_key_id", + secret="your_secret", + base_url="https://foundation-models.api.cloud.ru/api/gigacube/openai/v1", + project_id="your_project_id" + ) as client: + + questions = [ + "Что такое ИИ?", + "Как работает машинное обучение?", + "Что такое нейронные сети?" + ] + + # Создаем задачи для параллельного выполнения + tasks = [] + for question in questions: + task = client.chat.completions.create( + model="RefalMachine/RuadaptQwen2.5-7B-Lite-Beta", + messages=[ + {"role": "system", "content": "Дай краткий ответ."}, + {"role": "user", "content": question} + ], + max_tokens=50 + ) + tasks.append(task) + + # Выполняем все запросы параллельно + responses = await asyncio.gather(*tasks) + + for question, response in zip(questions, responses): + print(f"Вопрос: {question}") + print(f"Ответ: {response.choices[0].message.content}") + print("-" * 50) + + asyncio.run(parallel_requests()) + +Использование with_options +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + # Настройка дополнительных опций + client_with_options = client.with_options( + timeout=120.0, # Увеличенный timeout + max_retries=5, # Больше попыток + ) + + response = client_with_options.chat.completions.create( + model="RefalMachine/RuadaptQwen2.5-7B-Lite-Beta", + messages=[ + {"role": "user", "content": "Создай подробный план изучения Python"} + ], + max_tokens=300, + temperature=0.3 + ) + + print(response.choices[0].message.content) + +Управление токенами +------------------- + +Информация о токене +~~~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + # Получение информации о токене + token_info = client.get_token_info() + print(f"Токен активен: {token_info['has_token']}") + print(f"Токен валиден: {token_info['is_valid']}") + + # Текущий токен + current_token = client.current_token + print(f"Текущий токен: {current_token[:20]}...") + +Принудительное обновление токена +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + # Принудительное обновление токена + new_token = client.refresh_token() + print(f"Новый токен получен: {new_token[:20]}...") + +Обработка ошибок +---------------- + +Типичные ошибки и их обработка +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + from evolution_openai import OpenAI + from evolution_openai.exceptions import EvolutionAuthError + + try: + client = OpenAI( + key_id="your_key_id", + secret="your_secret", + base_url="https://foundation-models.api.cloud.ru/api/gigacube/openai/v1", + project_id="your_project_id" + ) + + response = client.chat.completions.create( + model="RefalMachine/RuadaptQwen2.5-7B-Lite-Beta", + messages=[ + {"role": "user", "content": "Привет!"} + ], + max_tokens=50 + ) + + except EvolutionAuthError as e: + print(f"Ошибка авторизации: {e}") + # Проверьте key_id, secret и project_id + + except Exception as e: + print(f"Общая ошибка: {e}") + +Неправильная модель +~~~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + try: + response = client.chat.completions.create( + model="non-existent-model", + messages=[{"role": "user", "content": "Test"}], + max_tokens=10 + ) + except Exception as e: + print(f"Модель не найдена: {e}") + +Неправильные параметры +~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + try: + response = client.chat.completions.create( + model="RefalMachine/RuadaptQwen2.5-7B-Lite-Beta", + messages=[], # Пустой список сообщений + max_tokens=10 + ) + except Exception as e: + print(f"Неправильные параметры: {e}") + + +Лучшие практики +--------------- + +Настройка timeout +~~~~~~~~~~~~~~~~~ + +Foundation Models могут работать медленнее обычных API: + +.. code-block:: python + + client = OpenAI( + key_id="your_key_id", + secret="your_secret", + base_url="https://foundation-models.api.cloud.ru/api/gigacube/openai/v1", + project_id="your_project_id", + timeout=90.0 # Увеличенный timeout для Foundation Models + ) + +Управление токенами +~~~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + # Ограничение количества токенов в ответе + response = client.chat.completions.create( + model="RefalMachine/RuadaptQwen2.5-7B-Lite-Beta", + messages=[{"role": "user", "content": "Объясни квантовую физику"}], + max_tokens=200, # Ограничение для контроля затрат + temperature=0.5 # Сбалансированная креативность + ) + +Кеширование соединений +~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + # Используйте context manager для автоматического управления ресурсами + with OpenAI( + key_id="your_key_id", + secret="your_secret", + base_url="https://foundation-models.api.cloud.ru/api/gigacube/openai/v1", + project_id="your_project_id" + ) as client: + # Множественные запросы с одним клиентом + for i in range(5): + response = client.chat.completions.create( + model="RefalMachine/RuadaptQwen2.5-7B-Lite-Beta", + messages=[{"role": "user", "content": f"Вопрос {i+1}"}], + max_tokens=50 + ) + print(response.choices[0].message.content) + +Мониторинг использования +~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + import time + + start_time = time.time() + + response = client.chat.completions.create( + model="RefalMachine/RuadaptQwen2.5-7B-Lite-Beta", + messages=[{"role": "user", "content": "Создай план проекта"}], + max_tokens=300 + ) + + elapsed_time = time.time() - start_time + + print(f"Время ответа: {elapsed_time:.2f} секунд") + print(f"Токенов использовано: {response.usage.total_tokens}") + print(f"Скорость: {response.usage.total_tokens / elapsed_time:.1f} токен/сек") + +Устранение неполадок +-------------------- + +Проблемы с авторизацией +~~~~~~~~~~~~~~~~~~~~~~~ + +**Проблема**: Ошибка авторизации при подключении + +**Решение**: Проверьте правильность key_id, secret и project_id: + +.. code-block:: python + + # Проверьте переменные окружения + import os + print(f"KEY_ID: {os.getenv('EVOLUTION_KEY_ID', 'не установлен')}") + print(f"SECRET: {os.getenv('EVOLUTION_SECRET', 'не установлен')[:10]}...") + print(f"PROJECT_ID: {os.getenv('EVOLUTION_PROJECT_ID', 'не установлен')}") + +Проблемы с моделью +~~~~~~~~~~~~~~~~~~ + +**Проблема**: Модель не найдена или недоступна + +**Решение**: Используйте проверенные модели: + +.. code-block:: python + + # Рекомендуемые модели для Foundation Models + models = [ + "RefalMachine/RuadaptQwen2.5-7B-Lite-Beta", + "deepseek-ai/DeepSeek-R1-Distill-Llama-70B" + ] + +Проблемы с сетью +~~~~~~~~~~~~~~~~ + +**Проблема**: Тайм-ауты или проблемы с подключением + +**Решение**: Увеличьте timeout и количество повторов: + +.. code-block:: python + + client = OpenAI( + key_id="your_key_id", + secret="your_secret", + base_url="https://foundation-models.api.cloud.ru/api/gigacube/openai/v1", + project_id="your_project_id", + timeout=120.0, # 2 минуты + max_retries=5, # 5 попыток + ) \ No newline at end of file diff --git a/docs/index.rst b/docs/index.rst index b5a41c0..08f6c75 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -74,6 +74,7 @@ Evolution OpenAI Documentation usage async_usage streaming + foundation_models error_handling migration @@ -82,8 +83,6 @@ Evolution OpenAI Documentation :caption: Дополнительно: README_DOCS - workflow - coverage-setup Индексы и таблицы ================= diff --git a/env.example b/env.example index 682374e..7bc3018 100644 --- a/env.example +++ b/env.example @@ -5,8 +5,16 @@ EVOLUTION_KEY_ID=your_key_id_here EVOLUTION_SECRET=your_secret_here -# Evolution Model Endpoint -EVOLUTION_BASE_URL=https://your-model-endpoint.cloud.ru/v1 +# Evolution OpenAI Endpoint +# Для обычных примеров используйте ваш рабочий endpoint +EVOLUTION_BASE_URL=https://your-endpoint.cloud.ru/v1 + +# Evolution Foundation Models Endpoint (опционально) +# Если установлен, то будет использован для Foundation Models примеров +EVOLUTION_FOUNDATION_MODELS_URL=https://foundation-models.api.cloud.ru/api/gigacube/openai/v1 + +# Evolution Foundation Models Project ID (опционально) +EVOLUTION_PROJECT_ID=your_project_id_here # Token Service Endpoint (обычно не нужно менять) EVOLUTION_TOKEN_URL=https://iam.api.cloud.ru/api/v1/auth/token @@ -16,4 +24,4 @@ EVOLUTION_TOKEN_URL=https://iam.api.cloud.ru/api/v1/auth/token ENABLE_INTEGRATION_TESTS=false # Logging Level (DEBUG, INFO, WARNING, ERROR) -LOG_LEVEL=DEBUG \ No newline at end of file +LOG_LEVEL=INFO \ No newline at end of file diff --git a/examples/foundation_models_example.py b/examples/foundation_models_example.py new file mode 100644 index 0000000..bda1db6 --- /dev/null +++ b/examples/foundation_models_example.py @@ -0,0 +1,413 @@ +#!/usr/bin/env python3 +""" +Примеры работы с Evolution Foundation Models +""" + +import os +import time +import asyncio + +from evolution_openai import OpenAI, AsyncOpenAI + +# Конфигурация +BASE_URL = os.getenv("EVOLUTION_BASE_URL", "https://your-endpoint.cloud.ru/v1") +FOUNDATION_MODELS_URL = os.getenv( + "EVOLUTION_FOUNDATION_MODELS_URL", + "https://foundation-models.api.cloud.ru/api/gigacube/openai/v1", +) +KEY_ID = os.getenv("EVOLUTION_KEY_ID", "your_key_id") +SECRET = os.getenv("EVOLUTION_SECRET", "your_secret") +PROJECT_ID = os.getenv("EVOLUTION_PROJECT_ID") + +# Выбираем Foundation Models endpoint если доступен +ENDPOINT_URL = FOUNDATION_MODELS_URL if FOUNDATION_MODELS_URL else BASE_URL +DEFAULT_MODEL = "RefalMachine/RuadaptQwen2.5-7B-Lite-Beta" + + +def get_foundation_model(): + """Возвращает модель для Foundation Models""" + print(f"🔧 Используем модель: {DEFAULT_MODEL}") + return DEFAULT_MODEL + + +async def get_foundation_model_async(): + """Возвращает модель для Foundation Models (асинхронно)""" + print(f"🔧 Используем модель: {DEFAULT_MODEL}") + return DEFAULT_MODEL + + +def basic_foundation_models_example(): + """Базовый пример Foundation Models""" + print("=== Базовый Foundation Models ===") + + if KEY_ID == "your_key_id" or SECRET == "your_secret": + print("Установите переменные окружения для работы с Foundation Models") + return None + + try: + with OpenAI( + key_id=KEY_ID, + secret=SECRET, + base_url=ENDPOINT_URL, + project_id=PROJECT_ID, + ) as client: + model_name = get_foundation_model() + + response = client.chat.completions.create( + model=model_name, + messages=[ + { + "role": "system", + "content": "Ты полезный помощник, использующий Evolution Foundation Models.", + }, + { + "role": "user", + "content": "Расскажи кратко о возможностях искусственного интеллекта", + }, + ], + max_tokens=50, + temperature=0.7, + ) + + if ( + response.choices + and len(response.choices) > 0 + and response.choices[0].message + ): + content = ( + response.choices[0].message.content + or "Нет содержимого в ответе" + ) + print(f"✅ Ответ: {content}") + print(f"📊 Модель: {response.model}") + print(f"🔢 Токенов: {response.usage.total_tokens}") + return True + else: + print("❌ Получен пустой ответ") + return False + + except Exception as e: + print(f"❌ Ошибка: {e}") + return False + + +def streaming_foundation_models_example(): + """Пример streaming с Foundation Models""" + print("\n=== Streaming Foundation Models ===") + + if KEY_ID == "your_key_id" or SECRET == "your_secret": + print("Установите переменные окружения для streaming") + return None + + try: + with OpenAI( + key_id=KEY_ID, + secret=SECRET, + base_url=ENDPOINT_URL, + project_id=PROJECT_ID, + ) as client: + model_name = get_foundation_model() + + print("Генерируем стихотворение...") + print("-" * 50) + + stream = client.chat.completions.create( + model=model_name, + messages=[ + { + "role": "user", + "content": "Напиши короткое стихотворение про технологии", + } + ], + stream=True, + max_tokens=80, + temperature=0.8, + ) + + content_parts = [] + for chunk in stream: + if ( + chunk.choices + and len(chunk.choices) > 0 + and chunk.choices[0].delta + and chunk.choices[0].delta.content + ): + content = chunk.choices[0].delta.content + content_parts.append(content) + print(content, end="", flush=True) + + print("\n" + "-" * 50) + print( + f"✅ Streaming завершен! Получено {len(content_parts)} частей." + ) + return True + + except Exception as e: + print(f"❌ Streaming ошибка: {e}") + return False + + +async def async_foundation_models_example(): + """Асинхронный пример Foundation Models""" + print("\n=== Асинхронный Foundation Models ===") + + if KEY_ID == "your_key_id" or SECRET == "your_secret": + print("Установите переменные окружения для async примера") + return None + + try: + async with AsyncOpenAI( + key_id=KEY_ID, + secret=SECRET, + base_url=ENDPOINT_URL, + project_id=PROJECT_ID, + ) as client: + model_name = await get_foundation_model_async() + + response = await client.chat.completions.create( + model=model_name, + messages=[ + { + "role": "user", + "content": "Объясни простыми словами, что такое машинное обучение", + } + ], + max_tokens=60, + temperature=0.5, + ) + + if ( + response.choices + and len(response.choices) > 0 + and response.choices[0].message + ): + content = ( + response.choices[0].message.content + or "Нет содержимого в ответе" + ) + print(f"✅ Async ответ: {content}") + print(f"📊 Модель: {response.model}") + print(f"🔢 Токенов: {response.usage.total_tokens}") + return True + else: + print("❌ Получен пустой ответ") + return False + + except Exception as e: + print(f"❌ Async ошибка: {e}") + return False + + +def advanced_foundation_models_example(): + """Пример с дополнительными опциями Foundation Models""" + print("\n=== Foundation Models с опциями ===") + + if KEY_ID == "your_key_id" or SECRET == "your_secret": + print("Установите переменные окружения для advanced примера") + return None + + try: + with OpenAI( + key_id=KEY_ID, + secret=SECRET, + base_url=ENDPOINT_URL, + project_id=PROJECT_ID, + ) as client: + model_name = get_foundation_model() + + # Используем with_options для настройки параметров + response = client.with_options( + timeout=60.0, max_retries=3 + ).chat.completions.create( + model=model_name, + messages=[ + { + "role": "user", + "content": "Создай план изучения Python для начинающих", + } + ], + max_tokens=80, + temperature=0.3, + ) + + if ( + response.choices + and len(response.choices) > 0 + and response.choices[0].message + ): + content = ( + response.choices[0].message.content + or "Нет содержимого в ответе" + ) + print(f"✅ Ответ с опциями: {content}") + print(f"📊 Модель: {response.model}") + print(f"🔢 Токенов: {response.usage.total_tokens}") + + # Информация о токене + token_info = client.get_token_info() + print(f"🔑 Статус токена: {token_info}") + return True + else: + print("❌ Получен пустой ответ") + return False + + except Exception as e: + print(f"❌ Ошибка с опциями: {e}") + return False + + +async def parallel_foundation_models_example(): + """Пример параллельных запросов к Foundation Models""" + print("\n=== Параллельные запросы Foundation Models ===") + + if KEY_ID == "your_key_id" or SECRET == "your_secret": + print("Установите переменные окружения для параллельных запросов") + return None + + try: + async with AsyncOpenAI( + key_id=KEY_ID, + secret=SECRET, + base_url=ENDPOINT_URL, + project_id=PROJECT_ID, + ) as client: + model_name = await get_foundation_model_async() + + # Список вопросов для параллельной обработки + questions = [ + "Что такое искусственный интеллект?", + "Как работает машинное обучение?", + "Что такое нейронные сети?", + ] + + # Создаем задачи для параллельного выполнения + tasks = [] + for question in questions: + task = client.chat.completions.create( + model=model_name, + messages=[ + { + "role": "system", + "content": "Дай краткий ответ в 1-2 предложения.", + }, + {"role": "user", "content": question}, + ], + max_tokens=50, + temperature=0.5, + ) + tasks.append(task) + + # Выполняем все запросы параллельно + start_time = time.time() + responses = await asyncio.gather(*tasks) + end_time = time.time() + + elapsed = end_time - start_time + print( + f"⚡ Обработано {len(questions)} запросов за {elapsed:.2f} секунд" + ) + print() + + for i, (question, response) in enumerate( + zip(questions, responses) + ): + print(f"❓ Вопрос {i + 1}: {question}") + if ( + response.choices + and len(response.choices) > 0 + and response.choices[0].message + ): + content = ( + response.choices[0].message.content + or "Нет содержимого в ответе" + ) + print(f"✅ Ответ: {content}") + print(f"🔢 Токенов: {response.usage.total_tokens}") + else: + print("❌ Получен пустой ответ") + print("-" * 50) + + return True + + except Exception as e: + print(f"❌ Ошибка параллельных запросов: {e}") + return False + + +def main(): + """Основная функция с примерами Foundation Models""" + print("🚀 Evolution Foundation Models - Примеры использования\n") + print(f"🌐 Endpoint: {ENDPOINT_URL}") + print(f"🤖 Модель: {DEFAULT_MODEL}") + + # Показываем, используются ли Foundation Models + is_foundation_models = ( + "foundation-models" in ENDPOINT_URL or "gigacube" in ENDPOINT_URL + ) + print(f"🔧 Используется Foundation Models: {is_foundation_models}\n") + + # Проверяем переменные окружения + if KEY_ID == "your_key_id" or SECRET == "your_secret": + print("⚠️ ВНИМАНИЕ: Не установлены переменные окружения!") + print( + "Установите переменные окружения для работы с Foundation Models:" + ) + print("export EVOLUTION_KEY_ID='your_key_id'") + print("export EVOLUTION_SECRET='your_secret'") + print("export EVOLUTION_PROJECT_ID='your_project_id'") + print( + "export EVOLUTION_FOUNDATION_MODELS_URL='https://foundation-models.api.cloud.ru/api/gigacube/openai/v1'" + ) + print("\n💡 Примеры будут запущены в демонстрационном режиме") + print() + + # Запускаем примеры + results = [] + + # Синхронные примеры + results.append(basic_foundation_models_example()) + results.append(streaming_foundation_models_example()) + results.append(advanced_foundation_models_example()) + + # Асинхронные примеры + async def run_async_examples(): + async_results = [] + async_results.append(await async_foundation_models_example()) + async_results.append(await parallel_foundation_models_example()) + return async_results + + # Запускаем асинхронные примеры + async_results = asyncio.run(run_async_examples()) + results.extend(async_results) + + # Подводим итоги + successful = sum(1 for r in results if r is True) + failed = sum(1 for r in results if r is False) + skipped = sum(1 for r in results if r is None) + + print("\n📊 Результаты выполнения:") + print(f"✅ Успешно: {successful}") + print(f"❌ Ошибки: {failed}") + print(f"⏭️ Пропущено: {skipped}") + + if failed == 0 and successful > 0: + print("\n🎉 Все примеры Foundation Models выполнены успешно!") + elif failed > 0: + print(f"\n⚠️ {failed} примеров завершились с ошибками") + + print("\n💡 Подсказки:") + print("- Убедитесь, что PROJECT_ID установлен для Foundation Models") + print("- Проверьте доступность Foundation Models endpoint") + print( + "- Используйте EVOLUTION_FOUNDATION_MODELS_URL для специального endpoint" + ) + print("- Документация: docs/foundation_models.md") + + return failed == 0 + + +if __name__ == "__main__": + import sys + + success = main() + sys.exit(0 if success else 1) diff --git a/examples/run_all_examples.py b/examples/run_all_examples.py index be26924..aa0e57a 100644 --- a/examples/run_all_examples.py +++ b/examples/run_all_examples.py @@ -43,12 +43,15 @@ def run_example(script_name: str, description: str) -> bool: return False try: - # Запуск примера + # Запуск примера (увеличенный таймаут для Foundation Models) + timeout_seconds = ( + 60 if script_name == "foundation_models_example.py" else 30 + ) result = subprocess.run( [sys.executable, str(script_path)], capture_output=True, text=True, - timeout=30, + timeout=timeout_seconds, ) if result.stdout: @@ -113,6 +116,12 @@ def main() -> bool: "EVOLUTION_SECRET", "EVOLUTION_BASE_URL", ] + + # Проверяем опциональные переменные для Foundation Models + optional_vars = [ + "EVOLUTION_FOUNDATION_MODELS_URL", + "EVOLUTION_PROJECT_ID", + ] missing_vars = [var for var in required_vars if not os.getenv(var)] if missing_vars: @@ -128,12 +137,23 @@ def main() -> bool: print("\n✅ Все необходимые переменные окружения установлены") print("Примеры будут работать с реальным API") + # Показываем статус опциональных переменных + missing_optional = [var for var in optional_vars if not os.getenv(var)] + if missing_optional: + print( + f"ℹ️ Опциональные переменные не установлены: {', '.join(missing_optional)}" + ) + print( + "Foundation Models примеры будут использовать значения по умолчанию" + ) + # Список примеров для запуска examples = [ ("basic_usage.py", "Базовые примеры использования"), ("streaming_examples.py", "Примеры Streaming API"), ("token_management.py", "Управление токенами"), ("async_examples.py", "Асинхронные примеры"), + ("foundation_models_example.py", "Примеры Foundation Models"), ] # Статистика diff --git a/scripts/utils/ruffen-docs.py b/scripts/utils/ruffen-docs.py index 4b809c5..4ba32c0 100644 --- a/scripts/utils/ruffen-docs.py +++ b/scripts/utils/ruffen-docs.py @@ -21,6 +21,21 @@ r"(?P^(?P=indent)```.*$)", re.DOTALL | re.MULTILINE, ) + +# RST code block patterns +RST_RE = re.compile( + r"(?P^(?P *)\.\. code-block:: python\n)" + r"(?P.*?)" + r"(?P^(?P=indent)\n)", + re.DOTALL | re.MULTILINE, +) +RST_PYCON_RE = re.compile( + r"(?P^(?P *)\.\. code-block:: pycon\n)" + r"(?P.*?)" + r"(?P^(?P=indent)\n)", + re.DOTALL | re.MULTILINE, +) + PYCON_PREFIX = ">>> " PYCON_CONTINUATION_PREFIX = "..." PYCON_CONTINUATION_RE = re.compile( @@ -103,8 +118,26 @@ def _md_pycon_match(match: Match[str]) -> str: code = textwrap.indent(code, match["indent"]) return f"{match['before']}{code}{match['after']}" + def _rst_match(match: Match[str]) -> str: + code = textwrap.dedent(match["code"]) + with _collect_error(match): + code = format_code_block(code) + code = textwrap.indent(code, match["indent"]) + return f"{match['before']}{code}{match['after']}" + + def _rst_pycon_match(match: Match[str]) -> str: + code = _pycon_match(match) + code = textwrap.indent(code, match["indent"]) + return f"{match['before']}{code}{match['after']}" + + # Process Markdown files src = MD_RE.sub(_md_match, src) src = MD_PYCON_RE.sub(_md_pycon_match, src) + + # Process RST files + src = RST_RE.sub(_rst_match, src) + src = RST_PYCON_RE.sub(_rst_pycon_match, src) + return src, errors diff --git a/src/evolution_openai/client.py b/src/evolution_openai/client.py index 397c498..7921b02 100644 --- a/src/evolution_openai/client.py +++ b/src/evolution_openai/client.py @@ -67,7 +67,7 @@ def __init__( # Параметры совместимые с OpenAI SDK api_key: Optional[str] = None, # Игнорируется organization: Optional[str] = None, - project: Optional[str] = None, + project_id: Optional[str] = None, timeout: Union[float, None] = None, max_retries: int = 2, default_headers: Optional[Dict[str, str]] = None, @@ -84,6 +84,7 @@ def __init__( # Сохраняем Cloud.ru credentials self.key_id = key_id self.secret = secret + self.project_id = project_id # Инициализируем token manager self.token_manager = EvolutionTokenManager(key_id, secret) @@ -91,15 +92,17 @@ def __init__( # Получаем первоначальный токен initial_token = self.token_manager.get_valid_token() + # Подготавливаем заголовки с project_id + prepared_headers = self._prepare_default_headers(default_headers) + # Инициализируем родительский OpenAI client super().__init__( # type: ignore[reportUnknownMemberType] api_key=initial_token, organization=organization, - project=project, base_url=base_url, timeout=timeout, max_retries=max_retries, - default_headers=default_headers, + default_headers=prepared_headers, default_query=default_query, http_client=http_client, **kwargs, @@ -108,6 +111,31 @@ def __init__( # Переопределяем _client для автоматического обновления токенов self._patch_client() + # Устанавливаем заголовки после инициализации родительского класса + self._initialize_headers() + + def _prepare_default_headers( + self, user_headers: Optional[Dict[str, str]] + ) -> Dict[str, str]: + """Подготавливает заголовки по умолчанию с учетом project_id""" + headers: Dict[str, str] = {} + + # Добавляем пользовательские заголовки + if user_headers: + headers.update(user_headers) + + # Добавляем project_id заголовок если он установлен + if self.project_id: + headers["x-project-id"] = self.project_id + + return headers + + def _initialize_headers(self) -> None: + """Инициализирует заголовки после создания клиента""" + current_token = self.token_manager.get_valid_token() + if current_token: + self._update_auth_headers(current_token) + def _patch_client(self) -> None: # type: ignore[reportUnknownMemberType] """Патчим client для автоматического обновления токенов""" # В новых версиях используется 'request' @@ -147,10 +175,41 @@ def patched_request(*args: Any, **kwargs: Any) -> Any: # type: ignore[reportUnk def _update_auth_headers(self, token: str) -> None: """Обновляет заголовки авторизации""" auth_header = f"Bearer {token}" + headers_updated = False + + # Пытаемся обновить заголовки различными способами if hasattr(self._client, "_auth_headers"): self._client._auth_headers["Authorization"] = auth_header # type: ignore[reportAttributeAccessIssue] - elif hasattr(self._client, "default_headers"): + # Добавляем project_id заголовок если он установлен + if self.project_id: + self._client._auth_headers["x-project-id"] = self.project_id # type: ignore[reportAttributeAccessIssue] + headers_updated = True + + if hasattr(self._client, "default_headers"): self._client.default_headers["Authorization"] = auth_header # type: ignore[reportAttributeAccessIssue] + # Добавляем project_id заголовок если он установлен + if self.project_id: + self._client.default_headers["x-project-id"] = self.project_id # type: ignore[reportAttributeAccessIssue] + headers_updated = True + + # Пытаемся обновить заголовки через _default_headers (для новых версий OpenAI SDK) + if hasattr(self._client, "_default_headers"): + self._client._default_headers["Authorization"] = auth_header # type: ignore[reportAttributeAccessIssue] + if self.project_id: + self._client._default_headers["x-project-id"] = self.project_id # type: ignore[reportAttributeAccessIssue] + headers_updated = True + + # Обновляем заголовки на уровне самого клиента + if hasattr(self, "default_headers") and self.default_headers: + self.default_headers["Authorization"] = auth_header # type: ignore[reportAttributeAccessIssue] + if self.project_id: + self.default_headers["x-project-id"] = self.project_id # type: ignore[reportAttributeAccessIssue] + headers_updated = True + + if not headers_updated: + logger.warning( + "Не удалось обновить заголовки - структура HTTP клиента не распознана" + ) def _is_auth_error(self, error: Exception) -> bool: """Проверяет, является ли ошибка связанной с авторизацией""" @@ -180,6 +239,31 @@ def get_token_info(self) -> Dict[str, Any]: """Возвращает информацию о токене""" return self.token_manager.get_token_info() + def get_request_headers(self) -> Dict[str, str]: + """Возвращает текущие заголовки запроса для отладки""" + headers: Dict[str, str] = {} + + # Собираем заголовки из различных источников + if ( + hasattr(self._client, "_auth_headers") + and self._client._auth_headers + ): + headers.update(self._client._auth_headers) # type: ignore[reportAttributeAccessIssue] + if ( + hasattr(self._client, "default_headers") + and self._client.default_headers + ): + headers.update(self._client.default_headers) # type: ignore[reportAttributeAccessIssue] + if ( + hasattr(self._client, "_default_headers") + and self._client._default_headers + ): + headers.update(self._client._default_headers) # type: ignore[reportAttributeAccessIssue] + if hasattr(self, "default_headers") and self.default_headers: + headers.update(self.default_headers) # type: ignore[reportAttributeAccessIssue] + + return headers + @override def with_options(self, **kwargs: Any) -> "EvolutionOpenAI": # type: ignore[reportUnknownReturnType,misc] """Создает новый клиент с дополнительными опциями""" @@ -189,7 +273,7 @@ def with_options(self, **kwargs: Any) -> "EvolutionOpenAI": # type: ignore[repo "secret": self.secret, "base_url": self.base_url, "organization": self.organization, - "project": getattr(self, "project", None), + "project_id": self.project_id, "timeout": self.timeout, "max_retries": self.max_retries, "default_headers": self.default_headers, @@ -234,7 +318,7 @@ def __init__( # Параметры совместимые с AsyncOpenAI api_key: Optional[str] = None, organization: Optional[str] = None, - project: Optional[str] = None, + project_id: Optional[str] = None, timeout: Union[float, None] = None, max_retries: int = 2, default_headers: Optional[Dict[str, str]] = None, @@ -250,6 +334,7 @@ def __init__( # Сохраняем Cloud.ru credentials self.key_id = key_id self.secret = secret + self.project_id = project_id # Инициализируем token manager self.token_manager = EvolutionTokenManager(key_id, secret) @@ -257,15 +342,17 @@ def __init__( # Получаем первоначальный токен initial_token = self.token_manager.get_valid_token() + # Подготавливаем заголовки с project_id + prepared_headers = self._prepare_default_headers(default_headers) + # Инициализируем родительский AsyncOpenAI client super().__init__( # type: ignore[reportUnknownMemberType] api_key=initial_token, organization=organization, - project=project, base_url=base_url, timeout=timeout, max_retries=max_retries, - default_headers=default_headers, + default_headers=prepared_headers, default_query=default_query, http_client=http_client, **kwargs, @@ -274,6 +361,31 @@ def __init__( # Патчим async client self._patch_async_client() + # Устанавливаем заголовки после инициализации родительского класса + self._initialize_headers() + + def _prepare_default_headers( + self, user_headers: Optional[Dict[str, str]] + ) -> Dict[str, str]: + """Подготавливает заголовки по умолчанию с учетом project_id""" + headers: Dict[str, str] = {} + + # Добавляем пользовательские заголовки + if user_headers: + headers.update(user_headers) + + # Добавляем project_id заголовок если он установлен + if self.project_id: + headers["x-project-id"] = self.project_id + + return headers + + def _initialize_headers(self) -> None: + """Инициализирует заголовки после создания клиента""" + current_token = self.token_manager.get_valid_token() + if current_token: + self._update_auth_headers(current_token) + def _patch_async_client(self) -> None: """Патчим async client для автоматического обновления токенов""" # В новых версиях используется 'request' @@ -313,10 +425,41 @@ async def patched_request(*args: Any, **kwargs: Any) -> Any: # type: ignore[rep def _update_auth_headers(self, token: str) -> None: """Обновляет заголовки авторизации""" auth_header = f"Bearer {token}" + headers_updated = False + + # Пытаемся обновить заголовки различными способами if hasattr(self._client, "_auth_headers"): self._client._auth_headers["Authorization"] = auth_header # type: ignore[reportAttributeAccessIssue] - elif hasattr(self._client, "default_headers"): + # Добавляем project_id заголовок если он установлен + if self.project_id: + self._client._auth_headers["x-project-id"] = self.project_id # type: ignore[reportAttributeAccessIssue] + headers_updated = True + + if hasattr(self._client, "default_headers"): self._client.default_headers["Authorization"] = auth_header # type: ignore[reportAttributeAccessIssue] + # Добавляем project_id заголовок если он установлен + if self.project_id: + self._client.default_headers["x-project-id"] = self.project_id # type: ignore[reportAttributeAccessIssue] + headers_updated = True + + # Пытаемся обновить заголовки через _default_headers (для новых версий OpenAI SDK) + if hasattr(self._client, "_default_headers"): + self._client._default_headers["Authorization"] = auth_header # type: ignore[reportAttributeAccessIssue] + if self.project_id: + self._client._default_headers["x-project-id"] = self.project_id # type: ignore[reportAttributeAccessIssue] + headers_updated = True + + # Обновляем заголовки на уровне самого клиента + if hasattr(self, "default_headers") and self.default_headers: + self.default_headers["Authorization"] = auth_header # type: ignore[reportAttributeAccessIssue] + if self.project_id: + self.default_headers["x-project-id"] = self.project_id # type: ignore[reportAttributeAccessIssue] + headers_updated = True + + if not headers_updated: + logger.warning( + "Не удалось обновить заголовки - структура HTTP клиента не распознана" + ) def _is_auth_error(self, error: Exception) -> bool: """Проверяет, является ли ошибка связанной с авторизацией""" @@ -346,6 +489,31 @@ def get_token_info(self) -> Dict[str, Any]: """Возвращает информацию о токене""" return self.token_manager.get_token_info() + def get_request_headers(self) -> Dict[str, str]: + """Возвращает текущие заголовки запроса для отладки""" + headers: Dict[str, str] = {} + + # Собираем заголовки из различных источников + if ( + hasattr(self._client, "_auth_headers") + and self._client._auth_headers + ): + headers.update(self._client._auth_headers) # type: ignore[reportAttributeAccessIssue] + if ( + hasattr(self._client, "default_headers") + and self._client.default_headers + ): + headers.update(self._client.default_headers) # type: ignore[reportAttributeAccessIssue] + if ( + hasattr(self._client, "_default_headers") + and self._client._default_headers + ): + headers.update(self._client._default_headers) # type: ignore[reportAttributeAccessIssue] + if hasattr(self, "default_headers") and self.default_headers: + headers.update(self.default_headers) # type: ignore[reportAttributeAccessIssue] + + return headers + @override def with_options(self, **kwargs: Any) -> "EvolutionAsyncOpenAI": # type: ignore[reportUnknownReturnType,misc] """Создает новый асинхронный клиент с дополнительными опциями""" @@ -355,7 +523,7 @@ def with_options(self, **kwargs: Any) -> "EvolutionAsyncOpenAI": # type: ignore "secret": self.secret, "base_url": self.base_url, "organization": self.organization, - "project": getattr(self, "project", None), + "project_id": self.project_id, "timeout": self.timeout, "max_retries": self.max_retries, "default_headers": self.default_headers, diff --git a/tests/conftest.py b/tests/conftest.py index 145a94a..e255e0a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,7 +6,7 @@ import os import logging -from typing import TYPE_CHECKING, Iterator, AsyncIterator +from typing import TYPE_CHECKING, Dict, Iterator, Optional, AsyncIterator import pytest from dotenv import load_dotenv @@ -18,6 +18,9 @@ ) if TYPE_CHECKING: + from _pytest.config import ( + Config, # pyright: ignore[reportPrivateImportUsage] + ) from _pytest.fixtures import ( FixtureRequest, # pyright: ignore[reportPrivateImportUsage] ) @@ -48,14 +51,34 @@ def pytest_collection_modifyitems(items: list[pytest.Function]) -> None: if "integration" in item.keywords: item.add_marker(skip_integration) + # Skip foundation models tests if not enabled + foundation_models_enabled = ( + os.getenv("ENABLE_FOUNDATION_MODELS_TESTS", "false").lower() == "true" + or os.getenv("ENABLE_INTEGRATION_TESTS", "false").lower() == "true" + ) + if not foundation_models_enabled: + skip_foundation_models = pytest.mark.skip( + reason="Foundation Models tests disabled. " + "Set ENABLE_FOUNDATION_MODELS_TESTS=true or ENABLE_INTEGRATION_TESTS=true to enable." + ) + for item in items: + if ( + "foundation_models" in item.keywords + or "test_foundation_models" in item.name + ): + item.add_marker(skip_foundation_models) + -def pytest_configure(config): +def pytest_configure(config: Config) -> None: """Register custom markers""" config.addinivalue_line( "markers", "integration: mark test as integration test" ) config.addinivalue_line("markers", "unit: mark test as unit test") config.addinivalue_line("markers", "slow: mark test as slow running") + config.addinivalue_line( + "markers", "foundation_models: mark test as foundation models test" + ) # Load environment variables from .env file if exists try: @@ -71,12 +94,13 @@ def pytest_configure(config): @pytest.fixture(scope="session") -def test_credentials(): +def test_credentials() -> Dict[str, Optional[str]]: """Fixture providing test credentials from environment variables""" return { "key_id": os.getenv("EVOLUTION_KEY_ID"), "secret": os.getenv("EVOLUTION_SECRET"), "base_url": os.getenv("EVOLUTION_BASE_URL"), + "project_id": os.getenv("EVOLUTION_PROJECT_ID"), "token_url": os.getenv( "EVOLUTION_TOKEN_URL", "https://iam.api.cloud.ru/api/v1/auth/token" ), @@ -84,25 +108,38 @@ def test_credentials(): @pytest.fixture(scope="session") -def integration_enabled(): +def integration_enabled() -> bool: """Fixture checking if integration tests are enabled""" return os.getenv("ENABLE_INTEGRATION_TESTS", "false").lower() == "true" @pytest.fixture -def mock_credentials(): +def mock_credentials() -> Dict[str, str]: """Fixture providing mock credentials for unit tests""" return { "key_id": "test_key_id", "secret": "test_secret", "base_url": "https://test.example.com/v1", + "project_id": "test_project_id", "token_url": "https://iam.api.cloud.ru/api/v1/auth/token", } +@pytest.fixture(scope="session") +def project_id() -> Optional[str]: + """Fixture providing project_id from environment variables""" + return os.getenv("EVOLUTION_PROJECT_ID") + + +@pytest.fixture +def mock_project_id() -> str: + """Fixture providing mock project_id for unit tests""" + return "test_project_id" + + @pytest.fixture(scope="session") def client( - request: FixtureRequest, test_credentials + request: FixtureRequest, test_credentials: Dict[str, Optional[str]] ) -> Iterator[EvolutionOpenAI]: """Session-scoped sync client fixture with proper cleanup""" if not test_credentials["key_id"] or not test_credentials["secret"]: @@ -116,9 +153,9 @@ def client( try: with EvolutionOpenAI( - key_id=test_credentials["key_id"], - secret=test_credentials["secret"], - base_url=test_credentials["base_url"], + key_id=test_credentials["key_id"] or "", + secret=test_credentials["secret"] or "", + base_url=test_credentials["base_url"] or "", timeout=30.0, ) as client: yield client @@ -128,7 +165,7 @@ def client( @pytest.fixture(scope="session") async def async_client( - request: FixtureRequest, test_credentials + request: FixtureRequest, test_credentials: Dict[str, Optional[str]] ) -> AsyncIterator[CloudAsyncOpenAI]: """Session-scoped async client fixture with proper cleanup""" if not test_credentials["key_id"] or not test_credentials["secret"]: @@ -142,11 +179,112 @@ async def async_client( try: async with CloudAsyncOpenAI( - key_id=test_credentials["key_id"], - secret=test_credentials["secret"], - base_url=test_credentials["base_url"], + key_id=test_credentials["key_id"] or "", + secret=test_credentials["secret"] or "", + base_url=test_credentials["base_url"] or "", timeout=30.0, ) as client: yield client except Exception as e: pytest.skip(f"Failed to create async client: {e}") + + +@pytest.fixture(scope="session") +def foundation_models_credentials() -> Dict[str, Optional[str]]: + """Fixture providing Foundation Models specific credentials from environment variables""" + return { + "key_id": os.getenv("EVOLUTION_KEY_ID"), + "secret": os.getenv("EVOLUTION_SECRET"), + "base_url": os.getenv( + "EVOLUTION_FOUNDATION_MODELS_URL", + "https://foundation-models.api.cloud.ru/api/gigacube/openai/v1", + ), + "project_id": os.getenv("EVOLUTION_PROJECT_ID"), + "token_url": os.getenv( + "EVOLUTION_TOKEN_URL", "https://iam.api.cloud.ru/api/v1/auth/token" + ), + } + + +@pytest.fixture(scope="session") +def foundation_models_enabled() -> bool: + """Fixture checking if foundation models integration tests are enabled""" + return ( + os.getenv("ENABLE_FOUNDATION_MODELS_TESTS", "false").lower() == "true" + or os.getenv("ENABLE_INTEGRATION_TESTS", "false").lower() == "true" + ) + + +@pytest.fixture(scope="session") +def foundation_models_client( + request: FixtureRequest, + foundation_models_credentials: Dict[str, Optional[str]], +) -> Iterator[EvolutionOpenAI]: + """Session-scoped Foundation Models sync client fixture with proper cleanup""" + if ( + not foundation_models_credentials["key_id"] + or not foundation_models_credentials["secret"] + ): + pytest.skip( + "Real Foundation Models credentials not provided for client fixture" + ) + + strict = getattr(request, "param", True) + if not isinstance(strict, bool): + raise TypeError( + f"Unexpected fixture parameter type {type(strict)}, expected {bool}" + ) + + try: + with EvolutionOpenAI( + key_id=foundation_models_credentials["key_id"] or "", + secret=foundation_models_credentials["secret"] or "", + base_url=foundation_models_credentials["base_url"] or "", + project_id=foundation_models_credentials["project_id"], + timeout=60.0, + ) as client: + yield client + except Exception as e: + pytest.skip(f"Failed to create Foundation Models client: {e}") + + +@pytest.fixture(scope="session") +async def foundation_models_async_client( + request: FixtureRequest, + foundation_models_credentials: Dict[str, Optional[str]], +) -> AsyncIterator[CloudAsyncOpenAI]: + """Session-scoped Foundation Models async client fixture with proper cleanup""" + if ( + not foundation_models_credentials["key_id"] + or not foundation_models_credentials["secret"] + ): + pytest.skip( + "Real Foundation Models credentials not provided for async client fixture" + ) + + strict = getattr(request, "param", True) + if not isinstance(strict, bool): + raise TypeError( + f"Unexpected fixture parameter type {type(strict)}, expected {bool}" + ) + + try: + async with CloudAsyncOpenAI( + key_id=foundation_models_credentials["key_id"] or "", + secret=foundation_models_credentials["secret"] or "", + base_url=foundation_models_credentials["base_url"] or "", + project_id=foundation_models_credentials["project_id"], + timeout=60.0, + ) as client: + yield client + except Exception as e: + pytest.skip(f"Failed to create Foundation Models async client: {e}") + + +@pytest.fixture(scope="session") +def foundation_models_default_model() -> str: + """Fixture providing default Foundation Models model name""" + return os.getenv( + "EVOLUTION_FOUNDATION_MODELS_DEFAULT_MODEL", + "RefalMachine/RuadaptQwen2.5-7B-Lite-Beta", + ) diff --git a/tests/test_client_advanced.py b/tests/test_client_advanced.py new file mode 100644 index 0000000..18f948d --- /dev/null +++ b/tests/test_client_advanced.py @@ -0,0 +1,860 @@ +""" +Advanced unit tests for Evolution OpenAI Client internal methods +""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from evolution_openai import OpenAI, AsyncOpenAI + + +@pytest.mark.unit +class TestHeaderManagement: + """Test header management functionality""" + + @patch("evolution_openai.client.EvolutionTokenManager") + def test_prepare_default_headers_with_project_id( + self, mock_token_manager, mock_credentials + ): + """Test _prepare_default_headers with project_id""" + mock_manager = MagicMock() + mock_manager.get_valid_token.return_value = "test_token" + mock_token_manager.return_value = mock_manager + + client = OpenAI( + key_id=mock_credentials["key_id"], + secret=mock_credentials["secret"], + base_url=mock_credentials["base_url"], + project_id="test_project_123", + default_headers={"Custom-Header": "custom_value"}, + ) + + assert client.project_id == "test_project_123" + # Check that headers are properly prepared + headers = client._prepare_default_headers( + {"User-Header": "user_value"} + ) + assert headers["User-Header"] == "user_value" + assert headers["x-project-id"] == "test_project_123" + + @patch("evolution_openai.client.EvolutionTokenManager") + def test_prepare_default_headers_without_project_id( + self, mock_token_manager, mock_credentials + ): + """Test _prepare_default_headers without project_id""" + mock_manager = MagicMock() + mock_manager.get_valid_token.return_value = "test_token" + mock_token_manager.return_value = mock_manager + + client = OpenAI( + key_id=mock_credentials["key_id"], + secret=mock_credentials["secret"], + base_url=mock_credentials["base_url"], + ) + + headers = client._prepare_default_headers( + {"User-Header": "user_value"} + ) + assert headers["User-Header"] == "user_value" + assert "x-project-id" not in headers + + @patch("evolution_openai.client.EvolutionTokenManager") + def test_update_auth_headers_multiple_sources( + self, mock_token_manager, mock_credentials + ): + """Test _update_auth_headers updates all possible header sources""" + mock_manager = MagicMock() + mock_manager.get_valid_token.return_value = "test_token" + mock_token_manager.return_value = mock_manager + + client = OpenAI( + key_id=mock_credentials["key_id"], + secret=mock_credentials["secret"], + base_url=mock_credentials["base_url"], + project_id="test_project", + ) + + # Mock various header sources + mock_http_client = MagicMock() + mock_http_client._auth_headers = {} + mock_http_client.default_headers = {} + mock_http_client._default_headers = {} + client._client = mock_http_client + + # Update headers + client._update_auth_headers("new_token") + + # Verify HTTP client sources were updated + assert ( + mock_http_client._auth_headers["Authorization"] + == "Bearer new_token" + ) + assert mock_http_client._auth_headers["x-project-id"] == "test_project" + assert ( + mock_http_client.default_headers["Authorization"] + == "Bearer new_token" + ) + assert ( + mock_http_client.default_headers["x-project-id"] == "test_project" + ) + assert ( + mock_http_client._default_headers["Authorization"] + == "Bearer new_token" + ) + assert ( + mock_http_client._default_headers["x-project-id"] == "test_project" + ) + + @patch("evolution_openai.client.EvolutionTokenManager") + def test_update_auth_headers_no_header_sources( + self, mock_token_manager, mock_credentials + ): + """Test _update_auth_headers when no header sources are available""" + mock_manager = MagicMock() + mock_manager.get_valid_token.return_value = "test_token" + mock_token_manager.return_value = mock_manager + + client = OpenAI( + key_id=mock_credentials["key_id"], + secret=mock_credentials["secret"], + base_url=mock_credentials["base_url"], + ) + + # Mock HTTP client with no header attributes + mock_http_client = MagicMock() + # Remove any header attributes + for attr in ["_auth_headers", "default_headers", "_default_headers"]: + if hasattr(mock_http_client, attr): + delattr(mock_http_client, attr) + client._client = mock_http_client + + # Should not raise an error, just log a warning + client._update_auth_headers("new_token") + + @patch("evolution_openai.client.EvolutionTokenManager") + def test_get_request_headers(self, mock_token_manager, mock_credentials): + """Test get_request_headers method""" + mock_manager = MagicMock() + mock_manager.get_valid_token.return_value = "test_token" + mock_token_manager.return_value = mock_manager + + client = OpenAI( + key_id=mock_credentials["key_id"], + secret=mock_credentials["secret"], + base_url=mock_credentials["base_url"], + ) + + # Mock HTTP client with various header sources + mock_http_client = MagicMock() + mock_http_client._auth_headers = {"Authorization": "Bearer test_token"} + mock_http_client.default_headers = {"User-Agent": "test-agent"} + mock_http_client._default_headers = { + "Content-Type": "application/json" + } + client._client = mock_http_client + + headers = client.get_request_headers() + + # Should contain headers from all sources + assert headers["Authorization"] == "Bearer test_token" + # User-Agent is set by OpenAI client itself, so don't check specific value + assert "User-Agent" in headers + assert headers["Content-Type"] == "application/json" + + +@pytest.mark.unit +class TestAuthErrorDetection: + """Test authentication error detection""" + + @patch("evolution_openai.client.EvolutionTokenManager") + def test_is_auth_error_detection( + self, mock_token_manager, mock_credentials + ): + """Test _is_auth_error method with various error types""" + mock_manager = MagicMock() + mock_manager.get_valid_token.return_value = "test_token" + mock_token_manager.return_value = mock_manager + + client = OpenAI( + key_id=mock_credentials["key_id"], + secret=mock_credentials["secret"], + base_url=mock_credentials["base_url"], + ) + + # Test various auth error scenarios + auth_errors = [ + Exception("401 Unauthorized"), + Exception("Authentication failed"), + Exception("403 Forbidden"), + Exception("UNAUTHORIZED access"), + Exception("Authentication error occurred"), + ] + + for error in auth_errors: + assert client._is_auth_error(error) is True + + # Test non-auth errors + non_auth_errors = [ + Exception("500 Internal Server Error"), + Exception("Rate limit exceeded"), + Exception("Network timeout"), + Exception("Bad Request"), + ] + + for error in non_auth_errors: + assert client._is_auth_error(error) is False + + +@pytest.mark.unit +class TestHTTPClientPatching: + """Test HTTP client patching functionality""" + + @patch("evolution_openai.client.EvolutionTokenManager") + def test_patch_client_with_request_method( + self, mock_token_manager, mock_credentials + ): + """Test _patch_client when request method exists""" + mock_manager = MagicMock() + mock_manager.get_valid_token.return_value = "test_token" + mock_token_manager.return_value = mock_manager + + client = OpenAI( + key_id=mock_credentials["key_id"], + secret=mock_credentials["secret"], + base_url=mock_credentials["base_url"], + ) + + # Mock HTTP client with request method + mock_http_client = MagicMock() + original_request = MagicMock() + original_request.return_value = "success" + mock_http_client.request = original_request + client._client = mock_http_client + + # Patch the client + client._patch_client() + + # Verify the request method was patched + assert hasattr(mock_http_client, "request") + assert mock_http_client.request != original_request + + @patch("evolution_openai.client.EvolutionTokenManager") + def test_patch_client_without_request_method( + self, mock_token_manager, mock_credentials + ): + """Test _patch_client when request method doesn't exist""" + mock_manager = MagicMock() + mock_manager.get_valid_token.return_value = "test_token" + mock_token_manager.return_value = mock_manager + + client = OpenAI( + key_id=mock_credentials["key_id"], + secret=mock_credentials["secret"], + base_url=mock_credentials["base_url"], + ) + + # Mock HTTP client without request method + mock_http_client = MagicMock() + delattr(mock_http_client, "request") + client._client = mock_http_client + + # Should not raise an error + client._patch_client() + + @patch("evolution_openai.client.EvolutionTokenManager") + def test_patched_request_success( + self, mock_token_manager, mock_credentials + ): + """Test patched request method successful execution""" + mock_manager = MagicMock() + mock_manager.get_valid_token.return_value = "test_token" + mock_token_manager.return_value = mock_manager + + client = OpenAI( + key_id=mock_credentials["key_id"], + secret=mock_credentials["secret"], + base_url=mock_credentials["base_url"], + ) + + # Mock HTTP client + mock_http_client = MagicMock() + original_request = MagicMock() + original_request.return_value = "success" + mock_http_client.request = original_request + client._client = mock_http_client + + # Patch the client + client._patch_client() + + # Call the patched request + result = mock_http_client.request("arg1", kwarg1="value1") + + # Verify original request was called + assert result == "success" + original_request.assert_called_with("arg1", kwarg1="value1") + + @patch("evolution_openai.client.EvolutionTokenManager") + def test_patched_request_auth_error_retry( + self, mock_token_manager, mock_credentials + ): + """Test patched request method with auth error and retry""" + mock_manager = MagicMock() + # Provide enough tokens for all the calls that happen during the test + mock_manager.get_valid_token.side_effect = [ + "test_token", + "test_token", + "new_token", + "new_token", + ] + mock_token_manager.return_value = mock_manager + + client = OpenAI( + key_id=mock_credentials["key_id"], + secret=mock_credentials["secret"], + base_url=mock_credentials["base_url"], + ) + + # Mock HTTP client + mock_http_client = MagicMock() + original_request = MagicMock() + # First call raises auth error, second succeeds + original_request.side_effect = [ + Exception("401 Unauthorized"), + "success", + ] + mock_http_client.request = original_request + client._client = mock_http_client + + # Patch the client + client._patch_client() + + # Call the patched request + result = mock_http_client.request("arg1", kwarg1="value1") + + # Verify retry logic + assert result == "success" + assert original_request.call_count == 2 + mock_manager.invalidate_token.assert_called_once() + + @patch("evolution_openai.client.EvolutionTokenManager") + def test_patched_request_non_auth_error( + self, mock_token_manager, mock_credentials + ): + """Test patched request method with non-auth error""" + mock_manager = MagicMock() + mock_manager.get_valid_token.return_value = "test_token" + mock_token_manager.return_value = mock_manager + + client = OpenAI( + key_id=mock_credentials["key_id"], + secret=mock_credentials["secret"], + base_url=mock_credentials["base_url"], + ) + + # Mock HTTP client + mock_http_client = MagicMock() + original_request = MagicMock() + original_request.side_effect = Exception("500 Internal Server Error") + mock_http_client.request = original_request + client._client = mock_http_client + + # Patch the client + client._patch_client() + + # Call the patched request - should raise the original error + with pytest.raises(Exception) as exc_info: + mock_http_client.request("arg1", kwarg1="value1") + + assert "500 Internal Server Error" in str(exc_info.value) + assert original_request.call_count == 1 + mock_manager.invalidate_token.assert_not_called() + + +@pytest.mark.unit +class TestAsyncHTTPClientPatching: + """Test async HTTP client patching functionality""" + + @patch("evolution_openai.client.EvolutionTokenManager") + async def test_patch_async_client_with_request_method( + self, mock_token_manager, mock_credentials + ): + """Test _patch_async_client when request method exists""" + mock_manager = MagicMock() + mock_manager.get_valid_token.return_value = "test_token" + mock_token_manager.return_value = mock_manager + + client = AsyncOpenAI( + key_id=mock_credentials["key_id"], + secret=mock_credentials["secret"], + base_url=mock_credentials["base_url"], + ) + + # Mock async HTTP client with request method + mock_http_client = MagicMock() + original_request = AsyncMock() + original_request.return_value = "success" + mock_http_client.request = original_request + client._client = mock_http_client + + # Patch the client + client._patch_async_client() + + # Verify the request method was patched + assert hasattr(mock_http_client, "request") + assert mock_http_client.request != original_request + + @patch("evolution_openai.client.EvolutionTokenManager") + async def test_patched_async_request_success( + self, mock_token_manager, mock_credentials + ): + """Test patched async request method successful execution""" + mock_manager = MagicMock() + mock_manager.get_valid_token.return_value = "test_token" + mock_token_manager.return_value = mock_manager + + client = AsyncOpenAI( + key_id=mock_credentials["key_id"], + secret=mock_credentials["secret"], + base_url=mock_credentials["base_url"], + ) + + # Mock async HTTP client + mock_http_client = MagicMock() + original_request = AsyncMock() + original_request.return_value = "success" + mock_http_client.request = original_request + client._client = mock_http_client + + # Patch the client + client._patch_async_client() + + # Call the patched request + result = await mock_http_client.request("arg1", kwarg1="value1") + + # Verify original request was called + assert result == "success" + original_request.assert_called_with("arg1", kwarg1="value1") + + @patch("evolution_openai.client.EvolutionTokenManager") + async def test_patched_async_request_auth_error_retry( + self, mock_token_manager, mock_credentials + ): + """Test patched async request method with auth error and retry""" + mock_manager = MagicMock() + # Provide enough tokens for all the calls that happen during the test + mock_manager.get_valid_token.side_effect = [ + "test_token", + "test_token", + "new_token", + "new_token", + ] + mock_token_manager.return_value = mock_manager + + client = AsyncOpenAI( + key_id=mock_credentials["key_id"], + secret=mock_credentials["secret"], + base_url=mock_credentials["base_url"], + ) + + # Mock async HTTP client + mock_http_client = MagicMock() + original_request = AsyncMock() + # First call raises auth error, second succeeds + original_request.side_effect = [ + Exception("401 Unauthorized"), + "success", + ] + mock_http_client.request = original_request + client._client = mock_http_client + + # Patch the client + client._patch_async_client() + + # Call the patched request + result = await mock_http_client.request("arg1", kwarg1="value1") + + # Verify retry logic + assert result == "success" + assert original_request.call_count == 2 + mock_manager.invalidate_token.assert_called_once() + + +@pytest.mark.unit +class TestContextManagers: + """Test context manager functionality""" + + @patch("evolution_openai.client.EvolutionTokenManager") + def test_sync_context_manager_with_parent( + self, mock_token_manager, mock_credentials + ): + """Test sync context manager when parent has __enter__/__exit__""" + mock_manager = MagicMock() + mock_manager.get_valid_token.return_value = "test_token" + mock_token_manager.return_value = mock_manager + + with patch("openai.OpenAI.__enter__") as mock_enter, patch( + "openai.OpenAI.__exit__" + ) as mock_exit: + mock_enter.return_value = MagicMock() + mock_exit.return_value = None + + client = OpenAI( + key_id=mock_credentials["key_id"], + secret=mock_credentials["secret"], + base_url=mock_credentials["base_url"], + ) + + # Test context manager + with client as ctx_client: + assert ctx_client is client + + # Verify parent methods were called + mock_enter.assert_called_once() + mock_exit.assert_called_once() + + @patch("evolution_openai.client.EvolutionTokenManager") + def test_sync_context_manager_without_parent( + self, mock_token_manager, mock_credentials + ): + """Test sync context manager when parent doesn't have __enter__/__exit__""" + mock_manager = MagicMock() + mock_manager.get_valid_token.return_value = "test_token" + mock_token_manager.return_value = mock_manager + + client = OpenAI( + key_id=mock_credentials["key_id"], + secret=mock_credentials["secret"], + base_url=mock_credentials["base_url"], + ) + + # Test context manager works even without parent implementation + with client as ctx_client: + assert ctx_client is client + + @patch("evolution_openai.client.EvolutionTokenManager") + def test_sync_context_manager_exit_error( + self, mock_token_manager, mock_credentials + ): + """Test sync context manager when parent __exit__ raises error""" + mock_manager = MagicMock() + mock_manager.get_valid_token.return_value = "test_token" + mock_token_manager.return_value = mock_manager + + with patch("openai.OpenAI.__enter__") as mock_enter, patch( + "openai.OpenAI.__exit__" + ) as mock_exit: + mock_enter.return_value = MagicMock() + mock_exit.side_effect = Exception("Parent exit error") + + client = OpenAI( + key_id=mock_credentials["key_id"], + secret=mock_credentials["secret"], + base_url=mock_credentials["base_url"], + ) + + # Should not raise error, just log warning + with client as ctx_client: + assert ctx_client is client + + @patch("evolution_openai.client.EvolutionTokenManager") + async def test_async_context_manager_with_parent( + self, mock_token_manager, mock_credentials + ): + """Test async context manager when parent has __aenter__/__aexit__""" + mock_manager = MagicMock() + mock_manager.get_valid_token.return_value = "test_token" + mock_token_manager.return_value = mock_manager + + with patch("openai.AsyncOpenAI.__aenter__") as mock_aenter, patch( + "openai.AsyncOpenAI.__aexit__" + ) as mock_aexit: + mock_aenter.return_value = AsyncMock() + mock_aexit.return_value = AsyncMock() + + client = AsyncOpenAI( + key_id=mock_credentials["key_id"], + secret=mock_credentials["secret"], + base_url=mock_credentials["base_url"], + ) + + # Test async context manager + async with client as ctx_client: + assert ctx_client is client + + # Verify parent methods were called + mock_aenter.assert_called_once() + mock_aexit.assert_called_once() + + @patch("evolution_openai.client.EvolutionTokenManager") + async def test_async_context_manager_exit_error( + self, mock_token_manager, mock_credentials + ): + """Test async context manager when parent __aexit__ raises error""" + mock_manager = MagicMock() + mock_manager.get_valid_token.return_value = "test_token" + mock_token_manager.return_value = mock_manager + + with patch("openai.AsyncOpenAI.__aenter__") as mock_aenter, patch( + "openai.AsyncOpenAI.__aexit__" + ) as mock_aexit: + mock_aenter.return_value = AsyncMock() + mock_aexit.side_effect = Exception("Parent async exit error") + + client = AsyncOpenAI( + key_id=mock_credentials["key_id"], + secret=mock_credentials["secret"], + base_url=mock_credentials["base_url"], + ) + + # Should not raise error, just log warning + async with client as ctx_client: + assert ctx_client is client + + +@pytest.mark.unit +class TestWithOptionsMethod: + """Test with_options method functionality""" + + @patch("evolution_openai.client.EvolutionTokenManager") + def test_with_options_creates_new_client( + self, mock_token_manager, mock_credentials + ): + """Test with_options creates a new client instance""" + mock_manager = MagicMock() + mock_manager.get_valid_token.return_value = "test_token" + mock_token_manager.return_value = mock_manager + + client = OpenAI( + key_id=mock_credentials["key_id"], + secret=mock_credentials["secret"], + base_url=mock_credentials["base_url"], + timeout=30.0, + max_retries=2, + ) + + # Create new client with different options + new_client = client.with_options(timeout=60.0, max_retries=5) + + # Verify new client has updated options + assert new_client.timeout == 60.0 + assert new_client.max_retries == 5 + assert new_client.key_id == mock_credentials["key_id"] + assert new_client.secret == mock_credentials["secret"] + + # Verify original client is unchanged + assert client.timeout == 30.0 + assert client.max_retries == 2 + + @patch("evolution_openai.client.EvolutionTokenManager") + def test_with_options_preserves_credentials( + self, mock_token_manager, mock_credentials + ): + """Test with_options preserves Evolution credentials""" + mock_manager = MagicMock() + mock_manager.get_valid_token.return_value = "test_token" + mock_token_manager.return_value = mock_manager + + client = OpenAI( + key_id=mock_credentials["key_id"], + secret=mock_credentials["secret"], + base_url=mock_credentials["base_url"], + project_id="test_project", + ) + + # Create new client with different options + new_client = client.with_options(timeout=60.0) + + # Verify credentials are preserved + assert new_client.key_id == mock_credentials["key_id"] + assert new_client.secret == mock_credentials["secret"] + assert new_client.project_id == "test_project" + + @patch("evolution_openai.client.EvolutionTokenManager") + def test_async_with_options_creates_new_client( + self, mock_token_manager, mock_credentials + ): + """Test async with_options creates a new client instance""" + mock_manager = MagicMock() + mock_manager.get_valid_token.return_value = "test_token" + mock_token_manager.return_value = mock_manager + + client = AsyncOpenAI( + key_id=mock_credentials["key_id"], + secret=mock_credentials["secret"], + base_url=mock_credentials["base_url"], + timeout=30.0, + max_retries=2, + ) + + # Create new client with different options + new_client = client.with_options(timeout=60.0, max_retries=5) + + # Verify new client has updated options + assert new_client.timeout == 60.0 + assert new_client.max_retries == 5 + assert new_client.key_id == mock_credentials["key_id"] + assert new_client.secret == mock_credentials["secret"] + + # Verify original client is unchanged + assert client.timeout == 30.0 + assert client.max_retries == 2 + + +@pytest.mark.unit +class TestInitializationMethods: + """Test initialization helper methods""" + + @patch("evolution_openai.client.EvolutionTokenManager") + def test_initialize_headers_with_valid_token( + self, mock_token_manager, mock_credentials + ): + """Test _initialize_headers with valid token""" + mock_manager = MagicMock() + mock_manager.get_valid_token.return_value = "test_token" + mock_token_manager.return_value = mock_manager + + client = OpenAI( + key_id=mock_credentials["key_id"], + secret=mock_credentials["secret"], + base_url=mock_credentials["base_url"], + ) + + # Mock HTTP client + mock_http_client = MagicMock() + mock_http_client._auth_headers = {} + client._client = mock_http_client + + # Initialize headers + client._initialize_headers() + + # Verify headers were updated + assert ( + mock_http_client._auth_headers["Authorization"] + == "Bearer test_token" + ) + + @patch("evolution_openai.client.EvolutionTokenManager") + def test_initialize_headers_with_no_token( + self, mock_token_manager, mock_credentials + ): + """Test _initialize_headers with no token""" + mock_manager = MagicMock() + # First call returns valid token for client creation, then None twice + mock_manager.get_valid_token.side_effect = ["test_token", None, None] + mock_token_manager.return_value = mock_manager + + client = OpenAI( + key_id=mock_credentials["key_id"], + secret=mock_credentials["secret"], + base_url=mock_credentials["base_url"], + ) + + # Should not raise error when called directly + client._initialize_headers() + + +@pytest.mark.unit +class TestEdgeCases: + """Test edge cases and error conditions""" + + @patch("evolution_openai.client.EvolutionTokenManager") + def test_client_with_none_values( + self, mock_token_manager, mock_credentials + ): + """Test client creation with None values""" + mock_manager = MagicMock() + mock_manager.get_valid_token.return_value = "test_token" + mock_token_manager.return_value = mock_manager + + client = OpenAI( + key_id=mock_credentials["key_id"], + secret=mock_credentials["secret"], + base_url=mock_credentials["base_url"], + project_id=None, + default_headers=None, + timeout=None, + ) + + assert client.project_id is None + assert client.timeout is None + + @patch("evolution_openai.client.EvolutionTokenManager") + def test_token_manager_get_valid_token_returns_none( + self, mock_token_manager, mock_credentials + ): + """Test behavior when token manager returns None""" + mock_manager = MagicMock() + # First call returns valid token for client creation, then None twice + mock_manager.get_valid_token.side_effect = ["test_token", None, None] + mock_token_manager.return_value = mock_manager + + client = OpenAI( + key_id=mock_credentials["key_id"], + secret=mock_credentials["secret"], + base_url=mock_credentials["base_url"], + ) + + assert client.current_token is None + + @patch("evolution_openai.client.EvolutionTokenManager") + def test_refresh_token_returns_none( + self, mock_token_manager, mock_credentials + ): + """Test refresh_token when it returns None""" + mock_manager = MagicMock() + # First call returns valid token for client creation, then None for refresh + mock_manager.get_valid_token.side_effect = ["test_token", None, None] + mock_token_manager.return_value = mock_manager + + client = OpenAI( + key_id=mock_credentials["key_id"], + secret=mock_credentials["secret"], + base_url=mock_credentials["base_url"], + ) + + new_token = client.refresh_token() + assert new_token is None + mock_manager.invalidate_token.assert_called_once() + + @patch("evolution_openai.client.EvolutionTokenManager") + def test_client_properties_with_empty_strings( + self, mock_token_manager, mock_credentials + ): + """Test client properties with empty strings""" + mock_manager = MagicMock() + mock_manager.get_valid_token.return_value = "" + mock_token_manager.return_value = mock_manager + + client = OpenAI( + key_id=mock_credentials["key_id"], + secret=mock_credentials["secret"], + base_url=mock_credentials["base_url"], + ) + + assert client.current_token == "" + + @patch("evolution_openai.client.EvolutionTokenManager") + def test_get_request_headers_with_none_values( + self, mock_token_manager, mock_credentials + ): + """Test get_request_headers with None values""" + mock_manager = MagicMock() + mock_manager.get_valid_token.return_value = "test_token" + mock_token_manager.return_value = mock_manager + + client = OpenAI( + key_id=mock_credentials["key_id"], + secret=mock_credentials["secret"], + base_url=mock_credentials["base_url"], + ) + + # Mock HTTP client with None values + mock_http_client = MagicMock() + mock_http_client._auth_headers = None + mock_http_client.default_headers = None + mock_http_client._default_headers = None + client._client = mock_http_client + # Should not raise error and return empty dict for attributes not found + headers = client.get_request_headers() + assert isinstance(headers, dict) diff --git a/tests/test_client_api_forwarding.py b/tests/test_client_api_forwarding.py new file mode 100644 index 0000000..e37d348 --- /dev/null +++ b/tests/test_client_api_forwarding.py @@ -0,0 +1,678 @@ +""" +Tests for API method forwarding and edge cases in Evolution OpenAI Client +""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from evolution_openai import OpenAI, AsyncOpenAI + + +@pytest.mark.unit +class TestAPIMethodForwarding: + """Test that API methods are properly forwarded""" + + @patch("evolution_openai.client.EvolutionTokenManager") + def test_chat_completions_create_forwarding( + self, mock_token_manager, mock_credentials + ): + """Test that chat.completions.create is properly forwarded""" + mock_manager = MagicMock() + mock_manager.get_valid_token.return_value = "test_token" + mock_token_manager.return_value = mock_manager + + client = OpenAI( + key_id=mock_credentials["key_id"], + secret=mock_credentials["secret"], + base_url=mock_credentials["base_url"], + ) + + # Mock the underlying chat.completions.create method + mock_response = MagicMock() + mock_response.choices = [MagicMock()] + mock_response.choices[0].message.content = "Test response" + + client.chat.completions.create = MagicMock(return_value=mock_response) + + # Call the method + response = client.chat.completions.create( + model="gpt-3.5-turbo", + messages=[{"role": "user", "content": "Hello"}], + max_tokens=100, + ) + + # Verify the method was called correctly + client.chat.completions.create.assert_called_once_with( + model="gpt-3.5-turbo", + messages=[{"role": "user", "content": "Hello"}], + max_tokens=100, + ) + assert response.choices[0].message.content == "Test response" + + @patch("evolution_openai.client.EvolutionTokenManager") + def test_models_list_forwarding( + self, mock_token_manager, mock_credentials + ): + """Test that models.list is properly forwarded""" + mock_manager = MagicMock() + mock_manager.get_valid_token.return_value = "test_token" + mock_token_manager.return_value = mock_manager + + client = OpenAI( + key_id=mock_credentials["key_id"], + secret=mock_credentials["secret"], + base_url=mock_credentials["base_url"], + ) + + # Mock the underlying models.list method + mock_response = MagicMock() + mock_response.data = [MagicMock()] + mock_response.data[0].id = "gpt-3.5-turbo" + + client.models.list = MagicMock(return_value=mock_response) + + # Call the method + response = client.models.list() + + # Verify the method was called correctly + client.models.list.assert_called_once() + assert response.data[0].id == "gpt-3.5-turbo" + + @patch("evolution_openai.client.EvolutionTokenManager") + def test_models_retrieve_forwarding( + self, mock_token_manager, mock_credentials + ): + """Test that models.retrieve is properly forwarded""" + mock_manager = MagicMock() + mock_manager.get_valid_token.return_value = "test_token" + mock_token_manager.return_value = mock_manager + + client = OpenAI( + key_id=mock_credentials["key_id"], + secret=mock_credentials["secret"], + base_url=mock_credentials["base_url"], + ) + + # Mock the underlying models.retrieve method + mock_response = MagicMock() + mock_response.id = "gpt-3.5-turbo" + mock_response.owned_by = "openai" + + client.models.retrieve = MagicMock(return_value=mock_response) + + # Call the method + response = client.models.retrieve("gpt-3.5-turbo") + + # Verify the method was called correctly + client.models.retrieve.assert_called_once_with("gpt-3.5-turbo") + assert response.id == "gpt-3.5-turbo" + + @patch("evolution_openai.client.EvolutionTokenManager") + def test_completions_create_forwarding( + self, mock_token_manager, mock_credentials + ): + """Test that completions.create is properly forwarded""" + mock_manager = MagicMock() + mock_manager.get_valid_token.return_value = "test_token" + mock_token_manager.return_value = mock_manager + + client = OpenAI( + key_id=mock_credentials["key_id"], + secret=mock_credentials["secret"], + base_url=mock_credentials["base_url"], + ) + + # Mock the underlying completions.create method + mock_response = MagicMock() + mock_response.choices = [MagicMock()] + mock_response.choices[0].text = "Test completion" + + client.completions.create = MagicMock(return_value=mock_response) + + # Call the method + response = client.completions.create( + model="gpt-3.5-turbo-instruct", + prompt="Hello", + max_tokens=100, + ) + + # Verify the method was called correctly + client.completions.create.assert_called_once_with( + model="gpt-3.5-turbo-instruct", + prompt="Hello", + max_tokens=100, + ) + assert response.choices[0].text == "Test completion" + + @patch("evolution_openai.client.EvolutionTokenManager") + async def test_async_chat_completions_create_forwarding( + self, mock_token_manager, mock_credentials + ): + """Test that async chat.completions.create is properly forwarded""" + mock_manager = MagicMock() + mock_manager.get_valid_token.return_value = "test_token" + mock_token_manager.return_value = mock_manager + + client = AsyncOpenAI( + key_id=mock_credentials["key_id"], + secret=mock_credentials["secret"], + base_url=mock_credentials["base_url"], + ) + + # Mock the underlying async chat.completions.create method + mock_response = MagicMock() + mock_response.choices = [MagicMock()] + mock_response.choices[0].message.content = "Async test response" + + client.chat.completions.create = AsyncMock(return_value=mock_response) + + # Call the method + response = await client.chat.completions.create( + model="gpt-3.5-turbo", + messages=[{"role": "user", "content": "Hello async"}], + max_tokens=100, + ) + + # Verify the method was called correctly + client.chat.completions.create.assert_called_once_with( + model="gpt-3.5-turbo", + messages=[{"role": "user", "content": "Hello async"}], + max_tokens=100, + ) + assert response.choices[0].message.content == "Async test response" + + @patch("evolution_openai.client.EvolutionTokenManager") + async def test_async_models_list_forwarding( + self, mock_token_manager, mock_credentials + ): + """Test that async models.list is properly forwarded""" + mock_manager = MagicMock() + mock_manager.get_valid_token.return_value = "test_token" + mock_token_manager.return_value = mock_manager + + client = AsyncOpenAI( + key_id=mock_credentials["key_id"], + secret=mock_credentials["secret"], + base_url=mock_credentials["base_url"], + ) + + # Mock the underlying async models.list method + mock_response = MagicMock() + mock_response.data = [MagicMock()] + mock_response.data[0].id = "gpt-3.5-turbo" + + client.models.list = AsyncMock(return_value=mock_response) + + # Call the method + response = await client.models.list() + + # Verify the method was called correctly + client.models.list.assert_called_once() + assert response.data[0].id == "gpt-3.5-turbo" + + +@pytest.mark.unit +class TestStreamingSupport: + """Test streaming response support""" + + @patch("evolution_openai.client.EvolutionTokenManager") + def test_streaming_chat_completions( + self, mock_token_manager, mock_credentials + ): + """Test streaming chat completions""" + mock_manager = MagicMock() + mock_manager.get_valid_token.return_value = "test_token" + mock_token_manager.return_value = mock_manager + + client = OpenAI( + key_id=mock_credentials["key_id"], + secret=mock_credentials["secret"], + base_url=mock_credentials["base_url"], + ) + + # Mock streaming response + mock_chunk1 = MagicMock() + mock_chunk1.choices = [MagicMock()] + mock_chunk1.choices[0].delta.content = "Hello" + + mock_chunk2 = MagicMock() + mock_chunk2.choices = [MagicMock()] + mock_chunk2.choices[0].delta.content = " world" + + mock_stream = iter([mock_chunk1, mock_chunk2]) + + client.chat.completions.create = MagicMock(return_value=mock_stream) + + # Call the method with streaming + stream = client.chat.completions.create( + model="gpt-3.5-turbo", + messages=[{"role": "user", "content": "Hello"}], + stream=True, + ) + + # Verify streaming works + chunks = list(stream) + assert len(chunks) == 2 + assert chunks[0].choices[0].delta.content == "Hello" + assert chunks[1].choices[0].delta.content == " world" + + @patch("evolution_openai.client.EvolutionTokenManager") + async def test_async_streaming_chat_completions( + self, mock_token_manager, mock_credentials + ): + """Test async streaming chat completions""" + mock_manager = MagicMock() + mock_manager.get_valid_token.return_value = "test_token" + mock_token_manager.return_value = mock_manager + + client = AsyncOpenAI( + key_id=mock_credentials["key_id"], + secret=mock_credentials["secret"], + base_url=mock_credentials["base_url"], + ) + + # Mock async streaming response + mock_chunk1 = MagicMock() + mock_chunk1.choices = [MagicMock()] + mock_chunk1.choices[0].delta.content = "Async hello" + + mock_chunk2 = MagicMock() + mock_chunk2.choices = [MagicMock()] + mock_chunk2.choices[0].delta.content = " async world" + + async def mock_async_generator(): + yield mock_chunk1 + yield mock_chunk2 + + client.chat.completions.create = AsyncMock( + return_value=mock_async_generator() + ) + + # Call the method with streaming + stream = await client.chat.completions.create( + model="gpt-3.5-turbo", + messages=[{"role": "user", "content": "Hello async"}], + stream=True, + ) + + # Verify async streaming works + chunks = [] + async for chunk in stream: + chunks.append(chunk) + + assert len(chunks) == 2 + assert chunks[0].choices[0].delta.content == "Async hello" + assert chunks[1].choices[0].delta.content == " async world" + + +@pytest.mark.unit +class TestAdvancedParameterHandling: + """Test advanced parameter handling scenarios""" + + @patch("evolution_openai.client.EvolutionTokenManager") + def test_extra_headers_handling( + self, mock_token_manager, mock_credentials + ): + """Test handling of extra headers""" + mock_manager = MagicMock() + mock_manager.get_valid_token.return_value = "test_token" + mock_token_manager.return_value = mock_manager + + client = OpenAI( + key_id=mock_credentials["key_id"], + secret=mock_credentials["secret"], + base_url=mock_credentials["base_url"], + ) + + # Mock the underlying method + mock_response = MagicMock() + client.chat.completions.create = MagicMock(return_value=mock_response) + + # Call with extra headers + client.chat.completions.create( + model="gpt-3.5-turbo", + messages=[{"role": "user", "content": "Hello"}], + extra_headers={"X-Custom": "value"}, + ) + + # Verify extra headers were passed + client.chat.completions.create.assert_called_once_with( + model="gpt-3.5-turbo", + messages=[{"role": "user", "content": "Hello"}], + extra_headers={"X-Custom": "value"}, + ) + + @patch("evolution_openai.client.EvolutionTokenManager") + def test_extra_query_handling(self, mock_token_manager, mock_credentials): + """Test handling of extra query parameters""" + mock_manager = MagicMock() + mock_manager.get_valid_token.return_value = "test_token" + mock_token_manager.return_value = mock_manager + + client = OpenAI( + key_id=mock_credentials["key_id"], + secret=mock_credentials["secret"], + base_url=mock_credentials["base_url"], + ) + + # Mock the underlying method + mock_response = MagicMock() + client.chat.completions.create = MagicMock(return_value=mock_response) + + # Call with extra query parameters + client.chat.completions.create( + model="gpt-3.5-turbo", + messages=[{"role": "user", "content": "Hello"}], + extra_query={"param": "value"}, + ) + + # Verify extra query parameters were passed + client.chat.completions.create.assert_called_once_with( + model="gpt-3.5-turbo", + messages=[{"role": "user", "content": "Hello"}], + extra_query={"param": "value"}, + ) + + @patch("evolution_openai.client.EvolutionTokenManager") + def test_extra_body_handling(self, mock_token_manager, mock_credentials): + """Test handling of extra body parameters""" + mock_manager = MagicMock() + mock_manager.get_valid_token.return_value = "test_token" + mock_token_manager.return_value = mock_manager + + client = OpenAI( + key_id=mock_credentials["key_id"], + secret=mock_credentials["secret"], + base_url=mock_credentials["base_url"], + ) + + # Mock the underlying method + mock_response = MagicMock() + client.chat.completions.create = MagicMock(return_value=mock_response) + + # Call with extra body parameters + client.chat.completions.create( + model="gpt-3.5-turbo", + messages=[{"role": "user", "content": "Hello"}], + extra_body={"custom": "data"}, + ) + + # Verify extra body parameters were passed + client.chat.completions.create.assert_called_once_with( + model="gpt-3.5-turbo", + messages=[{"role": "user", "content": "Hello"}], + extra_body={"custom": "data"}, + ) + + @patch("evolution_openai.client.EvolutionTokenManager") + def test_timeout_parameter_handling( + self, mock_token_manager, mock_credentials + ): + """Test handling of timeout parameters""" + mock_manager = MagicMock() + mock_manager.get_valid_token.return_value = "test_token" + mock_token_manager.return_value = mock_manager + + client = OpenAI( + key_id=mock_credentials["key_id"], + secret=mock_credentials["secret"], + base_url=mock_credentials["base_url"], + ) + + # Mock the underlying method + mock_response = MagicMock() + client.chat.completions.create = MagicMock(return_value=mock_response) + + # Call with timeout parameter + client.chat.completions.create( + model="gpt-3.5-turbo", + messages=[{"role": "user", "content": "Hello"}], + timeout=30.0, + ) + + # Verify timeout parameter was passed + client.chat.completions.create.assert_called_once_with( + model="gpt-3.5-turbo", + messages=[{"role": "user", "content": "Hello"}], + timeout=30.0, + ) + + +@pytest.mark.unit +class TestRawResponseSupport: + """Test raw response support""" + + @patch("evolution_openai.client.EvolutionTokenManager") + def test_with_raw_response_support( + self, mock_token_manager, mock_credentials + ): + """Test with_raw_response functionality""" + mock_manager = MagicMock() + mock_manager.get_valid_token.return_value = "test_token" + mock_token_manager.return_value = mock_manager + + client = OpenAI( + key_id=mock_credentials["key_id"], + secret=mock_credentials["secret"], + base_url=mock_credentials["base_url"], + ) + + # Mock raw response functionality + mock_raw_response = MagicMock() + mock_raw_response.parsed = MagicMock() + mock_raw_response.parsed.choices = [MagicMock()] + mock_raw_response.parsed.choices[ + 0 + ].message.content = "Raw response test" + + client.chat.completions.with_raw_response = MagicMock() + client.chat.completions.with_raw_response.create = MagicMock( + return_value=mock_raw_response + ) + + # Call with raw response + response = client.chat.completions.with_raw_response.create( + model="gpt-3.5-turbo", + messages=[{"role": "user", "content": "Hello"}], + ) + + # Verify raw response was used + client.chat.completions.with_raw_response.create.assert_called_once_with( + model="gpt-3.5-turbo", + messages=[{"role": "user", "content": "Hello"}], + ) + assert ( + response.parsed.choices[0].message.content == "Raw response test" + ) + + @patch("evolution_openai.client.EvolutionTokenManager") + def test_with_streaming_response_support( + self, mock_token_manager, mock_credentials + ): + """Test with_streaming_response functionality""" + mock_manager = MagicMock() + mock_manager.get_valid_token.return_value = "test_token" + mock_token_manager.return_value = mock_manager + + client = OpenAI( + key_id=mock_credentials["key_id"], + secret=mock_credentials["secret"], + base_url=mock_credentials["base_url"], + ) + + # Mock streaming response functionality + mock_streaming_response = MagicMock() + mock_streaming_response.parsed = iter([MagicMock()]) + + client.chat.completions.with_streaming_response = MagicMock() + client.chat.completions.with_streaming_response.create = MagicMock( + return_value=mock_streaming_response + ) + + # Call with streaming response + response = client.chat.completions.with_streaming_response.create( + model="gpt-3.5-turbo", + messages=[{"role": "user", "content": "Hello"}], + stream=True, + ) + + # Verify streaming response was used + client.chat.completions.with_streaming_response.create.assert_called_once_with( + model="gpt-3.5-turbo", + messages=[{"role": "user", "content": "Hello"}], + stream=True, + ) + assert response.parsed is not None + + +@pytest.mark.unit +class TestEdgeCaseScenarios: + """Test edge case scenarios""" + + @patch("evolution_openai.client.EvolutionTokenManager") + def test_client_with_empty_base_url( + self, mock_token_manager, mock_credentials + ): + """Test client creation with empty base_url""" + mock_manager = MagicMock() + mock_manager.get_valid_token.return_value = "test_token" + mock_token_manager.return_value = mock_manager + + client = OpenAI( + key_id=mock_credentials["key_id"], + secret=mock_credentials["secret"], + base_url="", + ) + + assert client.key_id == mock_credentials["key_id"] + assert client.secret == mock_credentials["secret"] + + @patch("evolution_openai.client.EvolutionTokenManager") + def test_client_with_none_credentials(self, mock_token_manager): + """Test client creation with None credentials""" + mock_manager = MagicMock() + mock_manager.get_valid_token.return_value = "test_token" + mock_token_manager.return_value = mock_manager + + # This should work as the client doesn't validate credentials + client = OpenAI( + key_id="", + secret="", + base_url="https://api.example.com", + ) + + assert client.key_id == "" + assert client.secret == "" + + @patch("evolution_openai.client.EvolutionTokenManager") + def test_client_method_attribute_access( + self, mock_token_manager, mock_credentials + ): + """Test that client preserves all method attributes""" + mock_manager = MagicMock() + mock_manager.get_valid_token.return_value = "test_token" + mock_token_manager.return_value = mock_manager + + client = OpenAI( + key_id=mock_credentials["key_id"], + secret=mock_credentials["secret"], + base_url=mock_credentials["base_url"], + ) + + # Test that common OpenAI client attributes are accessible + assert hasattr(client, "chat") + assert hasattr(client, "models") + assert hasattr(client, "completions") + + # Test nested attributes + assert hasattr(client.chat, "completions") + assert hasattr(client.chat.completions, "create") + + @patch("evolution_openai.client.EvolutionTokenManager") + def test_async_client_method_attribute_access( + self, mock_token_manager, mock_credentials + ): + """Test that async client preserves all method attributes""" + mock_manager = MagicMock() + mock_manager.get_valid_token.return_value = "test_token" + mock_token_manager.return_value = mock_manager + + client = AsyncOpenAI( + key_id=mock_credentials["key_id"], + secret=mock_credentials["secret"], + base_url=mock_credentials["base_url"], + ) + + # Test that common OpenAI client attributes are accessible + assert hasattr(client, "chat") + assert hasattr(client, "models") + assert hasattr(client, "completions") + + # Test nested attributes + assert hasattr(client.chat, "completions") + assert hasattr(client.chat.completions, "create") + + @patch("evolution_openai.client.EvolutionTokenManager") + def test_client_with_unusual_parameters( + self, mock_token_manager, mock_credentials + ): + """Test client with unusual but valid parameters""" + mock_manager = MagicMock() + mock_manager.get_valid_token.return_value = "test_token" + mock_token_manager.return_value = mock_manager + + client = OpenAI( + key_id=mock_credentials["key_id"], + secret=mock_credentials["secret"], + base_url=mock_credentials["base_url"], + timeout=0.1, # Very short timeout + max_retries=0, # No retries + ) + + assert client.timeout == 0.1 + assert client.max_retries == 0 + + @patch("evolution_openai.client.EvolutionTokenManager") + def test_client_inheritance_chain( + self, mock_token_manager, mock_credentials + ): + """Test that client maintains proper inheritance chain""" + mock_manager = MagicMock() + mock_manager.get_valid_token.return_value = "test_token" + mock_token_manager.return_value = mock_manager + + client = OpenAI( + key_id=mock_credentials["key_id"], + secret=mock_credentials["secret"], + base_url=mock_credentials["base_url"], + ) + + # Should be instance of both Evolution and OpenAI classes + assert isinstance(client, OpenAI) + # Check that it has the expected methods + assert hasattr(client, "current_token") + assert hasattr(client, "refresh_token") + assert hasattr(client, "get_token_info") + + @patch("evolution_openai.client.EvolutionTokenManager") + def test_async_client_inheritance_chain( + self, mock_token_manager, mock_credentials + ): + """Test that async client maintains proper inheritance chain""" + mock_manager = MagicMock() + mock_manager.get_valid_token.return_value = "test_token" + mock_token_manager.return_value = mock_manager + + client = AsyncOpenAI( + key_id=mock_credentials["key_id"], + secret=mock_credentials["secret"], + base_url=mock_credentials["base_url"], + ) + + # Should be instance of both Evolution and AsyncOpenAI classes + assert isinstance(client, AsyncOpenAI) + # Check that it has the expected methods + assert hasattr(client, "current_token") + assert hasattr(client, "refresh_token") + assert hasattr(client, "get_token_info") diff --git a/tests/test_client_integration_scenarios.py b/tests/test_client_integration_scenarios.py new file mode 100644 index 0000000..fd1a731 --- /dev/null +++ b/tests/test_client_integration_scenarios.py @@ -0,0 +1,646 @@ +""" +Integration scenario tests for Evolution OpenAI Client +""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from evolution_openai import OpenAI, AsyncOpenAI +from evolution_openai.exceptions import EvolutionAuthError + + +@pytest.mark.unit +class TestOpenAIVersionCompatibility: + """Test compatibility with different OpenAI SDK versions""" + + def test_openai_version_check_success(self, mock_credentials): + """Test successful OpenAI version check""" + with patch("evolution_openai.client.openai") as mock_openai: + mock_openai.__version__ = "1.30.0" + + with patch( + "evolution_openai.client.EvolutionTokenManager" + ) as mock_token_manager: + mock_manager = MagicMock() + mock_manager.get_valid_token.return_value = "test_token" + mock_token_manager.return_value = mock_manager + + # Should not raise an error + client = OpenAI( + key_id=mock_credentials["key_id"], + secret=mock_credentials["secret"], + base_url=mock_credentials["base_url"], + ) + assert client is not None + + def test_openai_version_check_malformed_version(self, mock_credentials): + """Test OpenAI version check with malformed version string""" + with patch("evolution_openai.client.openai") as mock_openai: + mock_openai.__version__ = "1.30" # Missing patch version + + with patch( + "evolution_openai.client.EvolutionTokenManager" + ) as mock_token_manager: + mock_manager = MagicMock() + mock_manager.get_valid_token.return_value = "test_token" + mock_token_manager.return_value = mock_manager + + # Should still work as we check if len(version_parts) > 1 + client = OpenAI( + key_id=mock_credentials["key_id"], + secret=mock_credentials["secret"], + base_url=mock_credentials["base_url"], + ) + assert client is not None + + def test_openai_not_available_fallback(self, mock_credentials): + """Test behavior when OpenAI is not available""" + with patch("evolution_openai.client.OPENAI_AVAILABLE", False): + with pytest.raises(ImportError) as exc_info: + OpenAI( + key_id=mock_credentials["key_id"], + secret=mock_credentials["secret"], + base_url=mock_credentials["base_url"], + ) + + assert "OpenAI SDK required" in str(exc_info.value) + + def test_supports_project_flag(self, mock_credentials): + """Test SUPPORTS_PROJECT flag behavior""" + with patch("evolution_openai.client.SUPPORTS_PROJECT", True): + with patch( + "evolution_openai.client.EvolutionTokenManager" + ) as mock_token_manager: + mock_manager = MagicMock() + mock_manager.get_valid_token.return_value = "test_token" + mock_token_manager.return_value = mock_manager + + client = OpenAI( + key_id=mock_credentials["key_id"], + secret=mock_credentials["secret"], + base_url=mock_credentials["base_url"], + project_id="test_project", + ) + + assert client.project_id == "test_project" + + +@pytest.mark.unit +class TestClientParameterHandling: + """Test client parameter handling scenarios""" + + @patch("evolution_openai.client.EvolutionTokenManager") + def test_client_ignores_api_key_parameter( + self, mock_token_manager, mock_credentials + ): + """Test that client ignores the api_key parameter""" + mock_manager = MagicMock() + mock_manager.get_valid_token.return_value = "test_token" + mock_token_manager.return_value = mock_manager + + client = OpenAI( + key_id=mock_credentials["key_id"], + secret=mock_credentials["secret"], + base_url=mock_credentials["base_url"], + api_key="should_be_ignored", + ) + + # Should use token from token manager, not the provided api_key + assert client.current_token == "test_token" + + @patch("evolution_openai.client.EvolutionTokenManager") + def test_client_with_all_openai_parameters( + self, mock_token_manager, mock_credentials + ): + """Test client with all possible OpenAI parameters""" + mock_manager = MagicMock() + mock_manager.get_valid_token.return_value = "test_token" + mock_token_manager.return_value = mock_manager + + client = OpenAI( + key_id=mock_credentials["key_id"], + secret=mock_credentials["secret"], + base_url=mock_credentials["base_url"], + api_key="ignored", + organization="test_org", + project_id="test_project", + timeout=45.0, + max_retries=3, + default_headers={"X-Custom": "test"}, + default_query={"param": "value"}, + ) + + assert client.key_id == mock_credentials["key_id"] + assert client.secret == mock_credentials["secret"] + assert client.project_id == "test_project" + assert client.timeout == 45.0 + assert client.max_retries == 3 + + @patch("evolution_openai.client.EvolutionTokenManager") + def test_async_client_with_all_parameters( + self, mock_token_manager, mock_credentials + ): + """Test async client with all possible parameters""" + mock_manager = MagicMock() + mock_manager.get_valid_token.return_value = "test_token" + mock_token_manager.return_value = mock_manager + + client = AsyncOpenAI( + key_id=mock_credentials["key_id"], + secret=mock_credentials["secret"], + base_url=mock_credentials["base_url"], + api_key="ignored", + organization="test_org", + project_id="test_project", + timeout=45.0, + max_retries=3, + default_headers={"X-Custom": "test"}, + default_query={"param": "value"}, + ) + + assert client.key_id == mock_credentials["key_id"] + assert client.secret == mock_credentials["secret"] + assert client.project_id == "test_project" + assert client.timeout == 45.0 + assert client.max_retries == 3 + + +@pytest.mark.unit +class TestTokenRefreshScenarios: + """Test various token refresh scenarios""" + + @patch("evolution_openai.client.EvolutionTokenManager") + def test_token_refresh_on_client_creation( + self, mock_token_manager, mock_credentials + ): + """Test token refresh behavior during client creation""" + mock_manager = MagicMock() + mock_manager.get_valid_token.return_value = "initial_token" + mock_token_manager.return_value = mock_manager + + client = OpenAI( + key_id=mock_credentials["key_id"], + secret=mock_credentials["secret"], + base_url=mock_credentials["base_url"], + ) + + # Should call get_valid_token during initialization + assert mock_manager.get_valid_token.called + assert client.current_token == "initial_token" + + @patch("evolution_openai.client.EvolutionTokenManager") + def test_multiple_token_refresh_calls( + self, mock_token_manager, mock_credentials + ): + """Test multiple token refresh calls""" + mock_manager = MagicMock() + # Provide tokens: initial creation, then 2 refresh calls + token access checks + mock_manager.get_valid_token.side_effect = [ + "token1", + "token2", + "token3", + "token4", + "token5", + ] + mock_token_manager.return_value = mock_manager + + client = OpenAI( + key_id=mock_credentials["key_id"], + secret=mock_credentials["secret"], + base_url=mock_credentials["base_url"], + ) + + # First refresh + token1 = client.refresh_token() + assert token1 == "token3" # Updated based on side_effect sequence + + # Second refresh + token2 = client.refresh_token() + assert token2 == "token4" # Updated based on side_effect sequence + + # Should have called invalidate_token twice + assert mock_manager.invalidate_token.call_count == 2 + + @patch("evolution_openai.client.EvolutionTokenManager") + def test_token_info_retrieval(self, mock_token_manager, mock_credentials): + """Test token info retrieval""" + mock_manager = MagicMock() + mock_manager.get_valid_token.return_value = "test_token" + mock_manager.get_token_info.return_value = { + "token": "test_token", + "expires_at": 1234567890, + "is_valid": True, + } + mock_token_manager.return_value = mock_manager + + client = OpenAI( + key_id=mock_credentials["key_id"], + secret=mock_credentials["secret"], + base_url=mock_credentials["base_url"], + ) + + info = client.get_token_info() + assert info["token"] == "test_token" + assert info["expires_at"] == 1234567890 + assert info["is_valid"] is True + + @patch("evolution_openai.client.EvolutionTokenManager") + def test_token_manager_exception_handling( + self, mock_token_manager, mock_credentials + ): + """Test token manager exception handling""" + mock_manager = MagicMock() + mock_manager.get_valid_token.side_effect = EvolutionAuthError( + "Token error" + ) + mock_token_manager.return_value = mock_manager + + with pytest.raises(EvolutionAuthError): + OpenAI( + key_id=mock_credentials["key_id"], + secret=mock_credentials["secret"], + base_url=mock_credentials["base_url"], + ) + + +@pytest.mark.unit +class TestHeaderInjectionScenarios: + """Test header injection scenarios""" + + @patch("evolution_openai.client.EvolutionTokenManager") + def test_project_id_header_injection_sync( + self, mock_token_manager, mock_credentials + ): + """Test project_id header injection in sync client""" + mock_manager = MagicMock() + mock_manager.get_valid_token.return_value = "test_token" + mock_token_manager.return_value = mock_manager + + client = OpenAI( + key_id=mock_credentials["key_id"], + secret=mock_credentials["secret"], + base_url=mock_credentials["base_url"], + project_id="test_project_123", + ) + + # Mock HTTP client to test header injection + mock_http_client = MagicMock() + mock_http_client._auth_headers = {} + mock_http_client.default_headers = {} + mock_http_client._default_headers = {} + client._client = mock_http_client + + # Update headers + client._update_auth_headers("new_token") + + # Check that project_id header was added to all sources + assert ( + mock_http_client._auth_headers.get("x-project-id") + == "test_project_123" + ) + assert ( + mock_http_client.default_headers.get("x-project-id") + == "test_project_123" + ) + assert ( + mock_http_client._default_headers.get("x-project-id") + == "test_project_123" + ) + + @patch("evolution_openai.client.EvolutionTokenManager") + def test_project_id_header_injection_async( + self, mock_token_manager, mock_credentials + ): + """Test project_id header injection in async client""" + mock_manager = MagicMock() + mock_manager.get_valid_token.return_value = "test_token" + mock_token_manager.return_value = mock_manager + + client = AsyncOpenAI( + key_id=mock_credentials["key_id"], + secret=mock_credentials["secret"], + base_url=mock_credentials["base_url"], + project_id="test_project_async", + ) + + # Mock HTTP client to test header injection + mock_http_client = MagicMock() + mock_http_client._auth_headers = {} + mock_http_client.default_headers = {} + mock_http_client._default_headers = {} + client._client = mock_http_client + + # Update headers + client._update_auth_headers("new_token") + + # Check that project_id header was added to all sources + assert ( + mock_http_client._auth_headers.get("x-project-id") + == "test_project_async" + ) + assert ( + mock_http_client.default_headers.get("x-project-id") + == "test_project_async" + ) + assert ( + mock_http_client._default_headers.get("x-project-id") + == "test_project_async" + ) + + @patch("evolution_openai.client.EvolutionTokenManager") + def test_custom_headers_preservation( + self, mock_token_manager, mock_credentials + ): + """Test that custom headers are preserved""" + mock_manager = MagicMock() + mock_manager.get_valid_token.return_value = "test_token" + mock_token_manager.return_value = mock_manager + + custom_headers = { + "X-Custom-Header": "custom_value", + "User-Agent": "custom-agent", + } + + client = OpenAI( + key_id=mock_credentials["key_id"], + secret=mock_credentials["secret"], + base_url=mock_credentials["base_url"], + default_headers=custom_headers, + ) + + # Headers should be preserved + assert client.default_headers["X-Custom-Header"] == "custom_value" + assert client.default_headers["User-Agent"] == "custom-agent" + + @patch("evolution_openai.client.EvolutionTokenManager") + def test_headers_with_none_project_id( + self, mock_token_manager, mock_credentials + ): + """Test header handling when project_id is None""" + mock_manager = MagicMock() + mock_manager.get_valid_token.return_value = "test_token" + mock_token_manager.return_value = mock_manager + + client = OpenAI( + key_id=mock_credentials["key_id"], + secret=mock_credentials["secret"], + base_url=mock_credentials["base_url"], + project_id=None, + ) + + # Mock HTTP client + mock_http_client = MagicMock() + mock_http_client._auth_headers = {} + client._client = mock_http_client + + # Update headers + client._update_auth_headers("new_token") + + # Should not add project_id header when it's None + assert "x-project-id" not in mock_http_client._auth_headers + + +@pytest.mark.unit +class TestRequestInterceptionScenarios: + """Test request interception and modification scenarios""" + + @patch("evolution_openai.client.EvolutionTokenManager") + def test_request_interception_token_update( + self, mock_token_manager, mock_credentials + ): + """Test that requests are intercepted and tokens are updated""" + mock_manager = MagicMock() + # Provide tokens: initial creation, initialization, patching, request + mock_manager.get_valid_token.side_effect = [ + "token1", + "token2", + "token3", + "token4", + ] + mock_token_manager.return_value = mock_manager + + client = OpenAI( + key_id=mock_credentials["key_id"], + secret=mock_credentials["secret"], + base_url=mock_credentials["base_url"], + ) + + # Mock HTTP client + mock_http_client = MagicMock() + original_request = MagicMock() + original_request.return_value = "success" + mock_http_client.request = original_request + client._client = mock_http_client + + # Patch the client + client._patch_client() + + # Make a request + result = mock_http_client.request("test_arg") + + # Verify token was updated before request + assert ( + client.api_key == "token3" + ) # Updated based on side_effect sequence + assert result == "success" + + @patch("evolution_openai.client.EvolutionTokenManager") + def test_request_interception_auth_error_recovery( + self, mock_token_manager, mock_credentials + ): + """Test authentication error recovery during request""" + mock_manager = MagicMock() + # Provide tokens: initial creation, initialization, first request, invalidate/retry + mock_manager.get_valid_token.side_effect = [ + "token1", + "token2", + "token3", + "token4", + "token5", + ] + mock_token_manager.return_value = mock_manager + + client = OpenAI( + key_id=mock_credentials["key_id"], + secret=mock_credentials["secret"], + base_url=mock_credentials["base_url"], + ) + + # Mock HTTP client + mock_http_client = MagicMock() + original_request = MagicMock() + # First call fails with auth error, second succeeds + original_request.side_effect = [ + Exception("401 Unauthorized"), + "success", + ] + mock_http_client.request = original_request + client._client = mock_http_client + + # Patch the client + client._patch_client() + + # Make a request + result = mock_http_client.request("test_arg") + + # Verify recovery logic + assert result == "success" + assert original_request.call_count == 2 + mock_manager.invalidate_token.assert_called_once() + + @patch("evolution_openai.client.EvolutionTokenManager") + async def test_async_request_interception_token_update( + self, mock_token_manager, mock_credentials + ): + """Test that async requests are intercepted and tokens are updated""" + mock_manager = MagicMock() + # Provide tokens: initial creation, initialization, patching, request + mock_manager.get_valid_token.side_effect = [ + "token1", + "token2", + "token3", + "token4", + ] + mock_token_manager.return_value = mock_manager + + client = AsyncOpenAI( + key_id=mock_credentials["key_id"], + secret=mock_credentials["secret"], + base_url=mock_credentials["base_url"], + ) + + # Mock async HTTP client + mock_http_client = MagicMock() + original_request = AsyncMock() + original_request.return_value = "async_success" + mock_http_client.request = original_request + client._client = mock_http_client + + # Patch the client + client._patch_async_client() + + # Make an async request + result = await mock_http_client.request("async_test_arg") + + # Verify token was updated before request + assert ( + client.api_key == "token3" + ) # Updated based on side_effect sequence + assert result == "async_success" + + +@pytest.mark.unit +class TestClientStateManagement: + """Test client state management scenarios""" + + @patch("evolution_openai.client.EvolutionTokenManager") + def test_client_credentials_immutability( + self, mock_token_manager, mock_credentials + ): + """Test that client credentials remain immutable""" + mock_manager = MagicMock() + mock_manager.get_valid_token.return_value = "test_token" + mock_token_manager.return_value = mock_manager + + client = OpenAI( + key_id=mock_credentials["key_id"], + secret=mock_credentials["secret"], + base_url=mock_credentials["base_url"], + ) + + # Credentials should remain unchanged + assert client.key_id == mock_credentials["key_id"] + assert client.secret == mock_credentials["secret"] + + # Even after token refresh + client.refresh_token() + assert client.key_id == mock_credentials["key_id"] + assert client.secret == mock_credentials["secret"] + + +@pytest.mark.unit +class TestErrorPropagation: + """Test error propagation scenarios""" + + @patch("evolution_openai.client.EvolutionTokenManager") + def test_non_auth_error_propagation( + self, mock_token_manager, mock_credentials + ): + """Test that non-auth errors are propagated correctly""" + mock_manager = MagicMock() + mock_manager.get_valid_token.return_value = "test_token" + mock_token_manager.return_value = mock_manager + + client = OpenAI( + key_id=mock_credentials["key_id"], + secret=mock_credentials["secret"], + base_url=mock_credentials["base_url"], + ) + + # Mock HTTP client + mock_http_client = MagicMock() + original_request = MagicMock() + original_request.side_effect = ValueError("Test error") + mock_http_client.request = original_request + client._client = mock_http_client + + # Patch the client + client._patch_client() + + # Should propagate the ValueError + with pytest.raises(ValueError) as exc_info: + mock_http_client.request("test_arg") + + assert "Test error" in str(exc_info.value) + + @patch("evolution_openai.client.EvolutionTokenManager") + def test_token_manager_error_propagation( + self, mock_token_manager, mock_credentials + ): + """Test that token manager errors are propagated correctly""" + mock_manager = MagicMock() + mock_manager.get_valid_token.side_effect = Exception( + "Token manager error" + ) + mock_token_manager.return_value = mock_manager + + with pytest.raises(Exception) as exc_info: + OpenAI( + key_id=mock_credentials["key_id"], + secret=mock_credentials["secret"], + base_url=mock_credentials["base_url"], + ) + + assert "Token manager error" in str(exc_info.value) + + @patch("evolution_openai.client.EvolutionTokenManager") + async def test_async_error_propagation( + self, mock_token_manager, mock_credentials + ): + """Test that async errors are propagated correctly""" + mock_manager = MagicMock() + mock_manager.get_valid_token.return_value = "test_token" + mock_token_manager.return_value = mock_manager + + client = AsyncOpenAI( + key_id=mock_credentials["key_id"], + secret=mock_credentials["secret"], + base_url=mock_credentials["base_url"], + ) + + # Mock async HTTP client + mock_http_client = MagicMock() + original_request = MagicMock() + original_request.side_effect = RuntimeError("Async error") + mock_http_client.request = original_request + client._client = mock_http_client + + # Patch the client + client._patch_async_client() + + # Should propagate the RuntimeError + with pytest.raises(RuntimeError) as exc_info: + await mock_http_client.request("async_test_arg") + + assert "Async error" in str(exc_info.value) diff --git a/tests/test_foundation_models_integration.py b/tests/test_foundation_models_integration.py new file mode 100644 index 0000000..3ede59f --- /dev/null +++ b/tests/test_foundation_models_integration.py @@ -0,0 +1,719 @@ +""" +Integration tests for Evolution Foundation Models + +These tests require real Evolution Foundation Models credentials and are only run when +ENABLE_FOUNDATION_MODELS_TESTS=true or ENABLE_INTEGRATION_TESTS=true is set in environment or .env file. + +Based on examples/foundation_models_example.py +""" + +import time +import asyncio + +import pytest + + +@pytest.mark.integration +@pytest.mark.foundation_models +class TestFoundationModelsIntegration: + """Integration tests with real Evolution Foundation Models API""" + + def test_foundation_models_token_acquisition( + self, foundation_models_client, foundation_models_credentials + ): + """Test acquiring real token from Foundation Models API""" + # Test token acquisition + token = foundation_models_client.current_token + assert token is not None + assert len(token) > 0 + + # Test token info + token_info = foundation_models_client.get_token_info() + assert token_info["has_token"] is True + assert token_info["is_valid"] is True + + # Verify project_id is configured + assert ( + foundation_models_client.project_id + == foundation_models_credentials["project_id"] + ) + + print( + f"✅ Foundation Models token acquired successfully: {token[:20]}..." + ) + print(f"🏷️ Project ID: {foundation_models_client.project_id}") + + def test_foundation_models_basic_chat_completion( + self, foundation_models_client, foundation_models_default_model + ): + """Test basic chat completion with Foundation Models API""" + print(f"🔧 Using model: {foundation_models_default_model}") + + response = foundation_models_client.chat.completions.create( + model=foundation_models_default_model, + messages=[ + { + "role": "system", + "content": "Ты полезный помощник, использующий Evolution Foundation Models.", + }, + { + "role": "user", + "content": "Расскажи кратко о возможностях искусственного интеллекта", + }, + ], + max_tokens=10, + temperature=0.7, + ) + + # Verify response structure + assert response.choices is not None + assert len(response.choices) > 0 + assert response.choices[0].message is not None + assert response.choices[0].message.content is not None + assert len(response.choices[0].message.content) > 0 + + # Verify response metadata + assert response.model is not None + assert response.usage is not None + assert response.usage.total_tokens > 0 + + print(f"✅ Response: {response.choices[0].message.content}") + print(f"📊 Model: {response.model}") + print(f"🔢 Total tokens: {response.usage.total_tokens}") + + def test_foundation_models_streaming( + self, foundation_models_client, foundation_models_default_model + ): + """Test streaming with Foundation Models API""" + print( + f"🔧 Using model for streaming: {foundation_models_default_model}" + ) + + stream = foundation_models_client.chat.completions.create( + model=foundation_models_default_model, + messages=[ + { + "role": "user", + "content": "Напиши короткое стихотворение про технологии", + } + ], + stream=True, + max_tokens=15, + temperature=0.8, + ) + + content_parts = [] + chunk_count = 0 + for chunk in stream: + chunk_count += 1 + if ( + chunk.choices + and len(chunk.choices) > 0 + and chunk.choices[0].delta + and chunk.choices[0].delta.content + ): + content = chunk.choices[0].delta.content + content_parts.append(content) + + full_content = "".join(content_parts) + assert len(full_content) > 0 + assert len(content_parts) > 0 + assert chunk_count > 0 + + print(f"✅ Streaming response: {full_content}") + print( + f"📊 Received {len(content_parts)} content chunks in {chunk_count} total chunks" + ) + + def test_foundation_models_with_options( + self, foundation_models_client, foundation_models_default_model + ): + """Test Foundation Models with additional options""" + print( + f"🔧 Using model with options: {foundation_models_default_model}" + ) + + # Test with_options for configuration + client_with_options = foundation_models_client.with_options( + timeout=60.0, max_retries=3 + ) + + response = client_with_options.chat.completions.create( + model=foundation_models_default_model, + messages=[ + { + "role": "user", + "content": "Создай план изучения Python для начинающих", + } + ], + max_tokens=15, + temperature=0.3, + ) + + assert response.choices is not None + assert len(response.choices) > 0 + assert response.choices[0].message is not None + assert response.choices[0].message.content is not None + assert len(response.choices[0].message.content) > 0 + + # Test token info + token_info = client_with_options.get_token_info() + assert token_info["has_token"] is True + assert token_info["is_valid"] is True + + print( + f"✅ Response with options: {response.choices[0].message.content}" + ) + print(f"📊 Model: {response.model}") + print(f"🔢 Total tokens: {response.usage.total_tokens}") + print(f"🔑 Token status: {token_info}") + + def test_foundation_models_token_refresh(self, foundation_models_client): + """Test token refresh with Foundation Models API""" + # Get initial token + token1 = foundation_models_client.current_token + assert token1 is not None + assert len(token1) > 0 + + # Force refresh + token2 = foundation_models_client.refresh_token() + assert token2 is not None + assert len(token2) > 0 + + # Tokens should be different (new token) + assert token1 != token2 + + print(f"✅ Token refresh: {token1[:15]}... -> {token2[:15]}...") + + async def test_foundation_models_async_basic( + self, foundation_models_async_client, foundation_models_default_model + ): + """Test basic async Foundation Models functionality""" + print(f"🔧 Using model for async: {foundation_models_default_model}") + + response = await foundation_models_async_client.chat.completions.create( + model=foundation_models_default_model, + messages=[ + { + "role": "user", + "content": "Объясни простыми словами, что такое машинное обучение", + } + ], + max_tokens=12, + temperature=0.5, + ) + + assert response.choices is not None + assert len(response.choices) > 0 + assert response.choices[0].message is not None + assert response.choices[0].message.content is not None + assert len(response.choices[0].message.content) > 0 + + print(f"✅ Async response: {response.choices[0].message.content}") + print(f"📊 Model: {response.model}") + print(f"🔢 Total tokens: {response.usage.total_tokens}") + + async def test_foundation_models_async_streaming( + self, foundation_models_async_client, foundation_models_default_model + ): + """Test async streaming with Foundation Models""" + print( + f"🔧 Using model for async streaming: {foundation_models_default_model}" + ) + + stream = await foundation_models_async_client.chat.completions.create( + model=foundation_models_default_model, + messages=[ + { + "role": "user", + "content": "Напиши короткое стихотворение про космос", + } + ], + stream=True, + max_tokens=12, + temperature=0.8, + ) + + content_parts = [] + chunk_count = 0 + async for chunk in stream: + chunk_count += 1 + if ( + chunk.choices + and len(chunk.choices) > 0 + and chunk.choices[0].delta + and chunk.choices[0].delta.content + ): + content = chunk.choices[0].delta.content + content_parts.append(content) + + full_content = "".join(content_parts) + assert len(full_content) > 0 + assert len(content_parts) > 0 + assert chunk_count > 0 + + print(f"✅ Async streaming response: {full_content}") + print( + f"📊 Received {len(content_parts)} content chunks in {chunk_count} total chunks" + ) + + async def test_foundation_models_parallel_requests( + self, foundation_models_async_client, foundation_models_default_model + ): + """Test parallel requests to Foundation Models""" + print( + f"🔧 Using model for parallel requests: {foundation_models_default_model}" + ) + + # List of questions for parallel processing + questions = [ + "Что такое искусственный интеллект?", + "Как работает машинное обучение?", + "Что такое нейронные сети?", + ] + + # Create tasks for parallel execution + tasks = [] + for question in questions: + task = foundation_models_async_client.chat.completions.create( + model=foundation_models_default_model, + messages=[ + { + "role": "system", + "content": "Дай краткий ответ в 1-2 предложения.", + }, + {"role": "user", "content": question}, + ], + max_tokens=10, + temperature=0.5, + ) + tasks.append(task) + + # Execute all requests in parallel + start_time = time.time() + responses = await asyncio.gather(*tasks) + end_time = time.time() + + elapsed = end_time - start_time + print( + f"⚡ Processed {len(questions)} requests in {elapsed:.2f} seconds" + ) + + # Verify all responses + for i, (question, response) in enumerate(zip(questions, responses)): + assert response.choices is not None + assert len(response.choices) > 0 + assert response.choices[0].message is not None + assert response.choices[0].message.content is not None + assert len(response.choices[0].message.content) > 0 + + print(f"❓ Question {i + 1}: {question}") + print(f"✅ Answer: {response.choices[0].message.content}") + print(f"🔢 Tokens: {response.usage.total_tokens}") + print("-" * 50) + + assert len(responses) == len(questions) + + +@pytest.mark.integration +@pytest.mark.foundation_models +@pytest.mark.slow +class TestFoundationModelsPerformance: + """Performance and load tests for Foundation Models API""" + + def test_foundation_models_multiple_sequential_requests( + self, foundation_models_client, foundation_models_default_model + ): + """Test multiple sequential requests to Foundation Models""" + print( + f"🔧 Testing sequential requests with model: {foundation_models_default_model}" + ) + + request_count = 3 + responses = [] + start_time = time.time() + + for i in range(request_count): + response = foundation_models_client.chat.completions.create( + model=foundation_models_default_model, + messages=[ + { + "role": "user", + "content": f"Вопрос {i + 1}: Что такое программирование?", + } + ], + max_tokens=8, + temperature=0.3, + ) + responses.append(response) + + end_time = time.time() + elapsed = end_time - start_time + + # Verify all responses + for i, response in enumerate(responses): + assert response.choices is not None + assert len(response.choices) > 0 + assert response.choices[0].message is not None + assert response.choices[0].message.content is not None + print(f"✅ Request {i + 1}: {response.choices[0].message.content}") + + print( + f"⏱️ {request_count} sequential requests completed in {elapsed:.2f} seconds" + ) + print( + f"📊 Average time per request: {elapsed / request_count:.2f} seconds" + ) + + def test_foundation_models_streaming_performance( + self, foundation_models_client, foundation_models_default_model + ): + """Test streaming performance with Foundation Models""" + print( + f"🔧 Testing streaming performance with model: {foundation_models_default_model}" + ) + + start_time = time.time() + stream = foundation_models_client.chat.completions.create( + model=foundation_models_default_model, + messages=[ + { + "role": "user", + "content": "Напиши подробный рассказ о технологиях будущего", + } + ], + stream=True, + max_tokens=20, + temperature=0.7, + ) + + content_parts = [] + chunk_timestamps = [] + + for chunk in stream: + chunk_timestamps.append(time.time()) + if ( + chunk.choices + and len(chunk.choices) > 0 + and chunk.choices[0].delta + and chunk.choices[0].delta.content + ): + content_parts.append(chunk.choices[0].delta.content) + + end_time = time.time() + total_elapsed = end_time - start_time + + full_content = "".join(content_parts) + assert len(full_content) > 0 + + # Calculate streaming statistics + first_chunk_time = ( + chunk_timestamps[0] - start_time if chunk_timestamps else 0 + ) + avg_chunk_interval = 0 + if len(chunk_timestamps) > 1: + intervals = [ + chunk_timestamps[i] - chunk_timestamps[i - 1] + for i in range(1, len(chunk_timestamps)) + ] + avg_chunk_interval = sum(intervals) / len(intervals) + + print("✅ Streaming completed") + print(f"📊 Total content length: {len(full_content)} characters") + print(f"⏱️ Total time: {total_elapsed:.2f} seconds") + print(f"🚀 Time to first chunk: {first_chunk_time:.2f} seconds") + print(f"📈 Average chunk interval: {avg_chunk_interval:.3f} seconds") + print(f"🔢 Total chunks: {len(chunk_timestamps)}") + print(f"📝 Content chunks: {len(content_parts)}") + + async def test_foundation_models_concurrent_load( + self, foundation_models_async_client, foundation_models_default_model + ): + """Test concurrent load on Foundation Models API""" + print( + f"🔧 Testing concurrent load with model: {foundation_models_default_model}" + ) + + # Test with multiple concurrent requests + concurrent_count = 5 + tasks = [] + + for i in range(concurrent_count): + task = foundation_models_async_client.chat.completions.create( + model=foundation_models_default_model, + messages=[ + { + "role": "user", + "content": f"Concurrent request {i + 1}: Explain artificial intelligence briefly", + } + ], + max_tokens=8, + temperature=0.4, + ) + tasks.append(task) + + start_time = time.time() + responses = await asyncio.gather(*tasks, return_exceptions=True) + end_time = time.time() + + elapsed = end_time - start_time + + # Verify responses + successful_responses = 0 + failed_responses = 0 + + for i, response in enumerate(responses): + if isinstance(response, Exception): + print(f"❌ Request {i + 1} failed: {response}") + failed_responses += 1 + else: + assert response.choices is not None + assert len(response.choices) > 0 + assert response.choices[0].message is not None + assert response.choices[0].message.content is not None + print( + f"✅ Request {i + 1}: {response.choices[0].message.content}" + ) + successful_responses += 1 + + print( + f"⚡ {concurrent_count} concurrent requests completed in {elapsed:.2f} seconds" + ) + print( + f"📊 Success rate: {successful_responses}/{concurrent_count} ({successful_responses / concurrent_count * 100:.1f}%)" + ) + print( + f"📈 Average time per request: {elapsed / concurrent_count:.2f} seconds" + ) + + # At least 80% should succeed + assert successful_responses / concurrent_count >= 0.8 + + +@pytest.mark.integration +@pytest.mark.foundation_models +class TestFoundationModelsErrorHandling: + """Error handling tests for Foundation Models API""" + + def test_foundation_models_invalid_model(self, foundation_models_client): + """Test error handling with invalid model name""" + with pytest.raises(Exception) as exc_info: + foundation_models_client.chat.completions.create( + model="invalid-model-name-12345", + messages=[ + { + "role": "user", + "content": "This should fail", + } + ], + max_tokens=10, + ) + + print(f"✅ Expected error for invalid model: {exc_info.value}") + + def test_foundation_models_invalid_parameters( + self, foundation_models_client, foundation_models_default_model + ): + """Test error handling with invalid parameters""" + # Test with extremely high max_tokens (might be clamped instead of raising error) + try: + response = foundation_models_client.chat.completions.create( + model=foundation_models_default_model, + messages=[ + { + "role": "user", + "content": "Test with very high max_tokens", + } + ], + max_tokens=999999, # Extremely high value + ) + # If it succeeds, the API might clamp the value rather than error + print( + f"✅ API handled high max_tokens gracefully: {response.usage.total_tokens} tokens" + ) + except Exception as e: + print(f"✅ Expected error for invalid max_tokens: {e}") + + # Test with invalid temperature (outside normal range) + try: + response = foundation_models_client.chat.completions.create( + model=foundation_models_default_model, + messages=[ + { + "role": "user", + "content": "Test with invalid temperature", + } + ], + max_tokens=8, + temperature=10.0, # Invalid temperature value + ) + # If it succeeds, the API might clamp the value rather than error + print("✅ API handled high temperature gracefully") + except Exception as e: + print(f"✅ Expected error for invalid temperature: {e}") + + # Test with completely invalid parameter type (this should fail) + with pytest.raises(Exception) as exc_info: + foundation_models_client.chat.completions.create( + model=foundation_models_default_model, + messages="invalid_messages_type", # Should be list, not string + max_tokens=8, + ) + + print(f"✅ Expected error for invalid message type: {exc_info.value}") + + def test_foundation_models_empty_messages( + self, foundation_models_client, foundation_models_default_model + ): + """Test error handling with empty messages""" + with pytest.raises(Exception) as exc_info: + foundation_models_client.chat.completions.create( + model=foundation_models_default_model, + messages=[], + max_tokens=10, + ) + + print(f"✅ Expected error for empty messages: {exc_info.value}") + + async def test_foundation_models_async_error_handling( + self, foundation_models_async_client, foundation_models_default_model + ): + """Test async error handling""" + with pytest.raises(Exception) as exc_info: + await foundation_models_async_client.chat.completions.create( + model="invalid-async-model", + messages=[ + { + "role": "user", + "content": "This should fail async", + } + ], + max_tokens=10, + ) + + print(f"✅ Expected async error: {exc_info.value}") + + +@pytest.mark.integration +@pytest.mark.foundation_models +class TestFoundationModelsCompatibility: + """Compatibility tests for Foundation Models API""" + + def test_foundation_models_different_temperatures( + self, foundation_models_client, foundation_models_default_model + ): + """Test Foundation Models with different temperature settings""" + temperatures = [0.1, 0.5, 0.9] + + for temp in temperatures: + response = foundation_models_client.chat.completions.create( + model=foundation_models_default_model, + messages=[ + { + "role": "user", + "content": f"Generate text with temperature {temp}", + } + ], + max_tokens=8, + temperature=temp, + ) + + assert response.choices is not None + assert len(response.choices) > 0 + assert response.choices[0].message is not None + assert response.choices[0].message.content is not None + + print( + f"✅ Temperature {temp}: {response.choices[0].message.content}" + ) + + def test_foundation_models_different_max_tokens( + self, foundation_models_client, foundation_models_default_model + ): + """Test Foundation Models with different max_tokens settings""" + max_tokens_values = [5, 10, 15] + + for max_tokens in max_tokens_values: + response = foundation_models_client.chat.completions.create( + model=foundation_models_default_model, + messages=[ + { + "role": "user", + "content": "Tell me about programming", + } + ], + max_tokens=max_tokens, + temperature=0.5, + ) + + assert response.choices is not None + assert len(response.choices) > 0 + assert response.choices[0].message is not None + assert response.choices[0].message.content is not None + assert response.usage.total_tokens > 0 + + print( + f"✅ Max tokens {max_tokens}: {len(response.choices[0].message.content)} chars, {response.usage.total_tokens} tokens" + ) + + def test_foundation_models_system_messages( + self, foundation_models_client, foundation_models_default_model + ): + """Test Foundation Models with system messages""" + response = foundation_models_client.chat.completions.create( + model=foundation_models_default_model, + messages=[ + { + "role": "system", + "content": "Ты эксперт по программированию. Отвечай кратко и точно.", + }, + { + "role": "user", + "content": "Что такое Python?", + }, + ], + max_tokens=10, + temperature=0.3, + ) + + assert response.choices is not None + assert len(response.choices) > 0 + assert response.choices[0].message is not None + assert response.choices[0].message.content is not None + + print( + f"✅ System message response: {response.choices[0].message.content}" + ) + + def test_foundation_models_conversation_history( + self, foundation_models_client, foundation_models_default_model + ): + """Test Foundation Models with conversation history""" + response = foundation_models_client.chat.completions.create( + model=foundation_models_default_model, + messages=[ + { + "role": "user", + "content": "Привет! Как дела?", + }, + { + "role": "assistant", + "content": "Привет! Дела хорошо, спасибо за вопрос!", + }, + { + "role": "user", + "content": "Можешь рассказать о машинном обучении?", + }, + ], + max_tokens=12, + temperature=0.4, + ) + + assert response.choices is not None + assert len(response.choices) > 0 + assert response.choices[0].message is not None + assert response.choices[0].message.content is not None + + print( + f"✅ Conversation history response: {response.choices[0].message.content}" + ) diff --git a/tests/test_foundation_models_unit.py b/tests/test_foundation_models_unit.py new file mode 100644 index 0000000..af8af1b --- /dev/null +++ b/tests/test_foundation_models_unit.py @@ -0,0 +1,248 @@ +#!/usr/bin/env python3 +""" +Unit tests for Foundation Models functionality +""" + +from unittest.mock import MagicMock, patch + +import pytest + +from evolution_openai import OpenAI, AsyncOpenAI +from evolution_openai.exceptions import EvolutionAuthError + + +@pytest.mark.unit +class TestFoundationModelsUnit: + """Unit tests for Foundation Models functionality""" + + FOUNDATION_MODELS_URL = ( + "https://foundation-models.api.cloud.ru/api/gigacube/openai/v1" + ) + DEFAULT_MODEL = "RefalMachine/RuadaptQwen2.5-7B-Lite-Beta" + + @patch("evolution_openai.client.EvolutionTokenManager") + def test_foundation_models_client_initialization( + self, mock_token_manager, mock_credentials + ): + """Test Foundation Models client initialization with project_id""" + mock_manager = MagicMock() + mock_manager.get_valid_token.return_value = "test_token" + mock_token_manager.return_value = mock_manager + + client = OpenAI( + key_id=mock_credentials["key_id"], + secret=mock_credentials["secret"], + base_url=self.FOUNDATION_MODELS_URL, + project_id="test_project_id", + ) + + assert client.key_id == mock_credentials["key_id"] + assert client.secret == mock_credentials["secret"] + assert str(client.base_url) == self.FOUNDATION_MODELS_URL + "/" + assert client.project_id == "test_project_id" + assert client.token_manager == mock_manager + + @patch("evolution_openai.client.EvolutionTokenManager") + def test_foundation_models_async_client_initialization( + self, mock_token_manager, mock_credentials + ): + """Test Foundation Models async client initialization with project_id""" + mock_manager = MagicMock() + mock_manager.get_valid_token.return_value = "test_token" + mock_token_manager.return_value = mock_manager + + client = AsyncOpenAI( + key_id=mock_credentials["key_id"], + secret=mock_credentials["secret"], + base_url=self.FOUNDATION_MODELS_URL, + project_id="test_project_id", + ) + + assert client.key_id == mock_credentials["key_id"] + assert client.secret == mock_credentials["secret"] + assert str(client.base_url) == self.FOUNDATION_MODELS_URL + "/" + assert client.project_id == "test_project_id" + assert client.token_manager == mock_manager + + @patch("evolution_openai.client.EvolutionTokenManager") + def test_foundation_models_client_properties( + self, mock_token_manager, mock_credentials + ): + """Test Foundation Models client properties""" + mock_manager = MagicMock() + mock_manager.get_valid_token.return_value = "test_token" + mock_manager.get_token_info.return_value = { + "has_token": True, + "is_valid": True, + } + mock_token_manager.return_value = mock_manager + + client = OpenAI( + key_id=mock_credentials["key_id"], + secret=mock_credentials["secret"], + base_url=self.FOUNDATION_MODELS_URL, + project_id="test_project_id", + ) + + # Test current_token property + assert client.current_token == "test_token" + + # Test get_token_info method + info = client.get_token_info() + assert info["has_token"] is True + assert info["is_valid"] is True + + # Test refresh_token method + mock_manager.invalidate_token = MagicMock() + mock_manager.get_valid_token.return_value = "new_token" + + new_token = client.refresh_token() + assert new_token == "new_token" + mock_manager.invalidate_token.assert_called_once() + + @patch("evolution_openai.client.EvolutionTokenManager") + def test_foundation_models_with_options( + self, mock_token_manager, mock_credentials + ): + """Test Foundation Models client with_options method""" + mock_manager = MagicMock() + mock_manager.get_valid_token.return_value = "test_token" + mock_token_manager.return_value = mock_manager + + client = OpenAI( + key_id=mock_credentials["key_id"], + secret=mock_credentials["secret"], + base_url=self.FOUNDATION_MODELS_URL, + project_id="test_project_id", + timeout=30.0, + ) + + # Test with_options returns a new client instance + new_client = client.with_options(timeout=60.0, max_retries=3) + + # Original client should be unchanged + assert client.timeout == 30.0 + + # New client should have updated options + assert new_client.timeout == 60.0 + assert new_client.max_retries == 3 + + @patch("evolution_openai.client.EvolutionTokenManager") + def test_foundation_models_auth_error_handling( + self, mock_token_manager, mock_credentials + ): + """Test Foundation Models authentication error handling""" + mock_manager = MagicMock() + mock_manager.get_valid_token.side_effect = EvolutionAuthError( + "Authentication failed", status_code=401 + ) + mock_token_manager.return_value = mock_manager + + with pytest.raises(EvolutionAuthError): + OpenAI( + key_id=mock_credentials["key_id"], + secret=mock_credentials["secret"], + base_url=self.FOUNDATION_MODELS_URL, + project_id="test_project_id", + ) + + def test_foundation_models_missing_project_id(self, mock_credentials): + """Test Foundation Models client without project_id""" + # This should work fine - project_id is optional + with patch( + "evolution_openai.client.EvolutionTokenManager" + ) as mock_token_manager: + mock_manager = MagicMock() + mock_manager.get_valid_token.return_value = "test_token" + mock_token_manager.return_value = mock_manager + + client = OpenAI( + key_id=mock_credentials["key_id"], + secret=mock_credentials["secret"], + base_url=self.FOUNDATION_MODELS_URL, + # No project_id provided + ) + + assert client.project_id is None + + @patch("evolution_openai.client.EvolutionTokenManager") + def test_foundation_models_context_manager( + self, mock_token_manager, mock_credentials + ): + """Test Foundation Models client as context manager""" + mock_manager = MagicMock() + mock_manager.get_valid_token.return_value = "test_token" + mock_token_manager.return_value = mock_manager + + # Test sync client context manager + with OpenAI( + key_id=mock_credentials["key_id"], + secret=mock_credentials["secret"], + base_url=self.FOUNDATION_MODELS_URL, + project_id="test_project_id", + ) as client: + assert client.current_token == "test_token" + + @patch("evolution_openai.client.EvolutionTokenManager") + async def test_foundation_models_async_context_manager( + self, mock_token_manager, mock_credentials + ): + """Test Foundation Models async client as context manager""" + mock_manager = MagicMock() + mock_manager.get_valid_token.return_value = "test_token" + mock_token_manager.return_value = mock_manager + + # Test async client context manager + async with AsyncOpenAI( + key_id=mock_credentials["key_id"], + secret=mock_credentials["secret"], + base_url=self.FOUNDATION_MODELS_URL, + project_id="test_project_id", + ) as client: + assert client.current_token == "test_token" + + +@pytest.mark.unit +class TestFoundationModelsConfiguration: + """Unit tests for Foundation Models configuration""" + + def test_foundation_models_url_validation(self): + """Test Foundation Models URL validation""" + foundation_models_url = ( + "https://foundation-models.api.cloud.ru/api/gigacube/openai/v1" + ) + + # URL should be valid + assert foundation_models_url.startswith("https://") + assert "foundation-models.api.cloud.ru" in foundation_models_url + assert foundation_models_url.endswith("/v1") + + def test_foundation_models_default_model(self): + """Test Foundation Models default model""" + default_model = "RefalMachine/RuadaptQwen2.5-7B-Lite-Beta" + + # Model name should be valid + assert "/" in default_model + assert len(default_model) > 0 + assert default_model.startswith("RefalMachine/") + + def test_foundation_models_timeout_configuration(self): + """Test Foundation Models timeout configuration""" + # Foundation Models should have longer timeout than regular API + regular_timeout = 30.0 + foundation_models_timeout = 60.0 + + assert foundation_models_timeout > regular_timeout + assert foundation_models_timeout >= 60.0 # At least 1 minute + + def test_foundation_models_required_params(self): + """Test Foundation Models required parameters""" + required_params = ["key_id", "secret", "base_url"] + optional_params = ["project_id", "timeout", "max_retries"] + + # Verify we have the right parameter lists + assert len(required_params) == 3 + assert len(optional_params) == 3 + assert ( + "project_id" in optional_params + ) # project_id is optional but recommended