diff --git a/config.yaml b/config.yaml index b7e302e..ce90704 100644 --- a/config.yaml +++ b/config.yaml @@ -1,9 +1,12 @@ # Конфигурация агента. Ключ API здесь НЕ хранится — только имя переменной окружения # (key_env), а сама переменная лежит в .env (см. .env.EXAMPLE). model: - default: deepseek/deepseek-v4.1-flash - provider: custom:Polza.ai - base_url: https://polza.ai/api/v1 + # Модель и каталог подставляет установщик по ключу сотрудника: + # он запрашивает /v1/models у шлюза и вписывает разрешённый набор. + # Здесь — значение до подстановки, чтобы агент запускался даже без неё. + default: __MODEL__ + provider: custom:AILab + base_url: https://gw.ailabscrume.ru/v1 api_mode: chat_completions key_env: HERMES_CUSTOM_POLZA_AI_API_KEY database: @@ -98,23 +101,30 @@ check: false custom_providers: [] providers: - polza-ai: - name: Polza.ai - base_url: https://polza.ai/api/v1 - model: deepseek/deepseek-v4.1-flash - discover_models: true + ailab: + # Единственный провайдер сборки — наш шлюз. Прямого адреса агрегатора + # в конфиге нет намеренно: иначе сотрудник обходит шлюз, лимиты и учёт. + name: AILab + base_url: https://gw.ailabscrume.ru/v1 + model: __MODEL__ + # Автоподхват каталога ВЫКЛЮЧЕН: при нём клиент тянет весь каталог + # агрегатора (сотни моделей) и показывает то, чего нет на ключе. + discover_models: false + models: [] key_env: HERMES_CUSTOM_POLZA_AI_API_KEY auxiliary: + # Vision идёт ТЕМ ЖЕ ключом через шлюз, а не напрямую в агрегатор: + # иначе картинки не считаются в расход и не ограничиваются лимитом ключа. vision: - provider: custom:Polza.ai - model: google/gemini-3-flash-preview - base_url: https://polza.ai/api/v1 + provider: custom:AILab + model: __VISION__ + base_url: https://gw.ailabscrume.ru/v1 key_env: HERMES_CUSTOM_POLZA_AI_API_KEY timeout: 300 mcp_servers: # ГОСТ-ассистент: поиск и чтение нормативных документов (ГОСТ, СП, СНиП). # Ключ доступа — СВОЙ у получателя, в конфиге только имя переменной - # (headers.Authorization ссылается на .env), как и у ключа Polza.ai. + # (headers.Authorization ссылается на .env), как и у ключа моделей. # Без ключа сервер не авторизуется: инструменты mcp__gostassistent__* будут # видны, но отвечать не станут — это состояние «нет доступа», а не поломка # агента. Ключ запрашивается у владельца агента. diff --git a/desktop-plugins/polza-limit/plugin.js b/desktop-plugins/polza-limit/plugin.js deleted file mode 100644 index 8b19f09..0000000 --- a/desktop-plugins/polza-limit/plugin.js +++ /dev/null @@ -1,509 +0,0 @@ -/** - * polza-limit — прогресс-бар суточного лимита Polza.ai в строке управления - * композера, слева от выбора модели (composer.actions рендерится ровно перед - * пилюлей модели). Один чип = ОДИН ключ владельца: тот, что вписан в корневой - * .env агента. - * - * Цифры достаются локальным скриптом (scripts/polza-limits.cmd → - * polza_limits.py): он опрашивает /v1/key, /v1/balance и /v1/history/generations - * по этому ключу, складывает стоимость генераций за местные сутки и группирует - * их по моделям. Ключ остаётся в .env, в рендерер не попадает: плагин вызывает - * готовый .cmd через gateway RPC shell.exec (путь проверен модулем approval — - * без -c/-e, не dangerous). - * - * Наведение открывает подсказку с карточкой: полоса лимита, - * разбитая по моделям, легенда с суммами, долями и токенами (вход/выход), - * итог по токенам за сутки, цифры по лимиту и кошельку. Клик — обновление. - * - * ОГРАНИЧЕНИЯ shell.exec: только хвост stdout (4000 символов) и 30 с. Скрипт - * печатает компактный JSON (~800 символов, ~3 с), опрос идёт раз в 5 минут. - */ - -import { COMPOSER_AREAS, cn, haptic, host, queryClient, Tip, useQuery, useValue } from '@hermes/plugin-sdk' -import { jsx, jsxs } from 'react/jsx-runtime' - -const ID = 'polza-limit' - -// Путь через %LOCALAPPDATA%: shell.exec выполняет команду через cmd.exe, а он -// раскрывает переменную у каждого пользователя. Хардкод C:\\Users\\Admin на чужой -// машине не нашёлся бы. Пробелов в пути нет — кавычки не нужны. -const CMD = - '%LOCALAPPDATA%\\hermes\\.polza-limits\\polza-limits.cmd' - -const QUERY_KEY = [ID, 'usage'] -const REFRESH_MS = 300_000 - -/** - * Цвета моделей: тема даёт всего несколько акцентов, а моделей в сутках 5-8, - * поэтому палитра задана явно. «Другое» всегда серое. - */ -const PALETTE = ['#8B7CF6', '#4EA8DE', '#3FB950', '#D29922', '#F85149', '#DB61A2', '#39C5CF'] - -function colorFor(index, name) { - return name === 'Другое' ? 'var(--ui-text-quaternary)' : PALETTE[index % PALETTE.length] -} - -/** Разбор stdout скрипта: JSON от первой «{» в последней непустой строке. */ -function parsePayload(raw) { - const text = String(raw || '').trim() - const line = text.split('\n').map(s => s.trim()).filter(Boolean).pop() || '' - const cut = line.indexOf('{') - - if (cut < 0) { - throw new Error(line.slice(0, 120) || 'пустой вывод скрипта') - } - - let data - - try { - data = JSON.parse(line.slice(cut)) - } catch { - throw new Error('ответ обрезан (лимит shell.exec — 4000 символов)') - } - - if (data.ok === false) { - throw new Error(data.error || 'скрипт вернул ошибку') - } - - if (!Array.isArray(data.k) || data.k.length === 0) { - throw new Error('в ответе нет массива ключей k') - } - - return data -} - -async function fetchUsage() { - const res = await host.request('shell.exec', { command: CMD }, 45_000) - - if (res && res.blocked) { - throw new Error(res.hint || 'shell.exec заблокирован') - } - - if (!res || res.code !== 0) { - const err = String((res && res.stderr) || '').trim() - throw new Error(err.slice(0, 160) || `скрипт вернул код ${res ? res.code : '?'}`) - } - - return parsePayload(res.stdout) -} - -function money(v) { - // (Number(v) || 0) — иначе отсутствующее поле печатается как «NaN ₽»: - // payload обязан переживать и старую версию скрипта, и неполный ответ. - return (Number(v) || 0).toFixed(2) -} - -/** Цвет всей полосы по заполнению: <30% зелёный, 30–75% акцент, ≥75% красный. */ -function toneFor(pct) { - if (pct === null) { - return 'var(--ui-text-quaternary)' - } - - if (pct >= 75) { - return 'var(--ui-red)' - } - - if (pct >= 30) { - return 'var(--ui-accent)' - } - - return 'var(--ui-green)' -} - -/** «Модель \x1f рубли \x1f запросы \x1f вход \x1f выход \x1f кэш» → объект с цветом. - * - * Хвост с токенами добавлен позже. Скрипт и плагин едут в одном архиве, но при - * ручной подмене одного файла из двух развёртывание может разойтись, поэтому - * отсутствующие поля обязаны давать нули, а не NaN. - */ -function parseModels(rows) { - return (rows || []).map((row, i) => { - const [name, cost, count, tin, tout, cache] = String(row).split('\u001f') - - return { - name, - cost: Number(cost) || 0, - count: Number(count) || 0, - tin: Number(tin) || 0, - tout: Number(tout) || 0, - cache: Number(cache) || 0, - color: colorFor(i, name) - } - }) -} - -/** Токены компактно для колонки: 4105847 → «4.1M», 1127 → «1.1k», 42 → «42». */ -function tokens(v) { - const n = Number(v) || 0 - - if (n >= 1_000_000) { - return `${(n / 1_000_000).toFixed(1)}M` - } - - if (n >= 10_000) { - return `${Math.round(n / 1000)}k` - } - - if (n >= 1_000) { - return `${(n / 1000).toFixed(1)}k` - } - - return `${n}` -} - -/** Токены с разрядами — для итоговой строки под легендой. */ -function exact(v) { - return (Number(v) || 0).toLocaleString('ru-RU') -} - -/** Процент суточного лимита ключа; null — лимит не задан (или нет данных). */ -function limitPct(k) { - const lim = Number(k.limit) || 0 - - if (!lim) { - return null - } - - return Math.max(0, Math.min(100, (Number(k.used) / lim) * 100)) -} - -/** Короткая подпись ключа в чипе: «default + local» → «default». */ -function shortKey(label) { - return String(label || '').split(/[\s+]+/)[0] -} - -/** Карточка ОДНОГО ключа: полоса лимита по моделям, легенда, цифры. */ -function KeyCard({ k }) { - const models = parseModels(k.m) - const totalLimit = Number(k.limit) || 0 - const used = Number(k.used) || 0 - const pct = totalLimit > 0 ? Math.max(0, Math.min(100, (used / totalLimit) * 100)) : 0 - const modelsSum = models.reduce((acc, m) => acc + m.cost, 0) || 1 - - return jsxs('div', { - style: { width: '340px', display: 'flex', flexDirection: 'column', gap: '8px' }, - children: [ - // 1. Полоса лимита, разбитая по моделям: доля каждой — от ЛИМИТА, - // поэтому незакрашенный хвост и есть свободный лимит. - jsxs('div', { - style: { display: 'flex', flexDirection: 'column', gap: '4px' }, - children: [ - jsxs('div', { - style: { display: 'flex', justifyContent: 'space-between', fontSize: '11px' }, - children: [ - jsx('span', { children: k.p || 'ключ' }), - jsx('span', { - style: { color: toneFor(pct) }, - children: `${money(used)} из ${money(totalLimit)} ₽ · ${pct.toFixed(1)}%` - }) - ] - }), - jsx('span', { - style: { - display: 'flex', - width: '100%', - height: '10px', - borderRadius: '3px', - background: 'var(--ui-stroke-secondary)', - overflow: 'hidden' - }, - children: models.map((m, i) => - jsx( - 'span', - { - title: `${m.name}: ${money(m.cost)} ₽`, - style: { - display: 'block', - width: `${totalLimit > 0 ? (m.cost / totalLimit) * 100 : 0}%`, - height: '100%', - background: m.color - } - }, - `bar-${i}-${m.name}` - ) - ) - }) - ] - }), - // 2. Легенда: цвет, модель, доля от расхода, сумма, запросы, вход, выход. - // Шапка обязательна: без подписей колонки цифр не читаются. - jsxs('div', { - style: { display: 'flex', flexDirection: 'column', gap: '3px', fontSize: '11px' }, - children: [ - jsxs( - 'div', - { - style: { - display: 'flex', - alignItems: 'center', - gap: '6px', - color: 'var(--ui-text-quaternary)' - }, - children: [ - jsx('span', { style: { width: '8px', flex: '0 0 auto' } }), - jsx('span', { style: { flex: '1 1 auto' }, children: 'модель' }), - jsx('span', { style: { minWidth: '32px', textAlign: 'right' }, children: '%' }), - jsx('span', { style: { minWidth: '56px', textAlign: 'right' }, children: '₽' }), - jsx('span', { style: { minWidth: '26px', textAlign: 'right' }, children: 'запр' }), - jsx('span', { style: { minWidth: '40px', textAlign: 'right' }, children: 'вх' }), - jsx('span', { style: { minWidth: '34px', textAlign: 'right' }, children: 'исх' }) - ] - }, - 'legend-head' - ), - ...models.map((m, i) => - jsxs( - 'div', - { - style: { display: 'flex', alignItems: 'center', gap: '6px' }, - children: [ - jsx('span', { - style: { - width: '8px', - height: '8px', - borderRadius: '2px', - background: m.color, - flex: '0 0 auto' - } - }), - jsx('span', { - style: { - flex: '1 1 auto', - overflow: 'hidden', - textOverflow: 'ellipsis', - whiteSpace: 'nowrap' - }, - children: m.name - }), - jsx('span', { - style: { - color: 'var(--ui-text-tertiary)', - fontVariantNumeric: 'tabular-nums', - minWidth: '32px', - textAlign: 'right' - }, - children: `${((m.cost / modelsSum) * 100).toFixed(1)}%` - }), - jsx('span', { - style: { - fontVariantNumeric: 'tabular-nums', - minWidth: '56px', - textAlign: 'right' - }, - children: `${money(m.cost)} ₽` - }), - jsx('span', { - style: { - color: 'var(--ui-text-quaternary)', - fontVariantNumeric: 'tabular-nums', - minWidth: '26px', - textAlign: 'right' - }, - children: `${m.count}` - }), - jsx('span', { - style: { - color: 'var(--ui-text-tertiary)', - fontVariantNumeric: 'tabular-nums', - minWidth: '40px', - textAlign: 'right' - }, - children: tokens(m.tin) - }), - jsx('span', { - style: { - color: 'var(--ui-text-tertiary)', - fontVariantNumeric: 'tabular-nums', - minWidth: '34px', - textAlign: 'right' - }, - children: tokens(m.tout) - }) - ] - }, - `row-${i}-${m.name}` - ) - ) - ] - }), - // 3. Прежние цифры по ключу. - jsxs('div', { - style: { - borderTop: '1px solid var(--ui-stroke-secondary)', - paddingTop: '6px', - display: 'flex', - flexDirection: 'column', - gap: '1px', - fontSize: '11px', - color: 'var(--ui-text-tertiary)' - }, - children: [ - jsx('div', { - children: `токены за сутки: вход ${exact(k.tin)} · из кэша ${exact(k.tcache)} · выход ${exact(k.tout)}` - }), - jsx('div', { - children: `${k.gen || 0} генераций за сутки · сброс лимита: ${k.reset || 'daily'}` - }), - jsx('div', { children: `остаток лимита ${money(k.left)} ₽` }), - jsx('div', { - children: `кошелёк ${money(k.amount)} ₽ · доступно ${money(k.avail)} ₽` - }), - jsx('div', { children: `неделя ${money(k.week)} ₽` }) - ] - }) - ] - }) -} - -/** Подсказка: карточка на каждый ключ + шапка и строка подсказки. */ -function UsageCard({ data }) { - const keys = Array.isArray(data.k) ? data.k : [] - - return jsxs('div', { - style: { width: '340px', display: 'flex', flexDirection: 'column', gap: '12px' }, - children: [ - jsxs('div', { - style: { display: 'flex', justifyContent: 'space-between', fontSize: '11px' }, - children: [ - jsx('span', { children: 'Polza.ai · суточный лимит' }), - jsx('span', { style: { color: 'var(--ui-text-quaternary)' }, children: data.day || '' }) - ] - }), - keys.map((k, i) => - jsx( - 'div', - { - style: { - display: 'flex', - flexDirection: 'column', - gap: '8px', - borderTop: i === 0 ? 'none' : '1px solid var(--ui-stroke-secondary)', - paddingTop: i === 0 ? '0' : '10px' - }, - children: k.err - ? jsx('div', { style: { fontSize: '11px', color: 'var(--ui-red)' }, children: `${k.p}: ${k.err}` }) - : jsx(KeyCard, { k }) - }, - `key-${i}-${k.p}` - ) - ), - jsx('div', { - style: { fontSize: '11px', color: 'var(--ui-text-quaternary)', borderTop: '1px solid var(--ui-stroke-secondary)', paddingTop: '6px' }, - children: 'клик — обновить' - }) - ] - }) -} - -/** Один ключ в чипе: микрополоса + «имя процент». */ -function KeyMini({ k }) { - const pct = limitPct(k) - const tone = toneFor(pct) - // Ключ один: в чипе достаточно процента; без лимита — прочерк, а не второе «Polza». - const label = Number(k.limit) ? `${Math.round(pct)}%` : '—' - - return jsx('span', { - title: Number(k.limit) - ? `Polza.ai: ${money(k.used)} из ${money(k.limit)} ₽ за сутки` - : 'Polza.ai: суточный лимит не задан на ключе', - style: { display: 'inline-flex', alignItems: 'center', gap: '4px' }, - children: [ - jsx('span', { - style: { - display: 'inline-block', - width: '22px', - height: '4px', - borderRadius: '2px', - background: 'var(--ui-stroke-secondary)', - overflow: 'hidden' - }, - children: jsx('span', { - style: { - display: 'block', - width: `${pct === null ? 100 : pct}%`, - height: '100%', - borderRadius: '2px', - background: tone - } - }) - }), - jsx('span', { - style: { color: tone, fontVariantNumeric: 'tabular-nums' }, - children: label - }) - ] - }, `keymini-${k.p}`) -} - -function PolzaChip() { - const gateway = useValue(host.state.gateway) - const connected = Boolean(gateway) - - const { data, error, isError } = useQuery({ - queryKey: [...QUERY_KEY, connected], - enabled: connected, - queryFn: fetchUsage, - refetchInterval: REFRESH_MS, - staleTime: 60_000, - retry: false - }) - - const keys = data && Array.isArray(data.k) ? data.k : [] - - const tip = data - ? jsx(UsageCard, { data }) - : isError - ? `Polza.ai: нет данных — ${String(error && error.message ? error.message : error).slice(0, 240)}` - : connected - ? 'Polza.ai: загрузка…' - : 'Polza.ai: шлюз не подключён' - - return jsx(Tip, { - label: tip, - placement: 'control', - // Тема приложения вместо инверсного фона по умолчанию: карточке нужен - // обычный surface, иначе палитра моделей читается на вывернутом цвете. - className: - 'border border-(--ui-stroke-secondary) bg-(--ui-bg-elevated) text-foreground backdrop-blur-md', - children: jsxs('button', { - type: 'button', - className: cn( - 'inline-flex h-(--composer-control-size) shrink-0 items-center gap-1.5 rounded-md px-1.5 text-xs transition-colors', - 'text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground' - ), - onClick: () => { - haptic('tap') - queryClient.invalidateQueries({ queryKey: QUERY_KEY }) - }, - children: [ - jsx('span', { - style: { color: 'var(--ui-text-quaternary)', fontSize: '10px', lineHeight: '1' }, - children: 'Polza' - }), - ...keys.map((k, i) => jsx(KeyMini, { k }, `mini-${i}-${k.p}`)), - keys.length - ? null - : jsx('span', { - style: { color: 'var(--ui-text-quaternary)', fontSize: '10px' }, - children: isError ? '!' : '…' - }) - ] - }) - }) -} - -export default { - id: ID, - name: 'Polza limit', - defaultEnabled: true, - register(ctx) { - // composer.actions рендерится внутри строки контролов, до ModelPill — - // то есть визуально левее выбора модели. В статус-баре чипа больше нет. - ctx.register({ - id: 'chip', - area: COMPOSER_AREAS.actions, - order: 10, - render: () => jsx(PolzaChip, {}) - }) - } -} diff --git a/desktop-plugins/polza-models/plugin.js b/desktop-plugins/polza-models/plugin.js deleted file mode 100644 index 6cba22a..0000000 --- a/desktop-plugins/polza-models/plugin.js +++ /dev/null @@ -1,343 +0,0 @@ -/** - * polza-models — модели Polza.ai в строке управления композером - * (composer.actions), сразу за чипом расхода (order 15 против 10). - * - * ДВЕ СЕКЦИИ, и это главное в плагине. - * - * Прайс каталога — не то, что списывают. Проверено на своих генерациях: - * ставка входа у openai/gpt-6.1-sol 282,18 ₽ при каталоге 118,09 (×2,39), - * у deepseek/deepseek-v4.1-flash — 50,67 при каталоге 5,78 (×8,76), а у - * anthropic/claude-sonnet-5.5 совпала (×1,01). Множитель гуляет от 1,01 до - * 8,76, поэтому порядок по каталогу НЕ равен порядку по деньгам: по каталогу - * самый дорогой Claude Sonnet 5.5, по факту — GPT-6.1 Sol. - * - * Отсюда разделение: - * * «по факту списания» — модели, которыми уже пользовались; ставка - * посчитана из costLines каждой генерации (clientRub / quantity); - * * «прайс каталога — не факт» — остальные; это ориентир для выбора, - * а не цена. - * - * Данные достаёт локальный скрипт (scripts/polza-models/polza-models.cmd → - * polza_models.py). Ключ остаётся в .env, в рендерер не попадает. - * - * ОГРАНИЧЕНИЯ shell.exec: только ХВОСТ stdout (4000 символов) и 30 с. - * Скрипт печатает от дешёвого и ужимается под бюджет сам; опрос — раз в - * 30 минут (скрипт тянет каталог 600 КБ плюс историю генераций, ~21 с). - */ - -import { COMPOSER_AREAS, cn, haptic, host, queryClient, Tip, useQuery, useValue } from '@hermes/plugin-sdk' -import { jsx, jsxs } from 'react/jsx-runtime' - -const ID = 'polza-models' - -// Путь через %LOCALAPPDATA%: shell.exec выполняет команду через cmd.exe, а он -// раскрывает переменную у каждого пользователя. -const CMD = - '%LOCALAPPDATA%\\hermes\\.polza-models\\polza-models.cmd' - -const QUERY_KEY = [ID, 'catalog'] -const REFRESH_MS = 1_800_000 - -/** Разбор stdout скрипта: JSON от первой «{» в последней непустой строке. */ -function parsePayload(raw) { - const text = String(raw || '').trim() - const line = text.split('\n').map(s => s.trim()).filter(Boolean).pop() || '' - const cut = line.indexOf('{') - - if (cut < 0) { - throw new Error(line.slice(0, 120) || 'пустой вывод скрипта') - } - - let data - - try { - data = JSON.parse(line.slice(cut)) - } catch { - throw new Error('ответ обрезан (лимит shell.exec — 4000 символов)') - } - - if (data.ok === false) { - throw new Error(data.err || 'скрипт вернул ошибку') - } - - if (!Array.isArray(data.m) || data.m.length === 0) { - throw new Error('в ответе нет массива моделей m') - } - - return data -} - -async function fetchCatalog() { - const res = await host.request('shell.exec', { command: CMD }, 45_000) - - if (res && res.blocked) { - throw new Error(res.hint || 'shell.exec заблокирован') - } - - if (!res || res.code !== 0) { - const err = String((res && res.stderr) || '').trim() - throw new Error(err.slice(0, 160) || `скрипт вернул код ${res ? res.code : '?'}`) - } - - return parsePayload(res.stdout) -} - -/** «источник \x1f id \x1f имя \x1f вход \x1f выход \x1f кэш \x1f запросов \x1f всего ₽». - * - * Источник: «ф» — фактическая ставка из costLines, «п» — прайс каталога. - * Хвост (запросы, всего) добавлен позже, поэтому отсутствующее даёт нули, - * а не NaN: при ручной подмене одного файла из двух развёртывание разойдётся. - */ -function parseRows(rows) { - return (rows || []).map(row => { - const [src, id, name, pin, pout, pcache, gen, total] = String(row).split('\u001f') - - return { - fact: src === 'ф', - id: id || '?', - name: name || id || '?', - pin: Number(pin) || 0, - pout: Number(pout) || 0, - pcache: Number(pcache) || 0, - gen: Number(gen) || 0, - total: Number(total) || 0 - } - }) -} - -/** «52.61» → «52,61»: каталог отдаёт точку, в русском тексте это опечатка. */ -function rub(v) { - return (Number(v) || 0).toFixed(2).replace('.', ',') -} - -function LegendHead({ fact }) { - return jsxs('div', { - style: { display: 'flex', alignItems: 'center', gap: '6px', color: 'var(--ui-text-quaternary)' }, - children: [ - jsx('span', { style: { flex: '1 1 auto' }, children: 'модель' }), - jsx('span', { style: { minWidth: '50px', textAlign: 'right' }, children: 'вход' }), - jsx('span', { style: { minWidth: '50px', textAlign: 'right' }, children: 'выход' }), - jsx('span', { style: { minWidth: '42px', textAlign: 'right' }, children: 'кэш' }), - fact ? jsx('span', { style: { minWidth: '40px', textAlign: 'right' }, children: 'запр' }) : null, - fact ? jsx('span', { style: { minWidth: '58px', textAlign: 'right' }, children: '₽' }) : null - ] - }) -} - -function ModelRow({ m, index }) { - const bg = index % 2 ? 'transparent' : 'var(--ui-bg-secondary, transparent)' - - return jsxs('div', { - style: { - display: 'flex', - alignItems: 'center', - gap: '6px', - background: bg, - borderRadius: '3px', - padding: '1px 2px' - }, - children: [ - jsxs('span', { - style: { - flex: '1 1 auto', - overflow: 'hidden', - textOverflow: 'ellipsis', - whiteSpace: 'nowrap' - }, - children: [ - jsx('span', { children: m.name }), - jsx('span', { - style: { color: 'var(--ui-text-quaternary)', marginLeft: '6px', fontSize: '10px' }, - children: m.id - }) - ] - }), - jsx('span', { - style: { - fontVariantNumeric: 'tabular-nums', - minWidth: '50px', - textAlign: 'right', - color: m.fact ? 'var(--ui-text-primary, inherit)' : 'var(--ui-text-tertiary)' - }, - children: rub(m.pin) - }), - jsx('span', { - style: { - color: 'var(--ui-text-tertiary)', - fontVariantNumeric: 'tabular-nums', - minWidth: '50px', - textAlign: 'right' - }, - children: rub(m.pout) - }), - jsx('span', { - style: { - color: 'var(--ui-text-quaternary)', - fontVariantNumeric: 'tabular-nums', - minWidth: '42px', - textAlign: 'right' - }, - children: m.pcache > 0 ? rub(m.pcache) : '—' - }), - m.fact - ? jsx('span', { - style: { - color: 'var(--ui-text-quaternary)', - fontVariantNumeric: 'tabular-nums', - minWidth: '40px', - textAlign: 'right' - }, - children: `${m.gen}` - }) - : null, - m.fact - ? jsx('span', { - style: { - color: 'var(--ui-text-tertiary)', - fontVariantNumeric: 'tabular-nums', - minWidth: '58px', - textAlign: 'right' - }, - children: rub(m.total) - }) - : null - ] - }, `row-${index}-${m.id}`) -} - -function Section({ title, note, rows, fact }) { - if (!rows.length) { - return null - } - - return jsxs('div', { - style: { display: 'flex', flexDirection: 'column', gap: '2px', fontSize: '11px' }, - children: [ - jsxs('div', { - style: { color: 'var(--ui-text-tertiary)', fontSize: '10px', textTransform: 'uppercase' }, - children: [ - jsx('span', { children: title }), - note - ? jsx('span', { style: { textTransform: 'none', marginLeft: '6px' }, children: note }) - : null - ] - }), - jsx(LegendHead, { fact }), - ...rows.map((m, i) => jsx(ModelRow, { m, index: i }, `m-${i}-${m.id}`)) - ] - }) -} - -function CatalogCard({ data }) { - const all = parseRows(data.m) - const fact = all.filter(m => m.fact) - const list = all.filter(m => !m.fact) - // Выборка истории ограничена дедлайном shell.exec (30 с), поэтому «всего ₽» — - // это сумма по СОБРАННЫМ генерациям, а не полный итог за окно. Ставка от - // размера выборки почти не зависит, а сумма — зависит, и об этом надо сказать. - const sample = fact.reduce((acc, m) => acc + m.gen, 0) - - return jsxs('div', { - style: { width: '400px', display: 'flex', flexDirection: 'column', gap: '8px' }, - children: [ - jsxs('div', { - style: { display: 'flex', justifyContent: 'space-between', fontSize: '11px' }, - children: [ - jsx('span', { style: { color: 'var(--ui-text-tertiary)' }, children: 'Polza.ai · модели по цене' }), - jsx('span', { style: { color: 'var(--ui-text-quaternary)' }, children: `${data.cur || 'RUB'} за 1 млн токенов` }) - ] - }), - jsx('div', { - style: { color: 'var(--ui-text-quaternary)', fontSize: '10px' }, - children: `${data.nf} своих моделей по факту списания · ${data.nc} по прайсу каталога` - }), - jsx(Section, { - title: 'по факту списания', - note: `— costLines, списанные рубли; выборка ${sample} генераций`, - rows: fact, - fact: true - }), - jsx(Section, { - title: 'прайс каталога — не факт', - note: `— отличается от списания до 9 раз, показаны ${list.length} самых дешёвых`, - rows: list, - fact: false - }), - jsx('div', { - style: { color: 'var(--ui-text-quaternary)', fontSize: '10px' }, - children: 'клик — обновить · сортировка по цене входа' - }) - ] - }) -} - -function ModelsChip() { - const gateway = useValue(host.state.gateway) - const connected = Boolean(gateway) - - const { data, error, isError } = useQuery({ - queryKey: [...QUERY_KEY, connected], - enabled: connected, - queryFn: fetchCatalog, - refetchInterval: REFRESH_MS, - staleTime: 300_000, - retry: false - }) - - const tip = data - ? jsx(CatalogCard, { data }) - : isError - ? `Polza.ai: каталог недоступен — ${String(error && error.message ? error.message : error).slice(0, 240)}` - : connected - ? 'Polza.ai: загрузка каталога…' - : 'Polza.ai: шлюз не подключён' - - return jsx(Tip, { - label: tip, - placement: 'control', - className: - 'border border-(--ui-stroke-secondary) bg-(--ui-bg-elevated) text-foreground backdrop-blur-md', - children: jsxs('button', { - type: 'button', - className: cn( - 'inline-flex h-(--composer-control-size) shrink-0 items-center gap-1.5 rounded-md px-1.5 text-xs transition-colors', - 'text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground' - ), - onClick: () => { - haptic('tap') - queryClient.invalidateQueries({ queryKey: QUERY_KEY }) - }, - children: [ - jsx('span', { - style: { color: 'var(--ui-text-quaternary)', fontSize: '10px', lineHeight: '1' }, - children: 'Модели' - }), - data - ? jsx('span', { - style: { fontVariantNumeric: 'tabular-nums' }, - children: `от ${rub(data.min)}` - }) - : jsx('span', { - style: { color: 'var(--ui-text-quaternary)', fontSize: '10px' }, - children: isError ? '!' : '…' - }) - ] - }) - }) -} - -export default { - id: ID, - name: 'Polza models', - defaultEnabled: true, - register(ctx) { - // composer.actions рендерится внутри строки контролов, до ModelPill. - // Order 15 ставит чип сразу за расходом (10): цены и трата рядом. - ctx.register({ - id: 'chip', - area: COMPOSER_AREAS.actions, - order: 15, - render: () => jsx(ModelsChip, {}) - }) - } -} diff --git a/distribution.yaml b/distribution.yaml index 36c17bd..374e81b 100644 --- a/distribution.yaml +++ b/distribution.yaml @@ -1,5 +1,5 @@ name: ailab_scrume_agent -version: 1.6.1 +version: 1.7.0 description: "Личный помощник: документы, письма, договоры, расчёты, поиск, задачи." hermes_requires: ">=0.21.6" author: "AI laboratory SCRUM company" @@ -7,7 +7,7 @@ env_requires: - name: HERMES_CUSTOM_POLZA_AI_API_KEY - description: "Ключ API Polza.ai (свой, отдельный от ключа автора)" + description: "Ключ доступа к моделям. Выдаётся по рабочей почте при установке" required: true - name: MCP_GOSTASSISTENT_API_KEY description: "Ключ ГОСТ-ассистента: поиск и чтение ГОСТ, СП, СНиП. Без него агент работает полностью, но инструменты mcp__gostassistent__* не авторизуются" diff --git a/scripts/polza-limits/polza-limits.cmd b/scripts/polza-limits/polza-limits.cmd deleted file mode 100644 index 57345fc..0000000 --- a/scripts/polza-limits/polza-limits.cmd +++ /dev/null @@ -1,9 +0,0 @@ -@echo off -rem Суточный лимит Polza.ai по ключу владельца — компактный JSON в stdout. -rem Вызывается плагином polza-limit через gateway RPC shell.exec. -rem Путь %LOCALAPPDATA% раскрывается cmd.exe: у каждого пользователя свой. -setlocal -set "PY=%LOCALAPPDATA%\hermes\tools\python-3.14.7+202****0901-win32-x64\python.exe" -if not exist "%PY%" for /d %%D in ("%LOCALAPPDATA%\hermes\tools\python-*") do set "PY=%%D\python.exe" -"%PY%" "%LOCALAPPDATA%\hermes\.polza-limits\polza_limits.py" %* -endlocal diff --git a/scripts/polza-limits/polza_limits.py b/scripts/polza-limits/polza_limits.py deleted file mode 100644 index ff38036..0000000 --- a/scripts/polza-limits/polza_limits.py +++ /dev/null @@ -1,218 +0,0 @@ -#!/usr/bin/env python3 -"""Суточный лимит Polza.ai по ключу владельца — компактный JSON для чипа. - -Ключ (значение в вывод не попадает): - - * HERMES_CUSTOM_POLZA_AI_API_KEY из %LOCALAPPDATA%\\hermes\\.env - -Порядок работы: сначала /key и /balance (полоса и кошелёк готовы, даже если -история не досчитается), затем генерации за местные сутки с жёстким дедлайном — -плагин получает от shell.exec только 30 с и хвост stdout в 4000 символов. - -Формат: одна строка JSON - {"ok":true,"day":"2026-10-14","k":[ - {"p":"мой ключ","limit":500.0,"used":174.63,"left":325.37,"reset":"daily", - "amount":4829.57,"avail":325.37, - "gen":45,"tin":4105847,"tout":52856,"tcache":3038816, - "m":["DeepSeek: DeepSeek V4.1 Flash\\x1f30.25\\x1f45\\x1f4105847\\x1f52856\\x1f3038816", ...]}]} - -Модели — плоские строки через \\x1f (имя, рубли, число запросов, вход, выход, -кэш): объекты с длинными ключами не влезают в бюджет вывода. - -Токены берутся из usage каждой генерации. Важно, что prompt_tokens ВКЛЮЧАЕТ -cached_tokens — во входе доля кэша доходит до 90%, а кэш-чтение дешевле -примерно вчетверо. Поэтому вход отдаётся разбитым (tin — весь вход, tcache — -его кэшовая часть), иначе цифра входа не объясняет счёт. - -Совместимость: поля tin/tout/tcache и хвост строки модели добавлены позже. -Плагин обязан переживать их отсутствие. -""" -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], total: float, max_models: int) -> list[str]: - """Строки моделей в бюджет вывода: имя␟рубли␟запросы␟вход␟выход␟кэш.""" - head, dropped = [], [] - for row in rows: - cost = row[1] - if (cost >= GROUP_RUB and (total <= 0 or cost / total >= GROUP_SHARE) - and len(head) < max_models): - head.append(row) - else: - dropped.append(row) - out = [f"{r[0].replace(chr(31), ' ')}\x1f{r[1]:.2f}\x1f{r[2]}\x1f{r[3]}\x1f{r[4]}\x1f{r[5]}" - for r in head] - if dropped: - tail = [sum(r[i] for r in dropped) for i in (1, 2, 3, 4, 5)] - if tail[0] > 0.0 or tail[1]: - out.append(f"Другое\x1f{tail[0]:.2f}\x1f{tail[1]}\x1f{tail[2]}\x1f{tail[3]}\x1f{tail[4]}") - 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, 0, 0, 0]) - for it in items: - name = it.get("modelDisplayName") or it.get("model") or "—" - usage = it.get("usage") or {} - pdet = usage.get("prompt_tokens_details") or {} - per[name][0] += float(it.get("cost") or 0) - per[name][1] += 1 - per[name][2] += int(usage.get("prompt_tokens") or 0) - per[name][3] += int(usage.get("completion_tokens") or 0) - per[name][4] += int(pdet.get("cached_tokens") or 0) - rows = sorted(((n, v[0], v[1], v[2], v[3], v[4]) for n, v in per.items()), - key=lambda r: -r[2]) - row["gen"] = sum(r[2] for r in rows) - row["tin"] = sum(r[3] for r in rows) - row["tout"] = sum(r[4] for r in rows) - row["tcache"] = sum(r[5] 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(row[1] for row 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']}") - print(f" токены за сутки: вход {r.get('tin', 0):,} " - f"(из них из кэша {r.get('tcache', 0):,}) · выход {r.get('tout', 0):,}") - print(f" {'модель':<34}{'₽':>9}{'запр':>7}{'вход':>12}{'выход':>9}{'кэш':>12}") - for m in r["m"]: - n, c, k, tin, tout, cache = m.split("\x1f") - print(f" {n[:34]:<34}{float(c):>9.2f}{k:>7}{int(tin):>12,}{int(tout):>9,}{int(cache):>12,}") - return 0 - - json.dump(payload, sys.stdout, ensure_ascii=False, separators=(",", ":")) - sys.stdout.write("\n") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/polza-models/polza-models.cmd b/scripts/polza-models/polza-models.cmd deleted file mode 100644 index e3310ca..0000000 --- a/scripts/polza-models/polza-models.cmd +++ /dev/null @@ -1,16 +0,0 @@ -@echo off -rem Каталог моделей Polza.ai по возрастанию цены — компактный JSON в stdout. -rem Вызывается плагином polza-models через gateway RPC shell.exec. -rem Версия python НЕ зашита: имя каталога содержит дату сборки и меняется при -rem обновлении, поэтому берём единственный подходящий каталог по маске. -rem ВАЖНО: переводы строк только CRLF. С LF-файлом cmd.exe разбирает кириллицу -rem в rem-строках как команды и падает. -setlocal -set "PY=" -for /d %%D in ("%LOCALAPPDATA%\hermes\tools\python-*") do set "PY=%%D\python.exe" -if not exist "%PY%" ( - echo {"ok":false,"err":"python не найден в %LOCALAPPDATA%\hermes\tools"} - exit /b 1 -) -"%PY%" "%LOCALAPPDATA%\hermes\.polza-models\polza_models.py" %* -endlocal diff --git a/scripts/polza-models/polza_models.py b/scripts/polza-models/polza_models.py deleted file mode 100644 index 0a280c4..0000000 --- a/scripts/polza-models/polza_models.py +++ /dev/null @@ -1,238 +0,0 @@ -#!/usr/bin/env python3 -"""Модели Polza.ai: фактические ставки по своим генерациям + прайс каталога. - -Зачем два источника. Прайс каталога (`top_provider.pricing`) — это НЕ то, что -списывают. Проверено на своих генерациях за 7 суток, ставка входа: - - openai/gpt-6.1-sol 282,18 ₽/1M каталог 118,09 ×2,39 - anthropic/claude-sonnet-5.5 238,70 каталог 236,18 ×1,01 - google/gemini-3-flash-preview 61,37 каталог 29,52 ×2,08 - deepseek/deepseek-v4.1-flash 50,67 каталог 5,78 ×8,76 - -Множитель гуляет от 1,01 до 8,76: каталог не просто занижен, он занижен -ПО-РАЗНОМУ, поэтому порядок моделей по нему не совпадает с порядком по деньгам. -У DeepSeek в каталоге цена кэша вообще 0,00, а фактически кэш-чтение стоит -0,72 ₽/1M и даёт 280 ₽ из 2 298 ₽ за неделю. - -Поэтому: - * модели, которыми уже пользовались, идут со СВОЕЙ фактической ставкой — - она считается из costLines каждой генерации (`clientRub` / `quantity`); - * остальные — с прайсом каталога и пометкой «не факт». - -Источник факта: GET /history/generations, поле costLines — по каждому виду -токенов своя строка с quantity и clientRub. Это и есть списанные рубли; из -поля cost ставку не вывести, там нет разбивки по видам. - -Формат: одна строка JSON - {"ok":true,"nf":4,"nc":40,"cur":"RUB","min":50.67,"shown":44, - "m":["ф\\x1fdeepseek/deepseek-v4.1-flash\\x1fDeepSeek: DeepSeek V4.1 Flash\\x1f50.67\\x1f105.79\\x1f0.72\\x1f1921\\x1f2298.47", ...]} - -Поля строки: источник («ф» факт / «п» прайс), id, имя, вход, выход, кэш, запросов, -всего рублей. Источник — первое поле, потому что по нему плагин раскладывает -список на две секции. - -ВАЖНО про бюджет вывода: плагин получает от shell.exec только ХВОСТ stdout -(4000 символов). Список печатается от дешёвого и ужимается под бюджет, иначе -обрезка съела бы именно дешёвые модели — самое ценное здесь. -""" -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" -ENV_PATHS = [r"%LOCALAPPDATA%\hermes\.env"] -ENV_KEY = "HERMES_CUSTOM_POLZA_AI_API_KEY" - -MAX_ROWS = 40 # строк каталога (строки факта печатаются все) -CATALOG_DEADLINE_S = 12 -HISTORY_DEADLINE_S = 20 - -# Виды строк в costLines, раскладываемые по колонкам карточки. -KIND_PROMPT = "prompt" -KIND_COMPLETION = "completion" -KIND_CACHE = "cached_read" - - -def read_key() -> str: - key = (os.environ.get("POLZA_AI_API_KEY") or os.environ.get(ENV_KEY) or "").strip() - if key: - return key - for raw in ENV_PATHS: - path = raw.replace("%LOCALAPPDATA%", os.environ.get("LOCALAPPDATA", "")) - try: - with open(path, "r", encoding="utf-8", errors="replace") as fh: - m = re.search(rf"^{ENV_KEY}=(.+)$", fh.read(), re.M) - if m: - return m.group(1).strip().strip('"').strip("'") - except OSError: - continue - sys.exit("Не найден ключ Polza.ai в .env") - - -def api_get(path: str, key: str, timeout: int = 30) -> 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 num(v) -> float: - try: - return float(v or 0) - except (TypeError, ValueError): - return 0.0 - - -def catalog(key: str) -> tuple: - """Чат-модели с прайсом каталога, по возрастанию цены входа.""" - rows, currency = [], "RUB" - deadline = time.monotonic() + CATALOG_DEADLINE_S - for x in (api_get("/models", key).get("data") or []): - if time.monotonic() > deadline: - break - if x.get("type") != "chat": - continue - p = ((x.get("top_provider") or {}).get("pricing") or {}) - pin = num(p.get("prompt_per_million")) - if pin <= 0: - continue - currency = p.get("currency") or currency - rows.append((pin, num(p.get("completion_per_million")), - num(p.get("input_cache_read_per_million")), - x.get("id") or "?", x.get("name") or x.get("id") or "?")) - rows.sort(key=lambda r: (r[0], r[1], r[3])) - return rows, currency - - -def history(key: str, days: int) -> list: - """Генерации за окно с жёстким дедлайном: у плагина всего 30 с.""" - end = datetime.datetime.now() - start = end - datetime.timedelta(days=days) - items, page = [], 1 - deadline = time.monotonic() + HISTORY_DEADLINE_S - while page <= 25: - 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) - if page >= int((data.get("meta") or {}).get("totalPages") or 1) or not batch: - break - page += 1 - return items - - -def actual(items: list) -> dict: - """Фактические ставки: модель → вид → ставка ₽ за 1 млн. - - Считаем costLines, а не cost: costLines дают количество и списанные рубли - отдельно по каждому виду, из них ставка получается делением. - """ - per = collections.defaultdict(lambda: collections.defaultdict(lambda: [0, 0.0])) - gen = collections.Counter() - for it in items: - mid = it.get("model") or "?" - gen[mid] += 1 - for ln in (it.get("costLines") or []): - kind = ln.get("kind") or "?" - per[mid][kind][0] += int(num(ln.get("quantity"))) - per[mid][kind][1] += num(ln.get("clientRub")) - out = {} - for mid, kinds in per.items(): - out[mid] = {k: (rub / qty * 1e6 if qty else 0.0) for k, (qty, rub) in kinds.items()} - out[mid]["_total"] = sum(v[1] for v in kinds.values()) - out[mid]["_gen"] = gen[mid] - return out - - -def build(key: str, days: int): - cat, currency = catalog(key) - fact = actual(history(key, days)) - names = {r[3]: r[4] for r in cat} - - fact_rows = [] - for mid, r in fact.items(): - pin = r.get(KIND_PROMPT, 0.0) - if pin <= 0: - continue - fact_rows.append((pin, r.get(KIND_COMPLETION, 0.0), r.get(KIND_CACHE, 0.0), mid, - names.get(mid, mid), r["_gen"], r["_total"])) - fact_rows.sort(key=lambda x: (x[0], x[3])) - - used = {r[3] for r in fact_rows} - cat_rows = [r for r in cat if r[3] not in used] - - meta = { - "cur": currency, - "nf": len(fact_rows), - "nc": len(cat_rows), - "min": round(fact_rows[0][0] if fact_rows else (cat_rows[0][0] if cat_rows else 0.0), 2), - } - return fact_rows, cat_rows, meta - - -def fmt(fact_rows, cat_rows, meta, limit: int) -> str: - rows = [f"ф\x1f{r[3]}\x1f{r[4]}\x1f{r[0]:.2f}\x1f{r[1]:.2f}\x1f{r[2]:.2f}\x1f{r[5]}\x1f{r[6]:.2f}" - for r in fact_rows] - rows += [f"п\x1f{r[3]}\x1f{r[4]}\x1f{r[0]:.2f}\x1f{r[1]:.2f}\x1f{r[2]:.2f}\x1f0\x1f0.00" - for r in cat_rows[:limit]] - payload = dict(meta) - payload.update({"ok": True, "shown": len(rows), "m": rows}) - return json.dumps(payload, ensure_ascii=False, separators=(",", ":")) - - -def main() -> int: - ap = argparse.ArgumentParser(description="Модели Polza.ai: факт и прайс каталога") - ap.add_argument("--days", type=int, default=30, help="окно истории для фактических ставок") - ap.add_argument("--limit", type=int, default=MAX_ROWS, help="сколько строк каталога показать") - ap.add_argument("--pretty", action="store_true", help="таблицы для чтения глазами") - ap.add_argument("--budget", type=int, default=3900, help="предел длины вывода") - args = ap.parse_args() - - fact_rows, cat_rows, meta = build(read_key(), args.days) - if not fact_rows and not cat_rows: - print(json.dumps({"ok": False, "err": "нет данных ни по факту, ни по каталогу"}, - ensure_ascii=False)) - return 1 - - if not args.pretty: - limit = args.limit - line = fmt(fact_rows, cat_rows, meta, limit) - # ужимаем, пока не влезет: плагин видит только хвост stdout - while len(line) > args.budget and limit > 5: - limit -= 5 - line = fmt(fact_rows, cat_rows, meta, limit) - print(line) - return 0 - - print(f"факт по {meta['nf']} своим моделям (окно {args.days} сут) · " - f"каталог {meta['nc']} моделей · {meta['cur']} за 1 млн токенов\n") - print("ПО ФАКТУ СПИСАНИЯ (costLines)") - print(f" {'модель':<38}{'вход':>10}{'выход':>10}{'кэш':>9}{'запр':>7}{'всего руб':>12}") - for r in fact_rows: - print(f" {r[4][:38]:<38}{r[0]:>10.2f}{r[1]:>10.2f}{r[2]:>9.2f}{r[5]:>7}{r[6]:>12.2f}") - print("\nПРАЙС КАТАЛОГА — НЕ ФАКТ") - print(f" {'модель':<38}{'вход':>10}{'выход':>10}{'кэш':>9} id") - for r in cat_rows[:args.limit]: - print(f" {r[4][:38]:<38}{r[0]:>10.2f}{r[1]:>10.2f}{r[2]:>9.2f} {r[3]}") - if len(cat_rows) > args.limit: - print(f" ... ещё {len(cat_rows) - args.limit} моделей дороже") - return 0 - - -if __name__ == "__main__": - sys.exit(main())