diff --git a/README.md b/README.md index bfc4f94..6dbdbca 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # AILab SCRUME Agent -**Текущая версия: 1.7.4.** +**Текущая версия: 1.7.5.** Дистрибутив Hermes Agent: персона личного помощника, базовый конфиг, 58 скиллов. Автор: AI laboratory SCRUM company. diff --git a/desktop-plugins/ailab-limit/gw_usage.py b/desktop-plugins/ailab-limit/gw_usage.py index 30ed8fa..2db81b0 100644 --- a/desktop-plugins/ailab-limit/gw_usage.py +++ b/desktop-plugins/ailab-limit/gw_usage.py @@ -5,6 +5,10 @@ и в рендерер не попадает. Один запрос: GET /key/info с пользовательским ключом (master не нужен) — шлюз отдаёт spend, max_budget и budget_duration в рублях. +Имя переменной НЕ угадывается: скрипт читает `key_env` из config.yaml (активного +профиля, затем корневого) — у разных установок ключ лежит под разными именами +(AILAB, POLZA и т.п.), и жёсткий список молча давал «ключ не найден». + Формат ответа: {"ok":true,"spend":5.21,"budget":100.0,"duration":"1d","pct":5.2,"alias":"testoff"} {"ok":false,"err":"..."} @@ -12,11 +16,15 @@ import json import os +import re import sys +import time import urllib.error import urllib.request BASE = "https://gw.ailabscrume.ru" + +# Запасной список: если config.yaml не найден или в нём нет key_env. KEY_NAMES = ( "HERMES_CUSTOM_AILAB_API_KEY", "CUSTOM_PROVIDER_AILAB_KEY", @@ -25,44 +33,84 @@ "AILAB_API_KEY", ) +# shell.exec рвёт вызов на 30 с, поэтому 3 попытки должны укладываться в этот +# бюджет с запасом: 3 x 7 с + паузы = 24 с. +TIMEOUT = 7.0 +ATTEMPTS = 3 +PAUSE = 1.5 -def candidates(): - """Пути к .env: корневой и все профили, активный — первым.""" - root = os.path.expandvars(r"%LOCALAPPDATA%\hermes") + +def root_dir(): + return os.path.expandvars(r"%LOCALAPPDATA%\hermes") + + +def active_profile(): + path = os.path.expandvars(r"%APPDATA%\hermes\active-profile.json") + if not os.path.exists(path): + return None + try: + with open(path, encoding="utf-8") as f: + data = json.load(f) + return data.get("profile") or data.get("active") + except Exception: + return None + + +def profile_dirs(): + """Каталоги профилей: активный первым, затем остальные по алфавиту.""" + root = root_dir() out = [] - active = os.path.expandvars(r"%APPDATA%\hermes\active-profile.json") - if os.path.exists(active): - try: - with open(active, encoding="utf-8") as f: - name = json.load(f).get("profile") or json.load(f).get("active") - if name: - out.append(os.path.join(root, "profiles", name, ".env")) - except Exception: - pass + name = active_profile() + if name: + out.append(os.path.join(root, "profiles", name)) - out.append(os.path.join(root, ".env")) + out.append(root) + prof_dir = os.path.join(root, "profiles") if os.path.isdir(prof_dir): - for name in sorted(os.listdir(prof_dir)): - out.append(os.path.join(prof_dir, name, ".env")) + for entry in sorted(os.listdir(prof_dir)): + out.append(os.path.join(prof_dir, entry)) seen, uniq = set(), [] - for p in out: - if p not in seen: - seen.add(p) - uniq.append(p) + for d in out: + if d not in seen: + seen.add(d) + uniq.append(d) return uniq -def read_key(path): - """Первая непустая переменная из KEY_NAMES в файле .env.""" +def config_key_names(): + """Имена переменных из key_env в config.yaml — любые, а не жёсткий список.""" + names = [] + for d in profile_dirs(): + cfg = os.path.join(d, "config.yaml") + try: + with open(cfg, encoding="utf-8", errors="ignore") as f: + text = f.read() + except OSError: + continue + for m in re.finditer(r"^\s*key_env\s*:\s*[\"\']?([A-Za-z0-9_]+)", text, re.M): + name = m.group(1) + if name not in names: + names.append(name) + return names + + +def env_candidates(): + """Пути к .env в том же порядке, что и профили.""" + return [os.path.join(d, ".env") for d in profile_dirs()] + + +def read_key(path, names): + """Первая подходящая переменная из names в файле .env.""" try: with open(path, encoding="utf-8", errors="ignore") as f: lines = f.readlines() except OSError: return None, None + wanted = set(names) found = {} for line in lines: s = line.strip() @@ -70,27 +118,50 @@ continue name, _, value = s.partition("=") name = name.strip() - if name in KEY_NAMES: + if name in wanted: value = value.strip().strip('"').strip("'") if value.startswith("sk-"): found[name] = value - for name in KEY_NAMES: + for name in names: if name in found: return name, found[name] return None, None -def fetch(key, timeout=10.0): - req = urllib.request.Request(BASE + "/key/info", headers={"Authorization": "Bearer " + key}) - with urllib.request.urlopen(req, timeout=timeout) as r: - data = json.loads(r.read()) - return data.get("info", data) +def fetch(key): + """GET /key/info с повторами только на сетевых сбоях. + + 401/403 — реальный отказ ключа, повторять бессмысленно: отдаём сразу. + """ + last = None + for attempt in range(ATTEMPTS): + try: + req = urllib.request.Request( + BASE + "/key/info", headers={"Authorization": "Bearer " + key} + ) + with urllib.request.urlopen(req, timeout=TIMEOUT) as r: + data = json.loads(r.read()) + return data.get("info", data) + except urllib.error.HTTPError as e: + if e.code in (401, 403): + raise + last = e + except Exception as e: + last = e + if attempt + 1 < ATTEMPTS: + time.sleep(PAUSE) + raise last def main(): - for path in candidates(): - name, key = read_key(path) + names = config_key_names() + for name in KEY_NAMES: + if name not in names: + names.append(name) + + for path in env_candidates(): + name, key = read_key(path, names) if not key: continue try: diff --git a/distribution.yaml b/distribution.yaml index 3c5b4d1..6ed776d 100644 --- a/distribution.yaml +++ b/distribution.yaml @@ -1,5 +1,5 @@ name: ailab_scrume_agent -version: 1.7.4 +version: 1.7.5 description: "Личный помощник: документы, письма, договоры, расчёты, поиск, задачи." hermes_requires: ">=0.21.6" author: "AI laboratory SCRUM company"