Fix per-provider data loss, history regression, and decode fragility (#362)

* Fix per-provider data loss, division-by-zero, and decode fragility

- Per-provider multi-day queries only merged cost/calls from cache,
  dropping categories/models/sessions/tokens. Remove broken cache
  shortcut and always do full parse for per-provider periods.
- Remove per-provider daily history double-counting from overlapping
  cache + live data.
- Guard maxCost against zero in ActivitySection and ModelsSection to
  prevent NaN in bar width calculations.
- Use offset-based ForEach ID in BarTooltipCard to avoid duplicate
  model name collisions.
- Make cacheHitPercent, topActivities, topModels, providers use
  decodeIfPresent for backward compat with older CLI versions.
- Skip currency switch when FX rate fetch fails with no cache,
  preventing rate/symbol desync.
- Use readSessionFile in Gemini parser for 128MB size cap.
- Truncate Codex userMessage to 500 chars like other providers.

* Restore cache-backed trend history for provider-filtered views

The previous commit removed the broken per-provider cache shortcut but
also dropped cache-backed daily history, causing provider-filtered views
to lose trend data outside the selected period range.

Use allCacheDays for historical days (cost/calls per provider is accurate
in cache) and today's entry from the full parse. No overlap since cache
ends at yesterday.
This commit is contained in:
Resham Joshi 2026-05-20 04:16:48 -07:00 committed by GitHub
parent 3542407f8f
commit 0f55a446da
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 39 additions and 89 deletions

View file

@ -122,10 +122,10 @@ extension CurrentBlock {
oneShotRate = try c.decodeIfPresent(Double.self, forKey: .oneShotRate)
inputTokens = try c.decode(Int.self, forKey: .inputTokens)
outputTokens = try c.decode(Int.self, forKey: .outputTokens)
cacheHitPercent = try c.decode(Double.self, forKey: .cacheHitPercent)
topActivities = try c.decode([ActivityEntry].self, forKey: .topActivities)
topModels = try c.decode([ModelEntry].self, forKey: .topModels)
providers = try c.decode([String: Double].self, forKey: .providers)
cacheHitPercent = try c.decodeIfPresent(Double.self, forKey: .cacheHitPercent) ?? 0
topActivities = try c.decodeIfPresent([ActivityEntry].self, forKey: .topActivities) ?? []
topModels = try c.decodeIfPresent([ModelEntry].self, forKey: .topModels) ?? []
providers = try c.decodeIfPresent([String: Double].self, forKey: .providers) ?? [:]
topProjects = try c.decodeIfPresent([ProjectEntry].self, forKey: .topProjects) ?? []
modelEfficiency = try c.decodeIfPresent([ModelEfficiencyEntry].self, forKey: .modelEfficiency) ?? []
topSessions = try c.decodeIfPresent([TopSessionEntry].self, forKey: .topSessions) ?? []

View file

@ -20,7 +20,7 @@ struct ActivitySection: View {
}
) {
VStack(alignment: .leading, spacing: 7) {
let maxCost = store.payload.current.topActivities.map(\.cost).max() ?? 1
let maxCost = max(store.payload.current.topActivities.map(\.cost).max() ?? 1, 0.01)
ForEach(store.payload.current.topActivities, id: \.name) { activity in
ActivityRow(activity: activity, maxCost: maxCost)
}

View file

@ -373,7 +373,7 @@ private struct BarTooltipCard: View {
if !bar.topModels.isEmpty {
VStack(alignment: .leading, spacing: 3) {
ForEach(Array(bar.topModels.prefix(4).enumerated()), id: \.element.name) { idx, m in
ForEach(Array(bar.topModels.prefix(4).enumerated()), id: \.offset) { idx, m in
HStack(spacing: 6) {
RoundedRectangle(cornerRadius: 1)
.fill(Theme.brandAccent.opacity(0.75 - Double(idx) * 0.12))

View file

@ -653,8 +653,10 @@ struct FooterBar: View {
}
let fresh = await FXRateCache.shared.rate(for: code)
store.currency = code
CurrencyState.shared.apply(code: code, rate: fresh ?? cached, symbol: symbol)
if let rate = fresh ?? cached {
store.currency = code
CurrencyState.shared.apply(code: code, rate: rate, symbol: symbol)
}
}
CLICurrencyConfig.persist(code: code)

View file

@ -19,7 +19,7 @@ struct ModelsSection: View {
}
) {
VStack(alignment: .leading, spacing: 7) {
let maxCost = store.payload.current.topModels.map(\.cost).max() ?? 1
let maxCost = max(store.payload.current.topModels.map(\.cost).max() ?? 1, 0.01)
ForEach(store.payload.current.topModels, id: \.name) { model in
ModelRow(model: model, maxCost: maxCost)
}

View file

@ -467,7 +467,6 @@ program
let scanRange: DateRange
let cache: DailyCache
let todayProviderData: PeriodData | null = null
let usedPerProviderCachePath = false
if (isAllProviders) {
cache = await hydrateCache()
@ -487,32 +486,11 @@ program
}
} else {
cache = await loadDailyCache()
const cacheIsCurrent = cache.lastComputedDate !== null
&& cache.lastComputedDate >= yesterdayStr
if (cacheIsCurrent && rangeStartStr < todayStr) {
const todayProviderProjects = fp(await parseAllSessions(todayRange, pf))
todayProviderData = buildPeriodData(periodInfo.label, todayProviderProjects)
const historicalDays = getDaysInRange(cache, rangeStartStr, yesterdayStr)
let histCost = 0, histCalls = 0
for (const d of historicalDays) {
const prov = d.providers[pf]
if (prov) { histCost += prov.cost; histCalls += prov.calls }
}
currentData = {
...todayProviderData,
cost: todayProviderData.cost + histCost,
calls: todayProviderData.calls + histCalls,
}
scanProjects = todayProviderProjects
scanRange = todayRange
usedPerProviderCachePath = true
} else {
const fullProjects = fp(await parseAllSessions(periodInfo.range, pf))
todayProviderData = buildPeriodData(periodInfo.label, fullProjects)
currentData = todayProviderData
scanProjects = fullProjects
scanRange = periodInfo.range
}
const fullProjects = fp(await parseAllSessions(periodInfo.range, pf))
todayProviderData = buildPeriodData(periodInfo.label, fullProjects)
currentData = todayProviderData
scanProjects = fullProjects
scanRange = periodInfo.range
}
// PROVIDERS
@ -579,7 +557,8 @@ program
topModels,
}
})
} else if (usedPerProviderCachePath) {
} else {
const emptyModels = [] as { name: string; cost: number; calls: number; inputTokens: number; outputTokens: number }[]
const historyFromCache = allCacheDays.map(d => {
const prov = d.providers[pf] ?? { calls: 0, cost: 0 }
return {
@ -590,53 +569,25 @@ program
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
topModels: [] as { name: string; cost: number; calls: number; inputTokens: number; outputTokens: number }[],
topModels: emptyModels,
}
})
const todayCost = todayProviderData!.cost
const todayCalls = todayProviderData!.calls
if (todayCost > 0 || todayCalls > 0) {
historyFromCache.push({
date: todayStr,
cost: todayCost,
calls: todayCalls,
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
topModels: [],
const todayFromParse = aggregateProjectsIntoDays(scanProjects)
.filter(d => d.date === todayStr)
.map(d => {
const prov = d.providers[pf] ?? { calls: 0, cost: 0 }
return {
date: d.date,
cost: prov.cost,
calls: prov.calls,
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
topModels: emptyModels,
}
})
}
dailyHistory = historyFromCache
} else {
const histFromCache = allCacheDays.map(d => {
const prov = d.providers[pf] ?? { calls: 0, cost: 0 }
return {
date: d.date,
cost: prov.cost,
calls: prov.calls,
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
topModels: [] as { name: string; cost: number; calls: number; inputTokens: number; outputTokens: number }[],
}
})
const fallbackDays = aggregateProjectsIntoDays(scanProjects)
const liveDays = fallbackDays.map(d => {
const prov = d.providers[pf] ?? { calls: 0, cost: 0 }
return {
date: d.date,
cost: prov.cost,
calls: prov.calls,
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
topModels: [] as { name: string; cost: number; calls: number; inputTokens: number; outputTokens: number }[],
}
})
dailyHistory = [...histFromCache, ...liveDays]
dailyHistory = [...historyFromCache, ...todayFromParse]
}
const home = homedir()

View file

@ -370,7 +370,7 @@ function createParser(source: SessionSource, seenKeys: Set<string>): SessionPars
.filter(c => c.type === 'input_text')
.map(c => c.text ?? '')
.filter(Boolean)
if (texts.length > 0) pendingUserMessage = texts.join(' ')
if (texts.length > 0) pendingUserMessage = texts.join(' ').slice(0, 500)
continue
}

View file

@ -1,7 +1,8 @@
import { readdir, readFile, stat } from 'fs/promises'
import { readdir, stat } from 'fs/promises'
import { join } from 'path'
import { homedir } from 'os'
import { readSessionFile } from '../fs-utils.js'
import { calculateCost } from '../models.js'
import { extractBashCommands } from '../bash-utils.js'
import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js'
@ -185,12 +186,8 @@ function parseJsonl(raw: string): GeminiSession | null {
function createParser(source: SessionSource, seenKeys: Set<string>): SessionParser {
return {
async *parse(): AsyncGenerator<ParsedProviderCall> {
let raw: string
try {
raw = await readFile(source.path, 'utf-8')
} catch {
return
}
const raw = await readSessionFile(source.path)
if (raw === null) return
let data: GeminiSession | null = null