Metadata-Version: 2.5
Name: s-accountpoolkit
Version: 0.5.19
Summary: Зрелый пул аккаунтов/сессий поверх librarykit: статус-машина аккаунта, quota/подписки, rate-limit + circuit-breaker, OAuth-ротация (single-flight DCL-lock), egress-пул с quarantine, import/export, headless-фасад. Обобщение gemini-balancer в переиспользуемый доменный слой.
Author: Dmitry
License: MIT
License-File: LICENSE
Requires-Python: >=3.11
Requires-Dist: platformdirs>=4.0
Requires-Dist: pydantic>=2.6
Requires-Dist: s-corekit>=0.0.11
Requires-Dist: s-librarykit>=0.7.20
Requires-Dist: s-ormkit>=0.0.1
Provides-Extra: adapters
Requires-Dist: s-adapterkit>=0.1.4; extra == 'adapters'
Provides-Extra: cli
Requires-Dist: s-clikit>=0.1; extra == 'cli'
Provides-Extra: client
Requires-Dist: httpx>=0.27; extra == 'client'
Provides-Extra: egress
Requires-Dist: s-librarykit[antibot]>=0.7.20; extra == 'egress'
Provides-Extra: rest
Requires-Dist: starlette>=0.37; extra == 'rest'
Requires-Dist: uvicorn>=0.30; extra == 'rest'
Description-Content-Type: text/markdown

# s-accountpoolkit

Зрелый **пул аккаунтов/сессий** поверх [`s-librarykit`](https://gitlab.com/S-kits/librarykit) — доменный слой над `SessionStore`/`SessionPool`/`RateLimiter`/`RotatingAuth`/antibot-транспортами. Обобщение gemini-balancer в переиспользуемый кит.

**import-имя** `accountpoolkit` · **dist-имя** `s-accountpoolkit` (как s-librarykit → librarykit).

## Что даёт (сверх librarykit)

- **Account со статус-машиной** — `available / cooldown / quota_exhausted / disabled / blocked` с причинами и таймстемпами (не строка+`is_active`).
- **Quota / подписки** — `QuotaData` union (dual-window weekly+5h / header-based / JWT-claims), приоритет `ULTRA > PRO > FREE`, model-level изоляция.
- **Таксономия подписок по семействам** (`accountpoolkit.subscription`) — провайдеры одного вендора делят подписку, но тиры разные при общей оси free/paid: google (Plus/Pro/Ultra 5x·20x), openai (Go/Plus/Edu/Pro 5x·20x), anthropic (Pro/Max 5x·20x), telegram (Premium). Каждый тир несёт грубый `SubscriptionTier` для селектора; у `Account` — `plan`/`subscription_expires_at`/`project` (срок подписки + ярлык-проект).
- **Rate-limit + circuit-breaker** — разбор 429/403/5xx/Retry-After, exp-backoff по причинам, cooldown отдельно от CB-open, авто-recovery.
- **Selector** — двухслойный (жёсткий eligibility-фильтр → Power-of-Two-Choices), приоритет-каскад подписка→квота→health→reset, sticky-сессии, slow-start.
- **OAuth-ротация** — single-flight (double-checked-locking) на аккаунт, атомарный rolling-refresh, fallback-цепочка OAuth-клиентов, `invalid_grant` → карантин, проактивный фоновый рефреш, keyring-экспорт токена.
- **Egress-пул** — привязка аккаунт↔прокси (sticky IP) vs глобальный пул, стратегии (RR/Weighted/LeastConn/Priority/P2C), health-check, **настоящий per-proxy circuit-breaker/quarantine**.
- **Общий вход у поставщика личности (SSO)** — вход в Google хранится ОДИН раз и переиспользуется всеми сервисами и пулами; отдельное состояние «сессия сервиса жива, вход отозван»; продление одно на всех (см. ниже).
- **Журнал событий идентичности** — append-only история аккаунта (выдача, вход, продление, ротация, операция, отказ по лимиту, челлендж, бан, перелогин, смена выхода) с выходом/устройством/уликой и выборкой «что предшествовало бану». Без секретов by-design; отказ журнала не роняет работу (см. ниже).
- **Риск аккаунта одним числом** — показатель из журнала (свежее весит больше, бан ≠ отказ по лимиту) со слагаемыми, из которых он сложился, и третьим состоянием «истории не хватает». Селектор уточняется им осознанно — флагом, с режимом наблюдения (см. ниже).
- **Устройство аккаунта (персона)** — одно устройство на аккаунт: заводится при первой выдаче, лежит в реестре, переживает перезапуск и версионируется (`corekit.persona`). Выдача отдаёт персону ВМЕСТЕ с адресом выхода и сводит их между собой; смена устройства — событие журнала (см. ниже).
- **Выход по требованию** — тип выхода (`datacenter | residential | mobile` + честное «неизвестно»), ASN и страна в модели прокси/инбаунда; дорогой выход выдаётся только когда его просят (см. ниже).
- **Import/export** — версионированный конверт, идемпотентный upsert, чтение чужих сторов.
- **Headless `AccountService`-фасад** + `clikit` CLI (json-by-default); опц. REST admin (`[rest]`, `accountpoolkit.rest.create_admin_app`) + cloudflared quick-tunnel (`accountpoolkit.tunnel`, нужен бинарь `cloudflared` на PATH — не pip-пакет).

Секреты — только через librarykit `SessionStore` (envelope KEK/DEK) / `SecretStore` (keyring+fallback). Никакого plaintext.

Провайдер-специфика (gemini / codex / …) — через `AccountProvider` Protocol-плагины; ядро её не знает.

## Установка

```bash
uv add s-accountpoolkit          # ядро
uv add "s-accountpoolkit[egress]"  # + antibot health-транспорт для egress-пула
uv add "s-accountpoolkit[cli]"     # + CLI `accountpool` (json-by-default)
```

import-имя — `accountpoolkit`. `AccountService` — headless-фасад над всеми слоями:

```python
import accountpoolkit as apk
from librarykit.sessions import SessionStore

store = apk.AccountStore(SessionStore(root=None, encrypt=True), social="myservice")
pool = apk.AccountPool(store, tracker=apk.RateLimitTracker())
svc = apk.AccountService(store=store, pool=pool)

choice = await svc.acquire(require_tier=apk.SubscriptionTier.PRO)
if choice:
    ...  # запрос через choice.proxy (egress) + choice.account (device);
    # секреты хранятся отдельно (envelope): await store.load_creds(choice.ref)
    await svc.report(choice.ref, response=resp)  # 429/5xx → cooldown/circuit-breaker
```

## Общий вход у поставщика личности (SSO)

Аккаунт, заведённый «через Google», держится на ДВУХ входах сразу: сессии самого
сервиса и входе у Google. Сроки жизни у них разные, и умирают они порознь —
ChatGPT продолжает отвечать, когда войти в Google уже нельзя. Пока состояний
было два, этот случай прятался в «всё хорошо» и всплывал в час, когда вход
понадобился.

**Вход хранится один раз.** `SsoLogin` — запись по ключу «поставщик + кого он
узнаёт» (`google` + почта). Тело входа лежит в общем хранилище сессий по
каноническому адресу `sso_session_ref(...)` — профиль `sso`, а не профиль
потребителя, иначе у каждого профиля завелась бы своя копия. Аккаунты на него
ССЫЛАЮТСЯ: `Identity.sso_login_id` в реестре и пара «провайдер + почта» в строке
состояния сессии. Копии на навык нет ни одной.

**Три состояния вместо двух** (`SessionHealthSnapshot.auth_state`):

| состояние | что произошло | что чинить |
|---|---|---|
| `healthy` | сессия сервиса жива, плохих улик о входе нет | ничего |
| `sso_expired` | сессия сервиса ЖИВА, вход у поставщика МЁРТВ | восстановить вход; сервис не трогать, работа идёт |
| `unauthenticated` | мертва сама сессия сервиса | перелогин в сервис |
| `unknown` | сервис недоступен / лимит / капча | переспросить позже |

`logged_out` ставится ТОЛЬКО по прямой улике: поля формы входа (`identifierId`,
`Passwd`) или ответ 401/403 от поставщика. Ссылка «войти» на странице и сетевой
сбой уликами не считаются — по догадке выключаются рабочие аккаунты. Улика
хранится рядом с вердиктом и уходит в отчёт.

**Продление одно на всех.** Человек входит ОДИН раз; поколение входа растёт, и
каждый навык узнаёт об этом сам — его сессия отстала от поколения, а новое тело
уже лежит в общем хранилище:

```python
from accountpoolkit.domain import SsoEvidence
from accountpoolkit.services import SsoService

sso = SsoService(session_factory, store=db_store)
sso.attach("google", "me@gmail.com", service="gemini", account="me@gmail.com", profile=uid)
sso.attach("google", "me@gmail.com", service="chatgpt", account="me@gmail.com", profile=uid)

# наблюдение по улике — одно на ВСЕ сервисы, вход-то один
sso.observe("google", "me@gmail.com", SsoEvidence(status_code=401))   # → logged_out

# вход перехвачен заново: тело уезжает в общее хранилище, поколение +1
await sso.publish("google", "me@gmail.com", storage_state)

# навык спрашивает про СВОЮ сессию и подхватывает общий вход — человека не зовут
if sso.needs_resync("google", "me@gmail.com", service="gemini",
                    account="me@gmail.com", profile=uid):
    body = await sso.body("google", "me@gmail.com")     # один источник на всех
    ...                                                  # пере-минт сессии сервиса
    sso.mark_synced("google", "me@gmail.com", service="gemini",
                    account="me@gmail.com", profile=uid)
```

Аккаунт с мёртвым входом НЕ выключается из выдачи: работа идёт, и отсечь его
значило бы сломать её. Чинить надо вход, а не сервис.

`await sso.adopt(...)` дополнительно помечает сессию в ИНДЕКСЕ хранилища
(`credential_group` — задел librarykit «один логин ⇒ несколько сетей»), чтобы
ответ на «кто ляжет вместе с этим входом» был один, а не два расходящихся.

## Журнал событий идентичности (что предшествовало бану)

У аккаунта есть ИСТОРИЯ, а не только счётчики и мгновенное состояние. Журнал
append-only живёт в том же хранилище, что реестр, и отвечает на вопрос, ради
которого заведён: **что происходило перед баном**.

```python
from accountpoolkit import IdentityJournal

journal = IdentityJournal.open()          # или Gate(...).journal

# ЦКП: события до последнего бана И сам бан последней строкой
for e in journal.before_ban(identity_id=17, limit=20):
    print(e.at, e.kind, e.outcome, e.code, e.egress_host or "—", e.egress_country)

# лента за окно (по аккаунту реестра или по имени аккаунта)
journal.feed(identity_id=17, since=..., limit=100)
journal.feed(account="me@gmail.com", service="gemini-chat")
```

Из CLI (json-by-default):

```bash
accountpool journal before-ban --identity-id 17 --limit 20
accountpool journal feed --account me@gmail.com --hours 24 --kind limit
```

Событие отвечает на «кто, когда, чем и с каким исходом»: момент (UTC), аккаунт и
сервис, сессия и проект-потребитель, ВЫХОД (хост/страна/ASN ноды), устройство
(ссылка — заполняется разметкой устройств), род (`EventKind`: выдача,
освобождение, вход, продление, ротация токена, операция, отказ по лимиту,
челлендж, бан, перелогин, смена выхода), исход (`EventOutcome`) и улику —
МАШИННЫЙ КОД из уже существующих словарей (`LimitReason`, `HealthState`,
`SsoState`, `AccessState`).

**Секретов в журнале нет и быть не может.** Не по дисциплине пишущего, а по
устройству: свободного поля под «тело ответа» или «подробности» в контракте не
существует, а улика и приметы проходят фильтр формы и длины — токен, кука и
заголовок авторизации туда не пролезают. Журнал append-only: попавший в него
секрет остался бы там навсегда.

**Журнал не важнее работы.** Его отказ никогда не роняет горячий путь: выдача
аккаунта происходит и тогда, когда записать событие не удалось.

**Ретенция — 90 дней** (`ACCOUNTPOOL_JOURNAL_RETENTION_DAYS`, ноль — «не
убирать»). Столько живёт окно расследования: бан выясняется не сразу, а всплеск
надо сравнивать с несколькими нормальными циклами недельных лимитов; дальше
квартала — уже архив, а не расследование.

## Риск аккаунта одним числом

Журнал даёт историю, а показатель риска сводит её к ОДНОМУ сравнимому числу:
**насколько рискованно идти этим аккаунтом прямо сейчас**. Раньше на этот
вопрос отвечать было нечем — признаки описывали момент (жив ли, сколько
осталось, сколько подряд упало), а рискованность это свойство истории.

```python
from accountpoolkit import IdentityJournal
from accountpoolkit.services import RiskScorer

risk = RiskScorer(IdentityJournal.open()).assess(identity_id=17, account="me@gmail.com")
print(risk.score, risk.level)   # 63.4 high
print(risk.explain())           # me@gmail.com: риск 63.4 (high) по 41 событиям за 30 дн
                                # — ban 60.0 (1), challenge 2.4 (1), limit 1.0 (12)
```

```bash
accountpool journal risk --account me@gmail.com
```

Как считается: **свежее весит больше** (полураспад 14 дней — бан вчера и бан
три месяца назад это разные вещи), **роды весят по-разному** (бан 60, челлендж
15, отказ по лимиту 1 — лимит про исчерпание, а не про риск), сумма обрезается
сотней. Рядом с числом всегда едут **слагаемые** (`components`): род, сколько
событий, вклад в баллах, когда случилось последнее и какие коды-улики
встретились — иначе показателем нельзя пользоваться при расследовании.

**«Нет данных» — не «низкий риск».** У свежего аккаунта уровень `unknown`, а не
`low`: он непроверен, а не безупречен. Плохие улики при этом перебивают нехватку
данных — единственное событие-бан даёт `high`, а не «мало данных».

**Связь с селектором — осознанная.** По умолчанию учёт риска ВЫКЛЮЧЕН и не
делает ни одного лишнего запроса. Включается флагом
`ACCOUNTPOOL_RISK_AWARE_SELECTOR`: `shadow` — считать и логировать расхождения,
не трогая боевой выдачи; `on` — уточнять каскад (полоса риска встаёт между
health и reset). Отсечения по риску нет: рискованный аккаунт идёт позже, но
остаётся кандидатом.

## Выход по требованию (тип и ASN)

У выхода есть **тип** (`datacenter | residential | mobile` плюс честное
`unknown`), **ASN** и страна: именно их защита видит на краю, ещё до первого
запроса. Выход выдаётся ПО ТРЕБОВАНИЮ:

```python
mgr.choose()                        # требования нет → дешёвый (датацентр)
mgr.choose(require="residential")   # нужен домашний адрес → резидентский/мобильный
mgr.bind(identity_id, "nl-1", require="residential")   # мимо требования → ValueError
```

Резидентские прокси стоят денег, поэтому умолчание их не расходует: дорогой
выход выдаётся по явному требованию, а не «на всякий случай». Неразмеченная
нода (`unknown`) требование дороже датацентра НЕ закрывает — отказ честнее
подмены. Разметка задаётся при заведении инбаунда (`add(..., egress_kind=…,
asn=…)`) и переживает рестарт.

## Устройство аккаунта (персона)

Выдача отдаёт не только адрес выхода, но и **устройство**, которым этот аккаунт
представляется:

```python
granted = await gate.acquire("проект", "gemini-chat")
granted.proxy_endpoint      # socks5h://127.0.0.1:10801 — чем ходим
granted.persona.persona_id  # dev-d648b243a3e8 — КЕМ приходим (стабильно)
granted.persona.version     # 3 — поколение заявления
granted.persona.timezone    # Europe/Helsinki — часы из страны выхода
```

**Одно устройство на аккаунт, а не на вызов.** Раньше выдача отдавала только
адрес, устройство до потребителя не доезжало вовсе, и один аккаунт
представлялся разными клиентами при одном и том же IP. Для скоринга устройства
это худший из рисунков: стабильная сеть при плавающем клиенте читается как угон
сессии. Персона лежит в строке `device` (имя и поколение — колонки, слои — JSON)
и одна на все сервисы аккаунта: у человека один ноутбук на все сайты.

**Обновление — это `bump`, а не подмена.** Обновился Chrome, сменилась
платформа, переехал выход — растёт `version`, `persona_id` остаётся. Смена имени
означала бы для защиты нового посетителя: потерянное узнавание устройства и
подтверждение входа на ровном месте.

**Персона сводится с выходом, и молча это не делается.** Часы принадлежат
машине, а машина стоит там, откуда приходит запрос: пояс, спорящий со страной
выхода, — ошибка уровня «ходить нельзя». Такое расхождение ЧИНИТСЯ (выход
переставили мы) — с поднятием поколения и записью `device_switch` в журнал. А
расхождение, которое сменой места не лечится (окно больше экрана, десктопная
платформа при мобильном признаке, рукопожатие, отставшее от заголовков), —
`PersonaConflict`: чинить его значило бы подменить само устройство.

Чем мы выглядим (версия браузера, язык) кит НЕ пинит — это знает netkit, одно
место на систему; наблюдение можно задать явно:

```python
from accountpoolkit.services import Gate, PersonaService, PersonaTraits

gate = Gate(persona=PersonaService(traits=PersonaTraits(browser_version="152.0.8100.10")))
```

## Статус

Ядро стабильно: статус-машина аккаунта, quota/подписки, rate-limit + circuit-breaker,
OAuth-ротация (single-flight), egress-пул, import/export, `AccountService`-фасад + CLI.
Провайдер-плагины подключаются через entry-points `accountpoolkit.providers`.
