From 0e670d95b9c579821ff10bacc1f3383db8d53ae4 Mon Sep 17 00:00:00 2001 From: takecchi Date: Wed, 28 Oct 2026 16:13:16 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20=E6=95=B0=E3=81=88=E7=9B=B4=E3=81=97?= =?UTF-8?q?=E7=9B=B4=E5=BE=8C=E3=81=AB=E4=B8=BB=E3=81=AA=E3=83=A2=E3=83=87?= =?UTF-8?q?=E3=83=AB=E3=81=8C=E8=BC=89=E3=82=89=E3=81=AA=E3=81=84=E7=B4=AF?= =?UTF-8?q?=E7=A9=8D=E3=82=92=E5=8F=96=E3=82=8A=E3=81=93=E3=81=BC=E3=81=95?= =?UTF-8?q?=E3=81=AA=E3=81=84=EF=BC=88=E5=90=88=E8=A8=88=20cost=20?= =?UTF-8?q?=E3=81=8C=E6=B8=9B=E3=81=A3=E3=81=9F=E3=82=89=E6=95=B0=E3=81=88?= =?UTF-8?q?=E7=9B=B4=E3=81=97=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #5402 の続き。数え直し直後の最初の累積に主なモデル(opus)が載らず、別のモデル(haiku)だけが載り、 それが前回以上だと reset にならず、基準に残った前回の opus に追いつくまでの消費が台帳に積まれなかった。 detectReset に「基準の全モデルの合計 cost より今回の合計 cost が小さい(厳密な <)」を足した。 等しい(再送)は数え直しにしない。今回に cost の読めなかった欄が在るときは合計を見ない。 既存の試験 1 本(基準 opus 5 に対し今回 sonnet 1)は合計が減る形で、数え直し直後の形と区別できないため、sonnet を 6 に改めた。 --- packages/core/src/usage.test.ts | 117 ++++++++++++++++++++++++++++++-- packages/core/src/usage.ts | 8 +++ 2 files changed, 121 insertions(+), 4 deletions(-) diff --git a/packages/core/src/usage.test.ts b/packages/core/src/usage.test.ts index cb627406a..b2f259989 100644 --- a/packages/core/src/usage.test.ts +++ b/packages/core/src/usage.test.ts @@ -109,17 +109,93 @@ describe('累積スナップショットを増分へ畳む', () => { }); // 反転した試験: 以前は「基準にあったモデルが消えたことも数え直しである」だった。載るモデルの組は呼び出しごとに食い違いうる(本番 2026-10-26T23:16Z に haiku を含まない累積が来て opus 約 108 億 cache_read が二重に積まれた)ため、消えたモデルは「変化なし」へ改めた - it('基準にあったモデルが今回に無いことは数え直しではない(変化なしとして基準へ引き継ぐ)', () => { + // Issue #5405 で sonnet の cost を 1 から 6 へ改めた: 前回の合計(5)より今回の合計が小さい形は、数え直し直後に主なモデルが載らない場面と区別できず、数え直しとして扱う(下の #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('合計 cost が減ったら数え直し(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) }); + }); + + 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 +382,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..efbcbd7a3 100644 --- a/packages/core/src/usage.ts +++ b/packages/core/src/usage.ts @@ -189,9 +189,17 @@ function detectReset( return true; } } + // 合計 cost が減ったときも数え直しとする(Issue #5405): 数え直し直後の最初の累積に主なモデルが載らず別のモデルだけが載ると、載ったモデルが前回以上で上の判定に掛からず、基準に残った前回の主なモデルに追いつくまでの消費が積まれない + // 比べるのは基準の全モデル(今回に無いモデルの前回値を含む)の合計と今回の合計: 今回に無いモデルがあるだけで他が伸びていれば合計は減らず、数え直しにならない。等しい(再送)も数え直しにしない + // 今回に cost の読めなかった欄が在るときは見ない: 読めない cost は 0 で書かれ、合計が見かけ上減るため + if (!hasUnreadableCost(next) && sumCostUsd(next) < sumCostUsd(prev)) return true; return false; } +function hasUnreadableCost(models: Record): boolean { + return Object.values(models).some((m) => (m.unreadable?.costUsd ?? 0) > 0); +} + function subtract(after: UsageTotals, before: UsageTotals): UsageTotals { return { inputTokens: Math.max(0, after.inputTokens - before.inputTokens), From 46d969f4d3e0974b1a43f6a124299773345b1126 Mon Sep 17 00:00:00 2001 From: takecchi Date: Wed, 28 Oct 2026 17:30:47 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=E6=95=B0=E3=81=88=E7=9B=B4=E3=81=97?= =?UTF-8?q?=E3=81=AE=E5=88=A4=E5=AE=9A=E3=82=92=E3=80=8C=E5=90=88=E8=A8=88?= =?UTF-8?q?=20cost=20=E3=81=AE=E6=B8=9B=E5=B0=91=E3=80=8D=E3=81=8B?= =?UTF-8?q?=E3=82=89=E3=80=8C=E6=AC=A0=E8=90=BD=E3=81=97=E3=81=9F=E3=83=A2?= =?UTF-8?q?=E3=83=87=E3=83=AB=E3=81=8C=E6=94=AF=E9=85=8D=E7=9A=84=E3=80=8D?= =?UTF-8?q?=E3=81=B8=E6=94=B9=E3=82=81=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 合計 cost の減少で数え直しとみなすと、#5402 の本番事象の形(haiku が載った直後に haiku の無い累積が来て、 opus がほぼ増えない)で合計が haiku の cost ぶん減り、opus 約 108 億の二重計上が再発しうる。 今回に無い基準のモデルの前回 cost の合計(欠落ぶん)が、今回の全モデルの cost 合計より大きい(厳密な >)ときだけ 数え直しとする。haiku のような小さな欠落は reset にならず、主なモデルが丸ごと欠けて別のモデルだけが載る 数え直し直後の形は reset になる。欠落が無ければ働かない。cost の unreadable が在れば判定しない。 --- packages/core/src/usage.test.ts | 31 +++++++++++++++++++++---------- packages/core/src/usage.ts | 15 ++++++++++----- 2 files changed, 31 insertions(+), 15 deletions(-) diff --git a/packages/core/src/usage.test.ts b/packages/core/src/usage.test.ts index b2f259989..7427e26da 100644 --- a/packages/core/src/usage.test.ts +++ b/packages/core/src/usage.test.ts @@ -109,8 +109,8 @@ describe('累積スナップショットを増分へ畳む', () => { }); // 反転した試験: 以前は「基準にあったモデルが消えたことも数え直しである」だった。載るモデルの組は呼び出しごとに食い違いうる(本番 2026-10-26T23:16Z に haiku を含まない累積が来て opus 約 108 億 cache_read が二重に積まれた)ため、消えたモデルは「変化なし」へ改めた - // Issue #5405 で sonnet の cost を 1 から 6 へ改めた: 前回の合計(5)より今回の合計が小さい形は、数え直し直後に主なモデルが載らない場面と区別できず、数え直しとして扱う(下の #5405 の試験)。ここは「合計が減らない」形に限る - 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: 6 }) } }, @@ -124,11 +124,11 @@ describe('累積スナップショットを増分へ畳む', () => { }); }); - describe('合計 cost が減ったら数え直し(Issue #5405。数え直し直後に主なモデルが載らない)', () => { + describe('欠落したモデルが支配的なら数え直し(Issue #5405。数え直し直後に主なモデルが載らない)', () => { const opus = (n: number) => totals({ cacheReadInputTokens: n, costUsd: n }); const haiku = (n: number) => totals({ outputTokens: n, costUsd: n }); - it('今回に主なモデルが無く、載ったモデルが前回以上でも、合計が減れば数え直しで、全量が増分・基準は今回だけになる', () => { + it('今回に主なモデルが無く、載ったモデルが前回以上でも、欠落ぶんが支配的なら数え直しで、全量が増分・基準は今回だけになる', () => { const fold = foldUsageSnapshot( baseline({ opus: opus(100), haiku: haiku(2) }), { models: { haiku: haiku(3) } }, @@ -168,7 +168,7 @@ describe('累積スナップショットを増分へ畳む', () => { expect(grown.delta).toEqual({ opus: opus(5), haiku: haiku(1) }); }); - it('今回に無いモデルがあるだけで他が伸びていれば(合計が減らない)数え直しにならない', () => { + it('今回に無いモデルがあるだけで他が伸びていれば数え直しにならない', () => { const fold = foldUsageSnapshot( baseline({ opus: opus(100), haiku: haiku(2) }), { models: { opus: opus(103) } }, @@ -178,7 +178,18 @@ describe('累積スナップショットを増分へ畳む', () => { expect(fold.delta).toEqual({ opus: opus(3) }); }); - it('同じ累積の再送(合計が等しい)は数え直しにならず、増分も空', () => { + // #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, @@ -189,7 +200,7 @@ describe('累積スナップショットを増分へ畳む', () => { expect(fold.delta).toEqual({}); }); - it('今回に cost の読めなかった欄が在るときは、合計の減少で数え直しとしない', () => { + it('今回に cost の読めなかった欄が在るときは、欠落ぶんの比べで数え直しとしない', () => { const fold = foldUsageSnapshot( baseline({ opus: opus(100), haiku: haiku(2) }), { models: { haiku: { ...haiku(3), unreadable: { costUsd: 1 } } } }, @@ -383,8 +394,8 @@ describe('台帳へ畳む(foldRecordForStore)で累積に載るモデルの expect(stale.nextBaseline?.byRunner?.r1).toEqual({ opus: opus(110), haiku: haiku(8) }); }); - // Issue #5405: 数え直し直後に主なモデルが載らず、載ったモデルが前回以上でも、合計が減れば数え直し - it('現役の runner: 合計が減れば数え直しで、全量を積み、基準と控えは今回だけになる', () => { + // 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); @@ -398,7 +409,7 @@ describe('台帳へ畳む(foldRecordForStore)で累積に載るモデルの expect(appeared.fold.delta).toEqual({ opus: opus(7) }); }); - it('古い runner(superseded): 合計が減れば数え直しとして積まず(skipped: decreased)、控えを今回だけにする', () => { + 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( diff --git a/packages/core/src/usage.ts b/packages/core/src/usage.ts index efbcbd7a3..96456edca 100644 --- a/packages/core/src/usage.ts +++ b/packages/core/src/usage.ts @@ -189,11 +189,16 @@ function detectReset( return true; } } - // 合計 cost が減ったときも数え直しとする(Issue #5405): 数え直し直後の最初の累積に主なモデルが載らず別のモデルだけが載ると、載ったモデルが前回以上で上の判定に掛からず、基準に残った前回の主なモデルに追いつくまでの消費が積まれない - // 比べるのは基準の全モデル(今回に無いモデルの前回値を含む)の合計と今回の合計: 今回に無いモデルがあるだけで他が伸びていれば合計は減らず、数え直しにならない。等しい(再送)も数え直しにしない - // 今回に cost の読めなかった欄が在るときは見ない: 読めない cost は 0 で書かれ、合計が見かけ上減るため - if (!hasUnreadableCost(next) && sumCostUsd(next) < sumCostUsd(prev)) 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 {