Part of #94.
⚠️ Выводы не проверены человеком. Ишью заведено по аудиту, который сделал Claude Code. Перед исправлением достаточно один раз выполнить блок «Как проверить» и сравнить вывод с ожидаемым.
Что не так
Прокси (GLM через роутер) пишет один API-запрос несколькими строками JSONL с одним message.id, без requestId, и в каждой строке — полный usage. #27 исправил это во всех потребителях («один запрос = одно сообщение = один раунд»), #42 обещал одну реализацию ключа billedRequestKey. Инвентарь (pnpm analyze:sessions) остался со своим ключом — только requestId — и складывает такие строки:
|
токены сессии |
| сайдбар |
100 050 |
pnpm analyze:session |
100 050 |
pnpm analyze:sessions |
200 100 |
На сессиях через GLM-прокси итоги инвентаря, сортировка --sort tokens и доли моделей (--breakdown) завышены примерно вдвое. Так с мержа #27 — проверено на его коммите.
Как проверить (один раз, ~1 мин)
Из корня репозитория:
cat > /tmp/repro-glm.mts <<'EOF'
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const R = process.cwd(); // запускать из корня репозитория
const load = (p: string) => import(`${R}/${p}`);
let t = Date.parse('2026-10-01T07:00:00Z'), n = 0;
const at = (ms = 20_000) => new Date((t += ms)).toISOString();
const base = (x = {}) => ({ uuid: `u${++n}`, parentUuid: null, sessionId: 's', cwd: '/p', isSidechain: false, ...x });
const user = (content: unknown, x = {}, gap?: number) => ({ ...base(x), type: 'user', timestamp: at(gap), message: { role: 'user', content } });
const ai = (block: object, ctx: number, out: number, ids: { rid?: string; mid: string }, x = {}, gap?: number) => ({ ...base(x), type: 'assistant', timestamp: at(gap), ...(ids.rid ? { requestId: ids.rid } : {}), message: { id: ids.mid, role: 'assistant', model: 'claude-x', content: [block], usage: { input_tokens: 0, cache_read_input_tokens: ctx, cache_creation_input_tokens: 0, output_tokens: out } } });
const text = (s: string) => ({ type: 'text', text: s });
const write = (lines: object[]) => { const f = join(mkdtempSync(join(tmpdir(), 'repro-')), 's.jsonl'); writeFileSync(f, lines.map((l) => JSON.stringify(l)).join('\n') + '\n'); return f; };
// один запрос через прокси без requestId: 2 строки с одним message.id, полный usage в каждой
const f = write([
user('go'),
ai(text('часть 1'), 100_000, 50, { mid: 'msg_A' }),
ai(text('часть 2'), 100_000, 50, { mid: 'msg_A' }),
]);
const jsonl = await load('src/main/utils/jsonl.ts');
const cli = await load('src/cli/analyzeSession.ts');
const inv = await load('src/cli/sessionInventory.ts');
const tt = cli.buildLedger(await jsonl.parseJsonlFile(f)).totals;
const row = {
'сайдбар': (await jsonl.analyzeSessionFileMetadata(f)).totalTokens,
'analyze:session': tt.inputTokens + tt.cacheReadTokens + tt.cacheCreationTokens + tt.outputTokens,
'analyze:sessions': (await inv.scanSessionFile(f)).totalTokens,
};
console.table(row);
console.log(row['analyze:sessions'] === row['analyze:session'] ? 'OK: одинаково' : `ВОСПРОИЗВЕДЕНО: analyze:sessions считает запрос ${row['analyze:sessions'] / row['analyze:session']}×`);
EOF
pnpm exec tsx /tmp/repro-glm.mts
Правильно: три одинаковых числа. Сейчас: у analyze:sessions вдвое больше и строка ВОСПРОИЗВЕДЕНО: analyze:sessions считает запрос 2×.
Где в коде (гипотеза)
src/cli/sessionInventory.ts:249 — usageByRequestId ключуется только requestId; строки без него попадают в directByModel и суммируются.
- Канон:
billedRequestKey — requestId ?? messageId ?? message.id, последняя строка запроса побеждает. Так считают сайдбар (jsonl.ts) и analyze:session (parseJsonlFile → mergeAssistantFragments).
Что сделать
- В
scanSessionFile ключевать запрос через billedRequestKey, последняя строка побеждает — как в сайдбаре и analyze:session.
- Проверить, что
tokensByModel (--breakdown) тоже считает запрос один раз.
Acceptance
Part of #94.
Что не так
Прокси (GLM через роутер) пишет один API-запрос несколькими строками JSONL с одним
message.id, безrequestId, и в каждой строке — полныйusage. #27 исправил это во всех потребителях («один запрос = одно сообщение = один раунд»), #42 обещал одну реализацию ключаbilledRequestKey. Инвентарь (pnpm analyze:sessions) остался со своим ключом — толькоrequestId— и складывает такие строки:pnpm analyze:sessionpnpm analyze:sessionsНа сессиях через GLM-прокси итоги инвентаря, сортировка
--sort tokensи доли моделей (--breakdown) завышены примерно вдвое. Так с мержа #27 — проверено на его коммите.Как проверить (один раз, ~1 мин)
Из корня репозитория:
Правильно: три одинаковых числа. Сейчас: у
analyze:sessionsвдвое больше и строкаВОСПРОИЗВЕДЕНО: analyze:sessions считает запрос 2×.Где в коде (гипотеза)
src/cli/sessionInventory.ts:249—usageByRequestIdключуется толькоrequestId; строки без него попадают вdirectByModelи суммируются.billedRequestKey—requestId ?? messageId ?? message.id, последняя строка запроса побеждает. Так считают сайдбар (jsonl.ts) иanalyze:session(parseJsonlFile→mergeAssistantFragments).Что сделать
scanSessionFileключевать запрос черезbilledRequestKey, последняя строка побеждает — как в сайдбаре иanalyze:session.tokensByModel(--breakdown) тоже считает запрос один раз.Acceptance
requestIdиз нескольких строк с однимmessage.id» — одинаковые итоги уanalyzeSessionFileMetadata,buildLedgerиscanSessionFile