diff --git a/CHANGELOG.md b/CHANGELOG.md index afab9a9..92908a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,32 @@ --- +## 1.7.7 — исправление чипа расхода + +**Статус: собрано 05.11.2026.** + +Дефект: чип `ailab-limit` в сборке 1.7.6 всегда показывал `AI !`, в `desktop.log` каждые 60 с +`[ailab-limit] fetch failed: ключ шлюза не найден в .env`. + +Причина: `gw_usage.py` искал ключ только как литерал `sk-…` в `.env`, а с 1.7.6 ключа в `.env` +нет вообще — доступ выдаётся через `key_cmd` (`gw-key.ps1`, короткоживущий токен `as-…`). + +Что изменено — только `desktop-plugins/ailab-limit/gw_usage.py`: + +| Что | Как | +|---|---| +| `key_cmds()` | читает `key_cmd` из `config.yaml` профилей, активный первым | +| `token_from_cmd()` | запускает команду (`CREATE_NO_WINDOW`), берёт `access_token` из JSON | +| `key_candidates()` | сначала `sk-…` из `.env` (совместимость со старыми установками), затем токен `key_cmd`; на 401/403 — следующий кандидат | +| JSON ответа | добавлены `total_spend`, `reset_at`, `source` | +| `mark()` | последний запуск пишется в `%LOCALAPPDATA%\hermes\logs\ailab-limit-last.json` — чтобы отличать «чип не запускался» от «данных нет» | + +Формат ответа для `plugin.js` не менялся — плагин не правился. + +Проверено: прогон скрипта на живой установке и вызов из рендерера вернули одинаковый ответ — +`{"ok": true, "spend": 12.06, "budget": 100.0, "duration": "1d", "pct": 12.1, "alias": "testoff", +"total_spend": 26.5, "reset_at": "2026-11-06T00:00:00+00:00", "source": "… из .env"}`. + ## 1.7.6 — план (не выпущено) **Статус: состав накапливается. Сборка не запускается — Олег добавляет нововведения, релиз собирается после закрытия состава.** @@ -180,7 +206,7 @@ - **Пункт 8 (привязка доступа к машине) — сделано 05.11.2026.** Шлюз: `gw-issuer` v2 — `/enrol` (обмен старого `sk-…` на зачисление), `/app-key` (сессия `as-…` на 300 с по `X-Install-Token`), `/resolve` (подмена сессии на ключ человека для nginx), `/revoke`, `/list`. nginx на `gw.ailabscrume.ru` получил `auth_request`: в LiteLLM уходит тот же ключ, что и раньше, поэтому лимиты и учёт не меняются (короткие ключи через `/key/generate` отвергнуты — каждый ключ это отдельная строка расхода и прогресс-бар §1a показывал бы ноль). Клиент: `gw-key.ps1` (DPAPI-конверты, коды 0/1/2/3/4, кэш при недоступном хабе), установщик зачисляет машину по почте и коду, пишет `key_cmd` вместо `key_env`, чистит `.env`, файл зачисления кладёт в `%LOCALAPPDATA%\AILabSCRUME` (вне дома агента). Проверено живьём: агент на временном профиле ответил моделью через `key_cmd` при пустом `.env`; отзыв зачисления → helper выходит с кодом 3. `ISSUER_STRICT=0` — статические ключи пока проходят насквозь; включать `1` после обновления всех машин. -- **Брендинг 1.7.6 (решение Олега 05.11.2026).** Название агента — `OM_Lab_Agent` (видимые строки: установщик, ярлык, описание задачи Планировщика, README/INSTALL/CHANGELOG, проверка самоназвания `$NamePat`). Лого на пустом чате — `assets/scrum-engineering.svg` заменён на логотип OM Lab (PNG 3032×1376 → 640 px внутри SVG, alt `OM_Lab_Agent`, ширина 280 px, прозрачность 0.85); рендерер пересобран. Иконка — `dist/setup/assets/om-lab.ico` (256 px). **Display-имя Electron НЕ меняем** (`product-identity.cjs`: `display: 'Hermes'`): оно задаёт `%APPDATA%\Hermes`, где лежат список чатов и настройки окна — переименование «потеряло» бы их у всех. **Триггер автообновления не трогаем** (остаётся `Daily 03:00 + 0…44 мин` с `-StartWhenAvailable`); поведение при выключенном ПК: обновление выполняется при первом включении/входе, но если в этот момент открыто окно `Hermes.exe` → «отложено» до следующего окна. +- **Брендинг 1.7.6 (решение Олега 05.11.2026).** Название агента — `OM_Lab_Agent` (видимые строки: установщик, ярлык, описание задачи Планировщика, README/INSTALL/CHANGELOG, проверка самоназвания `$NamePat`). Лого на пустом чате — `assets/scrum-engineering.svg` заменён на логотип OM Lab (прозрачный PNG, обрезка по содержимому + поля 2 %, 640 px внутри SVG, alt `OM_Lab_Agent`, ширина 280 px, `height:auto`, `objectFit:contain`); под логотипом подпись «Приятной работы, с уважением, команда OM_LAB_Co». Иконка — `assets/om-lab.ico` = `image-cutout.ico` (256×256, прозрачная) в `resources/icon.ico` и в ярлыке; **иконка самого `Hermes.exe` не менялась** (нужен rcedit, Олег отложил). Номер сборки — метка `BuildVersionLabel` в `chat/composer/controls.tsx`: версия дистрибутива вписывается в рендерер при сборке (`AILAB_BUILD` → `__AILAB_BUILD__`), `appVersion` (версия ядра, 0.22.7) не показывается. **Рендерер окна лежит в ДВУХ местах** — `app.asar` и `app.asar.unpacked\dist`; обновлять оба, иначе интерфейс остаётся старым при живой иконке. Сборка окна для получателей идёт через staging-копию (`build17.ps1`), чтобы не подменять рабочий Hermes владельца. **Display-имя Electron НЕ меняем** (`product-identity.cjs`: `display: 'Hermes'`): оно задаёт `%APPDATA%\Hermes`, где лежат список чатов и настройки окна — переименование «потеряло» бы их у всех. **Триггер автообновления не трогаем** (остаётся `Daily 03:00 + 0…44 мин` с `-StartWhenAvailable`); поведение при выключенном ПК: обновление выполняется при первом включении/входе, но если в этот момент открыто окно `Hermes.exe` → «отложено» до следующего окна. - **Что реально войдёт в 1.7.6:** пункты без внешних блокеров — 1, 2, 4, 6, 6a, 6b, **8** (плюс безопасность п.1–3 выше) **и пункт 5** (резерв, работа шлюза — по решению Олега 05.11.2026 переделка делается при сборке 1.7.6, а не сейчас). - **Пункт 5 (резерв) — не клиентская работа, но входит в 1.7.6 и делается при сборке (решение Олега 05.11.2026).** Проверено 05.11.2026 на шлюзе: ключ `proxyapi.ru` живой, `GET /v1/models` → 200, 797 моделей, ограничений на ключе нет (подтверждено Олегом). Группы `proxy//` и `router_settings.fallbacks` для всех алиасов уже заведены, **но переход не выполняется**: в логе `gw-llm` — `Fallback to proxy/google/gemini-4-argon was skipped because the caller is not authorized to call it`. Причина: у ключа сотрудника в `models` только его 9 моделей, `proxy/*` в них нет. Отсюда решение: не отдельная группа, а второй деплоймент внутри той же группы `//` (список моделей у ключа не меняется — резерв остаётся невидимым), плюс явный `cooldown_time: 300` (сейчас «5 минут» держатся на дефолте роутера, в `router_settings` его нет). diff --git a/desktop-plugins/ailab-limit/gw_usage.py b/desktop-plugins/ailab-limit/gw_usage.py index 2db81b0..54fb0da 100644 --- a/desktop-plugins/ailab-limit/gw_usage.py +++ b/desktop-plugins/ailab-limit/gw_usage.py @@ -17,6 +17,7 @@ import json import os import re +import subprocess import sys import time import urllib.error @@ -129,6 +130,76 @@ return None, None +def key_cmds(): + """Готовые команды выдачи ключа (`key_cmd` в config.yaml) — по профилям. + + В сборке 1.7.x ключа в .env нет вообще: клиент получает короткоживущий + токен `as-…` от gw-key.ps1, и шлюз принимает его на /key/info. Без этого + чип всегда показывал «ключ шлюза не найден». + """ + out = [] + 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_cmd\s*:\s*(.+)$", text, re.M): + val = m.group(1).strip() + if val in ("", "null", "~") or val.startswith("#"): + continue + if len(val) > 1 and val[0] == val[-1] and val[0] in "'\"": + val = val[1:-1].replace("''", "'") + if val not in out: + out.append(val) + return out + + +def token_from_cmd(cmd): + """Запуск key_cmd: единственная строка JSON, ключ — access_token.""" + kwargs = {} + if os.name == "nt": + kwargs["creationflags"] = getattr(subprocess, "CREATE_NO_WINDOW", 0) + proc = subprocess.run( + cmd, shell=True, capture_output=True, text=True, timeout=20, **kwargs + ) + text = (proc.stdout or "").strip() + if proc.returncode != 0 or not text: + err = (proc.stderr or "").strip().splitlines() + hint = err[-1][:120] if err else "код %s" % proc.returncode + raise RuntimeError("key_cmd: %s" % hint) + data = json.loads(text.splitlines()[-1]) + token = data.get("access_token") or data.get("key") + if not token: + raise RuntimeError("key_cmd: в ответе нет access_token") + return token + + +def key_candidates(): + """(метка, ключ) — сначала .env, затем key_cmd профилей.""" + names = config_key_names() + for name in KEY_NAMES: + if name not in names: + names.append(name) + + seen = set() + for path in env_candidates(): + name, key = read_key(path, names) + if key and key not in seen: + seen.add(key) + yield "%s из .env" % name, key + + for cmd in key_cmds(): + try: + token = token_from_cmd(cmd) + except Exception as e: + raise RuntimeError(str(e)[:160]) + if token and token not in seen: + seen.add(token) + yield "токен gw-key", token + + def fetch(key): """GET /key/info с повторами только на сетевых сбоях. @@ -154,22 +225,38 @@ raise last -def main(): - names = config_key_names() - for name in KEY_NAMES: - if name not in names: - names.append(name) +def mark(state): + """Последний запуск — короткой строкой в logs/ailab-limit-last.json. - for path in env_candidates(): - name, key = read_key(path, names) - if not key: - continue + Проверка для поддержки: молчит чип = нет запусков, а не «нет расхода». + """ + try: + state = dict(state, ts=time.strftime("%Y-%m-%d %H:%M:%S")) + path = os.path.join(root_dir(), "logs", "ailab-limit-last.json") + with open(path, "w", encoding="utf-8") as f: + f.write(json.dumps(state, ensure_ascii=False)) + except OSError: + pass + + +def main(): + last_err = "ключ шлюза не найден: нет ни sk- в .env, ни key_cmd" + + try: + candidates = list(key_candidates()) + except RuntimeError as e: + mark({"ok": False, "err": str(e)}) + print(json.dumps({"ok": False, "err": str(e)}, ensure_ascii=False)) + return + + for label, key in candidates: try: info = fetch(key) except urllib.error.HTTPError as e: - print(json.dumps({"ok": False, "err": "шлюз вернул %s" % e.code}, ensure_ascii=False)) - return + last_err = "шлюз вернул %s (%s)" % (e.code, label) + continue except Exception as e: + mark({"ok": False, "err": str(e)[:120]}) print(json.dumps({"ok": False, "err": str(e)[:120]}, ensure_ascii=False)) return @@ -178,17 +265,23 @@ budget = float(budget) if budget is not None else None pct = round(spend / budget * 100, 1) if budget else None - print(json.dumps({ + payload = { "ok": True, "spend": round(spend, 4), "budget": round(budget, 2) if budget else None, "duration": info.get("budget_duration") or "", "pct": pct, "alias": info.get("key_alias") or "", - }, ensure_ascii=False)) + "total_spend": round(float(info.get("total_spend") or 0), 2), + "reset_at": info.get("budget_reset_at") or "", + "source": label, + } + mark(payload) + print(json.dumps(payload, ensure_ascii=False)) return - print(json.dumps({"ok": False, "err": "ключ шлюза не найден в .env"}, ensure_ascii=False)) + mark({"ok": False, "err": last_err}) + print(json.dumps({"ok": False, "err": last_err}, ensure_ascii=False)) if __name__ == "__main__": diff --git a/distribution.yaml b/distribution.yaml index 728bab9..ea99ae5 100644 --- a/distribution.yaml +++ b/distribution.yaml @@ -1,5 +1,5 @@ name: ailab_scrume_agent -version: 1.7.6 +version: 1.7.7 description: "Личный помощник: документы, письма, договоры, расчёты, поиск, задачи." hermes_requires: ">=0.21.6" author: "AI laboratory SCRUM company"