mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-11 03:13:12 +00:00
fix: guard against missing modelBreakdowns in usage aggregation
The CCS Bar server was crash-looping with:
[X] Cannot read properties of undefined (reading 'length')
Usage records from partially-written or legacy persisted files (e.g. a
Codex rollout scanned mid-write) can be missing modelBreakdowns despite
the type. getHourlyRequestCount() and the daily/monthly/hourly merges
read it unguarded, and the Bar re-runs aggregation every ~5 min and
restarts on crash - hence the loop.
Default modelBreakdowns to [] at every use site and fall back to 0 for
request count. Regression test added for all three merges.
I don't normally work in TS and this was heavily tool-assisted, but it
has fixed the crash loop on my machine.
This commit is contained in:
1 parent
507ce278b9
commit
a7f3e4bc0e
2 files changed
+116
-7
No files matched your search
@@ -113,7 +113,8 @@ async function loadInstanceData(instancePath: string): Promise<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getHourlyRequestCount(hour: HourlyUsage): number {
|
function getHourlyRequestCount(hour: HourlyUsage): number {
|
||||||
return hour.requestCount ?? hour.modelBreakdowns.length;
|
// modelBreakdowns can be absent at runtime (legacy snapshots, files read mid-write).
|
||||||
|
return hour.requestCount ?? hour.modelBreakdowns?.length ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
function finalizeDailyUsage(day: DailyUsage): DailyUsage {
|
function finalizeDailyUsage(day: DailyUsage): DailyUsage {
|
||||||
@@ -165,7 +166,7 @@ export function mergeDailyData(
|
|||||||
existing.cacheReadTokens += day.cacheReadTokens;
|
existing.cacheReadTokens += day.cacheReadTokens;
|
||||||
existing.totalCost += day.totalCost;
|
existing.totalCost += day.totalCost;
|
||||||
// Merge model breakdowns by aggregating same modelName
|
// Merge model breakdowns by aggregating same modelName
|
||||||
for (const breakdown of day.modelBreakdowns) {
|
for (const breakdown of day.modelBreakdowns ?? []) {
|
||||||
const breakdownKey = getProviderModelKey(breakdown);
|
const breakdownKey = getProviderModelKey(breakdown);
|
||||||
const existingBreakdown = existing.modelBreakdowns.find(
|
const existingBreakdown = existing.modelBreakdowns.find(
|
||||||
(b) => getProviderModelKey(b) === breakdownKey
|
(b) => getProviderModelKey(b) === breakdownKey
|
||||||
@@ -182,7 +183,7 @@ export function mergeDailyData(
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Clone to avoid mutating original
|
// Clone to avoid mutating original
|
||||||
const modelBreakdowns = day.modelBreakdowns.map((b) => ({ ...b }));
|
const modelBreakdowns = (day.modelBreakdowns ?? []).map((b) => ({ ...b }));
|
||||||
dateMap.set(mergeKey, {
|
dateMap.set(mergeKey, {
|
||||||
...day,
|
...day,
|
||||||
...(options.preserveProfile && day.profile ? { profile: day.profile } : {}),
|
...(options.preserveProfile && day.profile ? { profile: day.profile } : {}),
|
||||||
@@ -219,7 +220,7 @@ export function mergeMonthlyData(
|
|||||||
existing.cacheCreationTokens += month.cacheCreationTokens;
|
existing.cacheCreationTokens += month.cacheCreationTokens;
|
||||||
existing.cacheReadTokens += month.cacheReadTokens;
|
existing.cacheReadTokens += month.cacheReadTokens;
|
||||||
existing.totalCost += month.totalCost;
|
existing.totalCost += month.totalCost;
|
||||||
for (const breakdown of month.modelBreakdowns) {
|
for (const breakdown of month.modelBreakdowns ?? []) {
|
||||||
const breakdownKey = getProviderModelKey(breakdown);
|
const breakdownKey = getProviderModelKey(breakdown);
|
||||||
const existingBreakdown = existing.modelBreakdowns.find(
|
const existingBreakdown = existing.modelBreakdowns.find(
|
||||||
(item) => getProviderModelKey(item) === breakdownKey
|
(item) => getProviderModelKey(item) === breakdownKey
|
||||||
@@ -235,7 +236,9 @@ export function mergeMonthlyData(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const modelBreakdowns = month.modelBreakdowns.map((breakdown) => ({ ...breakdown }));
|
const modelBreakdowns = (month.modelBreakdowns ?? []).map((breakdown) => ({
|
||||||
|
...breakdown,
|
||||||
|
}));
|
||||||
monthMap.set(mergeKey, {
|
monthMap.set(mergeKey, {
|
||||||
...month,
|
...month,
|
||||||
...(options.preserveProfile && month.profile ? { profile: month.profile } : {}),
|
...(options.preserveProfile && month.profile ? { profile: month.profile } : {}),
|
||||||
@@ -275,7 +278,7 @@ export function mergeHourlyData(
|
|||||||
existing.totalCost += hour.totalCost;
|
existing.totalCost += hour.totalCost;
|
||||||
existing.requestCount = getHourlyRequestCount(existing) + getHourlyRequestCount(hour);
|
existing.requestCount = getHourlyRequestCount(existing) + getHourlyRequestCount(hour);
|
||||||
// Merge model breakdowns
|
// Merge model breakdowns
|
||||||
for (const breakdown of hour.modelBreakdowns) {
|
for (const breakdown of hour.modelBreakdowns ?? []) {
|
||||||
const breakdownKey = getProviderModelKey(breakdown);
|
const breakdownKey = getProviderModelKey(breakdown);
|
||||||
const existingBreakdown = existing.modelBreakdowns.find(
|
const existingBreakdown = existing.modelBreakdowns.find(
|
||||||
(b) => getProviderModelKey(b) === breakdownKey
|
(b) => getProviderModelKey(b) === breakdownKey
|
||||||
@@ -291,7 +294,7 @@ export function mergeHourlyData(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const modelBreakdowns = hour.modelBreakdowns.map((b) => ({ ...b }));
|
const modelBreakdowns = (hour.modelBreakdowns ?? []).map((b) => ({ ...b }));
|
||||||
hourMap.set(mergeKey, {
|
hourMap.set(mergeKey, {
|
||||||
...hour,
|
...hour,
|
||||||
...(options.preserveProfile && hour.profile ? { profile: hour.profile } : {}),
|
...(options.preserveProfile && hour.profile ? { profile: hour.profile } : {}),
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { describe, expect, it } from 'bun:test';
|
||||||
|
import {
|
||||||
|
mergeDailyData,
|
||||||
|
mergeHourlyData,
|
||||||
|
mergeMonthlyData,
|
||||||
|
} from '../../../src/web-server/usage/aggregator';
|
||||||
|
import type {
|
||||||
|
DailyUsage,
|
||||||
|
HourlyUsage,
|
||||||
|
ModelBreakdown,
|
||||||
|
MonthlyUsage,
|
||||||
|
} from '../../../src/web-server/usage/types';
|
||||||
|
|
||||||
|
const breakdown: ModelBreakdown = {
|
||||||
|
modelName: 'claude-sonnet-4-5',
|
||||||
|
inputTokens: 100,
|
||||||
|
outputTokens: 40,
|
||||||
|
cacheCreationTokens: 0,
|
||||||
|
cacheReadTokens: 0,
|
||||||
|
cost: 0.1,
|
||||||
|
};
|
||||||
|
|
||||||
|
function validHour(): HourlyUsage {
|
||||||
|
return {
|
||||||
|
hour: '2026-03-02 10:00',
|
||||||
|
source: 'test',
|
||||||
|
inputTokens: 100,
|
||||||
|
outputTokens: 40,
|
||||||
|
cacheCreationTokens: 0,
|
||||||
|
cacheReadTokens: 0,
|
||||||
|
cost: 0.1,
|
||||||
|
totalCost: 0.1,
|
||||||
|
modelsUsed: ['claude-sonnet-4-5'],
|
||||||
|
modelBreakdowns: [{ ...breakdown }],
|
||||||
|
requestCount: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function validDay(): DailyUsage {
|
||||||
|
return {
|
||||||
|
date: '2026-03-02',
|
||||||
|
source: 'test',
|
||||||
|
inputTokens: 100,
|
||||||
|
outputTokens: 40,
|
||||||
|
cacheCreationTokens: 0,
|
||||||
|
cacheReadTokens: 0,
|
||||||
|
cost: 0.1,
|
||||||
|
totalCost: 0.1,
|
||||||
|
modelsUsed: ['claude-sonnet-4-5'],
|
||||||
|
modelBreakdowns: [{ ...breakdown }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function validMonth(): MonthlyUsage {
|
||||||
|
return {
|
||||||
|
month: '2026-03',
|
||||||
|
source: 'test',
|
||||||
|
inputTokens: 100,
|
||||||
|
outputTokens: 40,
|
||||||
|
cacheCreationTokens: 0,
|
||||||
|
cacheReadTokens: 0,
|
||||||
|
totalCost: 0.1,
|
||||||
|
modelsUsed: ['claude-sonnet-4-5'],
|
||||||
|
modelBreakdowns: [{ ...breakdown }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simulates a partially-written or legacy persisted usage record that is
|
||||||
|
* missing `modelBreakdowns` (and `requestCount`) despite the type declaring
|
||||||
|
* them, e.g. a Codex rollout file scanned mid-write.
|
||||||
|
*/
|
||||||
|
function stripBreakdowns<T extends { modelBreakdowns: ModelBreakdown[] }>(record: T): T {
|
||||||
|
const partial = { ...record } as Partial<T>;
|
||||||
|
delete partial.modelBreakdowns;
|
||||||
|
if ('requestCount' in partial) {
|
||||||
|
delete (partial as { requestCount?: number }).requestCount;
|
||||||
|
}
|
||||||
|
return partial as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('usage aggregator merge with missing modelBreakdowns', () => {
|
||||||
|
it('does not throw when a same-hour record is missing modelBreakdowns (merge-into-existing path)', () => {
|
||||||
|
const merged = mergeHourlyData([[validHour()], [stripBreakdowns(validHour())]]);
|
||||||
|
expect(merged).toHaveLength(1);
|
||||||
|
// requestCount stays numeric instead of throwing on `.length` of undefined.
|
||||||
|
expect(typeof merged[0]?.requestCount).toBe('number');
|
||||||
|
// Tokens from both records still aggregate.
|
||||||
|
expect(merged[0]?.inputTokens).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not throw when the only record is missing modelBreakdowns (new-bucket path)', () => {
|
||||||
|
const merged = mergeHourlyData([[stripBreakdowns(validHour())]]);
|
||||||
|
expect(merged).toHaveLength(1);
|
||||||
|
expect(merged[0]?.modelBreakdowns).toEqual([]);
|
||||||
|
expect(merged[0]?.requestCount).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('daily merge tolerates a record missing modelBreakdowns', () => {
|
||||||
|
expect(() => mergeDailyData([[validDay()], [stripBreakdowns(validDay())]])).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('monthly merge tolerates a record missing modelBreakdowns', () => {
|
||||||
|
expect(() => mergeMonthlyData([[validMonth()], [stripBreakdowns(validMonth())]])).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user