Files
houseplan-card/scripts/model-usage.mjs
T
Claudeandclaude[bot] f5a6b47b39 feat(process): the pipeline records model usage in the review document's machine block (#737)
The weekly process metrics weigh tracks and the nightly ship review in the
order quality, speed, tokens (#707), but the third axis had no source: the
claude-code-action step hides usage from the Actions log on purpose, nothing
read its execution_file, and the #728 reader printed "no data" every week.

scripts/model-usage.mjs is the single module that builds and parses the line:
`<!-- hp:usage input_tokens=N output_tokens=N cache_creation_input_tokens=N
cache_read_input_tokens=N num_turns=N -->` (sums over every model in the last
`result` message, `result.usage` when modelUsage is absent) or
`<!-- hp:usage-none reason=<code> -->`. Only the result message is read; the
rest of the file holds tool results, and no byte of it is printed.

A new step right after Review in both model_review jobs (always(),
continue-on-error) hands the line out as the job output `usage`. Usage is a
reporting figure like the stage duration, so it travels as a job output and
not through the sealed artifact: REQUIRED_FILES and the #556 gate are
unchanged. Publication treats the line as untrusted input and writes the
normalized form as the last line of the anchor block (review-doc-guard
--anchor --usage=) or right after the SHIP-REVIEW block; empty becomes
reason=missing, anything off-format reason=invalid.

The #728 reader now takes the line only from the machine block: a reviewer
quoting the previous round in prose no longer doubles its usage, and
"no data" is counted as missing, never as zero. PROCESS.md §10.4 documents
the source, the format and why it is a job output.

Issue: #737
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-01 12:18:13 +00:00

177 lines
8.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* Расход модели одной машинной строкой (#737, PROCESS.md §10.4).
*
* node scripts/model-usage.mjs --execution-file=<путь>
*
* Шаг `Review` (`claude-code-action`) отдаёт выход `execution_file` — JSON-массив
* всех сообщений сессии SDK, он пишется и при ошибке SDK. Журнал Actions
* расхода не покажет: action сознательно печатает результат урезанным
* («without exposing token usage or cost details»). Других следов расхода у
* конвейера нет, а без них у отчёта «Метрики процесса» нет третьей оси
* «качество → скорость → токены» (#707, #728).
*
* Здесь — единственное место, которое собирает и разбирает строку (у
* публикации и отчёта копии формата нет):
*
* <!-- hp:usage input_tokens=N output_tokens=N cache_creation_input_tokens=N cache_read_input_tokens=N num_turns=N -->
* <!-- hp:usage-none reason=<код> -->
*
* Ровно пять ключей в этом порядке, N — десятичное целое без знака и без
* ведущих нулей, не длиннее 12 цифр. Формат замораживается первой публикацией:
* документы ревью задним числом не правят. Поэтому ключи — имена SDK без
* перевода, а «нет данных» — отдельный маркер, а не отсутствие строки: иначе
* «конвейер не смог» не отличить от «документа до #737». Предварительный
* читатель #728 строку данных читает, а `hp:usage-none` — нет: после
* `hp:usage` ему нужен пробел. В строке нет SHA и хешей: блок якорей кода
* считает якорем каждые 40 hex-символов (`materialAnchorsFrom`).
*
* Из файла берётся только последнее сообщение `type: "result"`. Остальное в
* нём — результаты инструментов, то есть возможные секреты, и ни одна их
* буква не печатается: даже сообщение об ошибке JSON.parse цитирует текст,
* поэтому причины здесь — коды, а не тексты ошибок. Скрипт не падает на
* данных: любой исход — строка, код выхода 0.
*/
import { readFileSync } from 'node:fs';
import { isMainModule } from './spawn-portable.mjs';
/** Ключи строки данных — в порядке строки. */
export const USAGE_KEYS = Object.freeze([
'input_tokens', 'output_tokens', 'cache_creation_input_tokens', 'cache_read_input_tokens', 'num_turns',
]);
/** Токены — поля `modelUsage[<модель>]` SDK (camelCase) и `result.usage` (snake_case). */
const MODEL_USAGE_FIELDS = Object.freeze({
input_tokens: 'inputTokens',
output_tokens: 'outputTokens',
cache_creation_input_tokens: 'cacheCreationInputTokens',
cache_read_input_tokens: 'cacheReadInputTokens',
});
/**
* Причины «нет данных». Первые четыре — снятие из `execution_file`, две
* последние — публикация: строки от стадии модели нет (`missing`) или она
* пришла не по формату (`invalid`).
*/
export const USAGE_REASONS = Object.freeze([
'no-execution-file', 'unreadable', 'no-result', 'no-usage', 'missing', 'invalid',
]);
/** Наибольшее значение, которое помещается в 12 цифр. */
const MAX_COUNT = 999_999_999_999;
const COUNT = '(0|[1-9]\\d{0,11})';
const DATA_RE = new RegExp(`^<!-- hp:usage ${USAGE_KEYS.map((key) => `${key}=${COUNT}`).join(' ')} -->$`);
const NONE_RE = new RegExp(`^<!-- hp:usage-none reason=(${USAGE_REASONS.join('|')}) -->$`);
const isCount = (value) => Number.isSafeInteger(value) && value >= 0 && value <= MAX_COUNT;
const isRecord = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
/**
* Строка из разобранного вида: `{ input_tokens, …, num_turns }` либо
* `{ reason }`. Обратна `parseUsageLine`. Вход не по контракту — исключение:
* это ошибка вызывающего кода, не данных.
*/
export function formatUsage(usage) {
if (isRecord(usage) && 'reason' in usage) {
if (!USAGE_REASONS.includes(usage.reason)) throw new TypeError(`model-usage: неизвестная причина ${JSON.stringify(usage.reason)}`);
return `<!-- hp:usage-none reason=${usage.reason} -->`;
}
if (!isRecord(usage) || !USAGE_KEYS.every((key) => isCount(usage[key]))) {
throw new TypeError('model-usage: расход не по формату');
}
return `<!-- hp:usage ${USAGE_KEYS.map((key) => `${key}=${usage[key]}`).join(' ')} -->`;
}
/**
* Строгий разбор одной строки: `{ input_tokens, …, num_turns }`,
* `{ reason }` или `null` (не по формату). Пробелы по краям, лишний или
* пропущенный ключ, другой порядок, знак, дробь, ведущий ноль, 13 цифр,
* перевод строки внутри — `null`.
*/
export function parseUsageLine(line) {
const text = String(line ?? '');
const data = DATA_RE.exec(text);
if (data) return Object.fromEntries(USAGE_KEYS.map((key, index) => [key, Number(data[index + 1])]));
const none = NONE_RE.exec(text);
return none ? { reason: none[1] } : null;
}
/**
* Строка для документа ревью из недоверенного выхода стадии модели (#556):
* пусто — `missing`, не по формату — `invalid`, иначе та же строка.
*/
export function publishedUsageLine(raw) {
const text = String(raw ?? '').trim();
if (!text) return formatUsage({ reason: 'missing' });
return formatUsage(parseUsageLine(text) ?? { reason: 'invalid' });
}
/** Последняя строка формата в тексте машинного блока, разобранная, либо `null`. */
export function lastUsageIn(text) {
let found = null;
for (const line of String(text ?? '').split('\n')) {
const parsed = parseUsageLine(line.trim());
if (parsed) found = parsed;
}
return found;
}
/**
* Расход из сообщений сессии SDK: сумма по всем моделям `modelUsage`
* последнего `result` (лимит подписки тратят и вспомогательные модели); без
* `modelUsage` — те же ключи из `result.usage`. Значение не целое ≥ 0 — это
* не данные (`unreadable`), а не ноль.
*/
export function usageFromMessages(messages) {
if (!Array.isArray(messages)) return { reason: 'unreadable' };
const result = messages.findLast((message) => isRecord(message) && message.type === 'result');
if (!result) return { reason: 'no-result' };
const models = isRecord(result.modelUsage) ? Object.values(result.modelUsage) : [];
const usage = {};
if (models.length) {
for (const [key, field] of Object.entries(MODEL_USAGE_FIELDS)) {
usage[key] = 0;
for (const model of models) {
const value = isRecord(model) ? model[field] : undefined;
if (!isCount(value)) return { reason: 'unreadable' };
usage[key] += value;
}
}
} else if (isRecord(result.usage)) {
for (const key of Object.keys(MODEL_USAGE_FIELDS)) usage[key] = result.usage[key];
} else {
return { reason: 'no-usage' };
}
usage.num_turns = result.num_turns;
return USAGE_KEYS.every((key) => isCount(usage[key])) ? usage : { reason: 'unreadable' };
}
/** Расход из файла `execution_file`; файла нет или путь пуст — `no-execution-file`. */
export function usageFromExecutionFile(path, read = (file) => readFileSync(file, 'utf8')) {
if (!String(path ?? '').trim()) return { reason: 'no-execution-file' };
let text;
try {
text = read(path);
} catch (error) {
return { reason: error?.code === 'ENOENT' ? 'no-execution-file' : 'unreadable' };
}
let messages;
try {
messages = JSON.parse(text);
} catch {
return { reason: 'unreadable' };
}
return usageFromMessages(messages);
}
if (isMainModule(import.meta.url)) {
const flag = process.argv.slice(2).find((item) => item.startsWith('--execution-file='));
let line;
try {
line = formatUsage(usageFromExecutionFile(flag ? flag.slice('--execution-file='.length) : ''));
} catch {
line = formatUsage({ reason: 'unreadable' });
}
process.stdout.write(`${line}\n`);
}