# Implementation Plan V2: llm-rotator

## Контекст

MVP (v0.1.0) реализован полностью. V2 расширяет пакет новыми провайдерами, бэкендами, интеграциями и улучшениями DX.

### Что уже реализовано (MVP)

| Компонент | Статус |
|-----------|--------|
| Config (Pydantic v2, программный) | ✅ |
| Exceptions (иерархия + ExceptionGroup) | ✅ |
| InMemoryBackend (circuit breaker + quota counters) | ✅ |
| Circuit Breaker (error classifier, granular TTL) | ✅ |
| Quota Manager (token + request, daily_utc + rolling_window) | ✅ |
| Rotator (Model-First CoR, tier ceiling) | ✅ |
| Lifecycle Hooks (Protocol, before/after/on_fallback) | ✅ |
| RoutingContext (tier, model_group, allowed_providers) | ✅ |
| OpenAI-compatible Client (httpx, streaming) | ✅ |
| Gemini Client (httpx, streaming, message translation) | ✅ |
| Streaming с mid-stream error recovery | ✅ |
| Unit + Integration + E2E тесты (140+ кейсов) | ✅ |

### Что реализуется в V2

| # | Фича | Приоритет |
|---|-------|-----------|
| 1 | Загрузка конфига из JSON-файла | High |
| 2 | Anthropic Client (httpx) | High |
| 3 | Redis Backend | High |
| 4 | Tool Calling + Structured Output | High |
| 5 | Structured Logging | Medium |
| 6 | Quota Warnings (80% alert) | Medium |
| 7 | LangChain Integration | Medium |

---

## Фазы реализации

### Фаза 11: Загрузка конфига из JSON-файла [S] ✅ DONE

**Зависимости:** Нет (самостоятельная фаза)

**Цель:** `LLMRotator.from_json("config.json")` — загрузка конфигурации из JSON-файла с подстановкой env-переменных для секретов (токенов).

**TDD:** `tests/unit/test_config_json.py` → `src/llm_rotator/config.py` (расширение)

**Тесты:**

| Тест-кейс | Что проверяет |
|---|---|
| `test_from_json_file_valid` | Минимальный валидный JSON → `RotatorConfig` |
| `test_from_json_file_full` | Полный JSON с groups, quotas, tiers → корректный парсинг |
| `test_from_json_file_not_found` | Несуществующий файл → `FileNotFoundError` |
| `test_from_json_file_invalid_json` | Битый JSON → `ValueError` с понятным сообщением |
| `test_from_json_file_validation_error` | Валидный JSON, невалидная схема → `ValidationError` |
| `test_from_json_env_substitution` | `"token": "$OPENAI_KEY"` или `"token": "${OPENAI_KEY}"` → подставляет значение из env |
| `test_from_json_env_missing_raises` | `"token": "$MISSING_VAR"` без fallback → `ValueError` с именем переменной |
| `test_from_json_env_default_value` | `"token": "${OPENAI_KEY:-sk-default}"` → fallback если env не задан |

**Реализация:**

1. `RotatorConfig.from_json(path: str | Path) -> RotatorConfig` — classmethod:
   - Читает JSON файл
   - Рекурсивно обходит все строковые значения, подставляя `$VAR` / `${VAR}` / `${VAR:-default}` из `os.environ`
   - Вызывает `RotatorConfig.model_validate(data)`
2. `LLMRotator.from_json(path: str | Path, **kwargs) -> LLMRotator` — convenience classmethod:
   - Вызывает `RotatorConfig.from_json(path)`, создаёт `LLMRotator(config, **kwargs)`

**Формат JSON-конфига (пример):**

```json
{
  "providers": [
    {
      "name": "openai",
      "client_type": "openai",
      "priority": 1,
      "model_groups": [
        {
          "name": "flagship",
          "tier": 1,
          "models": ["gpt-4o", "gpt-4-turbo"],
          "token_quota": {
            "limit": 250000,
            "reset": "daily_utc"
          }
        }
      ],
      "keys": [
        {"token": "$OPENAI_API_KEY", "alias": "main_key"},
        {"token": "${OPENAI_BACKUP_KEY:-}", "alias": "backup_key"}
      ]
    }
  ]
}
```

---

### Фаза 12: Anthropic Client [M] ✅ DONE

**Зависимости:** Фаза 11 (нужен `client_type: "anthropic"` в конфиге)

**Цель:** httpx-клиент для Anthropic Claude Messages API. Без SDK (`anthropic`), только REST через httpx.

**TDD:** `tests/integration/test_anthropic_client.py` (через respx) → `src/llm_rotator/clients/anthropic.py`

**Изменения в конфиге:**
- Добавить `ClientType.ANTHROPIC = "anthropic"` в `config.py`
- Зарегистрировать в `rotator.py` маппинг `ClientType.ANTHROPIC → AnthropicClient`

**Тесты:**

| Тест-кейс | Что проверяет |
|---|---|
| `test_generate_success` | POST `/v1/messages` → LLMResponse с content и usage |
| `test_generate_system_message` | system message → поле `system` (не в messages) |
| `test_generate_multi_turn` | Несколько user/assistant → корректная трансляция |
| `test_stream_success` | SSE stream → StreamChunk с delta и final usage |
| `test_stream_message_stop` | Event `message_stop` → done=True |
| `test_error_401_key_dead` | 401 → `KeyDeadError` |
| `test_error_429_rate_limit` | 429 + `retry-after` header → `ModelRateLimitError` |
| `test_error_529_overloaded` | 529 → `ServerError` (Anthropic-специфичный код) |
| `test_error_500_server` | 500 → `ServerError` |
| `test_max_tokens_default` | Если `max_tokens` не задан → подставляется дефолт (4096) |

**Реализация (`clients/anthropic.py`):**

```
AnthropicClient(AbstractLLMClient):
  BASE_URL = "https://api.anthropic.com"
  DEFAULT_MAX_TOKENS = 4096
  ANTHROPIC_VERSION = "2023-06-01"

  generate():
    POST /v1/messages
    Headers: x-api-key, anthropic-version
    Body: model, messages (без system), system (отдельно), max_tokens
    Response: content[0].text → LLMResponse

  stream():
    POST /v1/messages с stream=true
    SSE events: message_start, content_block_delta, message_delta, message_stop
    content_block_delta.delta.text → StreamChunk.delta
    message_delta.usage → final usage
```

**Трансляция сообщений:**
- `system` → отдельное поле `system` в теле запроса (строка)
- `user` / `assistant` → `messages[]` как есть
- Несколько system-сообщений → конкатенация через `\n\n`

**E2E тест** (опциональный, `@pytest.mark.e2e`):
- `test_live_anthropic_simple` — реальный запрос к Claude API

---

### Фаза 13: Structured Logging [S] ✅ DONE

**Зависимости:** Нет

**Цель:** Структурированные логи маршрутизации в формате из PRD: `[OpenAI/flagship] gpt-5 (key1) → 429 → gpt-5 (key2) → 200 OK`. Поддержка кастомного логгера.

**TDD:** `tests/unit/test_logging.py` → `src/llm_rotator/logging.py` + изменения в `rotator.py`

**Тесты:**

| Тест-кейс | Что проверяет |
|---|---|
| `test_routing_log_happy_path` | Один кандидат → `[OpenAI/flagship] gpt-4o (main_key) → 200 OK` |
| `test_routing_log_fallback_chain` | 429 → fallback → 200 → полная цепочка в одной строке |
| `test_routing_log_tier_skip` | `[tier=3] Skipping [OpenAI/flagship] (tier=1) → ...` |
| `test_routing_log_quota_skip` | `QuotaExceeded (248k/250k tokens)` в логе |
| `test_routing_log_all_failed` | Цепочка заканчивается `→ AllAttemptsFailed` |
| `test_custom_logger` | Передача кастомного `logging.Logger` → логи идут в него |
| `test_log_includes_request_id` | Каждый вызов `complete()`/`stream()` имеет уникальный request_id в логе |

**Реализация:**

1. `src/llm_rotator/logging.py`:
   - `RoutingLogger` — класс, формирующий цепочку шагов маршрутизации
   - `add_step(candidate, result)` — добавляет шаг (skip, error, success)
   - `format()` — возвращает строку в формате из PRD
   - Метод `emit()` — записывает в logger (INFO для success, WARNING для fallback)

2. Изменения в `rotator.py`:
   - Принимает опциональный `logger: logging.Logger` в конструкторе
   - Создаёт `RoutingLogger` на каждый `complete()`/`stream()` вызов
   - Логирует полную цепочку после завершения (одна строка)

**Формат логов:**
```
[req:abc123] [OpenAI/flagship] gpt-4o (main_key) → 429 RateLimit → gpt-4o (backup_key) → 200 OK (usage: 150 tokens)
[req:def456] [tier=3] Skip [OpenAI/flagship] (tier=1) → [OpenAI/mini] gpt-4o-mini (main_key) → QuotaExceeded (248k/250k) → [Gemini] gemini-flash (google_key) → 200 OK
```

---

### Фаза 14: Quota Warnings [S] ✅ DONE

**Зависимости:** Фаза 13 (использует structured logging)

**Цель:** Оповещение при приближении к порогу квоты (по умолчанию 80%). Два способа подписки:

1. **Простой callback** в конструкторе `LLMRotator` — для быстрого старта
2. **Метод `on_quota_warning` в lifecycle hook** — для сложных сценариев

**TDD:** `tests/unit/test_quota_warnings.py` → изменения в `quota.py` + `rotator.py`

**Тесты:**

| Тест-кейс | Что проверяет |
|---|---|
| `test_warning_at_80_percent` | При usage 80% → вызывается callback |
| `test_no_warning_below_threshold` | При usage 79% → callback не вызывается |
| `test_warning_custom_threshold` | `warning_threshold=0.9` → warning при 90% |
| `test_warning_called_once_per_cross` | Warning вызывается один раз при пересечении порога, не на каждый запрос |
| `test_warning_receives_quota_info` | Callback получает scope, current, limit, percentage |
| `test_warning_for_token_and_request_quotas` | Warning работает для обоих типов квот |
| `test_no_callback_no_error` | Без зарегистрированного callback → нет ошибки |
| `test_simple_callback_in_constructor` | `LLMRotator(config, on_quota_warning=my_fn)` → вызывается |
| `test_hook_on_quota_warning` | `hook.on_quota_warning()` → вызывается через lifecycle |
| `test_both_callback_and_hook` | Оба вызываются, callback первым |

**Реализация:**

1. **Простой callback в `LLMRotator`:**
   ```python
   rotator = LLMRotator(
       config,
       on_quota_warning=lambda scope, current, limit, pct: print(f"⚠️ {scope}: {pct:.0%}"),
       warning_threshold=0.8,  # по умолчанию
   )
   ```

2. **Метод в LifecycleHook (Protocol):**
   ```python
   class MyHook:
       async def on_quota_warning(
           self, scope: str, current: int, limit: int, percentage: float
       ) -> None:
           await bot.send_message(ADMIN, f"Quota {scope}: {percentage:.0%}")
   ```

3. В `QuotaManager`:
   - Добавить `warning_threshold: float = 0.8` в конструктор
   - Добавить `_warned_scopes: set[str]` — чтобы не спамить warning на каждый запрос
   - В `record_usage()` после инкремента: если `current / limit >= threshold` и scope не в `_warned_scopes` → вернуть warning info, добавить в `_warned_scopes`
   - Сбрасывать `_warned_scopes` при обнулении квоты (новый TTL period)

4. В `rotator.py`:
   - Принять `on_quota_warning: Callable | None` и `warning_threshold: float` в конструкторе
   - После `record_usage()` проверять warning info → вызывать callback + hook'и

3. Логирование: при warning логировать WARNING-уровень через structured logger.

---

### Фаза 15: Redis Backend [L] ✅ DONE

**Зависимости:** Нет

**Цель:** Полноценный `RedisBackend`, реализующий `AbstractStateBackend` через `redis[asyncio]`. Для multi-instance деплоев.

**TDD:** `tests/unit/test_backends.py` (расширение параметризованных тестов) + `tests/integration/test_redis_backend.py` → `src/llm_rotator/backends/redis.py`

**Тесты (в дополнение к существующим контрактным):**

| Тест-кейс | Что проверяет |
|---|---|
| Все существующие `test_backends.py` | Параметризация `["memory", "redis"]` — уже есть, но redis был заглушен |
| `test_redis_connection_from_url` | `RedisBackend.from_url("redis://localhost:6379/0")` |
| `test_redis_key_prefix` | Все ключи имеют настраиваемый prefix (`llm_rotator:`) |
| `test_redis_dead_key_persistent` | Dead key сохраняется без TTL (навсегда) |
| `test_redis_concurrent_quota_atomic` | 100 конкурентных INCRBY → ровно 100 |
| `test_redis_ttl_respected` | EXPIRE работает корректно |
| `test_redis_connection_error_handling` | При потере связи → понятная ошибка |

**Реализация (`backends/redis.py`):**

```python
class RedisBackend(AbstractStateBackend):
    def __init__(self, redis: redis.asyncio.Redis, prefix: str = "llm_rotator:"):
        ...

    @classmethod
    async def from_url(cls, url: str, prefix: str = "llm_rotator:") -> "RedisBackend":
        ...

    # Circuit breaker
    async def mark_key_dead(self, key_id):
        # SET {prefix}dead:{key_id} 1  (без TTL — навсегда)

    async def block_key_for_model(self, key_id, model, ttl_seconds):
        # SET {prefix}block:{key_id}:{model} 1 EX {ttl_seconds}

    async def is_key_available(self, key_id, model):
        # EXISTS dead:{key_id} → False
        # EXISTS block:{key_id}:{model} → False
        # else → True

    # Quota
    async def increment_quota(self, scope, amount, ttl_seconds):
        # INCRBY {prefix}quota:{scope} {amount}
        # Если TTL не установлен → EXPIRE {ttl_seconds}
        # Возвращает текущее значение

    async def get_quota_usage(self, scope):
        # GET {prefix}quota:{scope} → int или 0
```

**Тестирование:** `fakeredis[lua]` — in-process Redis без Docker.

**Обновления:**
- `RotatorConfig`: добавить опциональное поле `state_backend: str | None` (`"redis://..."`)
- `LLMRotator`: если `state_backend` начинается с `redis://` → создать `RedisBackend.from_url()`

---

### Фаза 16: Tool Calling + Structured Output [L] ✅ DONE

**Зависимости:** Фаза 12 (Anthropic Client)

**Цель:** Единый формат tools и response_format для всех провайдеров. Трансляция под капотом.

**TDD:** `tests/unit/test_tool_calling.py` + расширение `tests/integration/test_clients.py`

**Единый формат (OpenAI-совместимый):**

```python
# Tools (function calling)
tools = [
    {
        "type": "function",
        "function": {
            "name": "get_weather",
            "description": "Get current weather",
            "parameters": {
                "type": "object",
                "properties": {
                    "city": {"type": "string"}
                },
                "required": ["city"]
            }
        }
    }
]

# Structured Output (JSON mode)
response_format = {"type": "json_object"}
# или Pydantic model
response_format = WeatherResponse  # type[BaseModel]
```

**Тесты:**

| Тест-кейс | Что проверяет |
|---|---|
| `test_openai_tools_passthrough` | OpenAI client передаёт tools as-is |
| `test_openai_tool_call_response` | Парсинг tool_calls из ответа → `LLMResponse.tool_calls` |
| `test_openai_json_mode` | `response_format={"type": "json_object"}` → передаётся как есть |
| `test_openai_pydantic_schema` | `response_format=MyModel` → JSON Schema в response_format |
| `test_gemini_tools_translation` | OpenAI tools → Gemini `functionDeclarations` формат |
| `test_gemini_tool_call_response` | Парсинг `functionCall` из Gemini ответа |
| `test_gemini_json_mode` | `response_format` → `generationConfig.responseMimeType: "application/json"` |
| `test_anthropic_tools_translation` | OpenAI tools → Anthropic `tools` формат (name, description, input_schema) |
| `test_anthropic_tool_call_response` | Парсинг `tool_use` content block из ответа |
| `test_anthropic_tool_call_stream` | Streaming tool_use → `input_json_delta` сборка |
| `test_rotator_tools_forwarded` | `rotator.complete(tools=...)` → tools передаются в client |
| `test_rotator_response_format_forwarded` | `rotator.complete(response_format=...)` → передаётся в client |

**Изменения в типах (`_types.py`):**

```python
@dataclass(frozen=True)
class ToolCall:
    id: str
    name: str
    arguments: str  # JSON string

@dataclass
class LLMResponse:
    content: str | None  # None если tool_call
    tool_calls: list[ToolCall] | None  # None если text response
    usage: Usage
    ...
```

**Изменения в AbstractLLMClient:**

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

**Трансляция по провайдерам:**

| Формат | OpenAI | Gemini | Anthropic |
|--------|--------|--------|-----------|
| Tools | As-is | `functionDeclarations` (без `type: "function"` обёртки) | `tools[].name/description/input_schema` |
| Tool choice | As-is | `toolConfig.functionCallingConfig` | `tool_choice` |
| JSON mode | As-is | `generationConfig.responseMimeType` | Не поддерживается нативно (через system prompt) |
| Pydantic | JSON Schema → `response_format` | JSON Schema → `responseSchema` | JSON Schema → system prompt instruction |
| Tool call response | `tool_calls[]` | `functionCall` в parts | `tool_use` content block |

---

### Фаза 17: LangChain Integration [M] ✅ DONE

**Зависимости:** Фаза 16 (Tool Calling — для tool support в LangChain)

**Цель:** `RotatorChatModel(BaseChatModel)` — drop-in replacement для LangChain/LangGraph.

**TDD:** `tests/integration/test_langchain.py` → `src/llm_rotator/integrations/langchain.py`

**Тесты:**

| Тест-кейс | Что проверяет |
|---|---|
| `test_invoke_returns_ai_message` | `model.invoke([HumanMessage(...)])` → `AIMessage` |
| `test_invoke_with_system` | `SystemMessage` → корректно передаётся |
| `test_stream_returns_chunks` | `model.stream(...)` → `AIMessageChunk` iterator |
| `test_bind_tools` | `model.bind_tools([tool])` → tools передаются в rotator |
| `test_with_structured_output` | `model.with_structured_output(MyModel)` → Pydantic parsing |
| `test_routing_context_via_kwargs` | `model.invoke(..., routing=RoutingContext(tier=3))` |
| `test_fallback_transparent` | Ротация прозрачна для LangChain — клиент получает обычный AIMessage |
| `test_usage_in_response_metadata` | `response.response_metadata["usage"]` содержит token counts |
| `test_in_chain` | `prompt | model | parser` — работает в LangChain chain |

**Реализация:**

```python
from langchain_core.language_models import BaseChatModel
from langchain_core.messages import AIMessage, BaseMessage

class RotatorChatModel(BaseChatModel):
    rotator: LLMRotator
    default_routing: RoutingContext | None = None

    @property
    def _llm_type(self) -> str:
        return "llm-rotator"

    def _generate(self, messages, stop, run_manager, **kwargs):
        # sync wrapper (asyncio.run)
        ...

    async def _agenerate(self, messages, stop, run_manager, **kwargs):
        # Конвертация LangChain messages → OpenAI dict format
        # routing = kwargs.pop("routing", self.default_routing)
        # result = await self.rotator.complete(messages=..., routing=routing, tools=...)
        # Конвертация LLMResponse → ChatResult с AIMessage
        ...

    def _stream(self, messages, stop, run_manager, **kwargs):
        # sync streaming wrapper
        ...

    async def _astream(self, messages, stop, run_manager, **kwargs):
        # async streaming через rotator.stream()
        ...
```

**Конвертация сообщений:**
- `HumanMessage` → `{"role": "user", "content": "..."}`
- `AIMessage` → `{"role": "assistant", "content": "..."}`
- `SystemMessage` → `{"role": "system", "content": "..."}`
- `ToolMessage` → `{"role": "tool", "content": "...", "tool_call_id": "..."}`

---

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

**Зависимости:** Фазы 11-17

**TDD:** `tests/integration/test_full_rotation_v2.py` + расширение `tests/e2e/test_live_providers.py`

**Интеграционные тесты:**

| Тест-кейс | Что проверяет |
|---|---|
| `test_from_json_to_complete` | JSON-файл → `LLMRotator.from_json()` → `complete()` → ответ |
| `test_anthropic_in_rotation` | Anthropic как fallback провайдер в цепочке ротации |
| `test_tool_calling_with_fallback` | Tool call → 429 → fallback на другой провайдер → tool call response |
| `test_structured_output_cross_provider` | Pydantic schema → разные провайдеры → одинаковый результат |
| `test_quota_warning_in_rotation` | Quota warning callback вызывается при 80% в ходе ротации |
| `test_redis_backend_full_rotation` | Полный флоу с RedisBackend (fakeredis) |
| `test_langchain_chain_with_rotator` | `prompt | RotatorChatModel | parser` с fallback |
| `test_structured_logging_output` | Полная цепочка маршрутизации в логах |

**E2E тесты (опциональные, `@pytest.mark.e2e`):**

| Тест-кейс | Что проверяет |
|---|---|
| `test_live_anthropic_simple` | Реальный запрос к Claude API |
| `test_live_tool_calling_openai` | Реальный tool call к OpenAI |
| `test_live_tool_calling_anthropic` | Реальный tool call к Anthropic |

---

### Фаза 19: Public API V2 + Документация [S] ✅ DONE

**Зависимости:** Фазы 11-18

**Действия:**

1. Обновить `__init__.py`:
   - Добавить экспорты: `AnthropicClient`, `RedisBackend`, `RotatorChatModel`, `ToolCall`
   - Добавить convenience: `LLMRotator.from_json()`
2. Обновить `pyproject.toml`:
   - Bump version → `0.2.0`
   - Добавить extras: `anthropic` (не нужен SDK, но для маркировки), `all`
3. Обновить `README.md`:
   - JSON config example
   - Tool calling example
   - LangChain integration example
   - Structured logging example
4. Обновить `CLAUDE.md` с новыми компонентами
5. Обновить `docs/PRD.md` — отметить реализованные фичи V2

---

## Граф зависимостей фаз

```
Фаза 11 (JSON Config) ──────────────────────────────────────┐
                                                              │
Фаза 12 (Anthropic) ──────┐                                  │
                           ├──→ Фаза 16 (Tool Calling) ──→ Фаза 17 (LangChain) ──┐
Фаза 13 (Logging) ──┐     │                                                       │
                     ├──→ Фаза 14 (Quota Warnings)                                 ├──→ Фаза 18 (Integration Tests) → Фаза 19 (API + Docs)
Фаза 15 (Redis) ────┘     │                                                       │
                           └───────────────────────────────────────────────────────┘
```

**Параллельно можно выполнять:** Фазы 11, 12, 13, 15 — они независимы друг от друга.

---

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

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

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

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

Финальная (после Фазы 19):
1. `uv run pytest tests/unit tests/integration -v`
2. `uv run pytest tests/e2e -m e2e -v` (с реальными ключами)
3. `uv build` → smoke test
