mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-10-20 11:44:39 +00:00
Merge remote-tracking branch 'origin/main' into feat/windows-capacity-dock
This commit is contained in:
commit
378062ebc2
46 changed files with 2165 additions and 308 deletions
|
|
@ -28,8 +28,8 @@ There is no separate build step required to run the dev CLI. `npm run dev` runs
|
|||
| `npm run test:locks` | Runs the three parallelism-sensitive `cache-refresh-lock` suites serially. |
|
||||
| `npm run test:watch` | Same scope as `npm test`, in watch mode. |
|
||||
| `npm run dev -- status` | Runs the CLI in dev mode against your real data. |
|
||||
| `npm run build` | Bundles the litellm pricing snapshot, then runs `tsup` to produce `dist/cli.js`. |
|
||||
| `npm run bundle-litellm` | Refreshes `src/data/litellm-snapshot.json` from the upstream litellm repo. |
|
||||
| `npm run build` | Builds the CLI and dashboard from the checked-in pricing catalogs without mutating tracked source files. |
|
||||
| `npm run bundle-litellm` | Explicitly refreshes the checked-in pricing catalogs from their upstream sources. Review and commit the resulting data changes separately. |
|
||||
|
||||
To test a specific suite, run vitest directly with a path:
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
// @vitest-environment jsdom
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { App, overviewMemoKey, refreshedLabel, selectedReportMemoKeys, topCategoryByModel, usageSnapshotProps } from './App'
|
||||
|
|
@ -500,9 +500,28 @@ describe('App shortcuts', () => {
|
|||
await waitFor(() => expect(mocks.getOverview).toHaveBeenCalledWith('30days', 'grok'))
|
||||
})
|
||||
|
||||
it('lists a detected provider with no spend this period and sorts it last', async () => {
|
||||
// Hermes has usage only outside the current period: the CLI still emits it
|
||||
// as a detected provider (cost 0), so the picker must show it, at the bottom.
|
||||
it('hides idle providers while preserving explicit zero-cost activity', async () => {
|
||||
const payload = overviewPayload()
|
||||
payload.current.providers = { claude: 10, hermes: 0, cursor: 0 }
|
||||
payload.current.providerDetails = [
|
||||
{ id: 'claude', label: 'Claude', cost: 10, hasUsage: true },
|
||||
{ id: 'hermes', label: 'Hermes', cost: 0, hasUsage: false },
|
||||
{ id: 'cursor', label: 'Cursor', cost: 0, hasUsage: true },
|
||||
]
|
||||
mocks.getOverview.mockResolvedValue(payload)
|
||||
|
||||
render(<App />)
|
||||
expect(await screen.findByText('Most expensive sessions')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText('All providers'))
|
||||
expect(await screen.findByRole('option', { name: 'Claude' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('option', { name: 'Cursor' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('option', { name: 'Hermes' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps zero-cost providers in the picker when the CLI omits hasUsage', async () => {
|
||||
// Every released CLI omits the field. Falling back to cost > 0 there hid
|
||||
// subscription-backed providers whose period spend is $0.
|
||||
const payload = overviewPayload()
|
||||
payload.current.providers = { claude: 10, hermes: 0 }
|
||||
payload.current.providerDetails = [
|
||||
|
|
@ -515,14 +534,36 @@ describe('App shortcuts', () => {
|
|||
expect(await screen.findByText('Most expensive sessions')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText('All providers'))
|
||||
const claudeOption = await screen.findByRole('option', { name: 'Claude' })
|
||||
const hermesOption = screen.getByRole('option', { name: 'Hermes' })
|
||||
const options = screen.getAllByRole('option')
|
||||
// Zero-cost Hermes appears, and sorts after the provider that has spend.
|
||||
expect(options.indexOf(hermesOption)).toBeGreaterThan(options.indexOf(claudeOption))
|
||||
expect(await screen.findByRole('option', { name: 'Claude' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('option', { name: 'Hermes' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(hermesOption)
|
||||
await waitFor(() => expect(mocks.getOverview).toHaveBeenCalledWith('30days', 'hermes'))
|
||||
it('does not carry another period provider catalog into a scoped period view', async () => {
|
||||
const payload = overviewPayload()
|
||||
payload.current.providerDetails = [
|
||||
{ id: 'claude', label: 'Claude', cost: 10, hasUsage: true },
|
||||
{ id: 'hermes', label: 'Hermes', cost: 5, hasUsage: true },
|
||||
]
|
||||
mocks.getOverview.mockResolvedValue(payload)
|
||||
|
||||
render(<App />)
|
||||
expect(await screen.findByText('Most expensive sessions')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByLabelText('Providers'))
|
||||
fireEvent.click(await screen.findByRole('option', { name: 'Claude' }))
|
||||
await waitFor(() => expect(mocks.getOverview).toHaveBeenCalledWith('30days', 'claude'))
|
||||
|
||||
fireEvent.click(screen.getByText('Today'))
|
||||
await waitFor(() => expect(mocks.getOverview).toHaveBeenCalledWith('today', 'claude'))
|
||||
|
||||
fireEvent.click(screen.getByLabelText('Providers'))
|
||||
expect(screen.getByRole('option', { name: 'Claude' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('option', { name: 'Hermes' })).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Sessions/ }))
|
||||
const sessionFilters = await screen.findByRole('group', { name: 'Filter sessions by provider' })
|
||||
expect(within(sessionFilters).getByRole('button', { name: 'Claude' })).toBeInTheDocument()
|
||||
expect(within(sessionFilters).queryByRole('button', { name: 'Hermes' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides the Claude config picker when the payload carries no claudeConfigs', async () => {
|
||||
|
|
@ -1195,7 +1236,8 @@ describe('usage_snapshot telemetry props', () => {
|
|||
return {
|
||||
provider: 'claude', providerDisplayName: 'Claude', category: null,
|
||||
inputTokens: 0, outputTokens: 0, cacheWriteTokens: 0, cacheReadTokens: 0, totalTokens: 0,
|
||||
costUSD: 0, savingsUSD: 0, savingsBaselineModel: '', calls: 0, credits: null, ...over,
|
||||
costUSD: 0, savingsUSD: 0, savingsBaselineModel: '', calls: 0, credits: null,
|
||||
rawModels: [over.model], ...over,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -267,7 +267,11 @@ function AppMain() {
|
|||
const [settingsPane, setSettingsPane] = useState<SettingsPane>('general')
|
||||
const [period, setPeriod] = useState<Period>(initialPeriod)
|
||||
const [provider, setProvider] = useState<string>('all')
|
||||
const [detectedProviders, setDetectedProviders] = useState<Array<{ id: string; label: string }>>([])
|
||||
const [providerCatalog, setProviderCatalog] = useState<{
|
||||
key: string | null
|
||||
entries: Array<{ id: string; label: string }>
|
||||
}>({ key: null, entries: [] })
|
||||
const detectedProviders = providerCatalog.entries
|
||||
const [customRange, setCustomRange] = useState<DateRange | null>(null)
|
||||
const [claudeConfigSource, setClaudeConfigSource] = useState<string | null>(initialConfigSource)
|
||||
const [scope, setScopeState] = useState<Scope>(initialScope)
|
||||
|
|
@ -284,6 +288,10 @@ function AppMain() {
|
|||
// the config scope before this poll runs. Passing scope='local' produces the
|
||||
// same flag-free argv as before, so local users are unaffected.
|
||||
const activeOverviewKey = overviewMemoKey(provider, period, customRange, claudeConfigSource, scope, new Date(now))
|
||||
// Provider membership is period/range-specific. Keep the catalog tied to the
|
||||
// exact unscoped local overview that produced it so a scoped view cannot leak
|
||||
// providers from a different time horizon while its own payload is loading.
|
||||
const allProviderOverviewKey = overviewMemoKey('all', period, customRange, null, 'local', new Date(now))
|
||||
const overview = usePolled<MenubarPayload>(
|
||||
() => scope === 'combined'
|
||||
? codeburn.getOverview(period, 'all', customRange ?? undefined, undefined, undefined, 'combined')
|
||||
|
|
@ -396,30 +404,41 @@ function AppMain() {
|
|||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!overview.data) return
|
||||
// Only the all-provider payload is authoritative for the picker. A scoped
|
||||
// payload contains just the selected provider; merging it forever also
|
||||
// leaked idle providers across period changes.
|
||||
if (!overview.data || overview.switching || provider !== 'all' || claudeConfigSource || scope !== 'local') return
|
||||
const details = overview.data.current.providerDetails
|
||||
// Prefer providerDetails (internal id + display label); fall back to the
|
||||
// providers map keys (lowercased display names) for older CLIs. The CLI
|
||||
// only emits detected providers, so keep every entry (including ones with
|
||||
// no spend this period) and sort by cost so zero-cost ones sit at the
|
||||
// bottom of the picker.
|
||||
// providers map keys (lowercased display names) for older CLIs. `hasUsage`
|
||||
// keeps idle discovery rows out of the picker, but only when the CLI
|
||||
// actually emits it: every released CLI omits it, and falling back to cost
|
||||
// there hid subscription-backed providers whose period spend is $0.
|
||||
const found = details
|
||||
? [...details]
|
||||
.filter(entry => entry.hasUsage ?? true)
|
||||
.sort((a, b) => b.cost - a.cost)
|
||||
.map(entry => ({ id: entry.id, label: entry.label }))
|
||||
: Object.entries(overview.data.current.providers)
|
||||
// Fallback map keys are lowercased display names; ones with spaces
|
||||
// ("grok build") cannot round-trip as --provider, so exclude them
|
||||
// rather than offer a filter that is guaranteed to error.
|
||||
.filter(([key]) => /^[a-z0-9-]+$/.test(key))
|
||||
.filter(([key, cost]) => cost > 0 && /^[a-z0-9-]+$/.test(key))
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.map(([key]) => ({ id: key, label: providerName(key) }))
|
||||
setDetectedProviders(current => {
|
||||
const next = [...current]
|
||||
for (const item of found) if (!next.some(entry => entry.id === item.id)) next.push(item)
|
||||
return next.length === current.length ? current : next
|
||||
})
|
||||
}, [overview.data])
|
||||
setProviderCatalog({ key: allProviderOverviewKey, entries: found })
|
||||
}, [allProviderOverviewKey, claudeConfigSource, overview.data, overview.switching, provider, scope])
|
||||
|
||||
const selectedProviderEntry = useMemo(() => provider === 'all'
|
||||
? null
|
||||
: detectedProviders.find(entry => entry.id === provider) ?? { id: provider, label: providerName(provider) },
|
||||
[detectedProviders, provider])
|
||||
const visibleProviderEntries = useMemo(() => providerCatalog.key === allProviderOverviewKey
|
||||
? detectedProviders
|
||||
: selectedProviderEntry
|
||||
? [selectedProviderEntry]
|
||||
: [],
|
||||
[allProviderOverviewKey, detectedProviders, providerCatalog.key, selectedProviderEntry])
|
||||
|
||||
useEffect(() => {
|
||||
const currency = overview.data?.currency
|
||||
|
|
@ -540,7 +559,7 @@ function AppMain() {
|
|||
// Keep the current-main provider-switch contract while the shared Core
|
||||
// provider snapshot work is still held: warm the visible period for each
|
||||
// detected provider only after the higher-value period/report queue.
|
||||
for (const targetProvider of detectedProviders.map(entry => entry.id)) {
|
||||
for (const targetProvider of visibleProviderEntries.map(entry => entry.id)) {
|
||||
if (cancelled || targetProvider === provider) continue
|
||||
const key = overviewMemoKey(targetProvider, period, null, null)
|
||||
if (warmedKeys.current.has(key) || hasPolledMemo(key)) continue
|
||||
|
|
@ -566,7 +585,7 @@ function AppMain() {
|
|||
// `overview.data == null` (a boolean) gates on first-resolution without
|
||||
// re-running every poll; the data content itself is intentionally not a dep.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [ready, period, provider, detectedProviders, customRange, claudeConfigSource, scope, snapshotRevision, overview.data == null])
|
||||
}, [ready, period, provider, visibleProviderEntries, customRange, claudeConfigSource, scope, snapshotRevision, overview.data == null])
|
||||
|
||||
useEffect(() => {
|
||||
const id = window.setInterval(() => setNow(Date.now()), 1000)
|
||||
|
|
@ -670,9 +689,9 @@ function AppMain() {
|
|||
const claudeConfigs = overview.data?.claudeConfigs
|
||||
const providerOptions = [
|
||||
{ value: 'all', label: 'All providers' },
|
||||
...detectedProviders.map(entry => ({ value: entry.id, label: entry.label })),
|
||||
...visibleProviderEntries.map(entry => ({ value: entry.id, label: entry.label })),
|
||||
]
|
||||
const providerLabel = detectedProviders.find(entry => entry.id === provider)?.label ?? providerName(provider)
|
||||
const providerLabel = selectedProviderEntry?.label ?? providerName(provider)
|
||||
const activeConfigLabel = claudeConfigSource
|
||||
? claudeConfigs?.options.find(option => option.id === claudeConfigSource)?.label ?? null
|
||||
: null
|
||||
|
|
@ -727,7 +746,7 @@ function AppMain() {
|
|||
{section === 'overview' ? (
|
||||
<OverviewContent period={period} provider={provider} range={customRange} overview={overview} onNavigate={navigate} ready={ready} scope={scope} headlineSnapshot={headlineSnapshot} />
|
||||
) : section === 'sessions' ? (
|
||||
<Sessions period={period} provider={provider} range={customRange} refreshToken={refreshToken} detectedProviders={detectedProviders} onProviderChange={onProviderSelect} ready={ready} />
|
||||
<Sessions period={period} provider={provider} range={customRange} refreshToken={refreshToken} detectedProviders={visibleProviderEntries} onProviderChange={onProviderSelect} ready={ready} />
|
||||
) : section === 'pullRequests' ? (
|
||||
<PullRequestsContent overview={overview} period={period} provider={provider} range={customRange} />
|
||||
) : section === 'spend' ? (
|
||||
|
|
|
|||
|
|
@ -75,4 +75,34 @@ describe('StackedBars', () => {
|
|||
expect(legend.querySelectorAll('span')).toHaveLength(1)
|
||||
expect(legend).toHaveTextContent('Claude')
|
||||
})
|
||||
|
||||
it('renders one segment for a model merged from two raw routes and names both routes in the tooltip (#1239)', () => {
|
||||
const daily = [{
|
||||
...entry(26),
|
||||
topModels: [{
|
||||
name: 'MiniMax M3',
|
||||
cost: 6.99,
|
||||
savingsUSD: 0,
|
||||
calls: 415,
|
||||
inputTokens: 700,
|
||||
outputTokens: 150,
|
||||
rawModels: ['minimax/MiniMax-M3', 'MiniMaxAI/MiniMax-M3'],
|
||||
}],
|
||||
}]
|
||||
const { container } = render(<StackedBars daily={daily} />)
|
||||
|
||||
const segments = container.querySelectorAll('.sbars .c .s')
|
||||
expect(segments).toHaveLength(1)
|
||||
expect(segments[0]).toHaveAttribute('title', 'MiniMax M3 (minimax/MiniMax-M3, MiniMaxAI/MiniMax-M3) · $6.99')
|
||||
})
|
||||
|
||||
it('omits the route list from the tooltip when only one raw model fed the row', () => {
|
||||
const daily = [{
|
||||
...entry(26),
|
||||
topModels: [{ name: 'GPT-5', cost: 2, savingsUSD: 0, calls: 3, inputTokens: 10, outputTokens: 5 }],
|
||||
}]
|
||||
const { container } = render(<StackedBars daily={daily} />)
|
||||
|
||||
expect(container.querySelector('.sbars .c .s')).toHaveAttribute('title', 'GPT-5 · $2.00')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -59,12 +59,13 @@ export function StackedBars({ daily, fallbackLabel = 'All models', animateKey =
|
|||
(a, b) => SERIES_ORDER.indexOf(seriesKeyForModel(a.name)) - SERIES_ORDER.indexOf(seriesKeyForModel(b.name)),
|
||||
).map(model => {
|
||||
const pct = Math.max(1, (Math.max(0, model.cost) / maxTotal) * 100)
|
||||
const routes = model.rawModels && model.rawModels.length > 1 ? ` (${model.rawModels.join(', ')})` : ''
|
||||
return (
|
||||
<span
|
||||
key={`${day.date}-${model.name}`}
|
||||
className={`s ${seriesClassForModel(model.name)}`}
|
||||
style={{ height: `${pct}%` }}
|
||||
title={`${model.name} · ${formatUsd(model.cost)}`}
|
||||
title={`${model.name}${routes} · ${formatUsd(model.cost)}`}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -56,6 +56,9 @@ export type DailyModelBreakdown = {
|
|||
calls: number
|
||||
inputTokens: number
|
||||
outputTokens: number
|
||||
// Raw provider/model ids that collapsed into this display name. Present
|
||||
// only when more than one folded in; absent for an older CLI (#1239).
|
||||
rawModels?: string[]
|
||||
}
|
||||
|
||||
export type DailyHistoryEntry = {
|
||||
|
|
@ -329,6 +332,9 @@ export type ModelReportRow = {
|
|||
providerDisplayName: string
|
||||
model: string
|
||||
modelDisplayName: string
|
||||
// Raw provider/model ids folded into this row (e.g. two routes of the same
|
||||
// model). Length 1 when nothing merged.
|
||||
rawModels: string[]
|
||||
category: TaskCategory | null
|
||||
inputTokens: number
|
||||
outputTokens: number
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ const rows: ModelReportRow[] = [
|
|||
providerDisplayName: 'Anthropic',
|
||||
model: 'claude-opus-4.8',
|
||||
modelDisplayName: 'Claude Opus 4.8',
|
||||
rawModels: ['claude-opus-4.8'],
|
||||
category: null,
|
||||
topCategory: 'coding',
|
||||
topCategoryShare: 0.71,
|
||||
|
|
@ -39,6 +40,7 @@ const rows: ModelReportRow[] = [
|
|||
providerDisplayName: 'Codex',
|
||||
model: 'gpt-5.5-codex',
|
||||
modelDisplayName: 'GPT-5.5 Codex',
|
||||
rawModels: ['gpt-5.5-codex'],
|
||||
category: null,
|
||||
topCategory: 'debugging',
|
||||
topCategoryShare: 0.42,
|
||||
|
|
@ -58,6 +60,7 @@ const rows: ModelReportRow[] = [
|
|||
providerDisplayName: 'Local',
|
||||
model: 'llama-local',
|
||||
modelDisplayName: 'Llama Local',
|
||||
rawModels: ['llama-local'],
|
||||
category: null,
|
||||
inputTokens: 750_000,
|
||||
outputTokens: 400_000,
|
||||
|
|
@ -75,6 +78,7 @@ const rows: ModelReportRow[] = [
|
|||
providerDisplayName: 'Custom',
|
||||
model: 'my-proxy-model',
|
||||
modelDisplayName: 'my-proxy-model',
|
||||
rawModels: ['my-proxy-model'],
|
||||
category: null,
|
||||
inputTokens: 4_800_000,
|
||||
outputTokens: 400_000,
|
||||
|
|
|
|||
|
|
@ -272,7 +272,7 @@ function ProvidersPane({ period, refreshToken }: { period: Period; refreshToken:
|
|||
// the internal id. Fall back to the providers map keys (lowercased display
|
||||
// names) for older CLIs that omit providerDetails.
|
||||
const providers = details
|
||||
? details.filter(entry => entry.hasUsage ?? entry.cost > 0).map(entry => ({ id: entry.id, label: entry.label, cost: entry.cost }))
|
||||
? details.filter(entry => entry.hasUsage ?? true).map(entry => ({ id: entry.id, label: entry.label, cost: entry.cost }))
|
||||
: Object.entries(overview.data?.current.providers ?? {}).map(([id, cost]) => ({ id, label: id.charAt(0).toUpperCase() + id.slice(1), cost }))
|
||||
return <section className="set-p on">
|
||||
<div><h3 className="set-h">Providers</h3><p className="set-sub">codeburn auto-detects coding tools from local session files. No setup needed.</p></div>
|
||||
|
|
|
|||
|
|
@ -8,6 +8,10 @@ private let menubarPeriodDefaultsKey = "CodeBurnMenubarPeriod"
|
|||
struct CachedPayload {
|
||||
let payload: MenubarPayload
|
||||
let fetchedAt: Date
|
||||
/// This scoped payload still disagreed with its all-provider slice after a
|
||||
/// resident-bypassing one-shot re-parse. It is the best answer the CLI can
|
||||
/// give, so it is served rather than discarded, with the UI saying so.
|
||||
var contradictsAll: Bool = false
|
||||
var isFresh: Bool { Date().timeIntervalSince(fetchedAt) < cacheTTLSeconds }
|
||||
}
|
||||
|
||||
|
|
@ -281,10 +285,21 @@ final class AppStore {
|
|||
)
|
||||
}
|
||||
|
||||
/// Capture the keys that must describe one selection before any suspension
|
||||
/// point. Reading `periodAllKey` before an await and `currentKey` afterward
|
||||
/// can otherwise pair evidence from two different user selections.
|
||||
private func selectionRefreshKeys() -> (
|
||||
scoped: PayloadCacheKey,
|
||||
local: PayloadCacheKey,
|
||||
all: PayloadCacheKey
|
||||
) {
|
||||
(scoped: currentKey, local: localCurrentKey, all: periodAllKey)
|
||||
}
|
||||
|
||||
var payload: MenubarPayload {
|
||||
if effectiveSelectedScope == .combined {
|
||||
let combinedPayload = cache[currentKey]?.payload
|
||||
if let localPayload = cache[localCurrentKey]?.payload {
|
||||
if let localPayload = consistentCachedPayload(for: localCurrentKey) {
|
||||
if let combined = combinedPayload?.combined {
|
||||
return MenubarPayload(
|
||||
generated: combinedPayload?.generated ?? localPayload.generated,
|
||||
|
|
@ -301,7 +316,11 @@ final class AppStore {
|
|||
return combinedPayload
|
||||
}
|
||||
}
|
||||
return cache[currentKey]?.payload ?? .empty
|
||||
// No fallback to the PREVIOUS selection's payload. It belonged to a
|
||||
// different provider, so serving it under this tab printed another
|
||||
// provider's dollar figure as if it were this one's. Empty here means
|
||||
// `hasCachedData` is false and the popover renders its loading state.
|
||||
return consistentCachedPayload(for: currentKey) ?? .empty
|
||||
}
|
||||
|
||||
/// Today (across all providers) backs day-specific views in the popover.
|
||||
|
|
@ -403,7 +422,8 @@ final class AppStore {
|
|||
}
|
||||
|
||||
var hasCachedData: Bool {
|
||||
cache[currentKey] != nil || (effectiveSelectedScope == .combined && cache[localCurrentKey] != nil)
|
||||
consistentCachedPayload(for: currentKey) != nil
|
||||
|| (effectiveSelectedScope == .combined && consistentCachedPayload(for: localCurrentKey) != nil)
|
||||
}
|
||||
|
||||
var hasStaleLoading: Bool {
|
||||
|
|
@ -445,7 +465,10 @@ final class AppStore {
|
|||
if effectiveSelectedScope == .combined {
|
||||
requiredKeys.insert(localCurrentKey)
|
||||
}
|
||||
return requiredKeys.contains { cache[$0]?.isFresh != true } || hasStaleLoading
|
||||
return requiredKeys.contains { key in
|
||||
guard let cached = cache[key], cached.isFresh else { return true }
|
||||
return providerPayloadContradictsAll(cached.payload, for: key)
|
||||
} || hasStaleLoading
|
||||
}
|
||||
|
||||
/// True if any cached payload reports at least one provider. Used to keep the
|
||||
|
|
@ -477,6 +500,26 @@ final class AppStore {
|
|||
cache[PayloadCacheKey(scope: scope, period: period, provider: provider, day: day, days: days, claudeConfigSourceId: claudeConfigSourceId)]?.payload
|
||||
}
|
||||
|
||||
func providerPayloadContradictsAllForTesting(_ payload: MenubarPayload,
|
||||
scope: MenubarScope = .local,
|
||||
period: Period,
|
||||
provider: ProviderFilter,
|
||||
day: String? = nil,
|
||||
days: Set<String> = [],
|
||||
claudeConfigSourceId: String? = nil) -> Bool {
|
||||
providerPayloadContradictsAll(
|
||||
payload,
|
||||
for: PayloadCacheKey(
|
||||
scope: scope,
|
||||
period: period,
|
||||
provider: provider,
|
||||
day: day,
|
||||
days: days,
|
||||
claudeConfigSourceId: claudeConfigSourceId
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func setLastErrorForTesting(_ error: String,
|
||||
scope: MenubarScope = .local,
|
||||
period: Period,
|
||||
|
|
@ -491,13 +534,47 @@ final class AppStore {
|
|||
provider: ProviderFilter,
|
||||
day: String? = nil,
|
||||
insertedAt: Date) {
|
||||
inFlightKeys[PayloadCacheKey(scope: scope, period: period, provider: provider, day: day)] = insertedAt
|
||||
inFlightKeys[PayloadCacheKey(scope: scope, period: period, provider: provider, day: day)] =
|
||||
InFlightSlot(startedAt: insertedAt, token: UUID())
|
||||
}
|
||||
|
||||
func setCacheDateToTodayForTesting() {
|
||||
cacheDate = currentCacheDate()
|
||||
}
|
||||
|
||||
func isInFlightForTesting(scope: MenubarScope = .local, period: Period, provider: ProviderFilter, day: String? = nil) -> Bool {
|
||||
inFlightKeys[PayloadCacheKey(scope: scope, period: period, provider: provider, day: day)] != nil
|
||||
}
|
||||
|
||||
func refreshQuietlyForTesting(scope: MenubarScope = .local,
|
||||
period: Period,
|
||||
provider: ProviderFilter,
|
||||
day: String? = nil) async -> Bool {
|
||||
await refreshQuietly(
|
||||
key: PayloadCacheKey(scope: scope, period: period, provider: provider, day: day),
|
||||
includeOptimize: false
|
||||
)
|
||||
}
|
||||
|
||||
func inFlightWaiterCountForTesting(scope: MenubarScope = .local,
|
||||
period: Period,
|
||||
provider: ProviderFilter,
|
||||
day: String? = nil) -> Int {
|
||||
inFlightWaiters[PayloadCacheKey(scope: scope, period: period, provider: provider, day: day)]?.count ?? 0
|
||||
}
|
||||
|
||||
func finishInFlightForTesting(scope: MenubarScope = .local,
|
||||
period: Period,
|
||||
provider: ProviderFilter,
|
||||
day: String? = nil) {
|
||||
forceFinishInFlight(for: PayloadCacheKey(scope: scope, period: period, provider: provider, day: day))
|
||||
}
|
||||
|
||||
func selectionRefreshKeysForTesting() -> (scoped: PayloadCacheKey, all: PayloadCacheKey) {
|
||||
let snapshot = selectionRefreshKeys()
|
||||
return (snapshot.scoped, snapshot.all)
|
||||
}
|
||||
|
||||
func suppressRefreshesForTesting() {
|
||||
refreshSuppressedForTesting = true
|
||||
}
|
||||
|
|
@ -626,33 +703,107 @@ final class AppStore {
|
|||
let key = PayloadCacheKey(scope: scope, period: period, provider: provider, day: day, days: days, claudeConfigSourceId: claudeConfigSourceId)
|
||||
let localKey = PayloadCacheKey(scope: .local, period: period, provider: provider, day: day, days: days, claudeConfigSourceId: claudeConfigSourceId)
|
||||
let allKey = PayloadCacheKey(scope: .local, period: period, provider: .all, day: day, days: days, claudeConfigSourceId: claudeConfigSourceId)
|
||||
switchTask = Task {
|
||||
switchTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
// The all-provider slice is the evidence that lets a scoped false
|
||||
// zero be rejected, so it must be present BEFORE the scoped result
|
||||
// is accepted. It does not have to be present before the scoped
|
||||
// fetch STARTS: both run together and only the acceptance is
|
||||
// ordered, via the gate passed as acceptAfter.
|
||||
let allTask: Task<Bool, Never>? = provider == .all
|
||||
? nil
|
||||
: self.startAllProviderEvidence(key: allKey, force: false)
|
||||
let gate: (@Sendable () async -> Void)? = allTask.map { task in { @Sendable in _ = await task.value } }
|
||||
if scope == .combined {
|
||||
async let local = refresh(key: localKey, includeOptimize: false, force: false, showLoading: false)
|
||||
async let combined = refresh(key: key, includeOptimize: false, force: false, showLoading: false)
|
||||
if provider == .all {
|
||||
_ = await (local, combined)
|
||||
} else {
|
||||
async let all = refreshQuietly(key: allKey, includeOptimize: false, force: false)
|
||||
_ = await (local, combined, all)
|
||||
}
|
||||
} else if provider == .all {
|
||||
await refresh(key: key, includeOptimize: false, force: false, showLoading: false)
|
||||
async let local = self.refresh(key: localKey, includeOptimize: false, force: false, showLoading: false, acceptAfter: gate)
|
||||
async let combined = self.refresh(key: key, includeOptimize: false, force: false, showLoading: false, acceptAfter: gate)
|
||||
_ = await (local, combined)
|
||||
} else {
|
||||
async let main = refresh(key: key, includeOptimize: false, force: false, showLoading: false)
|
||||
async let all = refreshQuietly(key: allKey, includeOptimize: false, force: false)
|
||||
_ = await (main, all)
|
||||
await self.refresh(key: key, includeOptimize: false, force: false, showLoading: false, acceptAfter: gate)
|
||||
}
|
||||
_ = await allTask?.value
|
||||
}
|
||||
}
|
||||
|
||||
private var inFlightKeys: [PayloadCacheKey: Date] = [:]
|
||||
/// Start the all-provider fetch immediately and hand back its task, so a
|
||||
/// scoped fetch can run alongside it and merely AWAIT it before accepting.
|
||||
private func startAllProviderEvidence(
|
||||
key: PayloadCacheKey,
|
||||
force: Bool,
|
||||
qualityOfService: QualityOfService = .userInitiated
|
||||
) -> Task<Bool, Never> {
|
||||
Task { [weak self] in
|
||||
guard let self else { return false }
|
||||
return await self.refreshQuietly(
|
||||
key: key,
|
||||
includeOptimize: false,
|
||||
force: force,
|
||||
qualityOfService: qualityOfService
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// One occupancy of a key's in-flight slot. The token identifies WHICH
|
||||
/// fetch owns the slot right now: the watchdog and the display-sleep reset
|
||||
/// can evict an occupant, after which a later fetch legitimately claims the
|
||||
/// same key. Without the token the evicted fetch's `defer` would then clear
|
||||
/// the NEW owner's slot and resume its waiters, so a task parked on the
|
||||
/// live fetch woke to an empty cache and reported failure.
|
||||
private struct InFlightSlot {
|
||||
let startedAt: Date
|
||||
let token: UUID
|
||||
}
|
||||
|
||||
private var inFlightKeys: [PayloadCacheKey: InFlightSlot] = [:]
|
||||
private var inFlightWaiters: [PayloadCacheKey: [CheckedContinuation<Void, Never>]] = [:]
|
||||
|
||||
private func waitForInFlight(_ key: PayloadCacheKey) async {
|
||||
guard inFlightKeys[key] != nil else { return }
|
||||
await withCheckedContinuation { continuation in
|
||||
guard inFlightKeys[key] != nil else {
|
||||
continuation.resume()
|
||||
return
|
||||
}
|
||||
inFlightWaiters[key, default: []].append(continuation)
|
||||
}
|
||||
}
|
||||
|
||||
private func claimInFlight(_ key: PayloadCacheKey) -> UUID {
|
||||
let token = UUID()
|
||||
inFlightKeys[key] = InFlightSlot(startedAt: Date(), token: token)
|
||||
return token
|
||||
}
|
||||
|
||||
/// Release the slot only if this fetch still owns it. A fetch whose slot was
|
||||
/// evicted has nothing left to release and no waiters of its own.
|
||||
private func finishInFlight(for key: PayloadCacheKey, token: UUID) {
|
||||
guard inFlightKeys[key]?.token == token else { return }
|
||||
forceFinishInFlight(for: key)
|
||||
}
|
||||
|
||||
/// Evict whoever holds the slot and wake everyone parked on it. Only the
|
||||
/// watchdog and the pipeline resets use this: they are declaring the slot
|
||||
/// dead, not reporting a fetch that ended.
|
||||
private func forceFinishInFlight(for key: PayloadCacheKey) {
|
||||
inFlightKeys[key] = nil
|
||||
let waiters = inFlightWaiters.removeValue(forKey: key) ?? []
|
||||
for waiter in waiters {
|
||||
waiter.resume()
|
||||
}
|
||||
}
|
||||
|
||||
private func finishAllInFlight() {
|
||||
let keys = Set(inFlightKeys.keys).union(inFlightWaiters.keys)
|
||||
for key in keys {
|
||||
forceFinishInFlight(for: key)
|
||||
}
|
||||
}
|
||||
|
||||
func resetLoadingState() {
|
||||
payloadRefreshGeneration &+= 1
|
||||
loadingCountsByKey.removeAll()
|
||||
loadingStartedAtByKey.removeAll()
|
||||
inFlightKeys.removeAll()
|
||||
finishAllInFlight()
|
||||
attemptedKeys.removeAll()
|
||||
}
|
||||
|
||||
|
|
@ -675,8 +826,8 @@ final class AppStore {
|
|||
let staleLoading = loadingStartedAtByKey.filter {
|
||||
now.timeIntervalSince($0.value) > loadingWatchdogSeconds
|
||||
}
|
||||
let staleInFlight = inFlightKeys.filter { (key, insertedAt) in
|
||||
now.timeIntervalSince(insertedAt) > loadingWatchdogSeconds &&
|
||||
let staleInFlight = inFlightKeys.filter { (key, slot) in
|
||||
now.timeIntervalSince(slot.startedAt) > loadingWatchdogSeconds &&
|
||||
loadingStartedAtByKey[key] == nil
|
||||
}
|
||||
guard !staleLoading.isEmpty || !staleInFlight.isEmpty else { return false }
|
||||
|
|
@ -686,15 +837,15 @@ final class AppStore {
|
|||
Int(now.timeIntervalSince(started)), key.label, key.provider.rawValue)
|
||||
loadingCountsByKey[key] = nil
|
||||
loadingStartedAtByKey[key] = nil
|
||||
inFlightKeys[key] = nil
|
||||
forceFinishInFlight(for: key)
|
||||
if cache[key] == nil {
|
||||
lastErrorByKey[key] = "Refresh took longer than expected. CodeBurn will keep retrying in the background."
|
||||
}
|
||||
}
|
||||
for (key, insertedAt) in staleInFlight {
|
||||
for (key, slot) in staleInFlight {
|
||||
NSLog("CodeBurn: orphaned in-flight key stuck for %ds on %@/%@ — clearing",
|
||||
Int(now.timeIntervalSince(insertedAt)), key.label, key.provider.rawValue)
|
||||
inFlightKeys[key] = nil
|
||||
Int(now.timeIntervalSince(slot.startedAt)), key.label, key.provider.rawValue)
|
||||
forceFinishInFlight(for: key)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
|
@ -729,7 +880,7 @@ final class AppStore {
|
|||
cache.removeAll()
|
||||
loadingCountsByKey.removeAll()
|
||||
loadingStartedAtByKey.removeAll()
|
||||
inFlightKeys.removeAll()
|
||||
finishAllInFlight()
|
||||
attemptedKeys.removeAll()
|
||||
lastErrorByKey.removeAll()
|
||||
cacheDate = today
|
||||
|
|
@ -750,6 +901,128 @@ final class AppStore {
|
|||
}
|
||||
}
|
||||
|
||||
/// A cached payload the refresh paths may keep instead of refetching. A
|
||||
/// contradicting one is refetched; one already VERIFIED as contradicting is
|
||||
/// not, or every poll would re-run the expensive one-shot parse forever.
|
||||
private func cachedPayloadIsUsable(_ cached: CachedPayload, for key: PayloadCacheKey) -> Bool {
|
||||
cached.contradictsAll || !providerPayloadContradictsAll(cached.payload, for: key)
|
||||
}
|
||||
|
||||
private func consistentCachedPayload(for key: PayloadCacheKey) -> MenubarPayload? {
|
||||
guard let cached = cache[key] else { return nil }
|
||||
// A payload already verified by a resident-bypassing re-parse is served
|
||||
// even though it still disagrees: it is the CLI's real answer, and
|
||||
// `selectedPayloadMayBeIncomplete` is what tells the user so.
|
||||
if cached.contradictsAll { return cached.payload }
|
||||
guard !providerPayloadContradictsAll(cached.payload, for: key) else { return nil }
|
||||
return cached.payload
|
||||
}
|
||||
|
||||
/// The visible payload was verified against its all-provider slice and
|
||||
/// still disagreed. The popover shows one subdued line rather than an error.
|
||||
var selectedPayloadMayBeIncomplete: Bool {
|
||||
if cache[currentKey]?.contradictsAll == true { return true }
|
||||
return effectiveSelectedScope == .combined && cache[localCurrentKey]?.contradictsAll == true
|
||||
}
|
||||
|
||||
/// An all-provider payload and its scoped sibling describe the same period,
|
||||
/// day selection, scope, and Claude config. If the all-provider slice has a
|
||||
/// positive contribution for the selected provider, a scoped 0/0 payload is
|
||||
/// stale or incomplete rather than an honest answer. This is the exact
|
||||
/// contract that prevents a provider tab advertising spend and then opening
|
||||
/// onto a fabricated zero.
|
||||
private func providerPayloadContradictsAll(_ payload: MenubarPayload, for key: PayloadCacheKey) -> Bool {
|
||||
guard key.scope == .local,
|
||||
key.provider != .all else { return false }
|
||||
|
||||
let allKey = PayloadCacheKey(
|
||||
scope: .local,
|
||||
period: key.period,
|
||||
provider: .all,
|
||||
day: key.day,
|
||||
days: key.days,
|
||||
claudeConfigSourceId: key.claudeConfigSourceId
|
||||
)
|
||||
guard let all = cache[allKey]?.payload else { return false }
|
||||
let providerKeys = Set(key.provider.providerKeys + [key.provider.cliArg])
|
||||
let detail = all.current.providerDetails.first(where: {
|
||||
providerKeys.contains($0.id.lowercased()) || providerKeys.contains($0.label.lowercased())
|
||||
})
|
||||
let allCost = detail?.cost ?? key.provider.providerKeys.reduce(0.0) { sum, providerKey in
|
||||
sum + (all.current.providers[providerKey] ?? 0)
|
||||
}
|
||||
let allCalls = detail?.calls
|
||||
let allHasUsage = detail?.hasUsage ?? (allCost > 0)
|
||||
|
||||
// Cost and call dimensions are independent. A scoped result that keeps
|
||||
// its calls but loses the spend (or vice versa) is still stale.
|
||||
if allCost > 0, payload.current.cost == 0 { return true }
|
||||
if let allCalls, allCalls > 0, payload.current.calls == 0 { return true }
|
||||
|
||||
// `hasUsage` is authoritative for subscription/token-only providers,
|
||||
// where both cost and behavioral calls may legitimately be zero.
|
||||
let scopedDetail = payload.current.providerDetails.first(where: {
|
||||
providerKeys.contains($0.id.lowercased()) || providerKeys.contains($0.label.lowercased())
|
||||
})
|
||||
let scopedHasUsage = scopedDetail?.hasUsage ?? (
|
||||
payload.current.cost > 0
|
||||
|| payload.current.calls > 0
|
||||
|| payload.current.sessions > 0
|
||||
|| payload.current.inputTokens > 0
|
||||
|| payload.current.outputTokens > 0
|
||||
)
|
||||
return allHasUsage && !scopedHasUsage
|
||||
}
|
||||
|
||||
private func fetchPayload(
|
||||
for key: PayloadCacheKey,
|
||||
includeOptimize: Bool,
|
||||
qualityOfService: QualityOfService,
|
||||
acceptAfter: (@Sendable () async -> Void)? = nil
|
||||
) async throws -> (payload: MenubarPayload, contradictsAll: Bool) {
|
||||
let fresh = try await DataClient.fetch(
|
||||
period: key.period,
|
||||
day: key.day,
|
||||
days: key.days,
|
||||
provider: key.provider,
|
||||
includeOptimize: includeOptimize,
|
||||
scope: key.scope,
|
||||
claudeConfigSourceId: key.claudeConfigSourceId,
|
||||
qualityOfService: qualityOfService
|
||||
)
|
||||
// The all-provider slice is the evidence providerPayloadContradictsAll
|
||||
// reads. It is awaited HERE, after this fetch has already run, so the
|
||||
// two parses overlap and only the ACCEPTANCE is ordered. Waiting for it
|
||||
// before starting cost a full serialized parse on every tab click.
|
||||
await acceptAfter?()
|
||||
guard providerPayloadContradictsAll(fresh, for: key) else { return (fresh, false) }
|
||||
|
||||
NSLog("CodeBurn: resident %@ payload contradicted its all-provider slice; verifying with a one-shot parse",
|
||||
key.provider.rawValue)
|
||||
let verified = try await DataClient.fetch(
|
||||
period: key.period,
|
||||
day: key.day,
|
||||
days: key.days,
|
||||
provider: key.provider,
|
||||
includeOptimize: includeOptimize,
|
||||
scope: key.scope,
|
||||
claudeConfigSourceId: key.claudeConfigSourceId,
|
||||
bypassResident: true,
|
||||
qualityOfService: qualityOfService
|
||||
)
|
||||
// A second contradiction is not an error to show the user. The one-shot
|
||||
// parse bypassed the resident child, so this IS what the CLI reports for
|
||||
// this provider right now; throwing here replaced a real number with an
|
||||
// error banner and left the tab with nothing at all. Serve it, flagged,
|
||||
// and let the popover add one subdued line saying it may be incomplete.
|
||||
let stillContradicts = providerPayloadContradictsAll(verified, for: key)
|
||||
if stillContradicts {
|
||||
NSLog("CodeBurn: verified %@ payload still contradicted its all-provider slice; serving it as possibly incomplete",
|
||||
key.provider.rawValue)
|
||||
}
|
||||
return (verified, stillContradicts)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func recoverFromStuckLoading() async -> Bool {
|
||||
guard prepareStuckLoadingRecovery() else { return false }
|
||||
|
|
@ -785,29 +1058,39 @@ final class AppStore {
|
|||
showLoading: Bool = false,
|
||||
qualityOfService: QualityOfService = .userInitiated
|
||||
) async -> Bool {
|
||||
if effectiveSelectedScope == .combined {
|
||||
let keys = selectionRefreshKeys()
|
||||
// Runs concurrently with the scoped fetch below; only the acceptance is
|
||||
// ordered behind it (see fetchPayload's acceptAfter).
|
||||
let allTask: Task<Bool, Never>? = keys.scoped.provider == .all
|
||||
? nil
|
||||
: startAllProviderEvidence(key: keys.all, force: force, qualityOfService: qualityOfService)
|
||||
let gate: (@Sendable () async -> Void)? = allTask.map { task in { @Sendable in _ = await task.value } }
|
||||
if keys.scoped.scope == .combined {
|
||||
async let local = refreshQuietly(
|
||||
key: localCurrentKey,
|
||||
key: keys.local,
|
||||
includeOptimize: includeOptimize,
|
||||
force: force,
|
||||
qualityOfService: qualityOfService
|
||||
qualityOfService: qualityOfService,
|
||||
acceptAfter: gate
|
||||
)
|
||||
async let combined = refresh(
|
||||
key: currentKey,
|
||||
key: keys.scoped,
|
||||
includeOptimize: includeOptimize,
|
||||
force: force,
|
||||
showLoading: showLoading,
|
||||
qualityOfService: qualityOfService
|
||||
qualityOfService: qualityOfService,
|
||||
acceptAfter: gate
|
||||
)
|
||||
let (localSucceeded, combinedSucceeded) = await (local, combined)
|
||||
return localSucceeded && combinedSucceeded
|
||||
} else {
|
||||
return await refresh(
|
||||
key: currentKey,
|
||||
key: keys.scoped,
|
||||
includeOptimize: includeOptimize,
|
||||
force: force,
|
||||
showLoading: showLoading,
|
||||
qualityOfService: qualityOfService
|
||||
qualityOfService: qualityOfService,
|
||||
acceptAfter: gate
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -821,13 +1104,34 @@ final class AppStore {
|
|||
days: selectedDays,
|
||||
claudeConfigSourceId: selectedClaudeConfigSourceId
|
||||
)
|
||||
let localKey = PayloadCacheKey(
|
||||
scope: .local,
|
||||
period: scopedKey.period,
|
||||
provider: scopedKey.provider,
|
||||
day: scopedKey.day,
|
||||
days: scopedKey.days,
|
||||
claudeConfigSourceId: scopedKey.claudeConfigSourceId
|
||||
)
|
||||
let allKey = PayloadCacheKey(
|
||||
scope: .local,
|
||||
period: scopedKey.period,
|
||||
provider: .all,
|
||||
day: scopedKey.day,
|
||||
days: scopedKey.days,
|
||||
claudeConfigSourceId: scopedKey.claudeConfigSourceId
|
||||
)
|
||||
let allTask: Task<Bool, Never>? = scopedKey.provider == .all
|
||||
? nil
|
||||
: startAllProviderEvidence(key: allKey, force: force)
|
||||
let gate: (@Sendable () async -> Void)? = allTask.map { task in { @Sendable in _ = await task.value } }
|
||||
if scope == .combined {
|
||||
async let local = refreshQuietly(key: localCurrentKey, includeOptimize: false, force: false)
|
||||
async let combined = refreshQuietly(key: scopedKey, includeOptimize: false, force: force)
|
||||
async let local = refreshQuietly(key: localKey, includeOptimize: false, force: false, acceptAfter: gate)
|
||||
async let combined = refreshQuietly(key: scopedKey, includeOptimize: false, force: force, acceptAfter: gate)
|
||||
_ = await (local, combined)
|
||||
} else {
|
||||
await refreshQuietly(key: scopedKey, includeOptimize: false, force: force)
|
||||
await refreshQuietly(key: scopedKey, includeOptimize: false, force: force, acceptAfter: gate)
|
||||
}
|
||||
_ = await allTask?.value
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
|
|
@ -836,15 +1140,26 @@ final class AppStore {
|
|||
includeOptimize: Bool,
|
||||
force: Bool = false,
|
||||
showLoading: Bool = false,
|
||||
qualityOfService: QualityOfService = .userInitiated
|
||||
qualityOfService: QualityOfService = .userInitiated,
|
||||
acceptAfter: (@Sendable () async -> Void)? = nil
|
||||
) async -> Bool {
|
||||
invalidateStaleDayCache()
|
||||
let cacheDateAtStart = cacheDate
|
||||
let generationAtStart = payloadRefreshGeneration
|
||||
if Task.isCancelled { return false }
|
||||
if !force, cache[key]?.isFresh == true { return true }
|
||||
if inFlightKeys[key] != nil { return false }
|
||||
inFlightKeys[key] = Date()
|
||||
if !force,
|
||||
let cached = cache[key],
|
||||
cached.isFresh,
|
||||
cachedPayloadIsUsable(cached, for: key) { return true }
|
||||
// Join an in-flight fetch instead of reporting failure. Returning false
|
||||
// here made recoverFromStuckLoading() announce a failed recovery while a
|
||||
// perfectly healthy fetch was still running, which is what the quiet
|
||||
// path already avoided by waiting.
|
||||
if inFlightKeys[key] != nil {
|
||||
await waitForInFlight(key)
|
||||
return consistentCachedPayload(for: key) != nil
|
||||
}
|
||||
let inFlightToken = claimInFlight(key)
|
||||
attemptedKeys.insert(key)
|
||||
lastErrorByKey[key] = nil
|
||||
let didShowLoading = showLoading || cache[key] == nil
|
||||
|
|
@ -864,7 +1179,7 @@ final class AppStore {
|
|||
}
|
||||
defer {
|
||||
let abandonedAttempt = Task.isCancelled || generationAtStart != payloadRefreshGeneration
|
||||
inFlightKeys[key] = nil
|
||||
finishInFlight(for: key, token: inFlightToken)
|
||||
if didShowLoading {
|
||||
finishLoading(for: key)
|
||||
}
|
||||
|
|
@ -874,15 +1189,11 @@ final class AppStore {
|
|||
}
|
||||
var succeeded = false
|
||||
do {
|
||||
let fresh = try await DataClient.fetch(
|
||||
period: key.period,
|
||||
day: key.day,
|
||||
days: key.days,
|
||||
provider: key.provider,
|
||||
let fresh = try await fetchPayload(
|
||||
for: key,
|
||||
includeOptimize: includeOptimize,
|
||||
scope: key.scope,
|
||||
claudeConfigSourceId: key.claudeConfigSourceId,
|
||||
qualityOfService: qualityOfService
|
||||
qualityOfService: qualityOfService,
|
||||
acceptAfter: acceptAfter
|
||||
)
|
||||
if generationAtStart != payloadRefreshGeneration {
|
||||
NSLog("CodeBurn: dropping fetch result for \(key.label)/\(key.provider.rawValue) — refresh pipeline reset mid-fetch")
|
||||
|
|
@ -905,8 +1216,8 @@ final class AppStore {
|
|||
NSLog("CodeBurn: dropping fetch result for \(key.label)/\(key.provider.rawValue) — calendar rolled mid-fetch")
|
||||
return false
|
||||
}
|
||||
cache[key] = CachedPayload(payload: fresh, fetchedAt: Date())
|
||||
reconcileClaudeConfigSelection(from: fresh, for: key)
|
||||
cache[key] = CachedPayload(payload: fresh.payload, fetchedAt: Date(), contradictsAll: fresh.contradictsAll)
|
||||
reconcileClaudeConfigSelection(from: fresh.payload, for: key)
|
||||
lastSuccessByKey[key] = Date()
|
||||
lastErrorByKey[key] = nil
|
||||
succeeded = true
|
||||
|
|
@ -915,14 +1226,9 @@ final class AppStore {
|
|||
NSLog("CodeBurn: fetch failed for \(key.label)/\(key.provider.rawValue): \(error)")
|
||||
if includeOptimize, cache[key] == nil {
|
||||
do {
|
||||
let fallback = try await DataClient.fetch(
|
||||
period: key.period,
|
||||
day: key.day,
|
||||
days: key.days,
|
||||
provider: key.provider,
|
||||
let fallback = try await fetchPayload(
|
||||
for: key,
|
||||
includeOptimize: false,
|
||||
scope: key.scope,
|
||||
claudeConfigSourceId: key.claudeConfigSourceId,
|
||||
qualityOfService: qualityOfService
|
||||
)
|
||||
guard !Task.isCancelled else { return false }
|
||||
|
|
@ -931,8 +1237,8 @@ final class AppStore {
|
|||
invalidateStaleDayCache()
|
||||
return false
|
||||
}
|
||||
cache[key] = CachedPayload(payload: fallback, fetchedAt: Date())
|
||||
reconcileClaudeConfigSelection(from: fallback, for: key)
|
||||
cache[key] = CachedPayload(payload: fallback.payload, fetchedAt: Date(), contradictsAll: fallback.contradictsAll)
|
||||
reconcileClaudeConfigSelection(from: fallback.payload, for: key)
|
||||
lastSuccessByKey[key] = Date()
|
||||
lastErrorByKey[key] = nil
|
||||
return true
|
||||
|
|
@ -990,12 +1296,19 @@ final class AppStore {
|
|||
key: PayloadCacheKey,
|
||||
includeOptimize: Bool,
|
||||
force: Bool = false,
|
||||
qualityOfService: QualityOfService = .userInitiated
|
||||
qualityOfService: QualityOfService = .userInitiated,
|
||||
acceptAfter: (@Sendable () async -> Void)? = nil
|
||||
) async -> Bool {
|
||||
invalidateStaleDayCache()
|
||||
if !force, cache[key]?.isFresh == true { return true }
|
||||
if inFlightKeys[key] != nil { return false }
|
||||
inFlightKeys[key] = Date()
|
||||
if !force,
|
||||
let cached = cache[key],
|
||||
cached.isFresh,
|
||||
cachedPayloadIsUsable(cached, for: key) { return true }
|
||||
if inFlightKeys[key] != nil {
|
||||
await waitForInFlight(key)
|
||||
return consistentCachedPayload(for: key) != nil
|
||||
}
|
||||
let inFlightToken = claimInFlight(key)
|
||||
attemptedKeys.insert(key)
|
||||
let cacheDateAtStart = cacheDate
|
||||
let generationAtStart = payloadRefreshGeneration
|
||||
|
|
@ -1003,18 +1316,14 @@ final class AppStore {
|
|||
NSLog("CodeBurn: refreshing stale today status payload after %ds", age)
|
||||
}
|
||||
defer {
|
||||
inFlightKeys[key] = nil
|
||||
finishInFlight(for: key, token: inFlightToken)
|
||||
}
|
||||
do {
|
||||
let fresh = try await DataClient.fetch(
|
||||
period: key.period,
|
||||
day: key.day,
|
||||
days: key.days,
|
||||
provider: key.provider,
|
||||
let fresh = try await fetchPayload(
|
||||
for: key,
|
||||
includeOptimize: includeOptimize,
|
||||
scope: key.scope,
|
||||
claudeConfigSourceId: key.claudeConfigSourceId,
|
||||
qualityOfService: qualityOfService
|
||||
qualityOfService: qualityOfService,
|
||||
acceptAfter: acceptAfter
|
||||
)
|
||||
if generationAtStart != payloadRefreshGeneration {
|
||||
NSLog("CodeBurn: dropping quiet fetch result for \(key.label) — refresh pipeline reset mid-fetch")
|
||||
|
|
@ -1026,8 +1335,8 @@ final class AppStore {
|
|||
invalidateStaleDayCache()
|
||||
return false
|
||||
}
|
||||
cache[key] = CachedPayload(payload: fresh, fetchedAt: Date())
|
||||
reconcileClaudeConfigSelection(from: fresh, for: key)
|
||||
cache[key] = CachedPayload(payload: fresh.payload, fetchedAt: Date(), contradictsAll: fresh.contradictsAll)
|
||||
reconcileClaudeConfigSelection(from: fresh.payload, for: key)
|
||||
lastSuccessByKey[key] = Date()
|
||||
lastErrorByKey[key] = nil
|
||||
} catch {
|
||||
|
|
|
|||
|
|
@ -47,6 +47,14 @@ struct CLIDecodeFailure: Error, CustomStringConvertible {
|
|||
/// Runs the CLI via argv (no shell interpretation). See `CodeburnCLI` for why we never route
|
||||
/// commands through `/bin/zsh -c` anymore.
|
||||
struct DataClient {
|
||||
#if DEBUG
|
||||
/// Test seam. The in-flight slot and the all-provider acceptance ordering
|
||||
/// are only observable when two REAL fetches overlap, which needs a fetch
|
||||
/// whose completion the test controls rather than a spawned CLI.
|
||||
@MainActor
|
||||
static var fetchHookForTesting: (@Sendable (ProviderFilter, Bool) async throws -> MenubarPayload)?
|
||||
#endif
|
||||
|
||||
static func fetch(period: Period,
|
||||
day: String? = nil,
|
||||
days: Set<String> = [],
|
||||
|
|
@ -54,7 +62,13 @@ struct DataClient {
|
|||
includeOptimize: Bool,
|
||||
scope: MenubarScope = .local,
|
||||
claudeConfigSourceId: String? = nil,
|
||||
bypassResident: Bool = false,
|
||||
qualityOfService: QualityOfService = .userInitiated) async throws -> MenubarPayload {
|
||||
#if DEBUG
|
||||
if let hook = await MainActor.run(body: { fetchHookForTesting }) {
|
||||
return try await hook(provider, bypassResident)
|
||||
}
|
||||
#endif
|
||||
let subcommand = statusSubcommand(
|
||||
period: period,
|
||||
day: day,
|
||||
|
|
@ -64,7 +78,11 @@ struct DataClient {
|
|||
scope: scope,
|
||||
claudeConfigSourceId: claudeConfigSourceId
|
||||
)
|
||||
let result = try await runCLI(subcommand: subcommand, qualityOfService: qualityOfService)
|
||||
let result = try await runCLI(
|
||||
subcommand: subcommand,
|
||||
bypassResident: bypassResident,
|
||||
qualityOfService: qualityOfService
|
||||
)
|
||||
guard result.exitCode == 0 else {
|
||||
throw DataClientError.nonZeroExit(code: result.exitCode, stderr: result.stderr)
|
||||
}
|
||||
|
|
@ -133,10 +151,12 @@ struct DataClient {
|
|||
|
||||
private static func runCLI(
|
||||
subcommand: [String],
|
||||
bypassResident: Bool = false,
|
||||
qualityOfService: QualityOfService = .userInitiated
|
||||
) async throws -> ProcessResult {
|
||||
try await runCLI(
|
||||
subcommand: subcommand,
|
||||
bypassResident: bypassResident,
|
||||
serveRequest: { args in
|
||||
try await ServeConnection.shared.request(args: args)
|
||||
},
|
||||
|
|
@ -164,6 +184,7 @@ struct DataClient {
|
|||
/// the shared resident and globally limited one-shot closures above.
|
||||
static func runCLI(
|
||||
subcommand: [String],
|
||||
bypassResident: Bool = false,
|
||||
serveRequest: ([String]) async throws -> Data,
|
||||
spawnFallback: () async throws -> ProcessResult
|
||||
) async throws -> ProcessResult {
|
||||
|
|
@ -172,7 +193,7 @@ struct DataClient {
|
|||
// Transport/protocol failures fall back to the spawn path below, so
|
||||
// the resident remains an optimization. Resource-policy failures stay
|
||||
// terminal and cannot bypass the resident output ceiling.
|
||||
if ServeConnection.isEligible(subcommand) {
|
||||
if !bypassResident, ServeConnection.isEligible(subcommand) {
|
||||
do {
|
||||
let stdout = try await serveRequest(subcommand)
|
||||
return ProcessResult(stdout: stdout, stderr: "", exitCode: 0)
|
||||
|
|
|
|||
|
|
@ -389,18 +389,14 @@ struct ProviderDetail: Codable, Sendable {
|
|||
id = try c.decode(String.self, forKey: .id)
|
||||
label = try c.decode(String.self, forKey: .label)
|
||||
cost = try c.decode(Double.self, forKey: .cost)
|
||||
// Older CLIs emitted providerDetails without calls/hasUsage. When calls
|
||||
// exists, derive activity from calls/cost; with neither signal, keep the
|
||||
// detected provider visible rather than hiding valid $0 subscriptions.
|
||||
let decodedCalls = try c.decodeIfPresent(Int.self, forKey: .calls)
|
||||
calls = decodedCalls ?? 0
|
||||
if let decodedUsage = try c.decodeIfPresent(Bool.self, forKey: .hasUsage) {
|
||||
hasUsage = decodedUsage
|
||||
} else if decodedCalls != nil {
|
||||
hasUsage = cost > 0 || calls > 0
|
||||
} else {
|
||||
hasUsage = true
|
||||
}
|
||||
calls = try c.decodeIfPresent(Int.self, forKey: .calls) ?? 0
|
||||
// EVERY released CLI omits hasUsage, so the absent case is the common
|
||||
// one, not a legacy edge. Deriving activity from cost there hid every
|
||||
// subscription-backed provider whose period spend is $0 (Hermes, Kimi,
|
||||
// Copilot on an included plan). A provider the payload bothered to list
|
||||
// is one the user has, so absent means visible; the strict signal
|
||||
// applies only when the field is actually present.
|
||||
hasUsage = try c.decodeIfPresent(Bool.self, forKey: .hasUsage) ?? true
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -414,7 +410,9 @@ enum ProviderVisibility {
|
|||
.filter(\.hasUsage)
|
||||
.flatMap { [$0.id.lowercased(), $0.label.lowercased()] })
|
||||
}
|
||||
return Set(legacyProviders.keys.map { $0.lowercased() })
|
||||
return Set(legacyProviders.compactMap { key, cost in
|
||||
cost > 0 ? key.lowercased() : nil
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
import Foundation
|
||||
|
||||
struct ProviderReconnectPresentation: Sendable, Equatable {
|
||||
let title: String
|
||||
let defaultReason: String
|
||||
let instruction: String
|
||||
|
||||
init(provider: ProviderFilter) {
|
||||
title = "Reconnect \(provider.rawValue)"
|
||||
switch provider {
|
||||
case .claude:
|
||||
defaultReason = "Claude Code credentials need to be refreshed."
|
||||
instruction = "Open Claude Code in your terminal and type `/login`, then click Reconnect."
|
||||
case .codex:
|
||||
defaultReason = "Codex credentials need to be refreshed."
|
||||
instruction = "Run `codex login` in your terminal, then click Reconnect."
|
||||
case .kimiCode:
|
||||
defaultReason = "Kimi Code credentials need to be refreshed."
|
||||
instruction = "Run the Kimi CLI once to refresh your login, then click Reconnect."
|
||||
case .gemini:
|
||||
defaultReason = "Gemini credentials need to be refreshed."
|
||||
instruction = "Run the Gemini CLI once to refresh your login, then click Reconnect."
|
||||
case .copilot:
|
||||
defaultReason = "Copilot credentials need to be refreshed."
|
||||
instruction = "Sign in with the Copilot CLI, an editor plugin, or `gh auth login`, then click Reconnect."
|
||||
case .antigravity:
|
||||
defaultReason = "The local Antigravity service is unavailable."
|
||||
instruction = "Start the Antigravity app, then click Reconnect."
|
||||
default:
|
||||
defaultReason = "\(provider.rawValue) credentials need to be refreshed."
|
||||
instruction = "Sign in to \(provider.rawValue) again, then retry."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
import Foundation
|
||||
|
||||
enum ProviderStripPagingDirection: Sendable {
|
||||
case backward
|
||||
case forward
|
||||
|
||||
var offset: Int {
|
||||
switch self {
|
||||
case .backward: -1
|
||||
case .forward: 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ProviderStripPagingState: Sendable, Equatable {
|
||||
let filters: [ProviderFilter]
|
||||
let selectedProvider: ProviderFilter
|
||||
private(set) var viewportAnchor: ProviderFilter?
|
||||
|
||||
init(
|
||||
filters: [ProviderFilter],
|
||||
selectedProvider: ProviderFilter,
|
||||
viewportAnchor: ProviderFilter?
|
||||
) {
|
||||
self.filters = filters
|
||||
self.selectedProvider = selectedProvider
|
||||
if let viewportAnchor, filters.contains(viewportAnchor) {
|
||||
self.viewportAnchor = viewportAnchor
|
||||
} else if filters.contains(selectedProvider) {
|
||||
self.viewportAnchor = selectedProvider
|
||||
} else {
|
||||
self.viewportAnchor = filters.first
|
||||
}
|
||||
}
|
||||
|
||||
var canMoveBackward: Bool { anchorIndex > 0 }
|
||||
var canMoveForward: Bool { anchorIndex >= 0 && anchorIndex < filters.count - 1 }
|
||||
|
||||
@discardableResult
|
||||
mutating func move(direction: ProviderStripPagingDirection) -> ProviderFilter? {
|
||||
guard !filters.isEmpty else { return viewportAnchor }
|
||||
let targetIndex = min(max(anchorIndex + direction.offset, 0), filters.count - 1)
|
||||
viewportAnchor = filters[targetIndex]
|
||||
return viewportAnchor
|
||||
}
|
||||
|
||||
private var anchorIndex: Int {
|
||||
guard let viewportAnchor else { return -1 }
|
||||
return filters.firstIndex(of: viewportAnchor) ?? -1
|
||||
}
|
||||
}
|
||||
|
|
@ -16,6 +16,7 @@ struct AgentTabStrip: View {
|
|||
@State private var stripViewportWidth: CGFloat = 0
|
||||
@State private var stripContentWidth: CGFloat = 0
|
||||
@State private var scrollWheelMonitor: Any?
|
||||
@State private var viewportAnchor: ProviderFilter?
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { viewportGeo in
|
||||
|
|
@ -23,7 +24,7 @@ struct AgentTabStrip: View {
|
|||
HStack(spacing: 4) {
|
||||
if isOverflowing {
|
||||
Button {
|
||||
selectAdjacentProvider(direction: -1, proxy: proxy)
|
||||
pageProviderStrip(direction: .backward, proxy: proxy)
|
||||
} label: {
|
||||
Image(systemName: "chevron.left")
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
|
|
@ -44,6 +45,7 @@ struct AgentTabStrip: View {
|
|||
isActive: store.selectedProvider == filter,
|
||||
quota: store.quotaSummary(for: filter)
|
||||
) {
|
||||
viewportAnchor = filter
|
||||
store.switchTo(provider: filter)
|
||||
withAnimation(.easeInOut(duration: 0.18)) {
|
||||
proxy.scrollTo(filter.id, anchor: .center)
|
||||
|
|
@ -73,7 +75,7 @@ struct AgentTabStrip: View {
|
|||
|
||||
if isOverflowing {
|
||||
Button {
|
||||
selectAdjacentProvider(direction: 1, proxy: proxy)
|
||||
pageProviderStrip(direction: .forward, proxy: proxy)
|
||||
} label: {
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
|
|
@ -88,17 +90,19 @@ struct AgentTabStrip: View {
|
|||
.onAppear {
|
||||
stripViewportWidth = viewportGeo.size.width
|
||||
installScrollWheelMonitorIfNeeded()
|
||||
withAnimation(.easeInOut(duration: 0.18)) {
|
||||
proxy.scrollTo(store.selectedProvider.id, anchor: .center)
|
||||
}
|
||||
viewportAnchor = pagingState.viewportAnchor
|
||||
scrollToViewportAnchor(proxy: proxy)
|
||||
}
|
||||
.onChange(of: viewportGeo.size.width) { _, newWidth in
|
||||
stripViewportWidth = newWidth
|
||||
}
|
||||
.onChange(of: store.selectedProvider) { _, newProvider in
|
||||
withAnimation(.easeInOut(duration: 0.18)) {
|
||||
proxy.scrollTo(newProvider.id, anchor: .center)
|
||||
}
|
||||
viewportAnchor = visibleFilters.contains(newProvider) ? newProvider : pagingState.viewportAnchor
|
||||
scrollToViewportAnchor(proxy: proxy)
|
||||
}
|
||||
.onChange(of: visibleFilters) { _, _ in
|
||||
viewportAnchor = pagingState.viewportAnchor
|
||||
scrollToViewportAnchor(proxy: proxy)
|
||||
}
|
||||
.onDisappear {
|
||||
removeScrollWheelMonitorIfNeeded()
|
||||
|
|
@ -109,14 +113,18 @@ struct AgentTabStrip: View {
|
|||
}
|
||||
|
||||
private var periodAll: MenubarPayload {
|
||||
store.periodAllPayload ?? store.payload
|
||||
// `store.payload` is the SCOPED selection. Falling back to it while the
|
||||
// selection has no cached data of its own used to surface the previous
|
||||
// provider's totals here; the same hasCachedData gate the popover body
|
||||
// uses keeps that out of the tab strip.
|
||||
store.periodAllPayload ?? (store.hasCachedData ? store.payload : .empty)
|
||||
}
|
||||
|
||||
private var visibleFilters: [ProviderFilter] {
|
||||
// Tabs reflect the selected range. The payload's activity signal keeps
|
||||
// token-only and subscription/flat-rate providers visible while hiding
|
||||
// providers merely discovered on disk. Older payloads lack the activity
|
||||
// signal, so they preserve detected-provider visibility for compatibility.
|
||||
// signal, so their zero-cost discovery rows fail closed.
|
||||
let activeKeys = ProviderVisibility.activeKeys(
|
||||
providerDetails: periodAll.current.providerDetails,
|
||||
legacyProviders: periodAll.current.providers
|
||||
|
|
@ -149,21 +157,29 @@ struct AgentTabStrip: View {
|
|||
}
|
||||
}
|
||||
|
||||
private var currentFilterIndex: Int {
|
||||
visibleFilters.firstIndex(of: store.selectedProvider) ?? 0
|
||||
private var pagingState: ProviderStripPagingState {
|
||||
ProviderStripPagingState(
|
||||
filters: visibleFilters,
|
||||
selectedProvider: store.selectedProvider,
|
||||
viewportAnchor: viewportAnchor
|
||||
)
|
||||
}
|
||||
|
||||
private var canMoveBackward: Bool { currentFilterIndex > 0 }
|
||||
private var canMoveForward: Bool { currentFilterIndex < visibleFilters.count - 1 }
|
||||
private var canMoveBackward: Bool { pagingState.canMoveBackward }
|
||||
private var canMoveForward: Bool { pagingState.canMoveForward }
|
||||
private var isOverflowing: Bool { stripContentWidth > (stripViewportWidth - 30) }
|
||||
|
||||
private func selectAdjacentProvider(direction: Int, proxy: ScrollViewProxy) {
|
||||
guard !visibleFilters.isEmpty else { return }
|
||||
let targetIndex = min(max(currentFilterIndex + direction, 0), visibleFilters.count - 1)
|
||||
let target = visibleFilters[targetIndex]
|
||||
store.switchTo(provider: target)
|
||||
private func pageProviderStrip(direction: ProviderStripPagingDirection, proxy: ScrollViewProxy) {
|
||||
var state = pagingState
|
||||
guard let target = state.move(direction: direction) else { return }
|
||||
viewportAnchor = target
|
||||
scrollToViewportAnchor(proxy: proxy)
|
||||
}
|
||||
|
||||
private func scrollToViewportAnchor(proxy: ScrollViewProxy) {
|
||||
guard let viewportAnchor else { return }
|
||||
withAnimation(.easeInOut(duration: 0.18)) {
|
||||
proxy.scrollTo(target.id, anchor: .center)
|
||||
proxy.scrollTo(viewportAnchor.id, anchor: .center)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -410,38 +426,21 @@ private struct QuotaDetailPopover: View {
|
|||
|
||||
private func terminalFailureCard(reason: String?) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(reconnectTitle)
|
||||
Text(reconnectPresentation.title)
|
||||
.font(.system(size: 11.5, weight: .semibold))
|
||||
.foregroundStyle(.red)
|
||||
Text(reason ?? defaultReconnectReason)
|
||||
Text(reason ?? reconnectPresentation.defaultReason)
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
Text(reconnectInstruction)
|
||||
Text(reconnectPresentation.instruction)
|
||||
.font(.system(size: 10.5))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private var reconnectTitle: String {
|
||||
switch quota.providerFilter {
|
||||
case .codex: return "Reconnect Codex"
|
||||
default: return "Reconnect Claude"
|
||||
}
|
||||
}
|
||||
|
||||
private var defaultReconnectReason: String {
|
||||
switch quota.providerFilter {
|
||||
case .codex: return "Refresh token rejected by OpenAI."
|
||||
default: return "Refresh token rejected by Anthropic."
|
||||
}
|
||||
}
|
||||
|
||||
private var reconnectInstruction: String {
|
||||
switch quota.providerFilter {
|
||||
case .codex: return "Run `codex login` in your terminal, then click Reconnect."
|
||||
default: return "Open Claude Code in your terminal and type `/login`, then click Reconnect."
|
||||
}
|
||||
private var reconnectPresentation: ProviderReconnectPresentation {
|
||||
ProviderReconnectPresentation(provider: quota.providerFilter)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ struct FlameWordmark: View {
|
|||
// `flameShift 3s ease infinite` (keyframes 0%/100% at position 0%, 50% at
|
||||
// 100%). Here that's a 3x-wide gradient sweeping from its left edge aligned
|
||||
// with the text to its right edge aligned with the text (a 2x-width shift)
|
||||
// and back — a 1.5s ease-in-out each way, autoreversing.
|
||||
// and back, slowed to a 4s ease-in-out each way, autoreversing.
|
||||
private static let flameColors: [Color] = [
|
||||
Color(red: 0xFF / 255.0, green: 0x6A / 255.0, blue: 0x00 / 255.0), // #ff6a00
|
||||
Color(red: 0xFF / 255.0, green: 0xDA / 255.0, blue: 0x44 / 255.0), // #ffda44
|
||||
|
|
@ -27,8 +27,12 @@ struct FlameWordmark: View {
|
|||
}
|
||||
|
||||
var body: some View {
|
||||
// The letters keep a static flame fill of their own, so the sweep can only
|
||||
// ever brighten them; a mask that is a frame late no longer blanks the name.
|
||||
wordmark
|
||||
.hidden()
|
||||
.foregroundStyle(
|
||||
LinearGradient(colors: Self.flameColors, startPoint: .leading, endPoint: .trailing)
|
||||
)
|
||||
.overlay {
|
||||
GeometryReader { geo in
|
||||
LinearGradient(
|
||||
|
|
@ -41,7 +45,7 @@ struct FlameWordmark: View {
|
|||
.animation(
|
||||
reduceMotion || !store.menuPopoverVisible
|
||||
? nil
|
||||
: .easeInOut(duration: 1.5).repeatForever(autoreverses: true),
|
||||
: .easeInOut(duration: 4).repeatForever(autoreverses: true),
|
||||
value: sweeping
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,14 @@ struct MenuBarContent: View {
|
|||
ScrollView(.vertical, showsIndicators: false) {
|
||||
VStack(spacing: 0) {
|
||||
HeroSection()
|
||||
if store.selectedPayloadMayBeIncomplete {
|
||||
Text("This total may be incomplete.")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(Color.secondary.opacity(0.75))
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.bottom, 6)
|
||||
}
|
||||
Divider().opacity(0.5)
|
||||
PeriodSegmentedControl()
|
||||
ScopeSegmentedControl()
|
||||
|
|
@ -119,7 +127,7 @@ struct MenuBarContent: View {
|
|||
// visible. Without this, the strip disappears for one frame on a period
|
||||
// switch when the new key's payload is still empty.
|
||||
if store.hasAnyProvidersInCache { return true }
|
||||
let payload = store.todayPayload ?? store.payload
|
||||
let payload = store.todayPayload ?? (store.hasCachedData ? store.payload : .empty)
|
||||
return !payload.current.providers.isEmpty
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -22,28 +22,38 @@ struct AgentTabProviderVisibilityTests {
|
|||
#expect(!keys.contains("claude"))
|
||||
}
|
||||
|
||||
@Test("legacy payloads keep detected providers visible when activity is unknowable")
|
||||
func legacyDetectedProviderFallback() {
|
||||
@Test("legacy payloads show only providers with period spend")
|
||||
func legacyProviderCostFallback() {
|
||||
let keys = ProviderVisibility.activeKeys(
|
||||
providerDetails: [],
|
||||
legacyProviders: ["codex": 3.25, "hermes agent": 0]
|
||||
)
|
||||
|
||||
#expect(keys == ["codex", "hermes agent"])
|
||||
#expect(keys == ["codex"])
|
||||
}
|
||||
|
||||
@Test("legacy provider details without activity fields fail open, while calls remain authoritative")
|
||||
func legacyProviderDetailDecoding() throws {
|
||||
let noSignal = try JSONDecoder().decode(
|
||||
ProviderDetail.self,
|
||||
from: Data(#"{"id":"hermes","label":"Hermes Agent","cost":0}"#.utf8)
|
||||
)
|
||||
let explicitIdleCalls = try JSONDecoder().decode(
|
||||
ProviderDetail.self,
|
||||
from: Data(#"{"id":"hermes","label":"Hermes Agent","cost":0,"calls":0}"#.utf8)
|
||||
)
|
||||
@Test("provider details without hasUsage stay visible")
|
||||
func absentHasUsageDefaultsToVisible() throws {
|
||||
func decode(_ json: String) throws -> ProviderDetail {
|
||||
try JSONDecoder().decode(ProviderDetail.self, from: Data(json.utf8))
|
||||
}
|
||||
// Every RELEASED CLI omits hasUsage. Deriving it from cost there hid
|
||||
// every subscription-backed provider whose period spend is $0.
|
||||
let noHasUsage = try decode(#"{"id":"hermes","label":"Hermes Agent","cost":0}"#)
|
||||
let noHasUsageWithCalls = try decode(#"{"id":"hermes","label":"Hermes Agent","cost":0,"calls":0}"#)
|
||||
let explicitlyIdle = try decode(#"{"id":"hermes","label":"Hermes Agent","cost":0,"calls":0,"hasUsage":false}"#)
|
||||
let explicitlyActive = try decode(#"{"id":"codex","label":"Codex","cost":0,"calls":0,"hasUsage":true}"#)
|
||||
|
||||
#expect(noSignal.hasUsage)
|
||||
#expect(!explicitIdleCalls.hasUsage)
|
||||
#expect(noHasUsage.hasUsage)
|
||||
#expect(noHasUsageWithCalls.hasUsage)
|
||||
// The strict signal still applies wherever the CLI actually emits it.
|
||||
#expect(!explicitlyIdle.hasUsage)
|
||||
#expect(explicitlyActive.hasUsage)
|
||||
|
||||
let keys = ProviderVisibility.activeKeys(
|
||||
providerDetails: [noHasUsage, explicitlyIdle],
|
||||
legacyProviders: [:]
|
||||
)
|
||||
#expect(keys.contains("hermes"))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,270 @@
|
|||
import Foundation
|
||||
import Testing
|
||||
@testable import CodeBurnMenubar
|
||||
|
||||
/// A fetch whose completion the test controls, so two real refresh tasks can be
|
||||
/// held in flight at once. Every attempt records the provider it was issued for
|
||||
/// and parks until the test releases it by index.
|
||||
@MainActor
|
||||
private final class ScriptedFetch {
|
||||
private var parked: [Int: CheckedContinuation<Void, Never>] = [:]
|
||||
private(set) var attempts: [(index: Int, provider: ProviderFilter)] = []
|
||||
private(set) var released: [Int] = []
|
||||
var payloadForProvider: (ProviderFilter) -> MenubarPayload = { _ in scriptedPayload(cost: 1) }
|
||||
|
||||
func install() {
|
||||
DataClient.fetchHookForTesting = { [weak self] provider, _ in
|
||||
guard let self else { return scriptedPayload(cost: 0) }
|
||||
let index = await self.begin(provider)
|
||||
await self.park(index)
|
||||
return await self.payload(for: provider)
|
||||
}
|
||||
}
|
||||
|
||||
func uninstall() {
|
||||
DataClient.fetchHookForTesting = nil
|
||||
for (_, continuation) in parked { continuation.resume() }
|
||||
parked.removeAll()
|
||||
}
|
||||
|
||||
private func begin(_ provider: ProviderFilter) -> Int {
|
||||
let index = attempts.count
|
||||
attempts.append((index: index, provider: provider))
|
||||
return index
|
||||
}
|
||||
|
||||
private func park(_ index: Int) async {
|
||||
await withCheckedContinuation { parked[index] = $0 }
|
||||
}
|
||||
|
||||
private func payload(for provider: ProviderFilter) -> MenubarPayload {
|
||||
payloadForProvider(provider)
|
||||
}
|
||||
|
||||
func isParked(_ index: Int) -> Bool { parked[index] != nil }
|
||||
|
||||
func release(_ index: Int) {
|
||||
guard let continuation = parked.removeValue(forKey: index) else { return }
|
||||
released.append(index)
|
||||
continuation.resume()
|
||||
}
|
||||
|
||||
func indices(for provider: ProviderFilter) -> [Int] {
|
||||
attempts.filter { $0.provider == provider }.map(\.index)
|
||||
}
|
||||
}
|
||||
|
||||
private func scriptedPayload(cost: Double,
|
||||
calls: Int = 1,
|
||||
providers: [String: Double]? = nil,
|
||||
providerDetails: [ProviderDetail] = []) -> MenubarPayload {
|
||||
MenubarPayload(
|
||||
generated: "test",
|
||||
current: CurrentBlock(
|
||||
label: "Today",
|
||||
cost: cost,
|
||||
calls: calls,
|
||||
sessions: 1,
|
||||
oneShotRate: nil,
|
||||
inputTokens: 1,
|
||||
outputTokens: 1,
|
||||
cacheHitPercent: 0,
|
||||
codexCredits: nil,
|
||||
topActivities: [],
|
||||
topModels: [],
|
||||
localModelSavings: LocalModelSavings(totalUSD: 0, calls: 0, byModel: [], byProvider: []),
|
||||
providers: providers ?? ["claude": cost],
|
||||
providerDetails: providerDetails,
|
||||
topProjects: [],
|
||||
modelEfficiency: [],
|
||||
topSessions: [],
|
||||
retryTax: RetryTax(totalUSD: 0, retries: 0, editTurns: 0, byModel: []),
|
||||
routingWaste: RoutingWaste(totalSavingsUSD: 0, baselineModel: "", baselineCostPerEdit: 0, byModel: []),
|
||||
tools: [],
|
||||
skills: [],
|
||||
subagents: [],
|
||||
mcpServers: []
|
||||
),
|
||||
optimize: OptimizeBlock(findingCount: 0, savingsUSD: 0, topFindings: []),
|
||||
history: HistoryBlock(daily: []),
|
||||
combined: nil,
|
||||
claudeConfigs: nil
|
||||
)
|
||||
}
|
||||
|
||||
/// Yield repeatedly until `condition` holds. The tasks under test suspend on
|
||||
/// real continuations, so progress needs actual scheduler turns, not a sleep.
|
||||
@MainActor
|
||||
private func settle(until condition: () -> Bool, turns: Int = 500) async -> Bool {
|
||||
for _ in 0..<turns {
|
||||
if condition() { return true }
|
||||
await Task.yield()
|
||||
}
|
||||
return condition()
|
||||
}
|
||||
|
||||
// Serialized: the DataClient fetch hook is process-global, so two of these
|
||||
// running at once would each see the other's attempts.
|
||||
@Suite("AppStore refresh concurrency", .serialized)
|
||||
@MainActor
|
||||
struct AppStoreRefreshConcurrencyTests {
|
||||
|
||||
@Test("a fetch whose in-flight slot was evicted cannot release its successor's slot")
|
||||
func evictedFetchCannotReleaseSuccessorSlot() async {
|
||||
let script = ScriptedFetch()
|
||||
script.install()
|
||||
defer { script.uninstall() }
|
||||
|
||||
let store = AppStore()
|
||||
store.setCacheDateToTodayForTesting()
|
||||
|
||||
// A claims the slot for (today, claude) and parks inside its fetch.
|
||||
let taskA = Task { await store.refreshQuietlyForTesting(period: .today, provider: .claude) }
|
||||
#expect(await settle(until: { script.attempts.count == 1 }))
|
||||
#expect(store.isInFlightForTesting(period: .today, provider: .claude))
|
||||
|
||||
// The display-sleep reset evicts the slot out from under A.
|
||||
store.resetLoadingState()
|
||||
#expect(!store.isInFlightForTesting(period: .today, provider: .claude))
|
||||
|
||||
// B legitimately claims the same key afterwards.
|
||||
let taskB = Task { await store.refreshQuietlyForTesting(period: .today, provider: .claude) }
|
||||
#expect(await settle(until: { script.attempts.count == 2 }))
|
||||
#expect(store.isInFlightForTesting(period: .today, provider: .claude))
|
||||
|
||||
// C finds the key busy and parks as a waiter on B.
|
||||
var cFinished = false
|
||||
let taskC = Task {
|
||||
let result = await store.refreshQuietlyForTesting(period: .today, provider: .claude)
|
||||
cFinished = true
|
||||
return result
|
||||
}
|
||||
#expect(await settle(until: { store.inFlightWaiterCountForTesting(period: .today, provider: .claude) == 1 }))
|
||||
|
||||
// A finishes late. Its slot is gone, so its release must be a no-op:
|
||||
// B keeps the slot and C stays parked. Untokened, A's defer cleared B's
|
||||
// slot and woke C onto an empty cache.
|
||||
script.release(0)
|
||||
_ = await taskA.value
|
||||
#expect(store.isInFlightForTesting(period: .today, provider: .claude))
|
||||
#expect(store.inFlightWaiterCountForTesting(period: .today, provider: .claude) == 1)
|
||||
#expect(!cFinished)
|
||||
|
||||
// B finishes and C resumes off B's result.
|
||||
script.release(1)
|
||||
#expect(await taskB.value)
|
||||
#expect(await taskC.value)
|
||||
#expect(cFinished)
|
||||
#expect(!store.isInFlightForTesting(period: .today, provider: .claude))
|
||||
#expect(store.inFlightWaiterCountForTesting(period: .today, provider: .claude) == 0)
|
||||
}
|
||||
|
||||
@Test("the all-provider and scoped fetches run together and only acceptance is ordered")
|
||||
func allAndScopedFetchesOverlap() async {
|
||||
let script = ScriptedFetch()
|
||||
script.payloadForProvider = { provider in
|
||||
provider == .all
|
||||
? scriptedPayload(cost: 12, providers: ["claude": 12],
|
||||
providerDetails: [ProviderDetail(id: "claude", label: "Claude", cost: 12, calls: 4, hasUsage: true)])
|
||||
: scriptedPayload(cost: 12, providers: ["claude": 12],
|
||||
providerDetails: [ProviderDetail(id: "claude", label: "Claude", cost: 12, calls: 4, hasUsage: true)])
|
||||
}
|
||||
script.install()
|
||||
defer { script.uninstall() }
|
||||
|
||||
let store = AppStore()
|
||||
store.setCacheDateToTodayForTesting()
|
||||
store.suppressRefreshesForTesting()
|
||||
store.switchTo(provider: .claude)
|
||||
|
||||
let task = Task { await store.refresh(includeOptimize: false, force: true) }
|
||||
|
||||
// BOTH parses are in flight before either has produced a result. The
|
||||
// serialized version could not reach two attempts until the first
|
||||
// returned.
|
||||
#expect(await settle(until: { script.attempts.count == 2 }))
|
||||
#expect(Set(script.attempts.map(\.provider)) == Set([.all, .claude]))
|
||||
#expect(script.isParked(0))
|
||||
#expect(script.isParked(1))
|
||||
|
||||
// Acceptance is still ordered: release the scoped fetch first and it
|
||||
// must not land until the all-provider evidence has.
|
||||
let scopedIndex = script.indices(for: .claude)[0]
|
||||
let allIndex = script.indices(for: .all)[0]
|
||||
script.release(scopedIndex)
|
||||
for _ in 0..<50 { await Task.yield() }
|
||||
#expect(store.cachedPayloadForTesting(period: .today, provider: .claude) == nil)
|
||||
|
||||
script.release(allIndex)
|
||||
#expect(await task.value)
|
||||
#expect(store.cachedPayloadForTesting(period: .today, provider: .claude) != nil)
|
||||
#expect(store.cachedPayloadForTesting(period: .today, provider: .all) != nil)
|
||||
}
|
||||
|
||||
@Test("an interactive refresh joins an in-flight fetch instead of reporting failure")
|
||||
func interactiveRefreshJoinsInFlightFetch() async {
|
||||
let script = ScriptedFetch()
|
||||
script.install()
|
||||
defer { script.uninstall() }
|
||||
|
||||
let store = AppStore()
|
||||
store.setCacheDateToTodayForTesting()
|
||||
|
||||
let first = Task { await store.refresh(includeOptimize: false, force: true) }
|
||||
#expect(await settle(until: { script.attempts.count == 1 }))
|
||||
|
||||
// Second interactive refresh on the same key. It used to return false
|
||||
// immediately, which is what made recoverFromStuckLoading() announce a
|
||||
// failed recovery while a perfectly healthy fetch was still running.
|
||||
var secondResult: Bool?
|
||||
let second = Task {
|
||||
let value = await store.refresh(includeOptimize: false, force: true)
|
||||
secondResult = value
|
||||
return value
|
||||
}
|
||||
#expect(await settle(until: { store.inFlightWaiterCountForTesting(period: .today, provider: .all) == 1 }))
|
||||
#expect(secondResult == nil)
|
||||
|
||||
script.release(0)
|
||||
#expect(await first.value)
|
||||
#expect(await second.value)
|
||||
}
|
||||
|
||||
@Test("a twice-contradicting payload is cached and flagged instead of raising an error")
|
||||
func twiceContradictingPayloadIsCachedAndFlagged() async {
|
||||
let script = ScriptedFetch()
|
||||
// The scoped payload reports nothing for a provider the all-provider
|
||||
// slice says is active, and says so again on the verifying re-parse.
|
||||
script.payloadForProvider = { provider in
|
||||
provider == .all
|
||||
? scriptedPayload(cost: 12, providers: ["claude": 11.5, "hermes agent": 0.5],
|
||||
providerDetails: [
|
||||
ProviderDetail(id: "claude", label: "Claude", cost: 11.5, calls: 4, hasUsage: true),
|
||||
ProviderDetail(id: "hermes", label: "Hermes Agent", cost: 0.5, calls: 7, hasUsage: true),
|
||||
])
|
||||
: scriptedPayload(cost: 0, calls: 0, providers: ["hermes agent": 0],
|
||||
providerDetails: [ProviderDetail(id: "hermes", label: "Hermes Agent", cost: 0, calls: 0, hasUsage: false)])
|
||||
}
|
||||
script.install()
|
||||
defer { script.uninstall() }
|
||||
|
||||
let store = AppStore()
|
||||
store.setCacheDateToTodayForTesting()
|
||||
store.suppressRefreshesForTesting()
|
||||
store.switchTo(provider: .hermes)
|
||||
|
||||
let task = Task { await store.refresh(includeOptimize: false, force: true) }
|
||||
// all + scoped + the resident-bypassing verification of the scoped one.
|
||||
#expect(await settle(until: { script.attempts.count >= 2 }))
|
||||
for index in 0..<script.attempts.count { script.release(index) }
|
||||
#expect(await settle(until: { script.attempts.count == 3 }))
|
||||
script.release(2)
|
||||
_ = await task.value
|
||||
|
||||
// Cached and served, with the caveat flag set. It used to throw, which
|
||||
// left the tab with an error banner and no number at all.
|
||||
#expect(store.cachedPayloadForTesting(period: .today, provider: .hermes) != nil)
|
||||
#expect(store.selectedPayloadMayBeIncomplete)
|
||||
#expect(store.lastError == nil)
|
||||
}
|
||||
}
|
||||
|
|
@ -46,6 +46,12 @@ private func claudeConfigSelector(selectedId: String? = nil) -> ClaudeConfigSele
|
|||
}
|
||||
|
||||
private func menubarPayload(cost: Double,
|
||||
calls: Int = 1,
|
||||
sessions: Int = 1,
|
||||
inputTokens: Int = 1,
|
||||
outputTokens: Int = 1,
|
||||
providers: [String: Double]? = nil,
|
||||
providerDetails: [ProviderDetail] = [],
|
||||
combined: CombinedUsage? = nil,
|
||||
claudeConfigs: ClaudeConfigSelector? = nil) -> MenubarPayload {
|
||||
MenubarPayload(
|
||||
|
|
@ -53,17 +59,18 @@ private func menubarPayload(cost: Double,
|
|||
current: CurrentBlock(
|
||||
label: "Today",
|
||||
cost: cost,
|
||||
calls: 1,
|
||||
sessions: 1,
|
||||
calls: calls,
|
||||
sessions: sessions,
|
||||
oneShotRate: nil,
|
||||
inputTokens: 1,
|
||||
outputTokens: 1,
|
||||
inputTokens: inputTokens,
|
||||
outputTokens: outputTokens,
|
||||
cacheHitPercent: 0,
|
||||
codexCredits: nil,
|
||||
topActivities: [],
|
||||
topModels: [],
|
||||
localModelSavings: LocalModelSavings(totalUSD: 0, calls: 0, byModel: [], byProvider: []),
|
||||
providers: ["claude": cost],
|
||||
providers: providers ?? ["claude": cost],
|
||||
providerDetails: providerDetails,
|
||||
topProjects: [],
|
||||
modelEfficiency: [],
|
||||
topSessions: [],
|
||||
|
|
@ -84,6 +91,332 @@ private func menubarPayload(cost: Double,
|
|||
@Suite("AppStore refresh recovery")
|
||||
@MainActor
|
||||
struct AppStoreRefreshRecoveryTests {
|
||||
@Test("an active all-provider slice rejects a contradictory scoped zero")
|
||||
func activeAllProviderSliceRejectsScopedZero() {
|
||||
let store = AppStore()
|
||||
let all = menubarPayload(
|
||||
cost: 12,
|
||||
providers: ["claude": 11.5, "hermes agent": 0.5],
|
||||
providerDetails: [
|
||||
ProviderDetail(id: "claude", label: "Claude", cost: 11.5, calls: 4, hasUsage: true),
|
||||
ProviderDetail(id: "hermes", label: "Hermes Agent", cost: 0.5, calls: 7, hasUsage: true),
|
||||
]
|
||||
)
|
||||
let falseZero = menubarPayload(
|
||||
cost: 0,
|
||||
calls: 0,
|
||||
sessions: 0,
|
||||
providers: ["hermes agent": 0],
|
||||
providerDetails: [
|
||||
ProviderDetail(id: "hermes", label: "Hermes Agent", cost: 0, calls: 0, hasUsage: false),
|
||||
]
|
||||
)
|
||||
|
||||
store.setCachedPayloadForTesting(all, period: .today, provider: .all, fetchedAt: Date())
|
||||
store.suppressRefreshesForTesting()
|
||||
store.switchTo(provider: .hermes)
|
||||
store.setCachedPayloadForTesting(falseZero, period: .today, provider: .hermes, fetchedAt: Date())
|
||||
|
||||
#expect(store.providerPayloadContradictsAllForTesting(falseZero, period: .today, provider: .hermes))
|
||||
// The Hermes tab must show NO dollar figure. It used to fall back to the
|
||||
// previous selection's payload and print $12, which is Claude's spend
|
||||
// plus Hermes', under a tab labelled Hermes.
|
||||
#expect(!store.hasCachedData)
|
||||
#expect(store.payload.current.cost == 0)
|
||||
#expect(store.payload.current.calls == 0)
|
||||
#expect(store.needsInteractivePayloadRefresh)
|
||||
}
|
||||
|
||||
@Test("positive provider spend cannot disappear when scoped calls survive")
|
||||
func positiveSpendCannotDisappearWhenScopedCallsSurvive() {
|
||||
let store = AppStore()
|
||||
let all = menubarPayload(
|
||||
cost: 12,
|
||||
providers: ["claude": 11.5, "hermes agent": 0.5],
|
||||
providerDetails: [
|
||||
ProviderDetail(id: "claude", label: "Claude", cost: 11.5, calls: 4, hasUsage: true),
|
||||
ProviderDetail(id: "hermes", label: "Hermes Agent", cost: 0.5, calls: 7, hasUsage: true),
|
||||
]
|
||||
)
|
||||
let missingSpend = menubarPayload(
|
||||
cost: 0,
|
||||
calls: 7,
|
||||
sessions: 7,
|
||||
inputTokens: 100,
|
||||
outputTokens: 20,
|
||||
providers: ["hermes agent": 0],
|
||||
providerDetails: [
|
||||
ProviderDetail(id: "hermes", label: "Hermes Agent", cost: 0, calls: 7, hasUsage: true),
|
||||
]
|
||||
)
|
||||
|
||||
store.setCachedPayloadForTesting(all, period: .today, provider: .all, fetchedAt: Date())
|
||||
|
||||
#expect(store.providerPayloadContradictsAllForTesting(missingSpend, period: .today, provider: .hermes))
|
||||
}
|
||||
|
||||
@Test("flat-rate activity rejects a contradictory scoped zero")
|
||||
func flatRateActivityRejectsScopedZero() {
|
||||
let store = AppStore()
|
||||
let all = menubarPayload(
|
||||
cost: 12,
|
||||
providers: ["claude": 12, "hermes agent": 0],
|
||||
providerDetails: [
|
||||
ProviderDetail(id: "claude", label: "Claude", cost: 12, calls: 4, hasUsage: true),
|
||||
ProviderDetail(id: "hermes", label: "Hermes Agent", cost: 0, calls: 7, hasUsage: true),
|
||||
]
|
||||
)
|
||||
let falseZero = menubarPayload(
|
||||
cost: 0,
|
||||
calls: 0,
|
||||
sessions: 0,
|
||||
providers: ["hermes agent": 0],
|
||||
providerDetails: [
|
||||
ProviderDetail(id: "hermes", label: "Hermes Agent", cost: 0, calls: 0, hasUsage: false),
|
||||
]
|
||||
)
|
||||
|
||||
store.setCachedPayloadForTesting(all, period: .today, provider: .all, fetchedAt: Date())
|
||||
|
||||
#expect(store.providerPayloadContradictsAllForTesting(falseZero, period: .today, provider: .hermes))
|
||||
}
|
||||
|
||||
@Test("token-only activity accepts a scoped payload with matching usage")
|
||||
func tokenOnlyActivityAcceptsMatchingScopedUsage() {
|
||||
let store = AppStore()
|
||||
let all = menubarPayload(
|
||||
cost: 12,
|
||||
providers: ["claude": 12, "hermes agent": 0],
|
||||
providerDetails: [
|
||||
ProviderDetail(id: "claude", label: "Claude", cost: 12, calls: 4, hasUsage: true),
|
||||
ProviderDetail(id: "hermes", label: "Hermes Agent", cost: 0, calls: 0, hasUsage: true),
|
||||
]
|
||||
)
|
||||
let tokenOnly = menubarPayload(
|
||||
cost: 0,
|
||||
calls: 0,
|
||||
sessions: 1,
|
||||
inputTokens: 100,
|
||||
outputTokens: 20,
|
||||
providers: ["hermes agent": 0],
|
||||
providerDetails: [
|
||||
ProviderDetail(id: "hermes", label: "Hermes Agent", cost: 0, calls: 0, hasUsage: true),
|
||||
]
|
||||
)
|
||||
|
||||
store.setCachedPayloadForTesting(all, period: .today, provider: .all, fetchedAt: Date())
|
||||
|
||||
#expect(!store.providerPayloadContradictsAllForTesting(tokenOnly, period: .today, provider: .hermes))
|
||||
}
|
||||
|
||||
@Test("scoped providerDetails hasUsage remains authoritative with empty headline totals")
|
||||
func scopedProviderDetailsUsageRemainsAuthoritative() {
|
||||
let store = AppStore()
|
||||
let all = menubarPayload(
|
||||
cost: 12,
|
||||
providers: ["claude": 12, "hermes agent": 0],
|
||||
providerDetails: [
|
||||
ProviderDetail(id: "claude", label: "Claude", cost: 12, calls: 4, hasUsage: true),
|
||||
ProviderDetail(id: "hermes", label: "Hermes Agent", cost: 0, calls: 0, hasUsage: true),
|
||||
]
|
||||
)
|
||||
let providerDetailOnly = menubarPayload(
|
||||
cost: 0,
|
||||
calls: 0,
|
||||
sessions: 0,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
providers: ["hermes agent": 0],
|
||||
providerDetails: [
|
||||
ProviderDetail(id: "hermes", label: "Hermes Agent", cost: 0, calls: 0, hasUsage: true),
|
||||
]
|
||||
)
|
||||
|
||||
store.setCachedPayloadForTesting(all, period: .today, provider: .all, fetchedAt: Date())
|
||||
|
||||
#expect(!store.providerPayloadContradictsAllForTesting(providerDetailOnly, period: .today, provider: .hermes))
|
||||
}
|
||||
|
||||
@Test("token-only activity rejects a completely empty scoped payload")
|
||||
func tokenOnlyActivityRejectsEmptyScopedPayload() {
|
||||
let store = AppStore()
|
||||
let all = menubarPayload(
|
||||
cost: 12,
|
||||
providers: ["claude": 12, "hermes agent": 0],
|
||||
providerDetails: [
|
||||
ProviderDetail(id: "claude", label: "Claude", cost: 12, calls: 4, hasUsage: true),
|
||||
ProviderDetail(id: "hermes", label: "Hermes Agent", cost: 0, calls: 0, hasUsage: true),
|
||||
]
|
||||
)
|
||||
let empty = menubarPayload(
|
||||
cost: 0,
|
||||
calls: 0,
|
||||
sessions: 0,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
providers: ["hermes agent": 0],
|
||||
providerDetails: [
|
||||
ProviderDetail(id: "hermes", label: "Hermes Agent", cost: 0, calls: 0, hasUsage: false),
|
||||
]
|
||||
)
|
||||
|
||||
store.setCachedPayloadForTesting(all, period: .today, provider: .all, fetchedAt: Date())
|
||||
|
||||
#expect(store.providerPayloadContradictsAllForTesting(empty, period: .today, provider: .hermes))
|
||||
}
|
||||
|
||||
@Test("late all-provider evidence invalidates an already cached scoped zero")
|
||||
func lateAllProviderEvidenceInvalidatesScopedZero() {
|
||||
let store = AppStore()
|
||||
let falseZero = menubarPayload(
|
||||
cost: 0,
|
||||
calls: 0,
|
||||
sessions: 0,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
providers: ["hermes agent": 0],
|
||||
providerDetails: [
|
||||
ProviderDetail(id: "hermes", label: "Hermes Agent", cost: 0, calls: 0, hasUsage: false),
|
||||
]
|
||||
)
|
||||
let all = menubarPayload(
|
||||
cost: 12,
|
||||
providers: ["claude": 11.5, "hermes agent": 0.5],
|
||||
providerDetails: [
|
||||
ProviderDetail(id: "claude", label: "Claude", cost: 11.5, calls: 4, hasUsage: true),
|
||||
ProviderDetail(id: "hermes", label: "Hermes Agent", cost: 0.5, calls: 7, hasUsage: true),
|
||||
]
|
||||
)
|
||||
|
||||
store.suppressRefreshesForTesting()
|
||||
store.switchTo(provider: .hermes)
|
||||
store.setCachedPayloadForTesting(falseZero, period: .today, provider: .hermes, fetchedAt: Date())
|
||||
#expect(store.hasCachedData)
|
||||
|
||||
store.setCachedPayloadForTesting(all, period: .today, provider: .all, fetchedAt: Date())
|
||||
|
||||
#expect(!store.hasCachedData)
|
||||
#expect(store.needsInteractivePayloadRefresh)
|
||||
}
|
||||
|
||||
@Test("quiet refresh joins matching in-flight evidence instead of racing ahead")
|
||||
func quietRefreshJoinsMatchingInFlightEvidence() async {
|
||||
let store = AppStore()
|
||||
let all = menubarPayload(
|
||||
cost: 12,
|
||||
providers: ["claude": 11.5, "hermes agent": 0.5],
|
||||
providerDetails: [
|
||||
ProviderDetail(id: "claude", label: "Claude", cost: 11.5, calls: 4, hasUsage: true),
|
||||
ProviderDetail(id: "hermes", label: "Hermes Agent", cost: 0.5, calls: 7, hasUsage: true),
|
||||
]
|
||||
)
|
||||
store.setCacheDateToTodayForTesting()
|
||||
store.seedInFlightForTesting(period: .today, provider: .all, insertedAt: Date())
|
||||
|
||||
let joined = Task { @MainActor in
|
||||
await store.refreshQuietlyForTesting(period: .today, provider: .all)
|
||||
}
|
||||
while store.inFlightWaiterCountForTesting(period: .today, provider: .all) == 0 {
|
||||
await Task.yield()
|
||||
}
|
||||
|
||||
store.setCachedPayloadForTesting(all, period: .today, provider: .all, fetchedAt: Date())
|
||||
store.finishInFlightForTesting(period: .today, provider: .all)
|
||||
|
||||
#expect(await joined.value)
|
||||
}
|
||||
|
||||
@Test("refresh pipeline reset releases matching in-flight waiters")
|
||||
func refreshPipelineResetReleasesInFlightWaiters() async {
|
||||
let store = AppStore()
|
||||
store.setCacheDateToTodayForTesting()
|
||||
store.seedInFlightForTesting(period: .today, provider: .all, insertedAt: Date())
|
||||
|
||||
let joined = Task { @MainActor in
|
||||
await store.refreshQuietlyForTesting(period: .today, provider: .all)
|
||||
}
|
||||
while store.inFlightWaiterCountForTesting(period: .today, provider: .all) == 0 {
|
||||
await Task.yield()
|
||||
}
|
||||
|
||||
store.resetLoadingState()
|
||||
|
||||
#expect(!(await joined.value))
|
||||
#expect(!store.isInFlightForTesting(period: .today, provider: .all))
|
||||
}
|
||||
|
||||
@Test("selection refresh snapshots scoped and all-provider keys together")
|
||||
func selectionRefreshSnapshotsMatchingKeys() {
|
||||
let store = AppStore()
|
||||
store.suppressRefreshesForTesting()
|
||||
store.switchTo(period: .month)
|
||||
store.switchTo(provider: .hermes)
|
||||
|
||||
let snapshot = store.selectionRefreshKeysForTesting()
|
||||
store.switchTo(period: .today)
|
||||
store.switchTo(provider: .cursor)
|
||||
|
||||
#expect(snapshot.scoped.period == .month)
|
||||
#expect(snapshot.scoped.provider == .hermes)
|
||||
#expect(snapshot.all.period == .month)
|
||||
#expect(snapshot.all.provider == .all)
|
||||
#expect(snapshot.scoped.day == snapshot.all.day)
|
||||
#expect(snapshot.scoped.days == snapshot.all.days)
|
||||
#expect(snapshot.scoped.claudeConfigSourceId == snapshot.all.claudeConfigSourceId)
|
||||
}
|
||||
|
||||
@Test("a genuine provider zero remains valid when the all-provider slice is also zero")
|
||||
func genuineProviderZeroRemainsValid() {
|
||||
let store = AppStore()
|
||||
let all = menubarPayload(
|
||||
cost: 12,
|
||||
providers: ["claude": 12, "hermes agent": 0],
|
||||
providerDetails: [
|
||||
ProviderDetail(id: "claude", label: "Claude", cost: 12, calls: 4, hasUsage: true),
|
||||
ProviderDetail(id: "hermes", label: "Hermes Agent", cost: 0, calls: 0, hasUsage: false),
|
||||
]
|
||||
)
|
||||
let zero = menubarPayload(
|
||||
cost: 0,
|
||||
calls: 0,
|
||||
sessions: 0,
|
||||
providers: ["hermes agent": 0],
|
||||
providerDetails: [
|
||||
ProviderDetail(id: "hermes", label: "Hermes Agent", cost: 0, calls: 0, hasUsage: false),
|
||||
]
|
||||
)
|
||||
|
||||
store.setCachedPayloadForTesting(all, period: .today, provider: .all, fetchedAt: Date())
|
||||
|
||||
#expect(!store.providerPayloadContradictsAllForTesting(zero, period: .today, provider: .hermes))
|
||||
}
|
||||
|
||||
@Test("a provider switch shows no figure until the target provider loads")
|
||||
func providerSwitchShowsNoStaleFigure() {
|
||||
let store = AppStore()
|
||||
store.suppressRefreshesForTesting()
|
||||
store.setCachedPayloadForTesting(
|
||||
menubarPayload(cost: 12.34),
|
||||
period: .today,
|
||||
provider: .all,
|
||||
fetchedAt: Date()
|
||||
)
|
||||
|
||||
store.switchTo(provider: .cursor)
|
||||
|
||||
// Keeping the all-provider payload mounted under the Cursor tab is not
|
||||
// continuity, it is a wrong number: $12.34 is every provider's spend.
|
||||
// Empty here is what puts the popover on its loading state instead.
|
||||
#expect(store.selectedProvider == .cursor)
|
||||
#expect(!store.hasCachedData)
|
||||
#expect(store.payload.current.cost == 0)
|
||||
|
||||
// The all-provider selection still reads its own cached payload.
|
||||
store.switchTo(provider: .all)
|
||||
#expect(store.hasCachedData)
|
||||
#expect(store.payload.current.cost == 12.34)
|
||||
}
|
||||
|
||||
@Test("stale visible payload triggers hard recovery without clearing cache")
|
||||
func stalePayloadTriggersHardRecoveryWithoutClearingCache() {
|
||||
let store = AppStore()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,36 @@
|
|||
import Testing
|
||||
@testable import CodeBurnMenubar
|
||||
|
||||
@Suite("Provider reconnect presentation")
|
||||
struct ProviderReconnectPresentationTests {
|
||||
@Test("every quota provider gets provider-specific recovery copy", arguments: [
|
||||
ProviderFilter.claude,
|
||||
.codex,
|
||||
.kimiCode,
|
||||
.gemini,
|
||||
.copilot,
|
||||
.antigravity,
|
||||
])
|
||||
func providerSpecificRecoveryCopy(_ provider: ProviderFilter) {
|
||||
let presentation = ProviderReconnectPresentation(provider: provider)
|
||||
|
||||
#expect(presentation.title == "Reconnect \(provider.rawValue)")
|
||||
#expect(!presentation.defaultReason.isEmpty)
|
||||
#expect(!presentation.instruction.isEmpty)
|
||||
|
||||
for other in ["Claude", "Codex", "Kimi", "Gemini", "Copilot", "Antigravity"]
|
||||
where other != provider.rawValue && !(provider == .kimiCode && other == "Kimi") {
|
||||
#expect(!presentation.title.contains(other))
|
||||
#expect(!presentation.instruction.contains(other))
|
||||
}
|
||||
}
|
||||
|
||||
@Test("unsupported providers receive neutral recovery copy")
|
||||
func genericRecoveryCopy() {
|
||||
let presentation = ProviderReconnectPresentation(provider: .cursor)
|
||||
|
||||
#expect(presentation.title == "Reconnect Cursor")
|
||||
#expect(presentation.defaultReason == "Cursor credentials need to be refreshed.")
|
||||
#expect(presentation.instruction == "Sign in to Cursor again, then retry.")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
import Testing
|
||||
@testable import CodeBurnMenubar
|
||||
|
||||
@Suite("Provider strip paging")
|
||||
struct ProviderStripPagingStateTests {
|
||||
@Test("paging changes the viewport without selecting a provider")
|
||||
func pagingDoesNotSelect() {
|
||||
var state = ProviderStripPagingState(
|
||||
filters: [.all, .claude, .cursor],
|
||||
selectedProvider: .all,
|
||||
viewportAnchor: .all
|
||||
)
|
||||
|
||||
#expect(state.move(direction: .forward) == .claude)
|
||||
#expect(state.viewportAnchor == .claude)
|
||||
#expect(state.selectedProvider == .all)
|
||||
|
||||
#expect(state.move(direction: .forward) == .cursor)
|
||||
#expect(state.viewportAnchor == .cursor)
|
||||
#expect(state.selectedProvider == .all)
|
||||
#expect(!state.canMoveForward)
|
||||
}
|
||||
|
||||
@Test("paging clamps a missing anchor to the selected provider")
|
||||
func missingAnchorUsesSelection() {
|
||||
var state = ProviderStripPagingState(
|
||||
filters: [.all, .claude, .cursor],
|
||||
selectedProvider: .claude,
|
||||
viewportAnchor: .grok
|
||||
)
|
||||
|
||||
#expect(state.viewportAnchor == .claude)
|
||||
#expect(state.move(direction: .backward) == .all)
|
||||
#expect(state.selectedProvider == .claude)
|
||||
}
|
||||
|
||||
@Test("an empty strip cannot page")
|
||||
func emptyStrip() {
|
||||
var state = ProviderStripPagingState(
|
||||
filters: [],
|
||||
selectedProvider: .all,
|
||||
viewportAnchor: nil
|
||||
)
|
||||
|
||||
#expect(state.move(direction: .forward) == nil)
|
||||
#expect(!state.canMoveBackward)
|
||||
#expect(!state.canMoveForward)
|
||||
}
|
||||
}
|
||||
|
|
@ -1057,6 +1057,29 @@ struct ServeConnectionTests {
|
|||
#expect(await fallback.snapshot() == 1)
|
||||
}
|
||||
|
||||
@Test("a verified-fresh request can bypass the resident worker")
|
||||
func verifiedFreshRequestBypassesResident() async throws {
|
||||
let resident = FallbackRecorder()
|
||||
let fallback = FallbackRecorder()
|
||||
|
||||
let result = try await DataClient.runCLI(
|
||||
subcommand: ["status", "--format", "menubar-json", "--provider", "hermes"],
|
||||
bypassResident: true,
|
||||
serveRequest: { _ in
|
||||
await resident.record()
|
||||
return Data("stale".utf8)
|
||||
},
|
||||
spawnFallback: {
|
||||
await fallback.record()
|
||||
return DataClient.ProcessResult(stdout: Data("fresh".utf8), stderr: "", exitCode: 0)
|
||||
}
|
||||
)
|
||||
|
||||
#expect(String(decoding: result.stdout, as: UTF8.self) == "fresh")
|
||||
#expect(await resident.snapshot() == 0)
|
||||
#expect(await fallback.snapshot() == 1)
|
||||
}
|
||||
|
||||
@Test("the first real request is the only cold-start query")
|
||||
func firstRequestIsTheWarmup() async throws {
|
||||
let dir = NSTemporaryDirectory() + "serve-connection-test-" + UUID().uuidString
|
||||
|
|
|
|||
|
|
@ -14,9 +14,9 @@
|
|||
],
|
||||
"scripts": {
|
||||
"bundle-litellm": "node scripts/bundle-litellm.mjs",
|
||||
"build": "node scripts/bundle-litellm.mjs && tsup && node -e \"const fs=require('fs'); fs.copyFileSync('src/cli.ts','dist/cli.js'); fs.chmodSync('dist/cli.js',0o755)\" && npm run build:dash",
|
||||
"build": "tsup && node -e \"const fs=require('fs'); fs.copyFileSync('src/cli.ts','dist/cli.js'); fs.chmodSync('dist/cli.js',0o755)\" && npm run build:dash",
|
||||
"build:cli": "tsup && node -e \"const fs=require('fs'); fs.copyFileSync('src/cli.ts','dist/cli.js'); fs.chmodSync('dist/cli.js',0o755)\"",
|
||||
"build:dash": "cd dash && npm install --no-audit --no-fund --silent && npm run build",
|
||||
"build:dash": "cd dash && npm ci --no-audit --no-fund --silent && npm run build",
|
||||
"dev": "NODE_OPTIONS=--no-deprecation tsx src/cli.ts",
|
||||
"test": "vitest run tests --exclude \"tests/cache-refresh-lock*\"",
|
||||
"test:locks": "vitest run tests/cache-refresh-lock.test.ts tests/cache-refresh-lock-corrupt-body.test.ts tests/cache-refresh-lock-process.test.ts tests/cache-refresh-lock-status-snapshot.test.ts --poolOptions.forks.singleFork=true",
|
||||
|
|
|
|||
|
|
@ -33,7 +33,6 @@ const WORK = process.env['UPGRADE_PATH_WORK'] || join(tmpdir(), 'codeburn upgrad
|
|||
const OLD_SESSION_CACHE = 'session-cache.v7.json'
|
||||
const OLD_DAILY_CACHE = 'daily-cache.v17.json'
|
||||
const NEW_SESSION_CACHE_DIR = 'session-cache.v9'
|
||||
const NEW_DAILY_CACHE = 'daily-cache.v29.json'
|
||||
|
||||
const HOME = join(WORK, 'user home')
|
||||
const PAYLOADS = join(WORK, 'payloads')
|
||||
|
|
@ -48,6 +47,14 @@ const fail = msg => { failures++; console.log(` FAIL ${msg}`) }
|
|||
const skip = msg => { skipped++; console.log(` skip ${msg}`) }
|
||||
const check = (cond, msg) => (cond ? ok(msg) : fail(msg))
|
||||
|
||||
function newestDailyCacheAfter(dir, baselineName) {
|
||||
const baseline = Number(baselineName.match(/^daily-cache\.v(\d+)\.json$/)?.[1] ?? -1)
|
||||
return readdirSync(dir)
|
||||
.map(name => ({ name, version: Number(name.match(/^daily-cache\.v(\d+)\.json$/)?.[1] ?? -1) }))
|
||||
.filter(candidate => candidate.version > baseline)
|
||||
.sort((a, b) => b.version - a.version)[0]?.name ?? null
|
||||
}
|
||||
|
||||
function run(cmd, args, opts = {}) {
|
||||
const r = spawnSync(cmd, args, { encoding: 'utf8', maxBuffer: 1 << 29, shell: false, ...opts })
|
||||
if (r.error) throw new Error(`${cmd} ${args.join(' ')}: ${r.error.message}`)
|
||||
|
|
@ -207,10 +214,12 @@ const envelope = JSON.parse(readFileSync(join(upgradeCache, NEW_SESSION_CACHE_DI
|
|||
check(envelope.version === 9 && Object.keys(envelope.providers ?? {}).length > 0,
|
||||
`envelope at version ${envelope.version} with ${Object.keys(envelope.providers ?? {}).length} providers`)
|
||||
check(readdirSync(join(upgradeCache, NEW_SESSION_CACHE_DIR)).some(n => n !== 'envelope.json'), 'shards published alongside the envelope')
|
||||
check(existsSync(join(upgradeCache, NEW_DAILY_CACHE)), `${NEW_DAILY_CACHE} re-derived`)
|
||||
const newDailyCache = newestDailyCacheAfter(upgradeCache, OLD_DAILY_CACHE)
|
||||
check(newDailyCache !== null, `${newDailyCache ?? 'current daily cache'} re-derived`)
|
||||
check(existsSync(join(upgradeCache, OLD_DAILY_CACHE)), `${OLD_DAILY_CACHE} kept as the carry-forward baseline`)
|
||||
if (!newDailyCache) process.exit(1)
|
||||
const oldDays = JSON.parse(readFileSync(join(upgradeCache, OLD_DAILY_CACHE), 'utf8')).days.length
|
||||
const newDays = JSON.parse(readFileSync(join(upgradeCache, NEW_DAILY_CACHE), 'utf8')).days.length
|
||||
const newDays = JSON.parse(readFileSync(join(upgradeCache, newDailyCache), 'utf8')).days.length
|
||||
check(newDays >= oldDays, `daily history did not shrink: ${oldDays} -> ${newDays} days`)
|
||||
|
||||
// ── 4. payload parity ────────────────────────────────────────────────────────
|
||||
|
|
@ -415,7 +424,10 @@ else {
|
|||
for (const a of aged) ok(`${a.date}: ${a.kind} (removed ${a.removed} of ${a.removed + a.kept} transcripts)`)
|
||||
|
||||
capture(newBin, agingCache, join(PAYLOADS, 'aging-upgraded'))
|
||||
const upDaily = JSON.parse(readFileSync(join(agingCache, NEW_DAILY_CACHE), 'utf8'))
|
||||
const agingDailyCache = newestDailyCacheAfter(agingCache, OLD_DAILY_CACHE)
|
||||
check(agingDailyCache !== null, `${agingDailyCache ?? 'current aging daily cache'} re-derived`)
|
||||
if (!agingDailyCache) process.exit(1)
|
||||
const upDaily = JSON.parse(readFileSync(join(agingCache, agingDailyCache), 'utf8'))
|
||||
const usd = n => `$${n.toFixed(6)}`
|
||||
|
||||
for (const a of aged) {
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ import type { DateRange, ProjectSummary } from './types.js'
|
|||
// again here. 26 is the first free number on main's ladder — and it is load
|
||||
// bearing beyond the collision: the branch's own validators already hold
|
||||
// daily-cache.v21.json files whose copilot slices were carried stale by the
|
||||
// bug PENDING_REDERIVE_PROVIDERS fixes below, and only a number those files
|
||||
// bug PENDING_REDERIVE_PROVIDER_VERSIONS fixes below, and only a number those files
|
||||
// cannot claim gets them re-derived. isMigratableCache/adoptOlderDailyCaches
|
||||
// carry a same-or-newer version forward as FINALIZED without re-deriving it,
|
||||
// so a number can never mean two accountings. (feat/core-extraction also sits
|
||||
|
|
@ -174,7 +174,11 @@ import type { DateRange, ProjectSummary } from './types.js'
|
|||
// but optimize added reasoning again. Re-derive so report matches the live parse.
|
||||
// v29: #1118 OrcaRouter route pricing (fusion aliases + peel; auto stays unpriced).
|
||||
// v28 on main already shipped #1115.
|
||||
export const DAILY_CACHE_VERSION = 29
|
||||
// v31: Hermes cost provenance. Subscription-included sessions stay $0,
|
||||
// explicit estimates retain their status, and surviving Hermes sources replace
|
||||
// v29 slices produced by the old API-equivalent fallback.
|
||||
// 31: #1234 Hermes cost contract; 30 is claimed by #1132.
|
||||
export const DAILY_CACHE_VERSION = 31
|
||||
const MIN_SUPPORTED_VERSION = 28
|
||||
|
||||
/// Providers whose per-day CALL COUNT means something different at
|
||||
|
|
@ -197,7 +201,18 @@ const MIN_SUPPORTED_VERSION = 28
|
|||
/// for the (day, provider). A day whose copilot sources are gone yields no
|
||||
/// fresh slice at all, so it still carries forward whole — the #1033 bar is
|
||||
/// untouched, in both directions, and every other provider keeps the guard.
|
||||
const PENDING_REDERIVE_PROVIDERS: readonly string[] = ['copilot']
|
||||
const PENDING_REDERIVE_PROVIDER_VERSIONS: Readonly<Record<string, number>> = {
|
||||
copilot: 26,
|
||||
// Tracks DAILY_CACHE_VERSION: a v30 file may have been written by #1132's
|
||||
// accounting, which never carried the Hermes cost contract.
|
||||
hermes: 31,
|
||||
}
|
||||
|
||||
function providersPendingRederiveFrom(fromVersion: number): string[] {
|
||||
return Object.entries(PENDING_REDERIVE_PROVIDER_VERSIONS)
|
||||
.filter(([, contractVersion]) => fromVersion < contractVersion)
|
||||
.map(([provider]) => provider)
|
||||
}
|
||||
// Version-suffixed so different binaries each own a distinct file and never
|
||||
// clobber an incompatible schema. Bumping the version mints a fresh filename;
|
||||
// adoptOlderDailyCaches then unions days out of every previous file (including
|
||||
|
|
@ -299,7 +314,7 @@ export type DailyCache = {
|
|||
watermarkTrusted?: boolean
|
||||
/// Providers still owed the one re-derivation that a migration from a
|
||||
/// pre-`DAILY_CACHE_VERSION` cache entitles them to, despite a shrinking
|
||||
/// call count (see PENDING_REDERIVE_PROVIDERS). Set by the migration,
|
||||
/// call count (see PENDING_REDERIVE_PROVIDER_VERSIONS). Set by the migration,
|
||||
/// cleared by the first COMPLETE re-derive — persisted rather than computed
|
||||
/// so a partial parse in between does not silently spend the entitlement.
|
||||
pendingRederive?: string[]
|
||||
|
|
@ -453,11 +468,14 @@ function migrateDays(days: Record<string, unknown>[]): DailyEntry[] {
|
|||
/// an unspent entitlement out of the parsed file so a same-version reload does
|
||||
/// not drop it.
|
||||
function pendingRederiveFor(fromVersion: number, parsed: unknown): string[] | undefined {
|
||||
if (fromVersion < DAILY_CACHE_VERSION) return [...PENDING_REDERIVE_PROVIDERS]
|
||||
const raw = (parsed as { pendingRederive?: unknown } | null)?.pendingRederive
|
||||
if (!Array.isArray(raw)) return undefined
|
||||
const kept = raw.filter((p): p is string => typeof p === 'string')
|
||||
return kept.length > 0 ? kept : undefined
|
||||
const pending = new Set(
|
||||
Array.isArray(raw) ? raw.filter((p): p is string => typeof p === 'string') : [],
|
||||
)
|
||||
if (fromVersion < DAILY_CACHE_VERSION) {
|
||||
for (const provider of providersPendingRederiveFrom(fromVersion)) pending.add(provider)
|
||||
}
|
||||
return pending.size > 0 ? [...pending] : undefined
|
||||
}
|
||||
|
||||
function migratedFrom(parsed: { version: number; lastComputedDate: string | null; savingsConfigHash?: string; tzKey?: string; days: Record<string, unknown>[]; complete?: boolean; watermarkTrusted?: boolean }): DailyCache {
|
||||
|
|
@ -578,9 +596,17 @@ async function adoptOlderDailyCaches(): Promise<DailyCache> {
|
|||
days,
|
||||
// Anything adopted out of an OLDER file was derived under an older
|
||||
// accounting, so the providers whose call counts changed meaning get their
|
||||
// one guarded-shrink-exempt re-derivation (see PENDING_REDERIVE_PROVIDERS).
|
||||
pendingRederive: base.pendingRederive
|
||||
?? (rest.some(c => c.parsed.version < DAILY_CACHE_VERSION) ? [...PENDING_REDERIVE_PROVIDERS] : undefined),
|
||||
// one guarded-shrink-exempt re-derivation (see
|
||||
// PENDING_REDERIVE_PROVIDER_VERSIONS).
|
||||
pendingRederive: (() => {
|
||||
const pending = new Set(base.pendingRederive ?? [])
|
||||
for (const candidate of rest) {
|
||||
for (const provider of pendingRederiveFor(candidate.parsed.version, candidate.parsed) ?? []) {
|
||||
pending.add(provider)
|
||||
}
|
||||
}
|
||||
return pending.size > 0 ? [...pending] : undefined
|
||||
})(),
|
||||
// An untrusted base means nothing here was derived under the current
|
||||
// accounting: leave complete unset so the next hydration re-derives every
|
||||
// day whose sources survive (the merge keeps the rest).
|
||||
|
|
@ -1091,7 +1117,7 @@ export function mergeDayEntries(
|
|||
guardPartialSurvival = false,
|
||||
/// Providers whose baseline slices were recorded under an accounting where a
|
||||
/// call meant something else, so a shrink is not evidence of source loss for
|
||||
/// this one re-derivation (see PENDING_REDERIVE_PROVIDERS). Only consulted
|
||||
/// this one re-derivation (see PENDING_REDERIVE_PROVIDER_VERSIONS). Only consulted
|
||||
/// where the fresh parse actually produced a slice for the (date, provider);
|
||||
/// a slice it could not produce at all is carried by the branch above,
|
||||
/// exactly as before.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { open, rename, unlink, mkdir } from 'fs/promises'
|
||||
import { existsSync, readFileSync } from 'fs'
|
||||
import { existsSync, readFileSync, unlinkSync } from 'fs'
|
||||
import { randomBytes } from 'crypto'
|
||||
import { join } from 'path'
|
||||
|
||||
|
|
@ -22,10 +22,13 @@ import type { CachedCall, ProviderSection } from './session-cache.js'
|
|||
// dropped: sealed days are not re-read. This slice does not bump
|
||||
// DAILY_CACHE_VERSION (one cold re-parse for every user) to recover history.
|
||||
|
||||
export const HERMES_SESSION_LEDGER_FILENAME = 'hermes-session-ledger.v1.json'
|
||||
export const HERMES_SESSION_LEDGER_VERSION = 1 as const
|
||||
// v3 discards the local-only v2 migration candidate. Seeding v2 from the warm
|
||||
// session cache could preserve lifetime totals while assigning historical
|
||||
// observations to the migration day, inflating that day's scoped Hermes spend.
|
||||
export const HERMES_SESSION_LEDGER_FILENAME = 'hermes-session-ledger.v3.json'
|
||||
export const HERMES_SESSION_LEDGER_VERSION = 3 as const
|
||||
|
||||
export type HermesCostBasis = 'actual' | 'estimated' | 'calculated'
|
||||
export type HermesCostBasis = 'actual' | 'estimated' | 'calculated' | 'included'
|
||||
|
||||
export type HermesTokenTotals = {
|
||||
inputTokens: number
|
||||
|
|
@ -299,7 +302,7 @@ function isNum(v: unknown): v is number {
|
|||
}
|
||||
|
||||
function isCostBasis(v: unknown): v is HermesCostBasis {
|
||||
return v === 'actual' || v === 'estimated' || v === 'calculated'
|
||||
return v === 'actual' || v === 'estimated' || v === 'calculated' || v === 'included'
|
||||
}
|
||||
|
||||
function validateTokens(o: Record<string, unknown>): o is Record<string, unknown> & HermesTokenTotals {
|
||||
|
|
@ -346,7 +349,17 @@ function validateLedger(raw: unknown): raw is HermesSessionLedger {
|
|||
|
||||
function readLedgerFromDisk(): HermesSessionLedger {
|
||||
const path = hermesSessionLedgerPath()
|
||||
if (!existsSync(path)) return emptyHermesSessionLedger()
|
||||
if (!existsSync(path)) {
|
||||
// The v1 file is superseded and can never be read again: the parse-version
|
||||
// bump forces a cold re-parse that rebuilds every cursor from source.
|
||||
// Left behind it is dead bytes that only invite a future reader to trust
|
||||
// observations recorded under an accounting this branch replaced.
|
||||
const v1 = join(getCodeburnCacheDir(), 'hermes-session-ledger.v1.json')
|
||||
if (existsSync(v1)) {
|
||||
try { unlinkSync(v1) } catch { /* another process got there first */ }
|
||||
}
|
||||
return emptyHermesSessionLedger()
|
||||
}
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(readFileSync(path, 'utf-8'))
|
||||
if (!validateLedger(parsed)) return emptyHermesSessionLedger()
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ const { version } = require('../package.json')
|
|||
// Bump when the menubar payload's rendering semantics change without a package
|
||||
// release or daily-cache version change. The envelope version in session-cache
|
||||
// protects record shape; this protects the meaning of an otherwise valid one.
|
||||
const STATUS_SNAPSHOT_RENDER_VERSION = 2
|
||||
const STATUS_SNAPSHOT_RENDER_VERSION = 4
|
||||
const STATUS_SNAPSHOT_SEMANTIC_KEY = `${version}:render-${STATUS_SNAPSHOT_RENDER_VERSION}:daily-${DAILY_CACHE_VERSION}`
|
||||
import { loadCurrency, getCurrency, isValidCurrencyCode } from './currency.js'
|
||||
import { CodexThroughputReader, newestCodexSession, renderCodexThroughput } from './codex-throughput.js'
|
||||
|
|
|
|||
|
|
@ -111,6 +111,11 @@ export type DailyModelBreakdown = {
|
|||
calls: number
|
||||
inputTokens: number
|
||||
outputTokens: number
|
||||
/// Raw provider/model ids that collapsed into this display name (e.g.
|
||||
/// `minimax/MiniMax-M3` and `MiniMaxAI/MiniMax-M3` both showing as "MiniMax
|
||||
/// M3"). Present only when more than one raw id folded in, so a cached vs
|
||||
/// uncached route can still be told apart (#1239).
|
||||
rawModels?: string[]
|
||||
}
|
||||
|
||||
export type DailyHistoryEntry = {
|
||||
|
|
|
|||
|
|
@ -28,6 +28,11 @@ export type ModelReportRow = {
|
|||
savingsUSD: number
|
||||
savingsBaselineModel: string
|
||||
calls: number
|
||||
/// Raw provider/model ids folded into this row by the canonical-id merge
|
||||
/// above (e.g. `minimax/MiniMax-M3` and `MiniMaxAI/MiniMax-M3` both showing
|
||||
/// as "MiniMax M3"). Length 1 when nothing merged. Lets a cached vs
|
||||
/// uncached route stay visible even though the row is one model (#1239).
|
||||
rawModels: string[]
|
||||
/// Codex credit consumption (issues #408/#495). null for non-Codex models or
|
||||
/// Codex models without a known credit rate. A merged row that mixed rated
|
||||
/// and unrated buckets stores the partial sum of the rated ones.
|
||||
|
|
@ -217,6 +222,7 @@ export async function aggregateModels(projects: ProjectSummary[], opts: Aggregat
|
|||
existing.savingsUSD += bucket.savingsUSD
|
||||
existing.calls += bucket.calls
|
||||
existing.savingsBaselineModel = resolvedBaseline
|
||||
if (!existing.rawModels.includes(bucket.model)) existing.rawModels.push(bucket.model)
|
||||
const existingRated = existing.credits !== null
|
||||
const incomingRated = bucketCredits !== null
|
||||
if (incomingRated) existing.credits = (existing.credits ?? 0) + bucketCredits
|
||||
|
|
@ -238,6 +244,7 @@ export async function aggregateModels(projects: ProjectSummary[], opts: Aggregat
|
|||
savingsUSD: bucket.savingsUSD,
|
||||
savingsBaselineModel: resolvedBaseline,
|
||||
calls: bucket.calls,
|
||||
rawModels: [bucket.model],
|
||||
credits: bucketCredits,
|
||||
})
|
||||
}
|
||||
|
|
@ -657,6 +664,7 @@ export function renderJson(rows: ModelReportRow[]): string {
|
|||
providerDisplayName: r.providerDisplayName,
|
||||
model: r.model,
|
||||
modelDisplayName: r.modelDisplayName,
|
||||
rawModels: r.rawModels,
|
||||
category: r.category ?? r.topCategory ?? null,
|
||||
agentType: r.agentType ?? null,
|
||||
topCategory: r.topCategory ?? null,
|
||||
|
|
|
|||
|
|
@ -1248,9 +1248,6 @@ const autoModelNames: Record<string, string> = {
|
|||
}
|
||||
|
||||
const SHORT_NAMES: Record<string, string> = {
|
||||
// claude-fable-5 and claude-mythos-5 are outside the opus/sonnet/haiku families deriveClaudeShortName covers.
|
||||
'claude-fable-5': 'Fable 5',
|
||||
'claude-mythos-5': 'Mythos 5',
|
||||
// Modern claude-<family>-<major>-<minor> ids are derived in deriveClaudeShortName.
|
||||
// Only the legacy 3.x ids (family-last) need explicit mapping.
|
||||
'claude-3-7-sonnet': 'Sonnet 3.7',
|
||||
|
|
@ -1363,9 +1360,15 @@ const SORTED_SHORT_NAMES: [string, string][] = Object.entries(SHORT_NAMES)
|
|||
// Anthropic's id scheme is `claude-<family>-<major>[-<minor>]`, so every new
|
||||
// version is derivable — no hand-maintained entry per release. (Legacy 3.x ids
|
||||
// put the family last, e.g. `claude-3-5-sonnet`, and stay in SHORT_NAMES.)
|
||||
const CLAUDE_FAMILY: Record<string, string> = { opus: 'Opus', sonnet: 'Sonnet', haiku: 'Haiku' }
|
||||
const CLAUDE_FAMILY: Record<string, string> = {
|
||||
opus: 'Opus',
|
||||
sonnet: 'Sonnet',
|
||||
haiku: 'Haiku',
|
||||
fable: 'Fable',
|
||||
mythos: 'Mythos',
|
||||
}
|
||||
function deriveClaudeShortName(canonical: string): string | undefined {
|
||||
const m = canonical.match(/^claude-(opus|sonnet|haiku)-(\d+)(?:-(\d+))?/)
|
||||
const m = canonical.match(/^claude-(opus|sonnet|haiku|fable|mythos)-(\d+)(?:-(\d+))?/)
|
||||
if (!m) return undefined
|
||||
const [, family, major, minor] = m
|
||||
return `${CLAUDE_FAMILY[family]} ${major}${minor ? `.${minor}` : ''}`
|
||||
|
|
|
|||
|
|
@ -37,6 +37,8 @@ type HermesSessionRow = {
|
|||
reasoning_tokens: number | null
|
||||
estimated_cost_usd: number | null
|
||||
actual_cost_usd: number | null
|
||||
cost_status: string | null
|
||||
cost_source: string | null
|
||||
api_call_count: number | null
|
||||
tool_call_count: number | null
|
||||
started_at: number | null
|
||||
|
|
@ -474,13 +476,23 @@ function resolveHermesCost(
|
|||
if (row && row.actual_cost_usd != null) {
|
||||
return { costUSD: row.actual_cost_usd, costIsEstimated: false, costBasis: 'actual' }
|
||||
}
|
||||
// estimated_cost_usd = 0.0 is not a measurement: Hermes writes it when cost_status
|
||||
// is 'unknown' (no pricing data) or 'included' (flat subscription), so trusting it
|
||||
// would zero out every subscription session. Only positive estimates are trusted;
|
||||
// anything else falls through to the token-based calculation. A genuinely free
|
||||
// model still lands on $0 either way, since litellm prices it at 0.
|
||||
if (row && row.estimated_cost_usd != null && row.estimated_cost_usd > 0) {
|
||||
return { costUSD: row.estimated_cost_usd, costIsEstimated: false, costBasis: 'estimated' }
|
||||
const costStatus = row?.cost_status?.trim().toLowerCase()
|
||||
const costSource = row?.cost_source?.trim().toLowerCase()
|
||||
// Included subscription usage is real activity but not metered API spend.
|
||||
// Its recorded zero must win over API-equivalent token pricing.
|
||||
if (costStatus === 'included') {
|
||||
return { costUSD: 0, costIsEstimated: false, costBasis: 'included' }
|
||||
}
|
||||
// An explicit Hermes estimate is authoritative even when it is zero (for
|
||||
// example, a provider's free tier). Preserve that provenance in the UI.
|
||||
if (costStatus === 'estimated' && row?.estimated_cost_usd != null) {
|
||||
return { costUSD: row.estimated_cost_usd, costIsEstimated: true, costBasis: 'estimated' }
|
||||
}
|
||||
// Only rows predating BOTH provenance fields use the legacy positive-estimate
|
||||
// fallback. A provenance-aware `unknown` row can retain a partial estimate
|
||||
// from earlier priced calls; treating that as the session total undercounts.
|
||||
if (!costStatus && !costSource && row && row.estimated_cost_usd != null && row.estimated_cost_usd > 0) {
|
||||
return { costUSD: row.estimated_cost_usd, costIsEstimated: true, costBasis: 'estimated' }
|
||||
}
|
||||
return { costUSD: calculatedCost, costIsEstimated: true, costBasis: 'calculated' }
|
||||
}
|
||||
|
|
@ -514,9 +526,11 @@ function observationToCall(
|
|||
reasoningTokens: observation.reasoningTokens,
|
||||
webSearchRequests: 0,
|
||||
costUSD: observation.costUSD,
|
||||
// Later observations keep the stored basis: calculated stays estimated;
|
||||
// provider-recorded actual/estimated keep main's measured behavior.
|
||||
costIsEstimated: later ? observation.costBasis === 'calculated' : args.costIsEstimated,
|
||||
// Later observations keep the stored basis. Included and actual are
|
||||
// recorded facts; estimated and calculated remain estimates.
|
||||
costIsEstimated: later
|
||||
? observation.costBasis === 'calculated' || observation.costBasis === 'estimated'
|
||||
: args.costIsEstimated,
|
||||
tools: later ? [] : args.tools,
|
||||
bashCommands: later ? [] : args.bashCommands,
|
||||
timestamp: observation.timestamp,
|
||||
|
|
@ -644,6 +658,8 @@ function createParser(source: SessionSource, seenKeys: Set<string>, hermesHome:
|
|||
${numberColumn(columns, 'reasoning_tokens')},
|
||||
${nullableColumn(columns, 'estimated_cost_usd')},
|
||||
${nullableColumn(columns, 'actual_cost_usd')},
|
||||
${nullableColumn(columns, 'cost_status')},
|
||||
${nullableColumn(columns, 'cost_source')},
|
||||
${numberColumn(columns, 'api_call_count')},
|
||||
${numberColumn(columns, 'tool_call_count')},
|
||||
${nullableColumn(columns, 'started_at')},
|
||||
|
|
|
|||
|
|
@ -373,7 +373,11 @@ export const PROVIDER_PARSE_VERSIONS: Record<string, string> = {
|
|||
// replays (double-counted before), takes the model from the reporting
|
||||
// assistant/message, and keeps agent-injected context out of the preview.
|
||||
dsh: 'seed-aware-v1',
|
||||
hermes: 'reasoning-output-accounting-v1-est-cost-routed-ids-workspace-pr-v5-zero-estimate-fallback-v1',
|
||||
// cost-provenance-v3: preserve Hermes included/estimated/actual status and
|
||||
// rebuild the provider section alongside the v3 lifetime ledger. The parse
|
||||
// bump is required with the ledger bump: seeding a new ledger from a section
|
||||
// produced under v2 can turn historical accounting deltas into today's use.
|
||||
hermes: 'reasoning-output-accounting-v1-est-cost-routed-ids-workspace-pr-v5-cost-provenance-v3',
|
||||
'lingtai-tui': 'token-ledger-registry-activity-v3',
|
||||
'ibm-bob': 'worktree-project-grouping-v1',
|
||||
// project-path-v1: the parser now records the session's full working
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import { scanUserCorrections, medianTimeToFirstEditMs, aggregateFileChurn, compu
|
|||
import { buildPrAttribution, aggregateByBranch } from './sessions-report.js'
|
||||
import { scanAndDetect } from './optimize.js'
|
||||
import { callBillableOutputTokens, sessionBillableOutputTokens } from './session-output.js'
|
||||
import { getDaysInRange, ensureCacheHydrated, emptyCache, BACKFILL_DAYS, toDateString, type DailyCache, type DailyEntry, type ProjectDayStats, type ProviderDaySlice } from './daily-cache.js'
|
||||
import { getDaysInRange, ensureCacheHydrated, loadDailyCache, emptyCache, mergeDayEntries, BACKFILL_DAYS, toDateString, type DailyCache, type DailyEntry, type ProjectDayStats, type ProviderDaySlice } from './daily-cache.js'
|
||||
import { buildGranularHistory } from './granular-history.js'
|
||||
|
||||
// Row caps for the by-PR / by-branch payload aggregations, ranked by cost.
|
||||
|
|
@ -221,19 +221,51 @@ async function claudeConfigSelector(projects: ProjectSummary[], selectedId?: str
|
|||
return buildSelector(byId, selectedId)
|
||||
}
|
||||
|
||||
/// `d.models` keys by the raw provider id (day-aggregator), same as
|
||||
/// PeriodData.models — buildTopModels (menubar-json.ts) already merges those
|
||||
/// into display names for `current.topModels`. Merge here too, so
|
||||
/// `history.daily[].topModels` (the Spend/Trend charts) cannot disagree with
|
||||
/// it or with `codeburn models` about what counts as one model (#1239). Raw
|
||||
/// ids that collapse into one row are kept as `rawModels` so a cached vs
|
||||
/// uncached route stays visible.
|
||||
export function mergeDayModelsByDisplayName(models: DailyEntry['models']): Array<{
|
||||
name: string
|
||||
cost: number
|
||||
savingsUSD: number
|
||||
calls: number
|
||||
inputTokens: number
|
||||
outputTokens: number
|
||||
rawModels: string[]
|
||||
}> {
|
||||
const merged = new Map<string, { cost: number; savingsUSD: number; calls: number; inputTokens: number; outputTokens: number; rawModels: string[] }>()
|
||||
for (const [raw, m] of Object.entries(models)) {
|
||||
if (raw === '<synthetic>') continue
|
||||
const name = getShortModelName(raw)
|
||||
const acc = merged.get(name) ?? { cost: 0, savingsUSD: 0, calls: 0, inputTokens: 0, outputTokens: 0, rawModels: [] }
|
||||
acc.cost += m.cost
|
||||
acc.savingsUSD += m.savingsUSD ?? 0
|
||||
acc.calls += m.calls
|
||||
acc.inputTokens += m.inputTokens
|
||||
acc.outputTokens += m.outputTokens
|
||||
if (!acc.rawModels.includes(raw)) acc.rawModels.push(raw)
|
||||
merged.set(name, acc)
|
||||
}
|
||||
return [...merged.entries()].map(([name, d]) => ({ name, ...d }))
|
||||
}
|
||||
|
||||
function dailyEntriesToHistory(days: ReturnType<typeof aggregateProjectsIntoDays>): MenubarPayload['history']['daily'] {
|
||||
return days.map(d => {
|
||||
const topModels = Object.entries(d.models)
|
||||
.filter(([name]) => name !== '<synthetic>')
|
||||
.sort(([, a], [, b]) => b.cost - a.cost)
|
||||
const topModels = mergeDayModelsByDisplayName(d.models)
|
||||
.sort((a, b) => b.cost - a.cost)
|
||||
.slice(0, 5)
|
||||
.map(([name, m]) => ({
|
||||
name,
|
||||
.map(m => ({
|
||||
name: m.name,
|
||||
cost: m.cost,
|
||||
savingsUSD: m.savingsUSD,
|
||||
calls: m.calls,
|
||||
inputTokens: m.inputTokens,
|
||||
outputTokens: m.outputTokens,
|
||||
...(m.rawModels.length > 1 ? { rawModels: m.rawModels } : {}),
|
||||
}))
|
||||
return {
|
||||
date: d.date,
|
||||
|
|
@ -284,6 +316,36 @@ function sliceDayToProvider(day: DailyEntry, provider: string): DailyEntry {
|
|||
}
|
||||
}
|
||||
|
||||
/// Overlay surviving provider-scoped source data onto the durable all-provider
|
||||
/// cache without touching unrelated providers. The result is provider-sliced so
|
||||
/// headline and history consumers cannot accidentally count unrelated providers.
|
||||
///
|
||||
/// A settled day's sources age off disk continuously, so a fresh
|
||||
/// provider-scoped parse of a historical date is a LOWER BOUND, not a
|
||||
/// correction. Setting it unconditionally (what this did) replaced finalized
|
||||
/// cache days with whatever the shrunken parse could still see, so the scoped
|
||||
/// view reported a fraction of the day the all-provider view served off the
|
||||
/// same cache.
|
||||
///
|
||||
/// The rule is therefore: fresh may FILL a (date, provider) the cache lacks,
|
||||
/// and wins on any day still inside the settle window, but it may never SHRINK
|
||||
/// a settled day the cache already holds. That is exactly mergeDayEntries with
|
||||
/// guardPartialSurvival, whose argument order matters: `fresh` is primary and
|
||||
/// the cache baseline is secondary, so the guard reads the shrink in the
|
||||
/// direction it expects. Filling still has to work, because on a cold cache
|
||||
/// the scoped parse is the only source a historical day has.
|
||||
export function overlayProviderDaySlices(
|
||||
baseline: DailyEntry[],
|
||||
fresh: DailyEntry[],
|
||||
provider: string,
|
||||
): DailyEntry[] {
|
||||
const freshSliced = fresh
|
||||
.filter(day => Object.hasOwn(day.providers, provider))
|
||||
.map(day => sliceDayToProvider(day, provider))
|
||||
const sliced = baseline.map(day => sliceDayToProvider(day, provider))
|
||||
return mergeDayEntries(freshSliced, sliced, false, undefined, true)
|
||||
}
|
||||
|
||||
/// Does a cached day's project entry pass the active name filters? Mirrors
|
||||
/// parser.filterProjectsByName exactly — case-insensitive substring match
|
||||
/// against the project name OR its filesystem path, include first then exclude —
|
||||
|
|
@ -534,10 +596,10 @@ export type DurablePeriod = {
|
|||
/// Fresh per-period parse (provider + name filtered) for detail views that
|
||||
/// still need surviving session files.
|
||||
liveProjects: ProjectSummary[]
|
||||
/// Hydrated all-provider cache (reused by the menubar's provider list + daily
|
||||
/// history sections).
|
||||
/// Shared durable cache. All-provider requests hydrate it; provider-scoped
|
||||
/// requests load it without triggering unrelated provider scans.
|
||||
cache: DailyCache
|
||||
/// Today-only slice, all providers, name-filtered (memo seed for the menubar).
|
||||
/// Today-only, name-filtered slice for the active provider scope.
|
||||
todayAllDays: DailyEntry[]
|
||||
/// The scan range the live parse covered (today-only when the period is today).
|
||||
scanRange: DateRange
|
||||
|
|
@ -556,14 +618,18 @@ export async function buildDurablePeriod(periodInfo: PeriodInfo, opts: Aggregate
|
|||
const rangeEndStr = toDateString(periodInfo.range.end)
|
||||
const isTodayOnly = rangeStartStr === todayStr && rangeEndStr === todayStr
|
||||
|
||||
const cache = await hydrateCache()
|
||||
// The shared daily cache is hydrated only by an all-provider request. A
|
||||
// provider tab must not turn into a hidden all-provider scan on a cold or
|
||||
// migrating cache; it loads the durable cache as-is and overlays the selected
|
||||
// provider's surviving source data below.
|
||||
const cache = pf === 'all' ? await hydrateCache() : await loadDailyCache()
|
||||
|
||||
// Today's live data always comes from an all-provider parse so the union (and
|
||||
// any per-provider slice of it) sees every provider's today. `todayAllDays` is
|
||||
// the today bucket only — the union filters the historical remainder out of the
|
||||
// cache.
|
||||
// The unscoped path parses all providers. A provider-scoped path parses only
|
||||
// that provider and overlays its fresh slices onto the durable cache below.
|
||||
// `todayAllDays` is the today bucket for whichever scope is active.
|
||||
let liveProjects: ProjectSummary[]
|
||||
let todayAllDays: DailyEntry[]
|
||||
let freshProviderDays: DailyEntry[] = []
|
||||
let scanRange: DateRange
|
||||
if (pf === 'all') {
|
||||
if (isTodayOnly) {
|
||||
|
|
@ -587,10 +653,15 @@ export async function buildDurablePeriod(periodInfo: PeriodInfo, opts: Aggregate
|
|||
: aggregateProjectsIntoDays(fp(await parseAllSessions(todayRange, 'all'))).filter(d => d.date === todayStr)
|
||||
}
|
||||
} else {
|
||||
// Provider-filtered: today's all-provider parse feeds the union (sliced
|
||||
// below); the provider-scoped parse feeds the detail/enrichment fields.
|
||||
todayAllDays = aggregateProjectsIntoDays(fp(await parseAllSessions(todayRange, 'all'))).filter(d => d.date === todayStr)
|
||||
// Provider-filtered: one provider-scoped parse feeds both today's union
|
||||
// slice and the detail/enrichment fields. Scanning every unrelated provider
|
||||
// here made a first hover deserialize the entire multi-gigabyte cache even
|
||||
// though the returned payload contains only `pf`.
|
||||
const rawProv = fp(await parseAllSessions(isTodayOnly ? todayRange : periodInfo.range, pf))
|
||||
freshProviderDays = aggregateProjectsIntoDays(rawProv)
|
||||
todayAllDays = rangeEndStr >= todayStr
|
||||
? aggregateProjectsIntoDays(filterProjectsByDays(rawProv, new Set([todayStr]))).filter(d => d.date === todayStr)
|
||||
: []
|
||||
liveProjects = daysSelection && !isTodayOnly ? filterProjectsByDays(rawProv, daysSelection.days) : rawProv
|
||||
scanRange = isTodayOnly ? todayRange : periodInfo.range
|
||||
}
|
||||
|
|
@ -622,7 +693,14 @@ export async function buildDurablePeriod(periodInfo: PeriodInfo, opts: Aggregate
|
|||
: undefined
|
||||
|
||||
const allDays = unionDaysForPeriod(cache, todayAllDays, periodInfo, daysSelection?.days ?? null, sliceHistorical)
|
||||
const days = pf === 'all' ? allDays : allDays.map(d => sliceDayToProvider(d, pf))
|
||||
const freshDaysInSelection = freshProviderDays.filter(day =>
|
||||
day.date >= rangeStartStr
|
||||
&& day.date <= rangeEndStr
|
||||
&& (!daysSelection || daysSelection.days.has(day.date)),
|
||||
)
|
||||
const days = pf === 'all'
|
||||
? allDays
|
||||
: overlayProviderDaySlices(allDays, freshDaysInSelection, pf)
|
||||
const data = buildPeriodDataFromDays(days, periodInfo.label)
|
||||
|
||||
// Enrich the cache-authoritative headline with fields DailyEntry cannot carry.
|
||||
|
|
@ -745,11 +823,10 @@ export async function buildMenubarPayloadForRange(periodInfo: PeriodInfo, opts:
|
|||
// still renders.
|
||||
}
|
||||
if (!effectivelyScoped) {
|
||||
// Every non-scoped headline — all-provider AND provider-filtered — is built
|
||||
// by the one shared durable-totals builder. It unions the carry-forward
|
||||
// cache with today's live parse (slicing to the provider when filtered), so
|
||||
// days whose session files have expired still count. The provider list and
|
||||
// daily-history sections below reuse its cache + today slice.
|
||||
// Every non-config-scoped headline is built by the shared durable-totals
|
||||
// builder. The all-provider path hydrates the cache; a provider-filtered
|
||||
// path overlays that provider's surviving source slices without scanning
|
||||
// unrelated providers. Expired-source history remains durable in both.
|
||||
const durable = await buildDurablePeriod(periodInfo, {
|
||||
provider: pf,
|
||||
project: opts.project,
|
||||
|
|
@ -871,38 +948,9 @@ export async function buildMenubarPayloadForRange(periodInfo: PeriodInfo, opts:
|
|||
const fullHistory = [...allCacheDays, ...todayDays]
|
||||
dailyHistory = dailyEntriesToHistory(fullHistory)
|
||||
} else {
|
||||
const emptyModels = [] as { name: string; cost: number; savingsUSD: number; calls: number; inputTokens: number; outputTokens: number }[]
|
||||
const historyFromCache = allCacheDays.map(d => {
|
||||
const prov = d.providers[pf] ?? { calls: 0, cost: 0, savingsUSD: 0 }
|
||||
return {
|
||||
date: d.date,
|
||||
cost: prov.cost,
|
||||
savingsUSD: prov.savingsUSD,
|
||||
calls: prov.calls,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
topModels: emptyModels,
|
||||
}
|
||||
})
|
||||
const todayFromParse = aggregateProjectsIntoDays(scanProjects)
|
||||
.filter(d => d.date === todayStr)
|
||||
.map(d => {
|
||||
const prov = d.providers[pf] ?? { calls: 0, cost: 0, savingsUSD: 0 }
|
||||
return {
|
||||
date: d.date,
|
||||
cost: prov.cost,
|
||||
savingsUSD: prov.savingsUSD,
|
||||
calls: prov.calls,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
topModels: emptyModels,
|
||||
}
|
||||
})
|
||||
dailyHistory = [...historyFromCache, ...todayFromParse]
|
||||
const freshHistory = [...aggregateProjectsIntoDays(scanProjects), ...(todayAllDays ?? [])]
|
||||
.filter(day => day.date >= historyStartStr && day.date <= todayStr)
|
||||
dailyHistory = dailyEntriesToHistory(overlayProviderDaySlices(allCacheDays, freshHistory, pf))
|
||||
}
|
||||
|
||||
const home = homedir()
|
||||
|
|
|
|||
|
|
@ -84,6 +84,26 @@ async function seedCarriedCache(): Promise<string> {
|
|||
return day
|
||||
}
|
||||
|
||||
/** Sources for the settled day still exist but explain only a fraction of what
|
||||
* the cache finalized there, which is what source aging looks like in the
|
||||
* window before a transcript is deleted outright. */
|
||||
async function seedPartialSourcesFor(date: string): Promise<void> {
|
||||
const projectDir = join(ROOT, 'home', '.claude', 'projects', 'p')
|
||||
await mkdir(projectDir, { recursive: true })
|
||||
const [y, m, d] = date.split('-').map(Number)
|
||||
const line = JSON.stringify({
|
||||
type: 'assistant',
|
||||
timestamp: new Date(y!, m! - 1, d!, 12, 0, 0).toISOString(),
|
||||
sessionId: 's-partial',
|
||||
message: {
|
||||
type: 'message', role: 'assistant', model: 'claude-3-5-sonnet-20241022', id: 'm-partial',
|
||||
content: [],
|
||||
usage: { input_tokens: 1000, output_tokens: 200, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
|
||||
},
|
||||
})
|
||||
await writeFile(join(projectDir, 's-partial.jsonl'), line + '\n', 'utf-8')
|
||||
}
|
||||
|
||||
/** Seed a real, priced Claude session dated TODAY (the live, surviving half). */
|
||||
async function seedLiveTodaySession(): Promise<void> {
|
||||
const projectDir = join(ROOT, 'home', '.claude', 'projects', 'p')
|
||||
|
|
@ -211,6 +231,25 @@ describe('CLI totals ↔ menubar parity through the durable daily cache', () =>
|
|||
expect(codexDurable.carriedCostUSD).toBe(0)
|
||||
})
|
||||
|
||||
it('keeps the scoped view equal to the all-provider slice when a settled day only partially survives', async () => {
|
||||
const day = await seedCarriedCache()
|
||||
await seedPartialSourcesFor(day)
|
||||
await seedLiveTodaySession()
|
||||
const range = getDateRange('all').range
|
||||
|
||||
clearSessionCache()
|
||||
const all = await buildMenubarPayloadForRange({ range, label: 'p' }, { provider: 'all', optimize: false, timeline: false })
|
||||
clearSessionCache()
|
||||
const scoped = await buildMenubarPayloadForRange({ range, label: 'p' }, { provider: 'claude', optimize: false, timeline: false })
|
||||
|
||||
// The corpus is entirely Claude, so the scoped headline IS the all-provider
|
||||
// Claude slice. The scoped path used to overlay the shrunken re-parse over
|
||||
// the settled day and report a fraction of the all-provider total.
|
||||
expect(all.current.calls).toBeGreaterThan(40)
|
||||
expect(scoped.current.calls).toBe(all.current.calls)
|
||||
expect(scoped.current.cost).toBeCloseTo(all.current.cost, 6)
|
||||
})
|
||||
|
||||
it('holds the equality in the plain live regime with no carried days', async () => {
|
||||
// No cache seeded: the only data is today's surviving session.
|
||||
await seedLiveTodaySession()
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { existsSync, readdirSync, statSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { delimiter as pathDelimiter, join } from 'node:path'
|
||||
|
|
@ -842,6 +842,52 @@ describe('codeburn status --format menubar-json', () => {
|
|||
}
|
||||
})
|
||||
|
||||
it('rejects a provider payload cached under the previous render contract', async () => {
|
||||
const home = await mkdtemp(join(tmpdir(), 'codeburn-menubar-provider-render-'))
|
||||
|
||||
try {
|
||||
const projectDir = join(home, '.claude', 'projects', 'myapp')
|
||||
await mkdir(projectDir, { recursive: true })
|
||||
const now = new Date()
|
||||
const todayUtcMidnight = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())
|
||||
const base = new Date(Math.max(todayUtcMidnight, now.getTime() - 2 * 3600_000))
|
||||
const ts = (offset: number) => new Date(base.getTime() + offset).toISOString().replace(/\.\d+Z$/, 'Z')
|
||||
await writeFile(
|
||||
join(projectDir, 'session.jsonl'),
|
||||
[userLine('s1', ts(0)), assistantLine('s1', ts(60_000), 'msg-1')].join('\n'),
|
||||
)
|
||||
|
||||
const args = ['status', '--format', 'menubar-json', '--period', 'today', '--provider', 'all', '--no-optimize']
|
||||
const first = runCli(args, home)
|
||||
expect(first.status, `stderr: ${first.stderr}`).toBe(0)
|
||||
|
||||
const snapshotFiles = findSnapshotFiles(join(home, '.cache', 'codeburn'))
|
||||
expect(snapshotFiles).toHaveLength(1)
|
||||
const record = JSON.parse(await readFile(snapshotFiles[0]!, 'utf-8')) as {
|
||||
semanticKey: string
|
||||
payload: { current: { providerDetails: unknown[] } }
|
||||
}
|
||||
record.semanticKey = record.semanticKey.replace(/:render-\d+:/, ':render-2:')
|
||||
record.payload.current.providerDetails = [
|
||||
{ id: 'legacy-idle', label: 'Legacy Idle', cost: 0 },
|
||||
]
|
||||
await writeFile(snapshotFiles[0]!, JSON.stringify(record))
|
||||
|
||||
const second = runCli(args, home)
|
||||
expect(second.status, `stderr: ${second.stderr}`).toBe(0)
|
||||
const payload = JSON.parse(second.stdout) as {
|
||||
current: { providerDetails: Array<{ id: string; calls: number; hasUsage: boolean }> }
|
||||
}
|
||||
expect(payload.current.providerDetails.map(provider => provider.id)).not.toContain('legacy-idle')
|
||||
expect(payload.current.providerDetails.find(provider => provider.id === 'claude')).toMatchObject({
|
||||
calls: 1,
|
||||
hasUsage: true,
|
||||
})
|
||||
} finally {
|
||||
await rm(home, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('reprices from an updated live LiteLLM cache instead of serving a stale snapshot', async () => {
|
||||
const home = await mkdtemp(join(tmpdir(), 'codeburn-menubar-pricing-gen-'))
|
||||
|
||||
|
|
|
|||
|
|
@ -736,11 +736,16 @@ describe('#946: a migration re-derives copilot instead of carrying it', () => {
|
|||
|
||||
/// Seeds an older-version daily cache (what a 0.9.20 install leaves behind)
|
||||
/// so `loadDailyCache` takes the adoption path.
|
||||
/// Pinned, not derived from DAILY_CACHE_VERSION: this fixture is the
|
||||
/// pre-copilot-contract era, which is a fixed point in history.
|
||||
/// `DAILY_CACHE_VERSION - 5` drifted across the copilot boundary (26) on the
|
||||
/// next bump and silently stopped seeding the entitlement these tests check.
|
||||
const PRE_COPILOT_CONTRACT_VERSION = 25
|
||||
const seedOlderCache = async (days: DailyEntry[]) => {
|
||||
await writeFile(
|
||||
join(TMP_CACHE_ROOT, `daily-cache.v${DAILY_CACHE_VERSION - 4}.json`),
|
||||
join(TMP_CACHE_ROOT, `daily-cache.v${PRE_COPILOT_CONTRACT_VERSION}.json`),
|
||||
JSON.stringify({
|
||||
version: DAILY_CACHE_VERSION - 4,
|
||||
version: PRE_COPILOT_CONTRACT_VERSION,
|
||||
savingsConfigHash: 'cfg-A',
|
||||
tzKey: currentTzKey(),
|
||||
lastComputedDate: daysAgoStr(1),
|
||||
|
|
@ -763,6 +768,61 @@ describe('#946: a migration re-derives copilot instead of carrying it', () => {
|
|||
expect(out.pendingRederive).toBeUndefined()
|
||||
})
|
||||
|
||||
it('re-derives a Hermes slice after the cost-provenance contract changes', async () => {
|
||||
const corrupt = slice(4603, 6764, { sessions: 6764 })
|
||||
const corrected = slice(0.438, 6, { sessions: 6 })
|
||||
await seedOlderCache([day(settled, { hermes: corrupt })])
|
||||
|
||||
const out = await ensureCacheHydrated(
|
||||
noSessions,
|
||||
() => [day(settled, { hermes: corrected })],
|
||||
'cfg-A',
|
||||
)
|
||||
|
||||
expect(out.days.find(d => d.date === settled)!.providers['hermes'])
|
||||
.toMatchObject({ cost: 0.438, calls: 6, sessions: 6 })
|
||||
expect(out.pendingRederive).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not re-open Copilot re-derivation for a cache already past its contract change', async () => {
|
||||
await writeFile(
|
||||
join(TMP_CACHE_ROOT, `daily-cache.v${DAILY_CACHE_VERSION - 1}.json`),
|
||||
JSON.stringify({
|
||||
version: DAILY_CACHE_VERSION - 1,
|
||||
savingsConfigHash: 'cfg-A',
|
||||
tzKey: currentTzKey(),
|
||||
lastComputedDate: daysAgoStr(1),
|
||||
days: [day(settled, { copilot: PRE_STORE, hermes: slice(4603, 6764) })],
|
||||
complete: true,
|
||||
watermarkTrusted: true,
|
||||
}),
|
||||
'utf-8',
|
||||
)
|
||||
|
||||
const loaded = await loadDailyCache()
|
||||
expect(loaded.pendingRederive).toEqual(['hermes'])
|
||||
})
|
||||
|
||||
it('preserves an older cache pending repair while adding a newer provider repair', async () => {
|
||||
await writeFile(
|
||||
join(TMP_CACHE_ROOT, `daily-cache.v${DAILY_CACHE_VERSION - 1}.json`),
|
||||
JSON.stringify({
|
||||
version: DAILY_CACHE_VERSION - 1,
|
||||
savingsConfigHash: 'cfg-A',
|
||||
tzKey: currentTzKey(),
|
||||
lastComputedDate: daysAgoStr(1),
|
||||
days: [day(settled, { copilot: PRE_STORE, hermes: slice(4603, 6764) })],
|
||||
complete: true,
|
||||
watermarkTrusted: true,
|
||||
pendingRederive: ['copilot'],
|
||||
}),
|
||||
'utf-8',
|
||||
)
|
||||
|
||||
const loaded = await loadDailyCache()
|
||||
expect(loaded.pendingRederive).toEqual(['copilot', 'hermes'])
|
||||
})
|
||||
|
||||
it('still carries the slice whole when the sources are gone (never-lose, #1033)', async () => {
|
||||
await seedOlderCache([day(settled, { copilot: PRE_STORE })])
|
||||
// The re-derive finds nothing at all for that day: the store and the
|
||||
|
|
@ -794,7 +854,7 @@ describe('#946: a migration re-derives copilot instead of carrying it', () => {
|
|||
it('a PARTIAL parse does not spend the entitlement', async () => {
|
||||
await seedOlderCache([day(settled, { copilot: PRE_STORE })])
|
||||
const partial = await ensureCacheHydrated(noSessions, () => [], 'cfg-A', () => false)
|
||||
expect(partial.pendingRederive).toEqual(['copilot'])
|
||||
expect(partial.pendingRederive).toEqual(['copilot', 'hermes'])
|
||||
// The next COMPLETE run still gets to re-derive.
|
||||
const out = await ensureCacheHydrated(noSessions, () => [day(settled, { copilot: STORE_BACKED })], 'cfg-A')
|
||||
expect(out.days.find(d => d.date === settled)!.providers['copilot'])
|
||||
|
|
|
|||
|
|
@ -4,8 +4,10 @@ import { join } from 'path'
|
|||
import { tmpdir } from 'os'
|
||||
|
||||
import {
|
||||
DAILY_CACHE_VERSION,
|
||||
currentTzKey,
|
||||
ensureCacheHydrated,
|
||||
loadDailyCache,
|
||||
toDateString,
|
||||
type DailyEntry,
|
||||
} from '../src/daily-cache.js'
|
||||
|
|
@ -107,3 +109,50 @@ describe('daily-cache re-derivation on a DAILY_CACHE_VERSION bump', () => {
|
|||
expect(JSON.parse(await readFile(oldPath, 'utf8'))).toEqual(oldCache)
|
||||
})
|
||||
})
|
||||
|
||||
// v30 is claimed by two open branches at once: this one and #1132. A v30 file
|
||||
// on disk therefore carries UNKNOWN accounting, so adopting it as the
|
||||
// finalized base would serve #1132's numbers under this branch's contract.
|
||||
describe('daily-cache adoption of a v30 file written under a different accounting', () => {
|
||||
it('carries its days forward but never adopts it as the finalized base', async () => {
|
||||
const date = toDateString(new Date(Date.now() - 7 * 24 * 60 * 60 * 1000))
|
||||
const yesterday = toDateString(new Date(Date.now() - 24 * 60 * 60 * 1000))
|
||||
const foreign = day(date, 42)
|
||||
foreign.providers = {
|
||||
hermes: {
|
||||
calls: 1,
|
||||
cost: 42,
|
||||
savingsUSD: 0,
|
||||
sessions: 1,
|
||||
inputTokens: 100,
|
||||
outputTokens: 20,
|
||||
cacheReadTokens: 30,
|
||||
cacheWriteTokens: 0,
|
||||
},
|
||||
}
|
||||
await writeFile(
|
||||
join(cacheRoot, 'daily-cache.v30.json'),
|
||||
JSON.stringify({
|
||||
version: 30,
|
||||
savingsConfigHash: 'cfg',
|
||||
tzKey: currentTzKey(),
|
||||
lastComputedDate: yesterday,
|
||||
days: [foreign],
|
||||
complete: true,
|
||||
watermarkTrusted: true,
|
||||
}),
|
||||
)
|
||||
|
||||
const loaded = await loadDailyCache()
|
||||
|
||||
expect(DAILY_CACHE_VERSION).toBe(31)
|
||||
expect(loaded.version).toBe(31)
|
||||
// A v30 candidate must not satisfy the same-version adoption at
|
||||
// adoptOlderDailyCaches, so its trust markers cannot survive.
|
||||
expect(loaded.complete).toBe(false)
|
||||
expect(loaded.watermarkTrusted).toBe(false)
|
||||
const carried = loaded.days.find(entry => entry.date === date)
|
||||
expect(carried?.carried).toBe(true)
|
||||
expect(loaded.pendingRederive).toContain('hermes')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ function seedDb(): void {
|
|||
input_tokens INTEGER DEFAULT 0, output_tokens INTEGER DEFAULT 0,
|
||||
cache_read_tokens INTEGER DEFAULT 0, cache_write_tokens INTEGER DEFAULT 0,
|
||||
reasoning_tokens INTEGER DEFAULT 0, estimated_cost_usd REAL, actual_cost_usd REAL,
|
||||
cost_status TEXT, cost_source TEXT,
|
||||
api_call_count INTEGER DEFAULT 0, tool_call_count INTEGER DEFAULT 0,
|
||||
started_at REAL, title TEXT
|
||||
)
|
||||
|
|
@ -41,18 +42,24 @@ function seedDb(): void {
|
|||
const startedAt = Date.now() / 1000 - 3600
|
||||
const insert = db.prepare(
|
||||
`INSERT INTO sessions (id, source, model, input_tokens, output_tokens,
|
||||
estimated_cost_usd, actual_cost_usd, api_call_count, started_at)
|
||||
VALUES (?, 'cli', ?, ?, ?, ?, ?, 1, ?)`,
|
||||
estimated_cost_usd, actual_cost_usd, cost_status, cost_source, api_call_count, started_at)
|
||||
VALUES (?, 'cli', ?, ?, ?, ?, ?, ?, ?, 1, ?)`,
|
||||
)
|
||||
// Hermes writes estimated 0.0 when cost_status is 'unknown' or 'included'
|
||||
// (subscription). CodeBurn must not trust that $0: the tokens are real, so
|
||||
// cost falls through to the token-based calculation.
|
||||
insert.run('zero-estimate', 'claude-opus-4-6', 100000, 10000, 0.0, null, startedAt)
|
||||
// Subscription-included usage is recorded $0 spend even though it carries
|
||||
// real tokens. API-equivalent pricing must not overwrite that provenance.
|
||||
insert.run('included-zero', 'gpt-5.6-sol', 100000, 10000, 0.0, null, 'included', 'none', startedAt)
|
||||
// Unknown cost with no usable estimate falls back to token pricing.
|
||||
insert.run('unknown-zero', 'claude-opus-4-6', 100000, 10000, 0.0, null, 'unknown', 'none', startedAt)
|
||||
// An unknown row may retain a partial estimate from earlier priced calls.
|
||||
// It is not a complete session total and must still be recalculated.
|
||||
insert.run('unknown-partial', 'claude-opus-4-6', 100000, 10000, 0.25, null, 'unknown', 'official_docs_snapshot', startedAt)
|
||||
// A positive recorded estimate stays authoritative.
|
||||
insert.run('positive-estimate', 'claude-opus-4-6', 100000, 10000, 0.5, null, startedAt)
|
||||
insert.run('positive-estimate', 'claude-opus-4-6', 100000, 10000, 0.5, null, 'estimated', 'official_docs_snapshot', startedAt)
|
||||
// An explicitly estimated free value remains zero.
|
||||
insert.run('estimated-zero', 'free-model', 100000, 10000, 0.0, null, 'estimated', 'provider_models_api', startedAt)
|
||||
// An explicit $0 *actual* invoice amount is recorded fact and stays $0.
|
||||
insert.run('zero-actual', 'claude-opus-4-6', 100000, 10000, null, 0.0, startedAt)
|
||||
for (const id of ['zero-estimate', 'positive-estimate', 'zero-actual']) {
|
||||
insert.run('zero-actual', 'claude-opus-4-6', 100000, 10000, null, 0.0, 'actual', 'invoice', startedAt)
|
||||
for (const id of ['included-zero', 'unknown-zero', 'unknown-partial', 'positive-estimate', 'estimated-zero', 'zero-actual']) {
|
||||
db.prepare('INSERT INTO messages (session_id, role, content, timestamp) VALUES (?, ?, ?, ?)')
|
||||
.run(id, 'user', `session ${id}`, startedAt)
|
||||
db.prepare('INSERT INTO messages (session_id, role, content, timestamp) VALUES (?, ?, ?, ?)')
|
||||
|
|
@ -77,18 +84,28 @@ afterEach(async () => {
|
|||
const skipUnlessSqlite = isSqliteAvailable() ? describe : describe.skip
|
||||
|
||||
skipUnlessSqlite('hermes recorded-cost fallback', () => {
|
||||
it('ignores a $0 estimated cost and calculates from tokens', async () => {
|
||||
it('preserves included and recorded zeroes while estimating unknown cost', async () => {
|
||||
seedDb()
|
||||
const projects = await parseAllSessions(undefined, 'hermes')
|
||||
const sessions = projects.flatMap(project => project.sessions)
|
||||
const byId = new Map(sessions.map(s => [s.sessionId, s]))
|
||||
|
||||
const zeroEstimate = byId.get('zero-estimate')!
|
||||
expect(zeroEstimate.totalCostUSD).toBeGreaterThan(0)
|
||||
const included = byId.get('included-zero')!
|
||||
expect(included.totalCostUSD).toBe(0)
|
||||
|
||||
const unknown = byId.get('unknown-zero')!
|
||||
expect(unknown.totalCostUSD).toBeGreaterThan(0)
|
||||
|
||||
const unknownPartial = byId.get('unknown-partial')!
|
||||
expect(unknownPartial.totalCostUSD).toBeGreaterThan(0)
|
||||
expect(unknownPartial.totalCostUSD).not.toBe(0.25)
|
||||
|
||||
const positive = byId.get('positive-estimate')!
|
||||
expect(positive.totalCostUSD).toBe(0.5)
|
||||
|
||||
const estimatedZero = byId.get('estimated-zero')!
|
||||
expect(estimatedZero.totalCostUSD).toBe(0)
|
||||
|
||||
const zeroActual = byId.get('zero-actual')!
|
||||
expect(zeroActual.totalCostUSD).toBe(0)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
import { mkdir, mkdtemp, rm } from 'fs/promises'
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'fs/promises'
|
||||
import { existsSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
applyHermesSnapshot,
|
||||
HERMES_SESSION_LEDGER_VERSION,
|
||||
hermesBaselineKey,
|
||||
hermesObservationKey,
|
||||
hermesSessionLedgerPath,
|
||||
|
|
@ -237,10 +239,41 @@ describe('hermes session ledger unit', () => {
|
|||
await mkdir(hermesSessionLedgerPath())
|
||||
let caught: unknown
|
||||
try {
|
||||
await persistHermesSessionLedger({ version: 1, cursors: {} })
|
||||
await persistHermesSessionLedger({ version: HERMES_SESSION_LEDGER_VERSION, cursors: {} })
|
||||
} catch (err) {
|
||||
caught = err
|
||||
}
|
||||
expect(isHermesLedgerPublicationError(caught)).toBe(true)
|
||||
})
|
||||
|
||||
it('ignores a superseded v1 ledger and removes it from the cache dir', async () => {
|
||||
const v1Path = join(cacheDir, 'hermes-session-ledger.v1.json')
|
||||
await writeFile(v1Path, JSON.stringify({
|
||||
version: 1,
|
||||
cursors: {
|
||||
default: {
|
||||
historical: {
|
||||
profile: 'default',
|
||||
sessionId: 'historical',
|
||||
lastSeen: {
|
||||
inputTokens: 1,
|
||||
outputTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
reasoningTokens: 0,
|
||||
costUSD: 4603,
|
||||
costBasis: 'calculated',
|
||||
},
|
||||
observations: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
expect(existsSync(v1Path)).toBe(true)
|
||||
|
||||
// No v3 file: the loader starts empty, and the v1 file it can never read
|
||||
// again must not survive the load.
|
||||
expect(loadHermesSessionLedger().cursors).toEqual({})
|
||||
expect(existsSync(v1Path)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -295,7 +295,7 @@ describe('buildMenubarPayload', () => {
|
|||
])
|
||||
})
|
||||
|
||||
it('keeps zero-cost detected providers in the legacy dict for compatibility', () => {
|
||||
it('keeps the legacy provider dict additive while providerDetails remains the activity authority', () => {
|
||||
const providers: ProviderCost[] = [
|
||||
{ name: 'claude', displayName: 'Claude', cost: 76.45 },
|
||||
{ name: 'codex', displayName: 'Codex', cost: 0 },
|
||||
|
|
@ -303,6 +303,11 @@ describe('buildMenubarPayload', () => {
|
|||
]
|
||||
const payload = buildMenubarPayload(emptyPeriod('Today'), providers, null)
|
||||
expect(payload.current.providers).toEqual({ claude: 76.45, codex: 0, cursor: 2.18 })
|
||||
expect(payload.current.providerDetails).toEqual([
|
||||
{ id: 'claude', label: 'Claude', cost: 76.45, calls: 0, hasUsage: true },
|
||||
{ id: 'codex', label: 'Codex', cost: 0, calls: 0, hasUsage: false },
|
||||
{ id: 'cursor', label: 'Cursor', cost: 2.18, calls: 0, hasUsage: true },
|
||||
])
|
||||
})
|
||||
|
||||
it('includes up to 365 daily history entries sorted ascending by date', () => {
|
||||
|
|
|
|||
62
tests/menubar-provider-alias-contract.test.ts
Normal file
62
tests/menubar-provider-alias-contract.test.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import { readFileSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { allProviderNames } from '../src/providers/index.js'
|
||||
|
||||
// The menubar's ProviderFilter.cliArg is what gets passed to `--provider`, and
|
||||
// it is also the id the payload-consistency guard matches against
|
||||
// providerDetails. A case whose cliArg is not a real registry name silently
|
||||
// disables that guard for its tab: the lookup finds no detail row, so no
|
||||
// contradiction is ever detected. Nothing in either language can see both
|
||||
// sides, so the contract is checked here, where the registry is importable and
|
||||
// the Swift source is readable.
|
||||
const APP_STORE = join(process.cwd(), 'mac/Sources/CodeBurnMenubar/AppStore.swift')
|
||||
|
||||
function providerFilterSource(): string {
|
||||
const source = readFileSync(APP_STORE, 'utf-8')
|
||||
const start = source.indexOf('enum ProviderFilter')
|
||||
expect(start).toBeGreaterThan(-1)
|
||||
return source.slice(start)
|
||||
}
|
||||
|
||||
/** Every `case foo = "Bar"` declared on the enum. */
|
||||
function enumCases(source: string): string[] {
|
||||
const body = source.slice(0, source.indexOf('var id: String'))
|
||||
return [...body.matchAll(/^\s*case (\w+) = "/gm)].map(m => m[1]!)
|
||||
}
|
||||
|
||||
/** The `case .foo: "bar"` table inside `var cliArg`. */
|
||||
function cliArgTable(source: string): Record<string, string> {
|
||||
const start = source.indexOf('var cliArg: String {')
|
||||
expect(start).toBeGreaterThan(-1)
|
||||
const body = source.slice(start, source.indexOf('\n }', start))
|
||||
return Object.fromEntries(
|
||||
[...body.matchAll(/case \.(\w+): "([^"]+)"/g)].map(m => [m[1]!, m[2]!]),
|
||||
)
|
||||
}
|
||||
|
||||
describe('menubar ProviderFilter to CLI provider registry', () => {
|
||||
const source = providerFilterSource()
|
||||
const cases = enumCases(source)
|
||||
const cliArgs = cliArgTable(source)
|
||||
const registry = new Set(allProviderNames())
|
||||
|
||||
it('declares a cliArg for every enum case', () => {
|
||||
expect(cases.length).toBeGreaterThan(20)
|
||||
expect(cases.filter(name => !(name in cliArgs))).toEqual([])
|
||||
})
|
||||
|
||||
it.each(Object.entries(cliArgs).filter(([name]) => name !== 'all'))(
|
||||
'maps .%s to a provider the CLI registry knows',
|
||||
(_name, cliArg) => {
|
||||
expect(registry.has(cliArg)).toBe(true)
|
||||
},
|
||||
)
|
||||
|
||||
it('keeps .all outside the registry, since it is the no-filter sentinel', () => {
|
||||
expect(cliArgs['all']).toBe('all')
|
||||
expect(registry.has('all')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
|
@ -315,6 +315,20 @@ describe('aggregateModels', () => {
|
|||
expect(rows[0]!.calls).toBe(3)
|
||||
})
|
||||
|
||||
it('collapses two raw MiniMax routes into one row and keeps both visible as rawModels (#1239)', async () => {
|
||||
const rows = await aggregateModels([makeProject([
|
||||
makeTurn('feature', [makeCall({ provider: 'opencode', model: 'minimax/MiniMax-M3', input: 100, output: 50, costUSD: 0.05 })]),
|
||||
makeTurn('feature', [makeCall({ provider: 'opencode', model: 'MiniMaxAI/MiniMax-M3', input: 200, output: 100, costUSD: 6.94 })]),
|
||||
])])
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(rows[0]!.modelDisplayName).toBe('MiniMax M3')
|
||||
expect(rows[0]!.rawModels).toEqual(['minimax/MiniMax-M3', 'MiniMaxAI/MiniMax-M3'])
|
||||
expect(rows[0]!.costUSD).toBeCloseTo(6.99, 6)
|
||||
|
||||
const parsed = JSON.parse(renderJson(rows))
|
||||
expect(parsed[0].rawModels).toEqual(['minimax/MiniMax-M3', 'MiniMaxAI/MiniMax-M3'])
|
||||
})
|
||||
|
||||
it('does not merge the same display name across providers', async () => {
|
||||
const rows = await aggregateModels([makeProject([
|
||||
makeTurn('feature', [makeCall({ provider: 'cline-cli', model: 'glm-5p2', costUSD: 1 })]),
|
||||
|
|
|
|||
|
|
@ -298,6 +298,11 @@ describe('getShortModelName', () => {
|
|||
expect(getShortModelName('claude-opus-9-9-20300101')).toBe('Opus 9.9')
|
||||
})
|
||||
|
||||
it('derives versioned Fable and Mythos labels from their model ids', () => {
|
||||
expect(getShortModelName('claude-fable-5-1')).toBe('Fable 5.1')
|
||||
expect(getShortModelName('claude-mythos-5-2-20300101')).toBe('Mythos 5.2')
|
||||
})
|
||||
|
||||
it('shows the real model name for pricing-sibling aliases, not the internal key', () => {
|
||||
// GLM-5.2 (and its lowercase Hermes spelling) price via the glm-5p1 sibling;
|
||||
// reports must show GLM-5.2, not the pricing key.
|
||||
|
|
|
|||
18
tests/release-build-contract.test.ts
Normal file
18
tests/release-build-contract.test.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { readFileSync } from 'node:fs'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const packageJson = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as {
|
||||
scripts: Record<string, string>
|
||||
}
|
||||
|
||||
describe('release build contract', () => {
|
||||
it('builds from checked-in pricing catalogs instead of mutating them from the network', () => {
|
||||
expect(packageJson.scripts['bundle-litellm']).toBe('node scripts/bundle-litellm.mjs')
|
||||
expect(packageJson.scripts.build).not.toContain('bundle-litellm')
|
||||
})
|
||||
|
||||
it('installs dashboard dependencies without rewriting the lockfile', () => {
|
||||
expect(packageJson.scripts['build:dash']).toContain('npm ci')
|
||||
expect(packageJson.scripts['build:dash']).not.toContain('npm install')
|
||||
})
|
||||
})
|
||||
96
tests/usage-aggregator-provider-scope.test.ts
Normal file
96
tests/usage-aggregator-provider-scope.test.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import { beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { buildMenubarPayloadForRange, overlayProviderDaySlices } from '../src/usage-aggregator.js'
|
||||
import { getDateRange } from '../src/cli-date.js'
|
||||
import { loadPricing } from '../src/models.js'
|
||||
import type { DailyEntry, ProviderDaySlice } from '../src/daily-cache.js'
|
||||
|
||||
const parseAllSessions = vi.hoisted(() => vi.fn(async () => []))
|
||||
|
||||
vi.mock('../src/parser.js', async (importOriginal) => {
|
||||
const mod = await importOriginal<typeof import('../src/parser.js')>()
|
||||
return {
|
||||
...mod,
|
||||
parseAllSessions,
|
||||
isSessionHydrationComplete: vi.fn(() => true),
|
||||
sessionHydrationSnapshot: vi.fn(() => ({
|
||||
complete: true,
|
||||
deferredForFirstPaint: false,
|
||||
indexedFiles: 0,
|
||||
pendingFiles: 0,
|
||||
})),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../src/daily-cache.js', async (importOriginal) => {
|
||||
const mod = await importOriginal<typeof import('../src/daily-cache.js')>()
|
||||
return {
|
||||
...mod,
|
||||
ensureCacheHydrated: vi.fn(async (parseSessions: (range: { start: Date; end: Date }) => Promise<unknown>) => {
|
||||
await parseSessions({ start: new Date(0), end: new Date() })
|
||||
return mod.emptyCache()
|
||||
}),
|
||||
loadDailyCache: vi.fn(async () => mod.emptyCache()),
|
||||
}
|
||||
})
|
||||
|
||||
describe('provider-scoped menubar aggregation', () => {
|
||||
beforeAll(async () => {
|
||||
await loadPricing()
|
||||
})
|
||||
|
||||
it('never scans unrelated providers to render one selected provider', async () => {
|
||||
parseAllSessions.mockClear()
|
||||
|
||||
await buildMenubarPayloadForRange(getDateRange('today'), {
|
||||
provider: 'hermes',
|
||||
optimize: false,
|
||||
timeline: false,
|
||||
})
|
||||
|
||||
expect(parseAllSessions.mock.calls.map(([, provider]) => provider))
|
||||
.toEqual(['hermes'])
|
||||
})
|
||||
|
||||
it('overlays today without letting a shrunken parse rewrite settled history', () => {
|
||||
const slice = (cost: number, calls: number): ProviderDaySlice => ({ cost, calls, savingsUSD: 0, sessions: calls })
|
||||
const day = (date: string, providers: Record<string, ProviderDaySlice>): DailyEntry => ({
|
||||
date,
|
||||
cost: Object.values(providers).reduce((sum, provider) => sum + provider.cost, 0),
|
||||
calls: Object.values(providers).reduce((sum, provider) => sum + provider.calls, 0),
|
||||
sessions: Object.values(providers).reduce((sum, provider) => sum + (provider.sessions ?? 0), 0),
|
||||
savingsUSD: 0,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
editTurns: 0,
|
||||
oneShotTurns: 0,
|
||||
models: {},
|
||||
categories: {},
|
||||
providers,
|
||||
})
|
||||
|
||||
// 2026-08-20 is settled and its sources have partially aged off disk: the
|
||||
// fresh parse sees 3 of the 100 calls the cache finalized, so the cache
|
||||
// stays authoritative. 2026-08-21 is a settled day the cache never held
|
||||
// (cold cache), where the fresh parse is the only source there is.
|
||||
const result = overlayProviderDaySlices(
|
||||
[
|
||||
day('2026-08-20', { hermes: slice(10, 100), claude: slice(2, 3) }),
|
||||
],
|
||||
[
|
||||
day('2026-08-20', { hermes: slice(0.3, 3) }),
|
||||
day('2026-08-21', { hermes: slice(0.438, 6) }),
|
||||
],
|
||||
'hermes',
|
||||
)
|
||||
|
||||
expect(result.map(entry => ({ date: entry.date, cost: entry.cost, calls: entry.calls }))).toEqual([
|
||||
{ date: '2026-08-20', cost: 10, calls: 100 },
|
||||
{ date: '2026-08-21', cost: 0.438, calls: 6 },
|
||||
])
|
||||
expect(result[0]!.providers).toEqual({ hermes: slice(10, 100) })
|
||||
expect(result[1]!.providers).toEqual({ hermes: slice(0.438, 6) })
|
||||
})
|
||||
})
|
||||
|
|
@ -1,7 +1,12 @@
|
|||
import { describe, expect, it, beforeAll } from 'vitest'
|
||||
import { buildMenubarPayloadForRange, providerSliceHasUsage } from '../src/usage-aggregator.js'
|
||||
import { buildMenubarPayloadForRange, mergeDayModelsByDisplayName, providerSliceHasUsage } from '../src/usage-aggregator.js'
|
||||
import { getDateRange } from '../src/cli-date.js'
|
||||
import { loadPricing } from '../src/models.js'
|
||||
import type { ModelDayStats } from '../src/daily-cache.js'
|
||||
|
||||
function modelStats(overrides: Partial<ModelDayStats>): ModelDayStats {
|
||||
return { calls: 0, cost: 0, savingsUSD: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, ...overrides }
|
||||
}
|
||||
|
||||
describe('buildMenubarPayloadForRange', () => {
|
||||
beforeAll(async () => {
|
||||
|
|
@ -36,3 +41,29 @@ describe('buildMenubarPayloadForRange', () => {
|
|||
expect(payload.stale).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('mergeDayModelsByDisplayName', () => {
|
||||
it('collapses two raw MiniMax routes into one display-name row and keeps both raw ids (#1239)', () => {
|
||||
const merged = mergeDayModelsByDisplayName({
|
||||
'minimax/MiniMax-M3': modelStats({ calls: 355, cost: 6.99, inputTokens: 500, outputTokens: 100 }),
|
||||
'MiniMaxAI/MiniMax-M3': modelStats({ calls: 60, cost: 0.37, inputTokens: 200, outputTokens: 50 }),
|
||||
})
|
||||
|
||||
expect(merged).toHaveLength(1)
|
||||
expect(merged[0]!.name).toBe('MiniMax M3')
|
||||
expect(merged[0]!.rawModels).toEqual(['minimax/MiniMax-M3', 'MiniMaxAI/MiniMax-M3'])
|
||||
expect(merged[0]!.calls).toBe(415)
|
||||
expect(merged[0]!.cost).toBeCloseTo(7.36, 6)
|
||||
expect(merged[0]!.inputTokens).toBe(700)
|
||||
expect(merged[0]!.outputTokens).toBe(150)
|
||||
})
|
||||
|
||||
it('keeps distinct models on separate rows with a single-entry rawModels', () => {
|
||||
const merged = mergeDayModelsByDisplayName({
|
||||
'claude-sonnet-4-6': modelStats({ calls: 1, cost: 1 }),
|
||||
'gpt-5': modelStats({ calls: 2, cost: 2 }),
|
||||
})
|
||||
expect(merged.map(m => m.name).sort()).toEqual(['GPT-5', 'Sonnet 4.6'])
|
||||
for (const row of merged) expect(row.rawModels).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue