diff --git a/CHANGELOG.md b/CHANGELOG.md index 2472500..1d3d4a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## Unreleased + +- Add the day's estimated API value to `hourly-summary.json`, so a bar widget can show it without running a report. `value` prices the same events `tokens` counts, the way the dashboard's report prices them, and `unpricedTokens` is the part no rate covers. The file keeps schema version 1, and readers that ignore the new fields see no change. + ## 1.11.2 - 2026-10-08 - Colors work again on Qt 6.12. QtQuick now ships its own `Color` type, which hid the diff --git a/collector.py b/collector.py index d6510bb..37aab0a 100644 --- a/collector.py +++ b/collector.py @@ -2461,6 +2461,11 @@ def hourly_snapshot(ledger, now=None, cfg=None): Totals are kept per provider and per agent record. A labelled second login is its own record in the panel but shares its provider's table with the main login, so the record totals split each provider by account. + + `value` is the estimated API value of the same events `tokens` counts, + priced as `report` prices them, and `unpricedTokens` the part of `tokens` + no rate covers. Bar widgets can show the day's value from this file + instead of running a report. """ cfg = cfg or DEFAULTS now = now or dt.datetime.now().astimezone() @@ -2481,9 +2486,12 @@ def hourly_snapshot(ledger, now=None, cfg=None): for key, (provs, account, _) in targets.items(): for provider in provs: records.setdefault((provider, account), []).append(key) resolve, accounts = source_account(cfg), {} - total = timed = unplaced = 0 + total = timed = unplaced = unpriced = 0 + value_total = 0.0 + rates = load_rates() ledger.db.row_factory = sqlite3.Row - for row in ledger.db.execute('SELECT e.provider,e.client,e.timePrecision,e.ts,e.input,e.output,e.cacheRead,e.cacheWrite,' + for row in ledger.db.execute('SELECT e.provider,e.client,e.timePrecision,e.ts,e.model,e.apiProvider,e.reportedValue,e.reportedCostTicks,' + 'e.serviceTier,e.requestedServiceTier,e.input,e.output,e.cacheRead,e.cacheWrite,e.cacheWrite1h,' 'group_concat(s.path,char(31)) AS paths ' 'FROM events e INDEXED BY events_time LEFT JOIN event_sources s ON s.event_id=e.id ' 'WHERE e.ts>=? AND e.ts<=? GROUP BY e.id', (start, end)): @@ -2501,6 +2509,9 @@ def hourly_snapshot(ledger, now=None, cfg=None): total += tokens p['tokens'] += tokens for m in mine: m['tokens'] += tokens + value = price(r, rates['document'])[0] + if value is None: unpriced += tokens + else: value_total += value if not timed_event(r): unplaced += tokens p['unplacedTokens'] += tokens @@ -2519,6 +2530,7 @@ def hourly_snapshot(ledger, now=None, cfg=None): return {'schemaVersion': 1, 'date': str(day), 'generatedAt': now.timestamp(), 'timeZone': now.tzname() or '', 'utcOffsetMinutes': int(now.utcoffset().total_seconds() // 60), 'tokens': total, 'timedTokens': timed, 'unplacedTokens': unplaced, + 'value': value_total, 'unpricedTokens': unpriced, 'availableProviders': available_providers, 'providers': providers, 'hours': hours, # The same totals for each agent record, so a second login shows # its own usage instead of its provider's, and the records with diff --git a/tests/test_collector.py b/tests/test_collector.py index c731687..6c55c70 100644 --- a/tests/test_collector.py +++ b/tests/test_collector.py @@ -1794,6 +1794,27 @@ def test_drilldown_reconciles_sessions_and_partial_pricing(self): self.assertIsNone(empty['pricing']['coveragePercent']) ledger.db.close() + def test_hourly_snapshot_carries_the_days_value_as_report_prices_it(self): + ledger = c.Ledger(self.root / 'value.sqlite') + now = dt.datetime(2026, 9, 5, 12).astimezone() + for key, model, when, inp in [('a', 'known', '2026-09-05T10:00:00', 100), ('b', 'unknown', '2026-09-05T10:30:00', 200), + ('c', 'known', '2026-09-05T11:00:00', 300), ('prior', 'known', '2026-09-04T10:00:00', 700)]: + ledger.put(c.record(key, 'codex', key, when, model, '', 'CLI', input=inp)) + # A Fast turn is priced at its tier's rate, in the snapshot as in the report. + ledger.put({**c.record('fast', 'codex', 'fast', '2026-09-05T11:30:00', 'known', '', 'CLI', input=50), + 'requestedServiceTier': 'priority'}) + rates = {'source': 'test', 'document': {'known': {'input_cost_per_token': 0.01, 'input_cost_per_token_priority': 0.02}}} + with patch.object(c, 'load_rates', return_value=rates): + snapshot = c.hourly_snapshot(ledger, now) + day = c.report(ledger, c.DEFAULTS, 1, now=now) + self.assertEqual((snapshot['tokens'], snapshot['value'], snapshot['unpricedTokens']), (650, 5, 200)) + self.assertEqual((snapshot['tokens'], snapshot['value'], snapshot['unpricedTokens']), + (day['summary']['tokens'], day['summary']['value'], day['summary']['unpricedTokens'])) + with patch.object(c, 'load_rates', return_value={'source': 'test', 'document': {}}): + unpriced = c.hourly_snapshot(ledger, now) + self.assertEqual((unpriced['value'], unpriced['unpricedTokens']), (0, 650)) + ledger.db.close() + def test_missing_source_does_not_claim_fresh_history(self): ledger = c.Ledger(self.root / 'coverage.sqlite') with patch.object(c, 'HOME', self.root), patch.dict('os.environ', {