Newer
Older
AI_Lab_SCRUME_Co / scripts / polza-limits / polza_limits.py
#!/usr/bin/env python3
"""Суточные лимиты Polza.ai по ДВУМ ключам профилей — компактный JSON для чипа.

Ключи (значения в вывод не попадают):

  * default + local → CUSTOM_PROVIDER_POLZA_AI_KEY  из %LOCALAPPDATA%\\hermes\\.env
  * ai-si           → HERMES_CUSTOM_POLZA_AI_API_KEY из %LOCALAPPDATA%\\hermes\\profiles\\ai-si\\.env

Порядок работы: сначала /key и /balance на каждый ключ (полосы готовы даже если
история не досчитается), затем генерации за местные сутки с жёстким дедлайном —
плагин получает от shell.exec только 30 с и хвост stdout в 4000 символов.

Формат: одна строка JSON
  {"ok":true,"day":"2026-10-14","k":[
     {"p":"default + local","limit":500.0,"used":174.63,"left":325.37,"reset":"daily",
      "amount":4829.57,"avail":325.37,
      "m":["DeepSeek: DeepSeek V4.1 Flash\\x1f169.80\\x1f518", ...]},
     {"p":"ai-si","limit":1500.0,...,"m":[...]}]}

Модели — плоские строки через \\x1f (имя, рубли, число запросов): объекты с
длинными ключами не влезают в бюджет вывода.
"""
import argparse
import collections
import datetime
import json
import os
import re
import sys
import time
import urllib.parse
import urllib.request

API = "https://polza.ai/api/v1"
HERE = os.path.dirname(os.path.abspath(__file__))

# Метка, .env, имя переменной. Путь к корневому .env агента: у получателя
# установка кладёт персону, конфиг и ключ именно туда.
TARGETS = [
    ("мой ключ",
     os.path.expandvars(r"%LOCALAPPDATA%\hermes\.env"),
     "HERMES_CUSTOM_POLZA_AI_API_KEY"),
]

GROUP_SHARE = 0.001     # порог «мелочи»: доля от суточного расхода
GROUP_RUB = 0.05        # порог «мелочи»: абсолютная сумма
MAX_MODELS = 6          # на ключ; плюс «Другое»
OUT_BUDGET = 3400       # оставляем запас до 4000 символов shell.exec
GEN_DEADLINE_S = 18.0   # генерации не должны съесть 30-секундный потолок


def read_key(path: str, name: str) -> str | None:
    try:
        with open(path, "r", encoding="utf-8", errors="replace") as fh:
            text = fh.read()
    except OSError:
        return None
    m = re.search(rf"^{re.escape(name)}=(.+)$", text, re.M)
    return m.group(1).strip().strip('"').strip("'") if m else None


def api_get(path: str, key: str, timeout: float = 8.0) -> dict:
    req = urllib.request.Request(API + path, headers={"Authorization": f"Bearer {key}"})
    with urllib.request.urlopen(req, timeout=timeout) as resp:
        return json.loads(resp.read().decode("utf-8"))


def fetch_generations(key: str, start: datetime.datetime, end: datetime.datetime) -> list[dict]:
    """Все генерации за сутки, но не дольше GEN_DEADLINE_S суммарно."""
    items: list[dict] = []
    page = 1
    deadline = time.monotonic() + GEN_DEADLINE_S
    while page <= 20:
        if time.monotonic() > deadline:
            break
        query = urllib.parse.urlencode({
            "limit": 100,
            "page": page,
            "dateFrom": start.isoformat(),
            "dateTo": end.isoformat(),
            "sortBy": "createdAt",
            "sortOrder": "desc",
        })
        try:
            data = api_get("/history/generations?" + query, key)
        except Exception:
            break
        batch = data.get("items") or []
        items.extend(batch)
        meta = data.get("meta") or {}
        if page >= int(meta.get("totalPages") or 1) or not batch:
            break
        page += 1
    return items


def compact_models(rows: list[tuple[str, float, int]], total: float, max_models: int) -> list[str]:
    head, dropped = [], []
    for name, cost, count in rows:
        if (cost >= GROUP_RUB and (total <= 0 or cost / total >= GROUP_SHARE)
                and len(head) < max_models):
            head.append((name, cost, count))
        else:
            dropped.append((cost, count))
    out = [f"{n.replace(chr(31), ' ')}\x1f{c:.2f}\x1f{k}" for n, c, k in head]
    tail_cost = sum(c for c, _ in dropped)
    tail_count = sum(k for _, k in dropped)
    if tail_cost > 0.0 or tail_count:
        out.append(f"Другое\x1f{tail_cost:.2f}\x1f{tail_count}")
    return out


def collect(label: str, env_path: str, env_name: str,
            start: datetime.datetime, end: datetime.datetime) -> dict:
    row: dict = {"p": label}
    key = read_key(env_path, env_name)
    if not key:
        row["err"] = f"ключ {env_name} не найден в .env"
        return row

    try:
        info = api_get("/key", key)
    except Exception as exc:
        row["err"] = f"{type(exc).__name__}: {exc}"
        return row

    row.update({
        "limit": float(info.get("limit") or 0),
        "used": float(info.get("usage_daily") or 0),
        "left": float(info.get("limit_remaining") or 0),
        "reset": info.get("limit_reset") or "",
        "week": float(info.get("usage_weekly") or 0),
    })

    try:
        bal = api_get("/balance", key)
        row["amount"] = float(bal.get("amount") or 0)
        row["avail"] = float(bal.get("available") or 0)
    except Exception:
        pass

    items = fetch_generations(key, start, end)
    per: dict[str, list] = collections.defaultdict(lambda: [0.0, 0])
    for it in items:
        cost = float(it.get("cost") or 0)
        name = it.get("modelDisplayName") or it.get("model") or "—"
        per[name][0] += cost
        per[name][1] += 1
    rows = sorted(((n, v[0], v[1]) for n, v in per.items()), key=lambda r: -r[1])
    row["gen"] = sum(r[2] for r in rows)
    row["_rows"] = rows
    return row


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("--pretty", action="store_true", help="человекочитаемый вывод")
    args = ap.parse_args()

    now = datetime.datetime.now().astimezone()
    start = now.replace(hour=0, minute=0, second=0, microsecond=0)

    rows = [collect(label, path, name, start, now) for label, path, name in TARGETS]

    # Модели укладываем в бюджет: пробуем 6 → 4 → 2 → 0 на ключ.
    keys_models = [(r, r.pop("_rows", [])) for r in rows]
    payload: dict = {}
    for cap in (MAX_MODELS, 4, 2, 0):
        for r, src in keys_models:
            total = sum(c for _, c, _ in src)
            r["m"] = compact_models(src, total, cap) if cap else []
        payload = {"ok": True, "day": start.strftime("%Y-%m-%d"), "k": rows}
        flat = json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
        if len(flat) <= OUT_BUDGET:
            break

    if args.pretty:
        for r in rows:
            if r.get("err"):
                print(f"{r['p']}: ОШИБКА {r['err']}")
                continue
            pct = (r["used"] / r["limit"] * 100) if r["limit"] else 0.0
            print(f"{r['p']}: {r['used']:.2f} из {r['limit']:.2f} ₽ ({pct:.1f} %), "
                  f"остаток {r['left']:.2f} ₽, генераций {r.get('gen', 0)}, сброс {r['reset']}")
            for m in r["m"]:
                n, c, k = m.split("\x1f")
                print(f"    {float(c):>8.2f} ₽ {k:>5}  {n}")
        return 0

    json.dump(payload, sys.stdout, ensure_ascii=False, separators=(",", ":"))
    sys.stdout.write("\n")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())