# Product Requirements Document (PRD): Пакет `llm-rotator`

## 1. Описание продукта и цель

`llm-rotator` — это Python-библиотека для обеспечения 100% отказоустойчивости при работе с LLM-провайдерами (OpenAI, Anthropic, Gemini, Playground и др.). Пакет реализует паттерны Chain of Responsibility, Strategy и Circuit Breaker для автоматической трехуровневой ротации (**Провайдер -> Модель -> Ключ**) при возникновении ошибок сети, исчерпании баланса или достижении лимитов (Rate Limit). Идеально подходит для высоконагруженных Telegram-ботов и интеграций в бизнес-процессы, где критически важно качество и непрерывность ответа.

## 2. Ключевые требования к функционалу (Core Features)

### 2.1. Управление состоянием и Health Check (Circuit Breaker)

- **Классификатор ошибок (Error Classifier):** Пакет должен различать типы отказов:
    - *Ошибки баланса/авторизации (401, 402)* — `KeyDeadError`.
    - *Ошибки частоты запросов (429 Rate Limit)* — `ModelRateLimitError`.
    - *Ошибки сервера (5xx)* — `ServerError`.
- **Гранулярная блокировка (Granular TTL):** * Если ключ ловит ошибку 429 для конкретной модели (например, `gpt-5`), он помечается как недоступный **только для этой модели** на заданное время (например, на основе заголовка `Retry-After`). Для других моделей этот ключ остается активным.
    - Если ключ ловит ошибку 402 (баланс исчерпан) или бан, он помечается как `dead` **глобально** для всех моделей.
- **Health Check:** Менеджер состояний возвращает только «живые» ключи для запрашиваемой модели. Заблокированные комбинации (Ключ + Модель) пропускаются без выполнения HTTP-запроса.
- **Бэкенды состояния:** Поддержка In-Memory (по умолчанию) и Redis (для микросервисов, например, при деплое через Traefik/Docker).

### 2.2. Интеллектуальный выбор моделей (Model-First Routing)

- **Приоритет качества (Model-First):** Ротация строится вокруг модели, а не ключа. Система берет приоритетную модель из списка и пытается простучать её по **всем** доступным ключам в пуле провайдера.
- **Даунгрейд как крайняя мера (Smart Fallback):** Снижение грейда до менее умной модели (например, переход с `gpt-4o` на `gpt-4-turbo`) происходит *только в том случае*, если абсолютно все доступные ключи провайдера словили Rate Limit для основной модели.
- **Группировка по задачам:** Возможность задавать классы моделей в конфиге (например, `reasoning_tier`, `fast_tier`) и жестко ограничивать ротацию внутри нужного класса, чтобы не выполнять сложные системные промпты на слабых моделях.
- **Уровни качества (Tier Ceiling):** Каждая группа моделей имеет числовой `tier` (1 = лучшее качество, 2 = среднее, 3 = экономное и т.д.). При создании запроса клиент указывает `tier` — «потолок качества». Ротация идёт **от указанного уровня вниз** (к более слабым моделям), но **никогда вверх** к более мощным. Это позволяет:
    - Для простых задач (классификация, извлечение данных) создавать экземпляр с `tier=3` — используются только экономные модели, даже если flagship-ключи свободны.
    - Для сложных задач (reasoning, кодогенерация) использовать `tier=1` — полный доступ ко всем моделям с приоритетом лучших.
    - Гарантировать, что бот для FAQ не потратит квоту flagship-моделей, предназначенную для premium-пользователей.

### 2.3. Унифицированный интерфейс для Tool Calling и Structured Output

- **Единый формат:** Пакет принимает описание функций (Tools) и схемы JSON (Pydantic models) в едином стандартизированном виде.
- **Трансляция под капотом:** Внутренние Стратегии самостоятельно конвертируют этот единый формат во внутренние структуры конкретных API (например, в `response_format` для OpenAI или `generation_config` для Gemini).

### 2.4. Совместимость с LangChain и LangGraph

- **Drop-in replacement:** Пакет предоставляет класс-обертку (например, `RotatorChatModel`), наследуемый от `BaseChatModel` из библиотеки LangChain.
- **Бесшовная интеграция:** Это позволяет вставлять ротатор в любые существующие цепочки (`chains`), агенты и графы (LangGraph) ровно так же, как обычный стандартный класс LLM.

### 2.5. Управление квотами (Quota Management)

Помимо реактивной обработки ошибок (circuit breaker), пакет должен поддерживать **превентивное** управление лимитами — когда система заранее знает, что квота исчерпана, и не делает заведомо обречённый запрос.

#### 2.5.1. Типы квот

Система поддерживает два независимых типа квот, которые можно комбинировать:

- **Токенные квоты (`token_quota`):** Ограничение на суммарное количество токенов (входящих + исходящих) за период. Актуально для провайдеров с лимитами вида «N токенов в сутки» (например, бесплатный тир OpenAI). Счётчик наполняется из поля `usage` в ответе API.
- **Квоты запросов (`request_quota`):** Ограничение на количество HTTP-запросов за период, без привязки к объёму токенов. Актуально для провайдеров, где тарификация идёт за вызов (например, бесплатные модели OpenRouter: 40 запросов/сутки или 1000 при наличии баланса).

#### 2.5.2. Гранулярность квот

Квоты могут задаваться на трёх уровнях (от частного к общему):

1. **На уровне конкретной модели** — например, `gpt-4o` имеет свою квоту токенов.
2. **На уровне группы моделей** — например, все «flagship»-модели провайдера делят общий пул токенов (250 000/сутки для бесплатного тира OpenAI).
3. **На уровне ключа** — квота привязана к конкретному API-ключу, а не к модели (типичный случай для ключей с фиксированным балансом).

#### 2.5.3. Периоды сброса (Reset Schedule)

- **`daily_utc`** — сброс в 00:00 UTC. Наиболее распространённый случай (OpenAI, OpenRouter).
- **`daily_local`** — сброс в 00:00 по локальному времени инстанса.
- **`rolling_window`** — скользящее окно (например, 60 минут), счётчик убывает по мере истечения TTL каждого запроса.
- Явное указание временной зоны: `daily_utc+3` для сброса в полночь по МСК.

#### 2.5.4. Поведение при исчерпании квоты

Когда квота исчерпана, модель/ключ помечается как заблокированная до момента следующего сброса — аналогично блокировке по Rate Limit в Circuit Breaker (раздел 2.1), но с TTL, рассчитанным до времени сброса, а не из заголовка `Retry-After`. Это позволяет логике ротации из раздела 2.2 работать без изменений.

#### 2.5.5. Динамические квоты (опционально)

Некоторые провайдеры изменяют лимит в зависимости от состояния аккаунта (например, OpenRouter: 40 запросов/сутки для нулевого баланса и 1000 при наличии средств). Система должна поддерживать:
- Статическую конфигурацию лимита пользователем.
- Опциональный периодический вызов Balance Check API провайдера для автоматического обновления применяемого лимита.

#### 2.5.6. Хранение счётчиков

Счётчики квот используют тот же бэкенд, что и Circuit Breaker (In-Memory или Redis), обеспечивая консистентность при работе нескольких инстансов сервиса.

### 2.6. Контекст запроса и точки расширения (Request Context)

#### 2.6.1. Проблема разделения ответственности

Пакет `llm-rotator` отвечает за **инфраструктурную** маршрутизацию: ключи, провайдеры, rate limits, квоты API. Он **не** должен знать про бизнес-логику клиентского приложения: тарифы пользователей, бюджеты в долларах, биллинг и т.д. Это обеспечивает низкую связность (low coupling) и позволяет использовать пакет в любом контексте.

Однако клиентскому коду нужна возможность **влиять** на маршрутизацию per-request. Для этого пакет предоставляет механизм `RoutingContext` — набор параметров, передаваемых с каждым вызовом.

#### 2.6.2. RoutingContext — параметры на уровне вызова

При каждом вызове `rotator.complete(...)` клиентский код может передать контекст, сужающий или модифицирующий поведение роутера:

```python
# Клиентский код (например, Telegram-бот)

# Пример 1: Сложная задача — полный доступ ко всем моделям
result = await rotator.complete(
    messages=[...],
    routing=RoutingContext(
        tier=1,  # Потолок качества: flagship и ниже (по умолчанию)
        allowed_providers=["openai", "gemini"],
        tags={"user_id": "123", "task": "reasoning"},
    )
)

# Пример 2: Простая задача — только экономные модели
result = await rotator.complete(
    messages=[...],
    routing=RoutingContext(
        tier=3,  # Потолок: mini-модели. Flagship и standard недоступны.
        tags={"user_id": "456", "task": "classification"},
    )
)

# Пример 3: Ограничение по конкретной группе (как раньше)
result = await rotator.complete(
    messages=[...],
    routing=RoutingContext(
        model_group="fast_tier",  # Только модели из этой группы, без даунгрейда
        allow_downgrade=False,
        tags={"user_id": "789"},
    )
)
```

Пакет использует `RoutingContext` только для фильтрации — он не принимает решений о бизнес-логике, а просто сужает пространство выбора по инструкции клиента.

**Параметры RoutingContext:**
- **`tier`** — потолок качества (int, default=1). Ротация рассматривает только группы моделей с `tier >= указанного`. Например, `tier=2` означает: стандартные и экономные модели, flagship недоступны.
- **`model_group`** — ограничить выбор конкретной именованной группой (без даунгрейда в другие группы).
- **`allowed_providers`** — список допустимых провайдеров.
- **`allow_downgrade`** — разрешён ли переход к более слабым моделям (default=True).
- **`tags`** — произвольные теги для хуков и логирования.

#### 2.6.3. Хуки жизненного цикла (Lifecycle Hooks)

Для интеграции с внешней бизнес-логикой пакет предоставляет набор async-хуков, которые клиент может зарегистрировать:

- **`before_request(context, candidate) -> bool`** — вызывается перед каждой попыткой запроса к конкретной комбинации (провайдер + модель + ключ). Возвращает `False` чтобы пропустить кандидата. Клиент может реализовать здесь проверку бюджета, кастомные правила маршрутизации и т.д.
- **`after_response(context, candidate, usage)`** — вызывается после успешного ответа. Получает информацию о потреблённых токенах. Клиент может обновить свои внутренние счётчики (биллинг, бюджеты пользователей).
- **`on_fallback(context, from_candidate, to_candidate, reason)`** — вызывается при каждом переключении. Клиент может логировать, отправлять алерты или даже прервать цепочку, выбросив исключение.

#### 2.6.4. Пример: бот с пользовательскими тарифами

```python
# --- Клиентский код (бизнес-логика бота) ---

class BudgetHook:
    """Контролирует бюджет на стороне бота, не внутри llm-rotator."""

    def __init__(self, budget_service):
        self.budget_service = budget_service

    async def before_request(self, context, candidate):
        user_id = context.tags.get("user_id")
        tier = context.tags.get("tier")

        # Для free-тира запрещаем flagship-модели
        if tier == "free" and candidate.model_group == "flagship":
            return False

        # Проверяем дневной бюджет пользователя
        remaining = await self.budget_service.get_remaining(user_id)
        if remaining <= 0:
            return False

        return True

    async def after_response(self, context, candidate, usage):
        user_id = context.tags.get("user_id")
        cost = usage.total_tokens * candidate.price_per_token
        await self.budget_service.deduct(user_id, cost)

# Регистрация хука
rotator = LLMRotator(config)
rotator.add_hook(BudgetHook(budget_service))

# Вызов из хендлера бота — tier определяет потолок качества
result = await rotator.complete(
    messages=user_messages,
    routing=RoutingContext(
        tier=3 if user.tier == "free" else 1,  # free → только mini, premium → всё
        tags={"user_id": user.id, "tier": user.tier},
    )
)
```

#### 2.6.5. Что остаётся за пределами пакета

Следующие задачи **не входят** в ответственность `llm-rotator` и реализуются клиентом через хуки и `RoutingContext`:

- Биллинг и бюджетирование в деньгах (доллары, рубли).
- Пользовательские тарифы и ограничения.
- Ценообразование моделей (стоимость за токен) — пакет оперирует токенами и запросами, а не деньгами.
- Аналитика использования по пользователям/командам.

## 3. Нефункциональные требования (NFRs)

### 3.1. Асинхронность (Async-First)

- Ядро библиотеки построено на базе `asyncio`.
- Все сетевые запросы к провайдерам выполняются через асинхронные клиенты, чтобы не блокировать event loop (особенно важно для одновременной обработки сотен запросов в ботах).

### 3.2. Логирование и Телеметрия

- Встроенная система логирования маршрутизации: в логах должен быть четко виден путь запроса (например: `[OpenAI] gpt-5 (Key 1) -> 429 RateLimit -> gpt-5 (Key 2) -> 200 OK`).
- В логах отражается причина пропуска комбинации (ключ + модель): `RateLimit`, `QuotaExceeded`, `KeyDead` — с указанием текущего и максимального значения счётчика где применимо.
- Поддержка передачи кастомного логгера для прозрачной интеграции.
- Опциональный callback/event `on_quota_warning` для оповещения (например, в Telegram) при приближении к порогу квоты (настраиваемый процент, по умолчанию 80%).

## 4. Концепт архитектуры конфигурации (Draft)

Конфигурация декларативна. Список моделей задается на уровне провайдера (определяя приоритет), а ключи выступают в роли общего пула.

```python
config = {
    "state_backend": "redis://redis:6379",  # Удобно для docker-compose
    "providers": [
        {
            "name": "openai",
            "priority": 1,
            # Группы моделей с раздельными токенными квотами (бесплатный тир OpenAI)
            "model_groups": [
                {
                    "name": "flagship",
                    "tier": 1,  # Уровень качества (1 = лучший)
                    "models": ["gpt-5", "gpt-4o"],  # Сначала пробуем лучшую
                    "token_quota": {
                        "limit": 250_000,
                        "reset": "daily_utc"
                    }
                },
                {
                    "name": "mini",
                    "tier": 3,  # Экономный уровень
                    "models": ["gpt-4o-mini", "gpt-4.1-nano"],
                    "token_quota": {
                        "limit": 2_500_000,
                        "reset": "daily_utc"
                    }
                }
            ],
            "keys": [
                {"token": "sk-123...", "alias": "client_a_key"},
                {"token": "sk-456...", "alias": "backup_key"}
            ]
        },
        {
            "name": "openrouter",
            "priority": 2,
            # Бесплатные модели OpenRouter: лимит зависит от баланса аккаунта
            "models": [
                "meta-llama/llama-3.3-70b-instruct:free",
                "google/gemini-2.0-flash-exp:free"
            ],
            "keys": [
                {
                    "token": "sk-or-...",
                    "alias": "openrouter_main",
                    # Квота на запросы (не токены) для free-моделей
                    "request_quota": {
                        "limit": 40,           # базовый лимит (без баланса)
                        "reset": "daily_utc",
                        # Опционально: проверять баланс и повышать лимит до 1000
                        "balance_check": {
                            "enabled": True,
                            "paid_limit": 1000,
                            "check_interval_seconds": 3600
                        }
                    }
                }
            ]
        },
        {
            "name": "gemini",
            "priority": 3,
            "models": ["gemini-2.0-pro", "gemini-2.0-flash"],
            "keys": [
                {"token": "AIza...", "alias": "google_free_tier"}
            ]
        }
    ]
}
```

Пример лога при исчерпании токенной квоты:

```
[OpenAI/flagship] gpt-5 (client_a_key) -> QuotaExceeded (248k/250k tokens) -> [OpenAI/flagship] gpt-4o (backup_key) -> QuotaExceeded -> [OpenRouter] llama-3.3-70b:free (openrouter_main) -> 200 OK
```

Пример лога при ограничении tier:

```
[tier=3] Skipping [OpenAI/flagship] (tier=1) -> Skipping [OpenAI/standard] (tier=2) -> [OpenAI/mini] gpt-4o-mini (client_a_key) -> 200 OK
```

## 5. Архитектурные решения (ADR)

### 5.1. Структура пакета

Плоская структура с выделением адаптеров в подпакеты. Принципы Clean Architecture (DIP, OCP) соблюдаются через ABC-контракты, но без церемониальных слоёв `domain/`, `ports/`, `application/`, которые избыточны для библиотеки такого масштаба.

```
llm_rotator/
├── __init__.py              # Публичный API: LLMRotator, RoutingContext, исключения
├── config.py                # Pydantic v2 модели конфигурации
├── exceptions.py            # Иерархия исключений
├── rotator.py               # Оркестратор (Chain of Responsibility, Model-First)
├── circuit_breaker.py       # Health check, гранулярные TTL-блокировки
├── quota.py                 # Счётчики токенов/запросов, периоды сброса
├── backends/
│   ├── __init__.py          # AbstractStateBackend (ABC) + InMemoryBackend
│   └── redis.py             # RedisBackend (опциональная зависимость redis[asyncio])
├── clients/
│   ├── __init__.py          # AbstractLLMClient (ABC)
│   ├── openai.py            # OpenAI + OpenAI-совместимые API
│   ├── anthropic.py         # Anthropic Claude
│   ├── gemini.py            # Google Gemini
│   └── openrouter.py        # OpenRouter (free и paid модели)
└── integrations/
    └── langchain.py         # RotatorChatModel (опциональная зависимость langchain-core)
```

**Обоснование:** ABC для бэкенда (`AbstractStateBackend`) и клиента (`AbstractLLMClient`) лежат в `__init__.py` соответствующих подпакетов — рядом с дефолтной реализацией. Это сохраняет инверсию зависимостей (ядро `rotator.py` импортирует только абстракции), но без лишнего файла `ports/`.

### 5.2. Иерархия исключений

```python
class LLMRotatorError(Exception):
    """Базовое исключение пакета."""

# --- Ошибки отдельной попытки (одна комбинация ключ+модель) ---

class KeyDeadError(LLMRotatorError):
    """Ключ мёртв глобально (401, 402, бан)."""
    def __init__(self, key_alias: str, status_code: int, message: str = ""):
        self.key_alias = key_alias
        self.status_code = status_code
        super().__init__(message or f"Key '{key_alias}' is dead (HTTP {status_code})")

class ModelRateLimitError(LLMRotatorError):
    """Rate limit для конкретной модели на конкретном ключе (429)."""
    def __init__(self, key_alias: str, model: str, retry_after: int | None = None):
        self.key_alias = key_alias
        self.model = model
        self.retry_after = retry_after  # секунды из заголовка Retry-After
        super().__init__(f"Rate limit for '{model}' on key '{key_alias}' (retry after {retry_after}s)")

class ServerError(LLMRotatorError):
    """Серверная ошибка провайдера (5xx)."""
    def __init__(self, provider: str, status_code: int, message: str = ""):
        self.provider = provider
        self.status_code = status_code
        super().__init__(message or f"Server error from {provider} (HTTP {status_code})")

class QuotaExceededError(LLMRotatorError):
    """Локальная квота исчерпана (токены или запросы)."""
    def __init__(self, scope: str, current: int, limit: int, resets_at: datetime | None = None):
        self.scope = scope      # например "openai/flagship" или "openrouter_main"
        self.current = current
        self.limit = limit
        self.resets_at = resets_at
        super().__init__(f"Quota exceeded for '{scope}': {current}/{limit}")

# --- Терминальная ошибка (все попытки исчерпаны) ---

class AllAttemptsFailedError(ExceptionGroup):
    """Все комбинации (провайдер × модель × ключ) исчерпаны.
    Содержит ExceptionGroup со всеми ошибками отдельных попыток.
    Python 3.11+; для 3.9-3.10 используется backport `exceptiongroup`.
    """
```

**Обоснование:**
- Каждое исключение несёт структурированные данные (`retry_after`, `status_code`, `resets_at`), а не просто строку — вызывающий код и Circuit Breaker могут принимать решения без парсинга.
- Терминальная ошибка `AllAttemptsFailedError` наследует `ExceptionGroup`, что позволяет клиенту через `except*` обработать разные типы ошибок из одной пачки.
- Для Python < 3.11 используется backport-пакет `exceptiongroup` (уже используется в pytest и anyio).

### 5.3. Retry-стратегия для серверных ошибок (5xx)

**Решение:** Один быстрый retry с фиксированной задержкой, без экспоненциального backoff. Без внешних зависимостей (без tenacity).

```python
# Настройка в конфиге провайдера
{
    "name": "openai",
    "server_error_retry": {
        "max_attempts": 1,       # 0 = без retry, сразу переключаем
        "delay_seconds": 0.5     # пауза перед retry
    }
}
```

**Обоснование:**
- Задача ротатора — **быстро** найти рабочую комбинацию. Переключиться на Gemini за 50ms лучше, чем ждать 1s → 2s → 4s на ретраях к упавшему OpenAI.
- Один retry покрывает единичный «икнул сервер». Если сервер лёг надолго — ротатор переключит провайдера.
- `tenacity` — лишняя зависимость ради одного `asyncio.sleep`. Простой цикл надёжнее и прозрачнее.
- Если клиенту нужен экспоненциальный backoff для специфических случаев, он может реализовать его через хук `before_request` (раздел 2.6.3).

### 5.4. Валидация конфигурации

**Решение:** Pydantic v2 + `pydantic-settings` для загрузки из env/файлов.

**Обоснование:**
- Конфиг сложный: вложенные провайдеры, группы моделей, квоты, `balance_check`. Ручная валидация — источник багов.
- Pydantic v2 даёт валидацию на старте (`ValidationError` с человекочитаемыми ошибками), автодокументирование схемы (JSON Schema), сериализацию.
- `pydantic-settings` позволяет клиенту подтягивать токены из переменных окружения или Vault без кастомного кода.
- Pydantic уже будет зависимостью через LangChain-интеграцию, поэтому не добавляет новых зависимостей в граф.

### 5.5. Инверсия зависимостей (DIP)

Ядро пакета (`rotator.py`, `circuit_breaker.py`, `quota.py`) зависит только от абстракций:

```
rotator.py ──imports──> AbstractLLMClient (ABC)
                        AbstractStateBackend (ABC)

Конкретные реализации (OpenAIClient, RedisBackend) подставляются
при инициализации через конфиг или фабрику.
```

**Контракт State Backend:**

```python
class AbstractStateBackend(ABC):
    """Единый интерфейс для хранения состояния блокировок и счётчиков квот."""

    # --- Circuit Breaker ---
    @abstractmethod
    async def mark_key_dead(self, key_id: str) -> None: ...

    @abstractmethod
    async def block_key_for_model(self, key_id: str, model: str, ttl_seconds: int) -> None: ...

    @abstractmethod
    async def is_key_available(self, key_id: str, model: str) -> bool: ...

    # --- Quota Counters ---
    @abstractmethod
    async def increment_quota(self, scope: str, amount: int, ttl_seconds: int) -> int:
        """Атомарно увеличить счётчик, вернуть новое значение. TTL = время до сброса."""
        ...

    @abstractmethod
    async def get_quota_usage(self, scope: str) -> int: ...
```

**Обоснование:**
- `InMemoryBackend` реализует контракт через `dict` + `asyncio.Lock` — подходит для однопроцессных ботов и тестов.
- `RedisBackend` реализует через `INCRBY` + `EXPIRE` — атомарно, подходит для нескольких инстансов за балансировщиком.
- Ядро тестируется с `InMemoryBackend` без Docker/Redis.

**Контракт LLM Client:**

```python
class AbstractLLMClient(ABC):
    """Стратегия для конкретного LLM-провайдера."""

    @abstractmethod
    def supports_model(self, model: str) -> bool: ...

    @abstractmethod
    async def generate(
        self,
        messages: list[dict],
        model: str,
        api_key: str,
        tools: list[dict] | None = None,
        response_format: type[BaseModel] | None = None,
    ) -> LLMResponse: ...
```

Каждый клиент (`OpenAIClient`, `AnthropicClient`) отвечает за трансляцию единого формата tools/response_format в свой формат API (паттерн Strategy из раздела 2.3).

### 5.6. Потокобезопасность и конкурентность

- **In-Memory Backend:** `asyncio.Lock` для защиты мутаций счётчиков и блокировок. Одного глобального лока недостаточно — используются гранулярные локи (per-key или sharded), чтобы не блокировать весь бэкенд при одновременных запросах.
- **Redis Backend:** Атомарность обеспечивается Redis-командами (`INCRBY`, `SET NX EX`). Дополнительная синхронизация на стороне Python не требуется.
- **Rotator:** Сам оркестратор stateless (состояние хранится в бэкенде), поэтому безопасен для конкурентного вызова из разных корутин.

### 5.7. Опциональные зависимости

Базовый пакет должен иметь минимум зависимостей. Тяжёлые интеграции подключаются через extras:

```toml
# pyproject.toml
[project]
dependencies = [
    "pydantic>=2.0",
    "httpx>=0.27",
    "exceptiongroup>=1.2; python_version<'3.11'",
]

[project.optional-dependencies]
redis = ["redis>=5.0"]
langchain = ["langchain-core>=0.3"]
anthropic = ["anthropic>=0.40"]
google = ["google-genai>=1.0"]
all = ["llm-rotator[redis,langchain,anthropic,google]"]
```

**Обоснование:** Пользователь, работающий только с OpenAI, не должен тянуть `anthropic`, `google-genai` и `redis`. Клиенты OpenAI и OpenRouter работают через `httpx` напрямую (OpenAI API — это REST, SDK-обёртка не обязательна).

## 6. Стратегия тестирования (TDD)

Разработка ведётся по TDD: тест пишется **до** реализации, фиксируя ожидаемое поведение. Тесты группируются по уровням — от быстрых юнитов до медленных интеграционных.

### 6.1. Инструментарий

```toml
[project.optional-dependencies]
dev = [
    "pytest>=8.0",
    "pytest-asyncio>=0.24",
    "pytest-cov>=6.0",
    "respx>=0.22",           # мок HTTP для httpx
    "fakeredis[lua]>=2.25",  # in-process Redis для тестов без Docker
    "freezegun>=1.4",        # управление временем (TTL, сброс квот)
    "anyio>=4.0",            # тестирование async-кода
]
```

### 6.2. Структура тестов

```
tests/
├── conftest.py                  # Общие фикстуры: конфиги, бэкенды, мок-клиенты
├── unit/
│   ├── test_circuit_breaker.py  # Блокировки, TTL, health check
│   ├── test_quota.py            # Счётчики токенов/запросов, сброс
│   ├── test_rotator.py          # Оркестрация, цепочка ротации
│   ├── test_config.py           # Валидация Pydantic-моделей
│   └── test_exceptions.py       # Иерархия исключений, ExceptionGroup
├── integration/
│   ├── test_backends.py         # Контрактные тесты (InMemory + FakeRedis)
│   ├── test_full_rotation.py    # Полный флоу: конфиг → ротация → fallback
│   └── test_hooks.py            # Lifecycle hooks, RoutingContext
└── e2e/                         # Запускаются вручную, требуют реальные ключи
    └── test_live_providers.py   # Smoke-тесты к реальным API
```

### 6.3. Юнит-тесты: Circuit Breaker (`test_circuit_breaker.py`)

Все тесты используют `InMemoryBackend` — без внешних зависимостей, выполняются за миллисекунды.

| Тест-кейс | Что проверяет |
|---|---|
| `test_healthy_key_returned` | Живой ключ возвращается для запрошенной модели |
| `test_block_key_for_model_granular` | После 429 ключ заблокирован для `gpt-5`, но доступен для `gpt-4o` |
| `test_block_key_dead_global` | После 402 ключ недоступен для **всех** моделей |
| `test_ttl_expires_key_recovers` | После истечения TTL ключ снова доступен (freezegun) |
| `test_all_keys_exhausted_raises` | Когда все ключи заблокированы — выбрасывается исключение |
| `test_retry_after_respected` | TTL блокировки берётся из `retry_after`, а не хардкода |

### 6.4. Юнит-тесты: Quota Manager (`test_quota.py`)

| Тест-кейс | Что проверяет |
|---|---|
| `test_token_quota_increments` | Счётчик растёт на `usage.total_tokens` после каждого ответа |
| `test_token_quota_exceeded_blocks` | При достижении лимита модель/группа блокируется |
| `test_request_quota_increments` | Счётчик запросов +1 после каждого вызова |
| `test_quota_daily_reset` | В 00:00 UTC счётчик обнуляется (freezegun) |
| `test_quota_rolling_window` | В скользящем окне старые записи «вытекают» по TTL |
| `test_quota_per_model_group` | Два запроса к `gpt-5` и `gpt-4o` оба расходуют общий пул `flagship` |
| `test_quota_per_key_independent` | Квота `key_a` не влияет на квоту `key_b` |
| `test_quota_exceeded_ttl_until_reset` | TTL блокировки = время до следующего сброса, а не фиксированное значение |

### 6.5. Юнит-тесты: Rotator (`test_rotator.py`)

Мок-клиенты (`FakeOpenAIClient`, `FakeGeminiClient`) реализуют `AbstractLLMClient` и возвращают предзаданные ответы или ошибки.

| Тест-кейс | Что проверяет |
|---|---|
| `test_happy_path` | Первый провайдер, первая модель, первый ключ — ответ 200 |
| `test_model_first_all_keys_before_downgrade` | При 429 на `gpt-5/key1` пробуется `gpt-5/key2`, а НЕ `gpt-4o/key1` |
| `test_downgrade_only_after_all_keys_exhausted` | Даунгрейд до `gpt-4o` только когда **все** ключи для `gpt-5` заблокированы |
| `test_provider_fallback` | Если весь OpenAI недоступен, переключаемся на OpenRouter |
| `test_server_error_single_retry` | При 500 делается один retry с задержкой, потом переключение |
| `test_server_error_retry_disabled` | При `max_attempts: 0` retry не делается, сразу переключение |
| `test_all_failed_exception_group` | При исчерпании всех комбинаций выбрасывается `AllAttemptsFailedError` с полным списком ошибок |
| `test_routing_context_model_group` | `RoutingContext(model_group="fast_tier")` ограничивает выбор только моделями из этой группы |
| `test_routing_context_allowed_providers` | `allowed_providers=["gemini"]` пропускает OpenAI |
| `test_routing_context_disallow_downgrade` | `allow_downgrade=False` — если основная модель недоступна, сразу ошибка |
| `test_routing_context_tier_ceiling` | `RoutingContext(tier=3)` — используются только mini-модели (tier≥3), flagship и standard пропускаются |
| `test_routing_context_tier_fallback_down_only` | `tier=2` — при недоступности standard (tier=2) падает на mini (tier=3), но **не** на flagship (tier=1) |
| `test_routing_context_tier_cross_provider` | `tier=2` — собирает подходящие группы из **всех** провайдеров, соблюдая provider priority |
| `test_routing_context_tier_default` | Без указания `tier` используются все модели начиная с лучших (tier=1 по умолчанию) |
| `test_quota_exceeded_skips_without_http` | Если квота исчерпана, кандидат пропускается **без** HTTP-запроса к API |

### 6.6. Юнит-тесты: Конфигурация (`test_config.py`)

| Тест-кейс | Что проверяет |
|---|---|
| `test_valid_config_parses` | Минимальный валидный конфиг проходит валидацию |
| `test_missing_provider_name_raises` | Отсутствие обязательного поля → `ValidationError` |
| `test_empty_keys_raises` | Провайдер без ключей → ошибка |
| `test_duplicate_priorities_raises` | Два провайдера с одинаковым priority → ошибка |
| `test_quota_without_reset_raises` | `token_quota` без `reset` → ошибка |
| `test_model_group_tier_required` | `model_groups` без `tier` → ошибка валидации |
| `test_model_group_tier_positive_int` | `tier=0` или `tier=-1` → ошибка валидации |
| `test_env_override_tokens` | Токены из переменных окружения перезаписывают значения из конфига |

### 6.7. Юнит-тесты: Lifecycle Hooks (`test_hooks.py`)

| Тест-кейс | Что проверяет |
|---|---|
| `test_before_request_skip_candidate` | Хук возвращает `False` → кандидат пропущен, следующий выбран |
| `test_before_request_all_rejected` | Хук отклоняет всех кандидатов → `AllAttemptsFailedError` |
| `test_after_response_receives_usage` | Хук получает корректные `total_tokens`, `prompt_tokens`, `completion_tokens` |
| `test_on_fallback_called_with_reason` | При переключении хук получает `from`, `to` и причину (`RateLimit` / `QuotaExceeded`) |
| `test_on_fallback_exception_aborts` | Если хук выбрасывает исключение, цепочка ротации прерывается |
| `test_hooks_execution_order` | `before_request` → HTTP → `after_response` (или `on_fallback` при ошибке) |

### 6.8. Контрактные тесты бэкендов (`test_backends.py`)

Один набор тестов, параметризованный по реализациям. Гарантирует, что `InMemoryBackend` и `RedisBackend` ведут себя одинаково.

```python
@pytest.fixture(params=["memory", "redis"])
async def backend(request):
    if request.param == "memory":
        yield InMemoryBackend()
    else:
        fake_redis = fakeredis.aioredis.FakeRedis()
        yield RedisBackend(fake_redis)

async def test_block_and_check(backend):
    await backend.block_key_for_model("key1", "gpt-5", ttl_seconds=60)
    assert not await backend.is_key_available("key1", "gpt-5")
    assert await backend.is_key_available("key1", "gpt-4o")  # гранулярность

async def test_increment_quota_atomic(backend):
    results = await asyncio.gather(*[
        backend.increment_quota("scope", 1, ttl_seconds=3600)
        for _ in range(100)
    ])
    assert max(results) == 100  # атомарность: нет потерянных инкрементов
```

| Тест-кейс | Что проверяет |
|---|---|
| `test_block_and_check` | Гранулярная блокировка (ключ + модель) |
| `test_mark_dead_global` | Глобальная блокировка ключа |
| `test_ttl_expiry` | Блокировка снимается после TTL (freezegun) |
| `test_increment_quota_atomic` | 100 конкурентных инкрементов → ровно 100 (проверка атомарности) |
| `test_quota_ttl_resets` | Счётчик квоты исчезает по TTL |
| `test_dead_key_survives_restart` | Для Redis: после переподключения состояние сохраняется |

### 6.9. Интеграционные тесты: полный флоу (`test_full_rotation.py`)

Используют `InMemoryBackend` + мок HTTP (respx). Проверяют сквозной сценарий от конфига до ответа.

| Тест-кейс | Что проверяет |
|---|---|
| `test_e2e_happy_path` | Конфиг → `LLMRotator(config)` → `complete()` → ответ от первого провайдера |
| `test_e2e_full_rotation_chain` | Конфиг с 3 провайдерами, первые два возвращают 429/500 → ответ от третьего |
| `test_e2e_quota_triggers_fallback` | Исчерпание токенной квоты → автоматический переход на следующую группу/провайдера |
| `test_e2e_hooks_integrated` | `BudgetHook` отклоняет flagship для free-тира → запрос уходит к mini |
| `test_e2e_concurrent_requests` | 50 параллельных `complete()` → корректная работа счётчиков и блокировок |
| `test_e2e_config_reload` | Горячее обновление конфига (добавление ключа) без остановки ротатора |

### 6.10. E2E-тесты с реальными API (`test_live_providers.py`)

Запускаются только вручную (`pytest -m e2e`), требуют переменные окружения с реальными ключами. Не входят в CI.

| Тест-кейс | Что проверяет |
|---|---|
| `test_live_openai_simple` | Реальный запрос к OpenAI возвращает ответ с `usage` |
| `test_live_rate_limit_recovery` | Спровоцировать 429 (серия быстрых запросов) → ротатор переключает ключ |
| `test_live_invalid_key_detected` | Невалидный ключ → `KeyDeadError`, ротатор переключается |

### 6.11. Порядок реализации (TDD Red-Green-Refactor)

Тесты пишутся и реализуются в порядке зависимостей — от фундамента к оркестрации:

```
1. test_config.py           → config.py           (Pydantic-модели, парсинг)
2. test_exceptions.py       → exceptions.py       (иерархия исключений)
3. test_backends.py         → backends/           (InMemory, потом Redis)
4. test_circuit_breaker.py  → circuit_breaker.py  (блокировки, TTL)
5. test_quota.py            → quota.py            (счётчики, сброс)
6. test_rotator.py          → rotator.py          (оркестрация, Model-First)
7. test_hooks.py            → хуки в rotator.py   (lifecycle callbacks)
8. test_full_rotation.py    → интеграция всех слоёв
9. Клиенты (openai.py и др.) — тестируются через respx-моки
```

### 6.12. Критерии качества

- **Покрытие:** минимум 90% line coverage для пакета (без e2e).
- **Время:** полный прогон `unit/ + integration/` < 10 секунд (без сетевых запросов, без Docker).
- **CI:** `pytest unit/ integration/` запускается на каждый push. E2E — только вручную или по расписанию.

## 7. Решения из интервью (MVP scope)

| Вопрос | Решение |
|--------|---------|
| Python | 3.11+ |
| Build tool | uv + hatchling |
| MVP scope | Средний: ядро + circuit breaker + квоты + InMemory + hooks + RoutingContext |
| Провайдеры MVP | OpenAI-compatible (OpenAI + OpenRouter) + Gemini (V2: + Anthropic) |
| API клиенты | Один OpenAI-compatible с configurable `base_url` + отдельный GeminiClient |
| HTTP | Только httpx, без SDK провайдеров (openai, anthropic, google-genai) |
| Tool Calling / Structured Output | НЕ в MVP, только text-in/text-out + usage. ✅ Реализовано в V2 |
| Streaming | В MVP. Mid-stream error → retry с начала на другом ключе |
| Конфигурация | Только программная (Pydantic v2 model / dict). Без YAML/TOML парсинга. ✅ V2: + JSON файл с env substitution |
| State Backend | MVP: InMemoryBackend с injectable clock. ✅ V2: + RedisBackend |
| LangChain интеграция | Не в MVP. ✅ Реализовано в V2: RotatorChatModel(BaseChatModel) |
| LifecycleHook | `typing.Protocol` (не ABC). Partial implementation OK |
| Retry (5xx) | Один быстрый retry с фиксированной задержкой, без tenacity |
| Лицензия | MIT |
| PyPI | Да, публикация через trusted publishing |
| CI/CD | GitHub Actions сразу (ruff + pytest на push/PR, publish on tag) |
| Тестирование | pytest, pytest-asyncio, respx, freezegun. Coverage ≥ 90% |