Skip to content

analyze:sessions bills proxy (GLM) requests twice: inventory dedups by requestId only #95

Description

@axisrow

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).

Что сделать

  1. В scanSessionFile ключевать запрос через billedRequestKey, последняя строка побеждает — как в сайдбаре и analyze:session.
  2. Проверить, что tokensByModel (--breakdown) тоже считает запрос один раз.

Acceptance

  • Тест: фикстура «запрос без requestId из нескольких строк с одним message.id» — одинаковые итоги у analyzeSessionFileMetadata, buildLedger и scanSessionFile
  • Строка с этим сценарием добавлена в тест паритета (см. эпик Epic: one number on every surface — cross-surface parity (audit follow-ups) #94)

  • Проверено человеком: воспроизводится / не воспроизводится

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions