diff --git a/packages/core/src/usage.test.ts b/packages/core/src/usage.test.ts index cb627406a..7427e26da 100644 --- a/packages/core/src/usage.test.ts +++ b/packages/core/src/usage.test.ts @@ -109,17 +109,104 @@ describe('累積スナップショットを増分へ畳む', () => { }); // 反転した試験: 以前は「基準にあったモデルが消えたことも数え直しである」だった。載るモデルの組は呼び出しごとに食い違いうる(本番 2026-10-26T23:16Z に haiku を含まない累積が来て opus 約 108 億 cache_read が二重に積まれた)ため、消えたモデルは「変化なし」へ改めた - it('基準にあったモデルが今回に無いことは数え直しではない(変化なしとして基準へ引き継ぐ)', () => { + // Issue #5405 で sonnet の cost を 1 から 6 へ改めた: 元の形(基準 opus 5・今回 sonnet 1)は、欠けた opus(5)が今回の合計(1)より大きく、数え直し直後に主なモデルが載らない形そのもので、数え直しとして扱う(下の #5405 の試験)。ここは欠落ぶんが今回の合計以下の形に限る + it('基準にあったモデルが今回に無くても、欠落ぶんが今回の合計以下なら数え直しではない(変化なしとして基準へ引き継ぐ)', () => { const fold = foldUsageSnapshot( baseline({ opus: totals({ costUsd: 5 }) }), - { models: { sonnet: totals({ costUsd: 1 }) } }, + { models: { sonnet: totals({ costUsd: 6 }) } }, LATER, ); expect(fold.reset).toBeUndefined(); - expect(fold.delta).toEqual({ sonnet: totals({ costUsd: 1 }) }); + expect(fold.delta).toEqual({ sonnet: totals({ costUsd: 6 }) }); expect(nextBaseline(fold).models).toEqual({ opus: totals({ costUsd: 5 }), - sonnet: totals({ costUsd: 1 }), + sonnet: totals({ costUsd: 6 }), + }); + }); + + describe('欠落したモデルが支配的なら数え直し(Issue #5405。数え直し直後に主なモデルが載らない)', () => { + const opus = (n: number) => totals({ cacheReadInputTokens: n, costUsd: n }); + const haiku = (n: number) => totals({ outputTokens: n, costUsd: n }); + + it('今回に主なモデルが無く、載ったモデルが前回以上でも、欠落ぶんが支配的なら数え直しで、全量が増分・基準は今回だけになる', () => { + const fold = foldUsageSnapshot( + baseline({ opus: opus(100), haiku: haiku(2) }), + { models: { haiku: haiku(3) } }, + LATER, + ); + expect(fold.reset).toEqual({ + at: LATER, + fromCostUsd: 102, + toCostUsd: 3, + fromSessionId: undefined, + toSessionId: undefined, + }); + expect(fold.delta).toEqual({ haiku: haiku(3) }); + expect(nextBaseline(fold).models).toEqual({ haiku: haiku(3) }); + expect(nextBaseline(fold).resets).toBe(1); + }); + + it('その次に主なモデルが現れて累積しても、積まれるのは差分だけ(二重計上しない)', () => { + const reset = foldUsageSnapshot( + baseline({ opus: opus(100), haiku: haiku(2) }), + { models: { haiku: haiku(3) } }, + AT, + ); + const appeared = foldUsageSnapshot( + nextBaseline(reset), + { models: { opus: opus(7), haiku: haiku(3) } }, + LATER, + ); + expect(appeared.reset).toBeUndefined(); + expect(appeared.delta).toEqual({ opus: opus(7) }); + const grown = foldUsageSnapshot( + nextBaseline(appeared), + { models: { opus: opus(12), haiku: haiku(4) } }, + LATER, + ); + expect(grown.reset).toBeUndefined(); + expect(grown.delta).toEqual({ opus: opus(5), haiku: haiku(1) }); + }); + + it('今回に無いモデルがあるだけで他が伸びていれば数え直しにならない', () => { + const fold = foldUsageSnapshot( + baseline({ opus: opus(100), haiku: haiku(2) }), + { models: { opus: opus(103) } }, + LATER, + ); + expect(fold.reset).toBeUndefined(); + expect(fold.delta).toEqual({ opus: opus(3) }); + }); + + // #5402 の本番事象の形: haiku が載った直後に haiku の無い累積が来る。opus がほぼ増えず合計が haiku ぶん減っても数え直しにしない + it('haiku の欠落だけで opus が再送(同じ値)でも微増でも、数え直しにならない', () => { + const before = baseline({ opus: opus(100), haiku: haiku(2) }); + const resend = foldUsageSnapshot(before, { models: { opus: opus(100) } }, LATER); + expect(resend.reset).toBeUndefined(); + expect(resend.delta).toEqual({}); + const slight = foldUsageSnapshot(before, { models: { opus: opus(101) } }, LATER); + expect(slight.reset).toBeUndefined(); + expect(slight.delta).toEqual({ opus: opus(1) }); + }); + + it('同じ累積の再送は数え直しにならず、増分も空', () => { + const before = baseline({ opus: opus(100), haiku: haiku(2) }); + const fold = foldUsageSnapshot( + before, + { models: { opus: opus(100), haiku: haiku(2) } }, + LATER, + ); + expect(fold.reset).toBeUndefined(); + expect(fold.delta).toEqual({}); + }); + + it('今回に cost の読めなかった欄が在るときは、欠落ぶんの比べで数え直しとしない', () => { + const fold = foldUsageSnapshot( + baseline({ opus: opus(100), haiku: haiku(2) }), + { models: { haiku: { ...haiku(3), unreadable: { costUsd: 1 } } } }, + LATER, + ); + expect(fold.reset).toBeUndefined(); }); }); @@ -306,6 +393,39 @@ describe('台帳へ畳む(foldRecordForStore)で累積に載るモデルの expect(stale.fold.delta).toEqual({ opus: opus(10) }); expect(stale.nextBaseline?.byRunner?.r1).toEqual({ opus: opus(110), haiku: haiku(8) }); }); + + // Issue #5405: 数え直し直後に主なモデルが載らず、載ったモデルが前回以上でも、欠落ぶんが支配的なら数え直し + it('現役の runner: 欠落ぶんが支配的なら数え直しで、全量を積み、基準と控えは今回だけになる', () => { + const runner = { id: 'r1', superseded: false }; + const first = store(null, { opus: opus(100), haiku: haiku(2) }, runner); + const reset = store(first.nextBaseline, { haiku: haiku(3) }, runner); + expect(reset.fold.reset).toBeDefined(); + expect(reset.fold.delta).toEqual({ haiku: haiku(3) }); + expect(reset.nextBaseline?.models).toEqual({ haiku: haiku(3) }); + expect(reset.nextBaseline?.byRunner?.r1).toEqual({ haiku: haiku(3) }); + + const appeared = store(reset.nextBaseline, { opus: opus(7), haiku: haiku(3) }, runner); + expect(appeared.fold.reset).toBeUndefined(); + expect(appeared.fold.delta).toEqual({ opus: opus(7) }); + }); + + it('古い runner(superseded): 欠落ぶんが支配的なら数え直しとして積まず(skipped: decreased)、控えを今回だけにする', () => { + const old = { id: 'r1', superseded: false }; + const first = store(null, { opus: opus(100), haiku: haiku(2) }, old); + const moved = store( + first.nextBaseline, + { opus: opus(120), haiku: haiku(2) }, + { id: 'r2', superseded: false }, + ); + const stale = store(moved.nextBaseline, { haiku: haiku(3) }, { id: 'r1', superseded: true }); + expect(stale.fold.skipped).toEqual({ + reason: 'decreased', + fromCostUsd: 102, + toCostUsd: 3, + }); + expect(stale.fold.delta).toEqual({}); + expect(stale.nextBaseline?.byRunner?.r1).toEqual({ haiku: haiku(3) }); + }); }); describe('行の合計', () => { diff --git a/packages/core/src/usage.ts b/packages/core/src/usage.ts index a8b7d7fcb..96456edca 100644 --- a/packages/core/src/usage.ts +++ b/packages/core/src/usage.ts @@ -189,7 +189,20 @@ function detectReset( return true; } } - return false; + // 欠落したモデルが支配的なときも数え直しとする(Issue #5405): 数え直し直後の最初の累積に主なモデルが載らず別のモデルだけが載ると、載ったモデルが前回以上で上の判定に掛からず、基準に残った前回の主なモデルに追いつくまでの消費が積まれない + // 「合計の減少」にしない: haiku のような小さなモデルが欠けただけの累積(#5402 の本番事象。opus がほぼ増えない再送に近い読み)も合計が haiku の cost ぶん減り、約 108 億の二重計上が再発するため + // 今回に無い基準のモデルの前回 cost の合計(欠落ぶん)が、今回の全モデルの cost 合計より大きいときだけ。厳密な `>` なので等しい読みは数え直しにしない。欠落が無ければ働かない + // 今回に cost の読めなかった欄が在るときは見ない: 読めない cost は 0 で書かれ、今回の合計が見かけ上小さくなるため + if (hasUnreadableCost(next)) return false; + let missing = 0; + for (const [model, before] of Object.entries(prev)) { + if (next[model] === undefined) missing += before.costUsd; + } + return missing > sumCostUsd(next); +} + +function hasUnreadableCost(models: Record): boolean { + return Object.values(models).some((m) => (m.unreadable?.costUsd ?? 0) > 0); } function subtract(after: UsageTotals, before: UsageTotals): UsageTotals {