# Implementation Plan: llm-rotator MVP

## Context

Разрабатываем Python-библиотеку `llm-rotator` с нуля — от пустой директории до публикуемого на PyPI пакета. Цель: отказоустойчивая ротация LLM-провайдеров с circuit breaker, квотами и model-first маршрутизацией. Разработка по TDD.

## Решения из интервью

| Вопрос | Решение |
|--------|---------|
| Python | 3.11+ |
| Build tool | uv |
| MVP scope | Средний: ядро + circuit breaker + квоты + InMemory + hooks + RoutingContext |
| Провайдеры MVP | OpenAI-compatible (OpenAI + OpenRouter) + Gemini |
| API клиенты | Один OpenAI-compatible с base_url + отдельный GeminiClient |
| HTTP | Только httpx, без SDK провайдеров |
| Tool Calling | НЕ в MVP, только text-in/text-out + usage |
| Streaming | В MVP. Mid-stream error → retry с начала на другом ключе |
| Конфиг | Только Python (Pydantic model / dict) |
| Лицензия | MIT |
| PyPI | Да |
| CI/CD | GitHub Actions сразу |

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

```
llm-rotator/
├── .github/workflows/
│   ├── ci.yml                      # ruff + pytest on push/PR
│   └── publish.yml                 # PyPI publish on tag
├── docs/
│   ├── PRD.md                      # Product Requirements Document
│   └── IMPLEMENTATION_PLAN.md      # Этот файл
├── src/llm_rotator/
│   ├── __init__.py                 # Public API exports
│   ├── config.py                   # Pydantic v2 config models
│   ├── exceptions.py               # Exception hierarchy
│   ├── _types.py                   # LLMResponse, Usage, StreamChunk, Candidate, RoutingContext
│   ├── rotator.py                  # Main orchestrator (Model-First CoR)
│   ├── circuit_breaker.py          # Error classifier + TTL blocks
│   ├── quota.py                    # Token/request counters, reset schedules
│   ├── backends/
│   │   ├── __init__.py             # AbstractStateBackend (ABC) + InMemoryBackend
│   │   └── redis.py                # Заглушка (не MVP)
│   ├── clients/
│   │   ├── __init__.py             # AbstractLLMClient (ABC)
│   │   ├── openai.py               # OpenAI-compatible (base_url configurable)
│   │   └── gemini.py               # Google Gemini REST API
│   └── integrations/
│       └── langchain.py            # Заглушка (не MVP)
├── tests/
│   ├── conftest.py                 # FakeLLMClient, fixtures
│   ├── unit/
│   │   ├── test_config.py
│   │   ├── test_exceptions.py
│   │   ├── test_backends.py
│   │   ├── test_circuit_breaker.py
│   │   ├── test_quota.py
│   │   ├── test_rotator.py
│   │   └── test_hooks.py
│   ├── integration/
│   │   ├── test_clients.py         # OpenAI + Gemini через respx
│   │   └── test_full_rotation.py
│   └── e2e/
│       └── test_live_providers.py
├── pyproject.toml
├── LICENSE                         # MIT
├── README.md
└── .gitignore
```

---

## Фазы реализации (TDD: RED → GREEN → REFACTOR)

### Фаза 0: Инициализация проекта [S] ✅ DONE

**Действия:**
1. `git init`
2. `uv init --lib` (или ручное создание pyproject.toml)
3. Создать структуру каталогов
4. `pyproject.toml` с зависимостями:
   - core: `pydantic>=2.0,<3`, `httpx>=0.27,<1`
   - dev: `pytest>=8.0`, `pytest-asyncio>=0.24`, `pytest-cov>=6.0`, `respx>=0.22`, `freezegun>=1.4`, `ruff>=0.8`, `mypy>=1.13`
   - extras: `redis = ["redis[asyncio]>=5.0"]`, `langchain = ["langchain-core>=0.3"]`
5. `.github/workflows/ci.yml` — ruff lint + pytest (Python 3.11, 3.12, 3.13)
6. `.github/workflows/publish.yml` — PyPI trusted publishing on tag
7. `LICENSE` (MIT), `.gitignore`, `README.md`
8. `uv sync --dev` — проверить что всё ставится
9. Первый коммит

### Фаза 1: Config + Exceptions + Types [M] ✅ DONE

**Зависимости:** Фаза 0

**TDD порядок:**

1. **`test_exceptions.py`** → **`exceptions.py`**
   - Тесты: атрибуты каждого исключения, `AllAttemptsFailedError` как ExceptionGroup, `except*` работает, метод `derive()`
   - Реализация: `LLMRotatorError` → `KeyDeadError`, `ModelRateLimitError`, `ServerError`, `QuotaExceededError`, `AllAttemptsFailedError(ExceptionGroup)`

2. **`test_config.py`** → **`config.py`**
   - Тесты: минимальный конфиг, полный конфиг с groups/quotas, валидация (missing fields, empty keys, duplicate priorities, models XOR model_groups)
   - Реализация: `RotatorConfig`, `ProviderConfig`, `KeyConfig`, `ModelGroupConfig`, `TokenQuotaConfig`, `RequestQuotaConfig`, `ServerErrorRetryConfig`, `ResetSchedule`, `ClientType`
   - `ModelGroupConfig.tier: int` (≥1) — уровень качества группы (1=лучший). Обязательное поле.
   - Ключевые валидаторы: `validate_models_xor_groups`, `validate_unique_priorities`, `validate_tier_positive`, auto-sort по priority

3. **`_types.py`** (без отдельных тестов, используются в других тестах)
   - `Usage(prompt_tokens, completion_tokens, total_tokens)` — frozen dataclass
   - `LLMResponse(content, usage, model, provider, key_alias, raw)`
   - `StreamChunk(delta, usage, done)`
   - `Candidate(provider_name, model, key_alias, key_token, model_group, base_url, client_type)`
   - `RoutingContext(tier, model_group, allowed_providers, allow_downgrade, tags)` — `tier: int = 1` (потолок качества, ротация от этого уровня вниз)

### Фаза 2: InMemory Backend [M] ✅ DONE

**Зависимости:** Фаза 1

**TDD:** `test_backends.py` → `backends/__init__.py`

- Тесты: key available, granular block (key+model), global dead, TTL expiry (freezegun), quota increment, quota accumulation, quota TTL reset, concurrent atomic increments (100 tasks)
- **Ключевое решение:** injectable clock (`Callable[[], float]`) вместо `time.monotonic()` для тестируемости с freezegun
- `AbstractStateBackend(ABC)` — контракт: `mark_key_dead`, `block_key_for_model`, `is_key_available`, `increment_quota`, `get_quota_usage`
- `InMemoryBackend` — `dict` + `asyncio.Lock`

### Фаза 3: Circuit Breaker [S] ✅ DONE

**Зависимости:** Фаза 1, 2

**TDD:** `test_circuit_breaker.py` → `circuit_breaker.py`

- Тесты: classify 401/402→KeyDead, 429→RateLimit (с/без Retry-After), 5xx→ServerError, record_failure обновляет backend, is_candidate_available фильтрует
- `CircuitBreaker(backend)` — `record_failure()` классифицирует HTTP код и обновляет бэкенд, `is_candidate_available()` проверяет доступность

### Фаза 4: Quota Manager [M] ✅ DONE

**Зависимости:** Фаза 1, 2

**TDD:** `test_quota.py` → `quota.py`

- Тесты: under limit allows, at limit blocks, token increment, request increment, group scope shared, key scope independent, daily_utc TTL calculation (freezegun), rolling_window TTL, no quota = always allows, QuotaExceededError.resets_at
- `QuotaManager(backend)` — `pre_check()` (без записи), `check_and_record()` (проверка + запись usage)
- TTL калькулятор: `_calculate_ttl(reset_schedule)` — секунды до следующей полуночи UTC / rolling window

### Фаза 5: Rotator (ядро) + Hooks [L] ← центральная фаза ✅ DONE

**Зависимости:** Фазы 1-4

**TDD:** `test_rotator.py` + `test_hooks.py` → `rotator.py`

Тесты rotator (с FakeLLMClient из conftest.py):
- happy_path, model_first_all_keys_before_downgrade, downgrade_only_after_all_keys_exhausted, provider_fallback, server_error_retry, retry_disabled, all_failed_exception_group, routing_context_model_group, routing_context_allowed_providers, routing_context_disallow_downgrade, quota_exceeded_skips_without_http
- **Tier:** routing_context_tier_ceiling, tier_fallback_down_only, tier_cross_provider, tier_default

Тесты hooks:
- before_request_skip, before_request_all_rejected, after_response_receives_usage, on_fallback_called, on_fallback_exception_aborts, hooks_execution_order, multiple_hooks

**Ключевой алгоритм `complete()`:**
```
for provider (sorted by priority, filtered by RoutingContext.allowed_providers):
  groups = filter_groups(provider, RoutingContext.tier)  # только группы с tier >= requested
  groups = sort_by_tier(groups)  # сначала ближайший к запрошенному tier, потом слабее
  candidates = build_candidates(groups)  # Model-First: all keys for model1, then model2...
  for candidate in candidates:
    1. circuit_breaker.is_candidate_available() → skip if blocked
    2. quota_manager.pre_check() → skip if exhausted
    3. hooks.before_request() → skip if False
    4. retry loop (0..max_attempts for 5xx):
       client.generate() → success: record usage, call after_response, return
       catch LLMRotatorError → record in circuit_breaker, continue
raise AllAttemptsFailedError(all_errors)
```

**LifecycleHook** — `typing.Protocol` (не ABC), partial implementation OK. Проверка через `hasattr`.

### Фаза 6: LLM-клиенты [L] ✅ DONE

**Зависимости:** Фаза 1

**TDD:** `test_clients.py` (через respx) → `clients/openai.py` + `clients/gemini.py`

**OpenAIClient** (covers OpenAI + OpenRouter + Groq + any compatible):
- `base_url` configurable, default `https://api.openai.com/v1`
- `generate()`: POST `/chat/completions`, parse response → LLMResponse
- `stream()`: POST `/chat/completions` с `stream: true, stream_options: {include_usage: true}`, parse SSE → AsyncIterator[StreamChunk]
- HTTP error → типизированные исключения (401/402→KeyDead, 429→RateLimit, 5xx→ServerError)

**GeminiClient**:
- Endpoint: `POST /v1beta/models/{model}:generateContent`
- Stream: `POST /v1beta/models/{model}:streamGenerateContent?alt=sse`
- Auth: `x-goog-api-key` header (не Bearer)
- Message translation: `system`→`systemInstruction`, `assistant`→`model` role, `content`→`parts[{text}]`
- Usage: `usageMetadata.promptTokenCount` → `usage.prompt_tokens`

### Фаза 7: Streaming в Rotator [M] ✅ DONE

**Зависимости:** Фазы 5, 6

- `stream()` → `AsyncIterator[StreamChunk]`
- Та же логика ротации, что и `complete()`: circuit breaker → quota → hooks → client.stream()
- **Mid-stream error:** catch `LLMRotatorError`, закрыть текущий стрим, перейти к следующему кандидату, начать стрим заново с полными messages
- Usage записывается из последнего чанка после успешного завершения стрима

### Фаза 8: Public API + __init__.py [S] ✅ DONE

**Зависимости:** Фазы 1-7

- `__init__.py` с `__all__` — экспорт всех публичных классов

### Фаза 9: Интеграционные + E2E тесты [M] ✅ DONE

**Зависимости:** Фазы 1-8

- `test_full_rotation.py`: полный флоу, concurrent requests, stream with fallback
- `test_live_providers.py` (mark `e2e`): real OpenAI, Gemini, invalid key fallback

### Фаза 10: Документация и финализация [S] ✅ DONE

1. Обновить `.claude/CLAUDE.md` с инструкциями проекта
2. Обновить `docs/PRD.md` с решениями из интервью
3. `README.md` с Quick Start
4. Финальный коммит, tag `v0.1.0`

---

## Параллелизм

```
Фаза 0 → Фаза 1 → Фаза 2 ─┬─→ Фаза 3 ─┐
                              ├─→ Фаза 4 ─┼─→ Фаза 5 → Фаза 7 → Фаза 8 → Фаза 9 → Фаза 10
                              └─→ Фаза 6 ─┘
```

## Правила работы по фазам

1. **Одна фаза за раз.** Выполнить фазу полностью, прогнать тесты и линтер, **остановиться**. Не переходить к следующей фазе без явного подтверждения.
2. **Отчёт после каждой фазы.** По завершении фазы выдать краткий отчёт:
   - Какие файлы созданы / изменены
   - Какие тесты добавлены и их статус (pass/fail)
   - Результат линтера
   - Coverage (если применимо)
   - Известные проблемы или отклонения от плана
3. **Не коммитить.** Коммиты и пуш делает владелец репозитория вручную после ревью.
4. **Ожидание подтверждения.** После отчёта ждать команды на переход к следующей фазе.
5. **Отметка выполненной фазы.** Когда фаза полностью завершена (тесты зелёные, линтер чистый, нет проблем), добавить к заголовку фазы метку `✅ DONE`. Например: `### Фаза 1: Config + Exceptions + Types [M] ✅ DONE`. Это позволяет при новой сессии без контекста определить текущую фазу — достаточно написать «продолжай выполнение», и следующая незавершённая фаза будет найдена автоматически.

## Верификация

После каждой фазы:
1. `uv run pytest tests/unit -v` — все юнит-тесты зелёные
2. `uv run ruff check src/ tests/` — линтер чистый
3. `uv run pytest --cov --cov-report=term-missing` — coverage ≥ 90%

Финальная:
1. `uv run pytest tests/unit tests/integration -v`
2. `uv run pytest tests/e2e -m e2e -v`
3. `uv build` → `pip install dist/*.whl` → smoke test
