MilaAgentДокументацияGitHub

Главная / Документация

Учёт: разговоры и внешние траты

Обновлено: 05.11.2026Источник: docs/accounting.mdПравить на GitHub

На этой странице

Две базы. Одна отвечает на вопрос «что происходило», вторая — «сколько ушло денег». Смешивать их нельзя: в первой доллары почти всегда справочные, во второй — всегда настоящие.

~/.claude/analytics/
  conversations.db        turns          — ходы: кто, кому, чем, за сколько
  external-spend.db       external_calls — оплаченные вызовы наружу
  records-watermark.json  сколько строк было в прошлую проверку

Каталог 700, базы 600 — внутри тексты переписки. Другое место задаётся MILA_RECORDS_DIR.


1. Ходы (conversations.db, таблица turns)

Один ход — один обмен: сообщение человека и ответ агента, со всем, чем этот ответ был сделан.

Схема совместима с агентами. Первые четырнадцать колонок повторяют таблицу turns агента дословно — id, ts, brand, account_id, bot_slug, chat_id, message_id, user_text, bot_text, model, prompt_tokens, completion_tokens, latency_ms, idempotency_key. Это не вежливость к чужому коду: пока схема одна, «сколько мы разговаривали с этим клиентом» считается одним запросом по всем агентам сразу. Как только схем становится две, такой вопрос перестают задавать.

Своё добавлено сверху и добавляется через ALTER TABLE. Поэтому этим модулем можно открыть живую базу агента — она дополнится недостающими колонками, не потеряв ни строки, а старый инструмент продолжит читать первые четырнадцать и не заметит остальных.

Колонка Что в ней
agent, agent_kind кто говорил: director · companion · admin
session_id, turn_source из какой сессии и откуда пришёл ход (claude-code, claude-code/subagent)
temperature, max_tokens настройки модели — пусты у ходов из транскрипта, см. ниже
reasoning_mode, reasoning_tokens режим рассуждения (high/medium/low) и сколько токенов на него ушло
tool_calls, tools_used сколько инструментов позвано и какие
cache_write_tokens, cache_read_tokens кэш отдельно от входа
usd_cost, cost_basis цена и по какой таблице она посчитана
paid_by subscription или api_key — что означает цена
text_policy, user_len, bot_len что сделали с текстами и какой они были длины
meta_json вид хода, модели с числом ответов, навыки, чат, файл транскрипта

Чего в этой таблице нет и почему

Температуры и потолка токенов у ходов из транскрипта. Claude Code их не записывает. Ставить туда ноль или «по умолчанию» — значит выдумать настройку, по которой потом будут объяснять поведение модели. Колонки существуют для агентов, чей движок эти значения знает, и для навыков, которые зовут модель сами; у собранных ходов там пусто, и это честный пробел.

🔴 Цена в turns — справочная

Работа по подписке Claude Code не оплачивается этими долларами. Никто их не списывал. Цифра отвечает на другой вопрос: во сколько тот же объём обошёлся бы по прейскуранту API. Это мера того, что подписка отдаёт, и на загруженном дне она выглядит пугающе большой.

Что означает цифра в конкретной строке, говорит paid_by:

  • subscription — справочно. Не складывать с деньгами, не показывать клиенту как расход, не сверять с выпиской.
  • api_key — настоящий расход по ключу провайдера. Вот его и сверяют.

Таблица цен — одна на весь кит, лежит в install/usage_collect.py и перекрывается файлом ~/.claude/prices.json. Второй таблицы цен в проекте нет и быть не должно. Незнакомая модель даёт usd_cost = NULL: токены посчитаны, деньги — нет. Прочерк лучше уверенной цифры, взятой из чужого тарифа.

Как наполняется

mila turns                    # ходы за сегодня из всех транскриптов
mila turns --days 7
mila turns --project=-Users-milagpt
mila turns --dry-run --show 5 # посмотреть, ничего не записывая

Сборщик режет транскрипт Claude Code по promptId: он один на весь ход, включая все ответы модели и все возвраты инструментов между ними. Резать по «строке от пользователя» нельзя — возврат инструмента и вставки оболочки приходят тем же типом, и ход развалился бы на два десятка кусков.

Повторный прогон ничего не удваивает: у каждого хода есть idempotency_key (cc:<сессия>:<promptId>, у субагента плюс имя его файла — транскрипт субагента наследует и сессию, и promptId родителя).

Расписание: раз в час

Сбор ставится установщиком и идёт раз в час — launchd на Маке (com.milagpt.turns-collect), systemd на сервере (mila-turns-collect.timer).

Не в ровный час. Минута считается от имени машины и попадает в диапазон 10–49: в начале и в конце часа уже стоят чужие кроны, а сборщик, запущенный в ту же секунду, что и крон, записывает работающий ход как провал. Своя минута задаётся при установке: MILA_TURNS_MINUTE=17 install/install.sh. Имя агента берётся из MILA_AGENT — оно должно совпадать с тем, под которым ходы пишутся руками, иначе одна Мила разъедется в отчёте на две строки.

Раз в час, а не раз в сутки, по двум причинам. Ход, собранный ночью, теряет связь с разговором, который его породил, — а вся ценность записи в том, чтобы к вечеру можно было спросить «какой вопрос сколько стоил». И доктор, который сутки ругается на тишину, перестают читать.

Наложение безопасно. Пока идёт сбор, рядом с базой держится замок turns-collect.lock (flock, а не «есть ли файл»: файл от убитого процесса лежал бы вечно, а flock ядро снимает само). Второй сбор молча выходит с нулевым кодом; человеку за терминалом он всё-таки скажет «сбор уже идёт».

Посмотреть последний сбор:

cat ~/.claude/analytics/turns-collect-last.json   # когда, за сколько, сколько
tail ~/.claude/turns-collect.log                  # что печатал таймер
mila doctor                                       # стоит ли таймер и свеж ли он

launchctl list com.milagpt.turns-collect          # Мак
systemctl --user list-timers mila-turns-collect   # сервер

Журнал пуст, пока добавлять нечего: таймер запускается с --quiet и печатает строку только когда что-то записал. Строка раз в час о том, что всё в порядке, делает журнал нечитаемым.

Журнал сам себя укорачивает. Перед началом сбора: если turns-collect.log перевалил за 1 МБ, остаются последние 500 КБ, начало отбрасывается, а на его месте появляется строка [журнал укорочен … — начало отброшено] — чтобы не искать, куда делось начало. Отдельного таймера ротации нет, и файл режется на месте: служба держит его открытым, подмена файла отправила бы вывод текущего прогона в никуда.

Два подводных камня формата, из-за которых цифры расходились:

  1. Один ответ модели пишется в транскрипт несколькими строками — размышление, текст, каждый вызов инструмента, — и блок usage повторяется в каждой. Построчный счёт завышал расход в 1,7 раза (852 строки против 488 настоящих ответов в замеренной сессии). Считаем ответами.
  2. Изредка верхний уровень usage приходит нулями, а числа лежат в usage.iterations. Тогда берутся оттуда.

Оба разбирает usage_collect.py (функции usage_fields и response_key), и сборщик ходов зовёт тот же код. Двух разборов одного формата в проекте нет — расходиться они начинают молча.

Цифры до 10 сентября 2026 сравнивать с новыми нельзя. Всё, что записано в usage-log.jsonl раньше, посчитано построчно и завышено примерно в 1,7 раза. Расход не упал — упала ошибка счёта. Если увидите на графике обрыв десятого сентября, это он и есть, а не экономия. Новые строки помечены полем counting: per-response; у старых этого поля нет.

Тексты разговоров — это ПДн

Выключатель MILA_RECORD_TEXT:

Значение Что пишется
full (по умолчанию) тексты целиком — это личный агент владельца
length только длина в символах, самих текстов нет
none ни текста, ни длины

Выбор записан в каждой строке полем text_policy, поэтому пустой user_text нельзя спутать с потерянными данными. Очень длинный текст режется с пометкой …[обрезано на N знаках] (MILA_RECORD_TEXT_MAX, по умолчанию 100 000).

Ставя Милу клиенту, где в чатах чужие персональные данные, ставьте length или none — и запишите это решение там, где его найдут через полгода.


2. Внешние траты (external-spend.db, таблица external_calls)

Всё, за что платит не подписка, а счёт у провайдера: картинка, голос, видео, чужая модель. Сегодня Мила наружу не ходит вовсе — ноль вызовов OpenRouter и fal. Леджер заводится именно поэтому: первый навык с генерацией начнёт тратить молча, и заметит это выписка, а не мы.

Колонка Что в ней
ts, agent, agent_kind когда и кто
provider openrouter · fal · other
service, model что именно позвали
usd настоящие деньги
units, unit_kind сколько и чего: секунды видео, картинки, символы, токены
request_id обязателен — по нему строка сверяется с выпиской
meta_json что угодно ещё: чат, задача, размер

request_id обязателен и не имеет значения по умолчанию. Леджер, который нельзя сверить с выпиской провайдера, — это не леджер, а ощущение. Пара provider + request_id уникальна: повторная запись того же вызова не удваивает расход и возвращает None.

Как записать — одна строка

Из навыка:

from records import record_external

record_external(provider="fal", service="lyria-3", model="lyria-3-pro",
                usd=0.08, units=2, unit_kind="треки", request_id="fal-7c1a")

Из оболочки:

records.py external --provider fal --service lyria-3 --usd 0.08 \
    --units 2 --unit-kind треки --request-id fal-7c1a

Правило простое: вызов наружу и запись в леджер делаются в одном месте кода. Разнесённые, они расходятся на первой же ошибке сети.


3. Как посмотреть

mila records show turns --days 7
mila records show turns --days 1 --chat -987654321
mila records show external --days 30
mila records check                      # то же, что смотрит доктор
mila records show turns --days 7 --json # для своих подсчётов

Своим запросом:

sqlite3 ~/.claude/analytics/conversations.db \
  "SELECT date(ts,'unixepoch','localtime') d, count(*), round(sum(usd_cost),2)
   FROM turns WHERE paid_by='subscription' GROUP BY d ORDER BY d"

4. Что проверяет доктор

mila doctor спрашивает у учёта четыре вещи:

  • есть ли базы — нет файла, значит сборщик ни разу не запускался;
  • полна ли схема — не пропали ли колонки (лечится само при открытии);
  • пишется ли — полтора дня без единого хода это замечание, а не норма;
  • не усохла ли — счётчики сравниваются с прошлой проверкой (records-watermark.json). Это единственный способ поймать пропажу: база, из которой удалили строки, выглядит совершенно здоровой, и замечают такое через месяц;
  • стоит ли часовой таймер и отработал ли он за последние два часа — два разных вопроса: загруженный таймер, падающий на старте, выглядит здоровым в любом списке служб, а свежая отметка без таймера значит, что кто-то собрал руками один раз и на этом успокоился.

Плюс права 600 на базе с текстами и предупреждение, если тексты выключены — не как ошибка, а чтобы это не оказалось сюрпризом при разборе.


5. Чем это НЕ является

  • Не счётом. Цена в turns при paid_by = subscription справочная. Класть её в MONEY.md, в отчёт клиенту или в сравнение «дешевле/дороже» с настоящими деньгами нельзя.
  • Не полной стенограммой. В bot_text идёт то, что человек получил в чат (текст отправки), а если отправки не было — то, что модель сказала в терминале. Возвраты инструментов, файлы и картинки в базу не попадают.
  • Не заменой usage_collect.py. Тот отвечает на вопрос «сколько стоил день» и считает по всем транскриптам сразу, включая сессии, которые сюда не собирали. Числа сходятся, потому что разбор общий, но вопросы разные.
  • Не аудитом. Сборщик читает то, что записал Claude Code. Если ход не попал в транскрипт, его здесь не будет.

Tasks: cost and outcome per task type

install/tasks.py (mila tasks) adds one table, tasks, to conversations.db: a task is a time span [start, end] with a kind (post, email, doc…) and an outcome — done (accepted the first time), redo, complaint. Its cost is computed in the report from the turns of that span (same agent, same chat if given), so a lagging turn collector does not lose anything.

mila tasks start --kind email --chat <id>     # → task id
mila tasks done <id> [--outcome redo]
mila tasks mark <id> complaint               # later
mila tasks report --days 7 [--json]

--note is a short label of the task with no client names and no message content ("promo of the week", not "email to about the payment"): reports by kind go to the admin and the owner, and the database goes into backups.

The weekly report groups by kind: tasks, first-time %, redos, complaints, total cost, cost per good result, model mix. As everywhere in these ledgers, the cost is a reference price on subscription (paid_by), not a charge. Overlapping tasks in one chat share turns and are marked.