Skip to content

Commit 8dc6adc

Browse files
Store money as authoritative microdollars (#1224)
AgentsView currently carries currency as binary floating-point dollars across ingestion, storage, aggregation, and public contracts. That makes rounding behavior depend on where conversion happens and allows storage backends or API clients to disagree about the same charge. This change makes signed int64 microdollars the single machine representation. Public money values use semantic fields containing {"microdollars": ...}; CLI tables and UI labels continue to render ordinary dollars. SQLite and PostgreSQL convert legacy columns transactionally after validating them, while the disposable DuckDB mirror bumps its schema and rebuilds. The deliberate tradeoff is a broad contract change: export schemas are bumped and old floating-point fields are removed instead of retained through dual reads, writes, or aliases. Reviewers should focus on integer arithmetic and rounding boundaries, migration failure behavior, and parity across the supported storage backends. <sup>generated by a clanker</sup> Co-authored-by: Marius van Niekerk <mariusvniekerk@users.noreply.github.com>
1 parent 8fd3e8c commit 8dc6adc

212 files changed

Lines changed: 7694 additions & 3123 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -219,9 +219,9 @@ Features:
219219

220220
`agentsview session usage <id>` prints per-session token statistics plus a cost
221221
estimate for a single session. The output reports the session's total output
222-
tokens and peak context tokens, plus a cost estimate in USD (`cost_usd`) when
223-
pricing is available for the session's model(s) (`has_cost`). Cost is computed
224-
from input/output and cache tokens internally, but only the output-token and
222+
tokens and peak context tokens, plus a cost estimate (`cost`) when pricing is
223+
available for the session's model(s) (`has_cost`). Cost is computed from
224+
input/output and cache tokens internally, but only the output-token and
225225
peak-context totals are reported alongside the cost.
226226

227227
```bash
@@ -239,10 +239,13 @@ GET /api/v1/sessions/{id}/usage
239239
```
240240

241241
The response includes the `session_id`, `agent`, `project`,
242-
`total_output_tokens`, `peak_context_tokens`, `has_token_data`, `cost_usd`,
242+
`total_output_tokens`, `peak_context_tokens`, `has_token_data`, `cost`,
243243
`has_cost`, `models`, and `unpriced_models` fields from the CLI JSON schema.
244-
HTTP responses also include `server_running: true`. Existing sessions return
245-
`200` even when token or cost data is absent; missing sessions return `404`.
244+
Machine-readable money is always an integer microdollar object, for example
245+
`{"cost":{"microdollars":2410000}}`; CLI tables and labels render that value as
246+
ordinary dollars. HTTP responses also include `server_running: true`. Existing
247+
sessions return `200` even when token or cost data is absent; missing sessions
248+
return `404`.
246249

247250
The deprecated alias `agentsview token-use <id>` remains available for
248251
compatibility and now also reports cost estimates.

cmd/agentsview/activity_test.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,7 @@ func TestPrintActivityReport_SanitizesSessionDerivedStrings(t *testing.T) {
183183
func fallbackPricedModel(t *testing.T) string {
184184
t.Helper()
185185
for _, p := range pricing.FallbackPricing() {
186-
if p.OutputPerMTok > 0 {
186+
if p.OutputPerMTok.Microdollars > 0 {
187187
return p.ModelPattern
188188
}
189189
}
@@ -229,7 +229,7 @@ func TestResolveActivityReport_PricesFreshDBUsage(t *testing.T) {
229229
}, d, nil)
230230
require.NoError(t, err)
231231
assert.Equal(t, 500, r.Totals.OutputTokens)
232-
assert.Greater(t, r.Totals.Cost, 0.0,
232+
assert.Positive(t, r.Totals.Cost.Microdollars,
233233
"resolveActivityReportPriced must seed fallback pricing for fresh-DB usage")
234234
}
235235

@@ -249,7 +249,7 @@ func TestActivityReportJSONMatchesHTTPExportMetadata(t *testing.T) {
249249
})
250250
var cliReport activity.Report
251251
require.NoError(t, json.Unmarshal([]byte(cliOut), &cliReport))
252-
assert.Equal(t, 2, cliReport.SchemaVersion)
252+
assert.Equal(t, export.ActivityReportSchemaVersion, cliReport.SchemaVersion)
253253

254254
srv := server.New(config.Config{
255255
Host: "127.0.0.1", Port: 0, DataDir: dataDir, DBPath: dbPath,
@@ -342,5 +342,5 @@ func TestActivityReportGolden(t *testing.T) {
342342
})
343343
require.NoError(t, err, "activity report json golden command")
344344

345-
assertGoldenBytes(t, "activity_report_v2.json", []byte(stdout))
345+
assertGoldenBytes(t, "activity_report_v3.json", []byte(stdout))
346346
}

cmd/agentsview/archive_write_backend_test.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import (
1717
"go.kenn.io/agentsview/internal/db"
1818
"go.kenn.io/agentsview/internal/dbtest"
1919
duckdbsync "go.kenn.io/agentsview/internal/duckdb"
20+
"go.kenn.io/agentsview/internal/money"
2021
"go.kenn.io/agentsview/internal/parser"
2122
"go.kenn.io/agentsview/internal/postgres"
2223
syncpkg "go.kenn.io/agentsview/internal/sync"
@@ -105,8 +106,8 @@ func TestLocalPGPushEnsuresPricingBeforeConnecting(t *testing.T) {
105106
backend.ensurePricing = func(_ context.Context, database *db.DB) error {
106107
require.NoError(t, database.UpsertModelPricing([]db.ModelPricing{{
107108
ModelPattern: "new-model",
108-
InputPerMTok: 2,
109-
OutputPerMTok: 8,
109+
InputPerMTok: money.MustParseDollars("2"),
110+
OutputPerMTok: money.MustParseDollars("8"),
110111
}}))
111112
return nil
112113
}
@@ -121,7 +122,7 @@ func TestLocalPGPushEnsuresPricingBeforeConnecting(t *testing.T) {
121122
rate, err := backend.database.GetModelPricing("new-model")
122123
require.NoError(t, err)
123124
require.NotNil(t, rate)
124-
assert.Equal(t, 8.0, rate.OutputPerMTok)
125+
assert.Equal(t, money.MustParseDollars("8"), rate.OutputPerMTok)
125126
}
126127

127128
func TestLocalPGWatchPusherUsesBackendPricingEnsure(t *testing.T) {

cmd/agentsview/export_sessions_test.go

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import (
1919
"go.kenn.io/agentsview/internal/db"
2020
"go.kenn.io/agentsview/internal/dbtest"
2121
"go.kenn.io/agentsview/internal/export"
22+
"go.kenn.io/agentsview/internal/money"
2223
"go.kenn.io/agentsview/internal/pricing"
2324
)
2425

@@ -49,7 +50,7 @@ func TestExportSessionsJSONEmitsOneDocument(t *testing.T) {
4950
assert.Empty(t, stderr)
5051

5152
doc := decodeExportSessionsDocument(t, stdout)
52-
assert.Equal(t, 2, doc.SchemaVersion)
53+
assert.Equal(t, export.SessionSummarySchemaVersion, doc.SchemaVersion)
5354
assert.NotEmpty(t, doc.DatabaseID)
5455
assert.NotNil(t, doc.Pricing)
5556
assert.NotNil(t, doc.Projects)
@@ -69,7 +70,7 @@ func TestExportSessionsJSONAliasEmitsOneDocument(t *testing.T) {
6970
assert.Empty(t, stderr)
7071

7172
doc := decodeExportSessionsDocument(t, stdout)
72-
assert.Equal(t, 2, doc.SchemaVersion)
73+
assert.Equal(t, export.SessionSummarySchemaVersion, doc.SchemaVersion)
7374
assert.Len(t, doc.Sessions, 2)
7475
assert.Empty(t, strings.TrimSpace(decoderRemainder(t, stdout)),
7576
"--json must emit exactly one JSON document")
@@ -115,7 +116,7 @@ func TestExportSessionsNDJSONEmitsMetaThenRows(t *testing.T) {
115116
require.Len(t, lines, 3)
116117
meta := decodeExportSessionsDocument(t, lines[0])
117118
assert.Equal(t, "meta", meta.Type)
118-
assert.Equal(t, 2, meta.SchemaVersion)
119+
assert.Equal(t, export.SessionSummarySchemaVersion, meta.SchemaVersion)
119120
assert.NotEmpty(t, meta.DatabaseID)
120121
assert.NotNil(t, meta.Pricing)
121122
assert.NotNil(t, meta.Projects)
@@ -174,7 +175,7 @@ func TestExportSessionsAllJSONPreservesCostOnlyReportedPricingAcrossPages(
174175
require.NoError(t, database.SetDatabaseIDForTest(
175176
context.Background(), "cost-only-reported-export-db"))
176177
require.NoError(t, database.UpsertModelPricing([]db.ModelPricing{{
177-
ModelPattern: "computed-model", InputPerMTok: 1,
178+
ModelPattern: "computed-model", InputPerMTok: money.MustParseDollars("1"),
178179
}}))
179180
insertExportSessionsTestSession(t, database, db.Session{
180181
ID: "computed", Project: "alpha", Machine: "local", Agent: "codex",
@@ -193,11 +194,11 @@ func TestExportSessionsAllJSONPreservesCostOnlyReportedPricingAcrossPages(
193194
EndedAt: dbtest.Ptr("2026-06-16T10:10:00Z"),
194195
MessageCount: 2, UserMessageCount: 2,
195196
})
196-
reportedCost := 0.03
197+
reportedCost := money.MustParseDollars("0.03")
197198
require.NoError(t, database.ReplaceSessionUsageEvents(
198199
"cost-only-reported", []db.UsageEvent{{
199200
Source: "shutdown", Model: "copilot-cost-only",
200-
CostUSD: &reportedCost, CostStatus: "exact",
201+
Cost: &reportedCost, CostStatus: "exact",
201202
CostSource: db.CopilotReportedCostSource,
202203
OccurredAt: "2026-06-16T10:10:00Z", DedupKey: "final",
203204
}},
@@ -216,7 +217,7 @@ func TestExportSessionsAllJSONPreservesCostOnlyReportedPricingAcrossPages(
216217
assert.Equal(t, string(export.CostSourceMixed), doc.Pricing["cost_source"])
217218
require.NotNil(t, doc.Sessions[1].ModelUsage)
218219
assert.Equal(t, "cost-only-reported", doc.Sessions[1].ID)
219-
assert.InDelta(t, reportedCost, doc.Sessions[1].ModelUsage.CostUSD, 1e-12)
220+
assert.Equal(t, reportedCost, doc.Sessions[1].ModelUsage.Cost)
220221
}
221222

222223
func TestBuildExportSessionsOutputMarksCrossPageProjectConflictAmbiguous(t *testing.T) {
@@ -666,7 +667,7 @@ func TestExportSessionsJSONGolden(t *testing.T) {
666667
assert.NotContains(t, stdout, `"machine":"golden-host"`)
667668
assert.NotContains(t, stdout, `"root_path":"/`)
668669

669-
assertGoldenBytes(t, "session_export_v2.json", []byte(stdout))
670+
assertGoldenBytes(t, "session_export_v3.json", []byte(stdout))
670671
}
671672

672673
func TestExportSessionsNDJSONGolden(t *testing.T) {
@@ -681,7 +682,7 @@ func TestExportSessionsNDJSONGolden(t *testing.T) {
681682
require.NoError(t, err, "export sessions ndjson golden")
682683
require.Empty(t, stderr)
683684

684-
assertGoldenBytes(t, "session_export_v2.ndjson", []byte(stdout))
685+
assertGoldenBytes(t, "session_export_v3.ndjson", []byte(stdout))
685686
}
686687

687688
func firstExportSessionsCursor(t *testing.T) string {
@@ -745,7 +746,7 @@ func TestExportSessionsFallbackPricingOnUnseededArchive(t *testing.T) {
745746
require.NotNil(t, usage, "model usage")
746747
assert.True(t, usage.HasCost,
747748
"fallback-priced model %s should have cost", model)
748-
assert.Greater(t, usage.CostUSD, 0.0, "fallback-priced cost")
749+
assert.Positive(t, usage.Cost.Microdollars, "fallback-priced cost")
749750

750751
fallback, ok := doc.Pricing["fallback"].(map[string]any)
751752
require.True(t, ok, "pricing fallback block")
@@ -763,7 +764,7 @@ func exactFallbackPricedModel(t *testing.T) string {
763764
if strings.ContainsAny(p.ModelPattern, "*/_") {
764765
continue
765766
}
766-
if p.InputPerMTok > 0 && p.OutputPerMTok > 0 {
767+
if p.InputPerMTok.Microdollars > 0 && p.OutputPerMTok.Microdollars > 0 {
767768
return p.ModelPattern
768769
}
769770
}

cmd/agentsview/mcp_test.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818
"go.kenn.io/agentsview/internal/config"
1919
"go.kenn.io/agentsview/internal/db"
2020
"go.kenn.io/agentsview/internal/dbtest"
21+
"go.kenn.io/agentsview/internal/money"
2122
"go.kenn.io/agentsview/internal/service"
2223
)
2324

@@ -265,17 +266,17 @@ func TestMCPDaemonService_UsagePairwiseComparisonForwardsToDaemon(t *testing.T)
265266

266267
expected := service.UsagePairwiseComparisonResponse{
267268
Left: service.UsagePairwiseComparisonSide{
268-
TotalCost: 1.25,
269+
TotalCost: money.MustParseDollars("1.25"),
269270
TotalTokens: 150,
270271
SessionCount: 2,
271272
},
272273
Right: service.UsagePairwiseComparisonSide{
273-
TotalCost: 3.5,
274+
TotalCost: money.MustParseDollars("3.5"),
274275
TotalTokens: 420,
275276
SessionCount: 5,
276277
},
277278
Deltas: service.UsagePairwiseComparisonDelta{
278-
TotalCostDelta: 2.25,
279+
TotalCostDelta: money.MustParseDollars("2.25"),
279280
TotalTokensDelta: 270,
280281
SessionCountDelta: 3,
281282
},

cmd/agentsview/session_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -224,7 +224,7 @@ func remoteUsageJSON(spec remoteUsageSpec) string {
224224
"total_output_tokens": %d,
225225
"peak_context_tokens": 2048,
226226
"has_token_data": true,
227-
"cost_usd": 0.5,
227+
"cost": {"microdollars": 500000},
228228
"has_cost": true,
229229
"models": ["gpt-5.1"],
230230
"unpriced_models": []%s
@@ -1279,7 +1279,7 @@ func TestSessionUsage_UsesDiscoveredDaemon(t *testing.T) {
12791279
"total_output_tokens": 42,
12801280
"peak_context_tokens": 2048,
12811281
"has_token_data": true,
1282-
"cost_usd": 0.5,
1282+
"cost": {"microdollars": 500000},
12831283
"has_cost": true,
12841284
"models": ["gpt-5.1"],
12851285
"unpriced_models": []
@@ -1349,7 +1349,7 @@ func TestTokenUse_UsesDiscoveredDaemon(t *testing.T) {
13491349
"total_output_tokens": 42,
13501350
"peak_context_tokens": 2048,
13511351
"has_token_data": true,
1352-
"cost_usd": 0.5,
1352+
"cost": {"microdollars": 500000},
13531353
"has_cost": true,
13541354
"models": ["gpt-5.1"],
13551355
"unpriced_models": []

cmd/agentsview/session_usage.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import (
1717
"go.kenn.io/agentsview/internal/config"
1818
"go.kenn.io/agentsview/internal/db"
1919
"go.kenn.io/agentsview/internal/export"
20+
"go.kenn.io/agentsview/internal/money"
2021
"go.kenn.io/agentsview/internal/parser"
2122
"go.kenn.io/agentsview/internal/service"
2223
)
@@ -294,8 +295,8 @@ func renderSessionUsageHuman(w io.Writer, out *sessionUsageOutput) error {
294295
if models != "" {
295296
suffix = " (" + sanitizeTerminal(models) + ")"
296297
}
297-
fmt.Fprintf(w, "%s %s$%.2f%s\n", label("Cost"),
298-
prefix, out.CostUSD, suffix)
298+
fmt.Fprintf(w, "%s %s%s%s\n", label("Cost"), prefix,
299+
money.FormatUSD(out.Cost, money.DisplayCents), suffix)
299300
} else if len(out.UnpricedModels) > 0 {
300301
fmt.Fprintf(w, "%s n/a (unpriced: %s)\n", label("Cost"),
301302
sanitizeTerminal(strings.Join(out.UnpricedModels, ", ")))

cmd/agentsview/session_usage_test.go

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,15 @@ import (
88
"github.com/stretchr/testify/assert"
99
"github.com/stretchr/testify/require"
1010
"go.kenn.io/agentsview/internal/db"
11+
"go.kenn.io/agentsview/internal/money"
1112
)
1213

1314
func TestRenderSessionUsageHuman_WithCost(t *testing.T) {
1415
out := &sessionUsageOutput{
1516
SessionUsage: db.SessionUsage{
1617
SessionID: "claude:s1", Agent: "claude-code", Project: "proj",
1718
TotalOutputTokens: 28800, PeakContextTokens: 118000,
18-
HasTokenData: true, CostUSD: 0.42, HasCost: true,
19+
HasTokenData: true, Cost: money.MustParseDollars("0.42"), HasCost: true,
1920
Models: []string{"claude-opus-4-6"},
2021
},
2122
}
@@ -31,7 +32,7 @@ func TestRenderSessionUsageHuman_ReportedCostOmitsEstimateMarker(t *testing.T) {
3132
require.NoError(t, json.Unmarshal([]byte(`{
3233
"session_id":"hermes:s1",
3334
"agent":"hermes",
34-
"cost_usd":0.03,
35+
"cost":{"microdollars":30000},
3536
"has_cost":true,
3637
"cost_source":"reported",
3738
"models":["model-a"]
@@ -50,7 +51,7 @@ func TestRenderSessionUsageHuman_AuthoritativeCostWithoutModelsOmitsEstimateMark
5051
require.NoError(t, json.Unmarshal([]byte(`{
5152
"session_id":"copilot:cost-only",
5253
"agent":"copilot",
53-
"cost_usd":0.03,
54+
"cost":{"microdollars":30000},
5455
"has_cost":true,
5556
"cost_source":"reported",
5657
"models":[]
@@ -103,7 +104,7 @@ func TestRenderSessionUsageHuman_CopilotWithAICredits(t *testing.T) {
103104
TotalOutputTokens: 2000,
104105
PeakContextTokens: 5000,
105106
HasTokenData: true,
106-
CostUSD: 10.00,
107+
Cost: money.MustParseDollars("10.00"),
107108
HasCost: true,
108109
AICredits: 1000.0,
109110
Models: []string{"gpt-4"},
@@ -126,7 +127,7 @@ func TestRenderSessionUsageHuman_NonCopilotNoAICredits(t *testing.T) {
126127
TotalOutputTokens: 1000,
127128
PeakContextTokens: 5000,
128129
HasTokenData: true,
129-
CostUSD: 0.42,
130+
Cost: money.MustParseDollars("0.42"),
130131
HasCost: true,
131132
Models: []string{"claude-opus"},
132133
},
@@ -168,7 +169,7 @@ func TestSessionUsageJSONSchemaIncludesCostContract(t *testing.T) {
168169
TotalOutputTokens: 123,
169170
PeakContextTokens: 456,
170171
HasTokenData: true,
171-
CostUSD: 0.42,
172+
Cost: money.MustParseDollars("0.42"),
172173
HasCost: true,
173174
Models: []string{"gpt-5.1"},
174175
UnpricedModels: []string{"local-model"},
@@ -190,10 +191,12 @@ func TestSessionUsageJSONSchemaIncludesCostContract(t *testing.T) {
190191
"total_output_tokens": float64(123),
191192
"peak_context_tokens": float64(456),
192193
"has_token_data": true,
193-
"cost_usd": 0.42,
194-
"has_cost": true,
195-
"models": []any{"gpt-5.1"},
196-
"unpriced_models": []any{"local-model"},
197-
"server_running": true,
194+
"cost": map[string]any{
195+
"microdollars": float64(420000),
196+
},
197+
"has_cost": true,
198+
"models": []any{"gpt-5.1"},
199+
"unpriced_models": []any{"local-model"},
200+
"server_running": true,
198201
}, raw)
199202
}

cmd/agentsview/stats.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -398,9 +398,9 @@ func printCacheEconomics(w io.Writer, c *db.StatsCacheEconomics) {
398398
fmt.Fprintln(w, "Cache economics (claude-only)")
399399
fmt.Fprintf(w, " Overall hit ratio: %.2f\n",
400400
c.CacheHitRatio.Overall)
401-
fmt.Fprintf(w, " $ spent: $%.2f\n", c.DollarsSpent)
402-
fmt.Fprintf(w, " $ saved vs uncached: $%.2f\n",
403-
c.DollarsSavedVsUncached)
401+
fmt.Fprintf(w, " $ spent: %s\n", fmtCost(c.DollarsSpent))
402+
fmt.Fprintf(w, " $ saved vs uncached: %s\n",
403+
fmtCost(c.DollarsSavedVsUncached))
404404
fmt.Fprintln(w)
405405
}
406406

0 commit comments

Comments
 (0)