f99010fae1
CI / lint (push) Failing after 1s
CI / frontend (push) Failing after 1s
CI / scripts (push) Failing after 1s
CI / Go Test (ubuntu-latest) (push) Failing after 0s
CI / frontend-node-25 (push) Failing after 1s
CI / docs (push) Failing after 0s
CI / coverage (push) Failing after 0s
CI / e2e (push) Failing after 0s
Docker / build-and-push (push) Failing after 1s
CI / integration (push) Failing after 4m43s
CI / Go Test (windows-latest) (push) Has been cancelled
CI / Desktop Unit Tests (Windows) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Windows) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Has been cancelled
3675 lines
116 KiB
Go
3675 lines
116 KiB
Go
package db
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"io"
|
||
"log"
|
||
"math"
|
||
"os"
|
||
"path/filepath"
|
||
"strconv"
|
||
"strings"
|
||
"sync"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/stretchr/testify/assert"
|
||
"github.com/stretchr/testify/require"
|
||
|
||
"go.kenn.io/agentsview/internal/config"
|
||
"go.kenn.io/agentsview/internal/export"
|
||
"go.kenn.io/agentsview/internal/parser"
|
||
"go.kenn.io/agentsview/internal/parsertest"
|
||
)
|
||
|
||
var (
|
||
dailyUsageFixtureOnce sync.Once
|
||
dailyUsageFixtureDir string
|
||
dailyUsageFixturePath string
|
||
)
|
||
|
||
func TestDailyUsageResultOmitsUnsetPricingMetadata(t *testing.T) {
|
||
b, err := json.Marshal(DailyUsageResult{})
|
||
require.NoError(t, err)
|
||
|
||
assert.NotContains(t, string(b), `"pricing"`)
|
||
}
|
||
|
||
func TestDailyUsageResultEmitsEmptyProjectsMap(t *testing.T) {
|
||
b, err := json.Marshal(DailyUsageResult{
|
||
SchemaVersion: export.UsageDailySchemaVersion,
|
||
Projects: map[string]export.ProjectMapEntry{},
|
||
Daily: []DailyUsageEntry{},
|
||
})
|
||
require.NoError(t, err)
|
||
|
||
assert.Contains(t, string(b), `"projects":{}`)
|
||
}
|
||
|
||
func TestUsageDailyEmptyProjectsMapExcludesUnrelatedObservations(t *testing.T) {
|
||
d := testDB(t)
|
||
ctx := context.Background()
|
||
seedProjectIdentityObservation(t, d, "unrelated-project")
|
||
|
||
result, err := d.GetDailyUsage(ctx, UsageFilter{
|
||
From: "2026-06-01", To: "2026-06-01", Timezone: "UTC",
|
||
})
|
||
require.NoError(t, err)
|
||
assert.Empty(t, result.Daily)
|
||
assert.Empty(t, result.Projects)
|
||
}
|
||
|
||
func openDailyUsageFixtureDB(t *testing.T) *DB {
|
||
t.Helper()
|
||
|
||
dailyUsageFixtureOnce.Do(func() {
|
||
dailyUsageFixtureDir, dailyUsageFixturePath =
|
||
buildDailyUsageFixtureTemplate(t)
|
||
})
|
||
|
||
dst := filepath.Join(t.TempDir(), "test.db")
|
||
for _, suffix := range []string{"", "-wal", "-shm"} {
|
||
require.NoError(t,
|
||
copyTemplateDBFile(
|
||
dailyUsageFixturePath+suffix, dst+suffix, suffix == "",
|
||
),
|
||
"copy daily usage fixture %q", suffix)
|
||
}
|
||
d, err := OpenPreparedTestDB(dst)
|
||
require.NoError(t, err, "open daily usage fixture")
|
||
t.Cleanup(func() { require.NoError(t, d.Close()) })
|
||
return d
|
||
}
|
||
|
||
func buildDailyUsageFixtureTemplate(t *testing.T) (string, string) {
|
||
t.Helper()
|
||
|
||
dir, err := os.MkdirTemp("", "agentsview-daily-usage-*")
|
||
require.NoError(t, err, "create daily usage fixture dir")
|
||
path := filepath.Join(dir, "test.db")
|
||
require.NoError(t, copyTestDBTemplate(path),
|
||
"copy base db template for daily usage fixture")
|
||
|
||
d, err := OpenPreparedTestDB(path)
|
||
require.NoError(t, err, "open daily usage template")
|
||
seedDailyUsageFixture(t, d)
|
||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||
defer cancel()
|
||
require.NoError(t, d.CheckpointWALTruncate(ctx),
|
||
"checkpoint daily usage template")
|
||
require.NoError(t, d.Close(), "close daily usage template")
|
||
return dir, path
|
||
}
|
||
|
||
func seedDailyUsageFixture(t *testing.T, d *DB) {
|
||
t.Helper()
|
||
|
||
require.NoError(t, d.UpsertModelPricing([]ModelPricing{
|
||
{ModelPattern: "model-a", InputPerMTok: 2.0,
|
||
OutputPerMTok: 10.0},
|
||
{ModelPattern: "gpt-5", InputPerMTok: 2.5,
|
||
OutputPerMTok: 10.0},
|
||
}), "UpsertModelPricing")
|
||
|
||
type combo struct {
|
||
project string
|
||
agent string
|
||
}
|
||
combos := []combo{
|
||
{"proj-a", "claude"},
|
||
{"proj-a", "codex"},
|
||
{"proj-b", "claude"},
|
||
{"proj-b", "codex"},
|
||
}
|
||
for i, c := range combos {
|
||
sid := "usage-fixture-" + strconv.Itoa(i)
|
||
insertSession(t, d, sid, c.project, func(s *Session) {
|
||
s.Agent = c.agent
|
||
s.StartedAt = Ptr("2024-06-15T10:00:00Z")
|
||
})
|
||
insertMessages(t, d,
|
||
Message{
|
||
SessionID: sid,
|
||
Ordinal: 0,
|
||
Role: "assistant",
|
||
Timestamp: "2024-06-15T10:30:00Z",
|
||
Model: "model-a",
|
||
TokenUsage: json.RawMessage(`{"input_tokens":1000,"output_tokens":500}`),
|
||
},
|
||
Message{
|
||
SessionID: sid,
|
||
Ordinal: 1,
|
||
Role: "assistant",
|
||
Timestamp: "2024-06-15T10:31:00Z",
|
||
Model: "gpt-5",
|
||
TokenUsage: json.RawMessage(`{"input_tokens":1000,"output_tokens":500}`),
|
||
},
|
||
)
|
||
}
|
||
|
||
insertSession(t, d, "usage-fixture-no-price", "proj-unknown", func(s *Session) {
|
||
s.Agent = "claude"
|
||
s.StartedAt = Ptr("2024-07-15T10:00:00Z")
|
||
})
|
||
insertMessages(t, d, Message{
|
||
SessionID: "usage-fixture-no-price",
|
||
Ordinal: 0,
|
||
Role: "assistant",
|
||
Timestamp: "2024-07-15T10:30:00Z",
|
||
Model: "unknown-model",
|
||
TokenUsage: json.RawMessage(`{"input_tokens":500,"output_tokens":250}`),
|
||
})
|
||
}
|
||
|
||
func TestGetDailyUsageEmpty(t *testing.T) {
|
||
d := testDB(t)
|
||
ctx := context.Background()
|
||
|
||
result, err := d.GetDailyUsage(ctx, UsageFilter{
|
||
From: "2024-01-01",
|
||
To: "2024-12-31",
|
||
})
|
||
requireNoError(t, err, "GetDailyUsage empty")
|
||
|
||
require.NotNil(t, result.Daily, "Daily should be non-nil empty slice")
|
||
assert.Len(t, result.Daily, 0, "got")
|
||
assert.Equal(t, 0.0, result.Totals.TotalCost, "TotalCost")
|
||
}
|
||
|
||
func TestUsageRowQueryPushesDateBoundsIntoUnion(t *testing.T) {
|
||
query, args := usageRowQuery(UsageFilter{
|
||
From: "2024-06-01",
|
||
To: "2024-06-30",
|
||
ExcludeAutomated: true,
|
||
})
|
||
|
||
normalized := strings.ToLower(query)
|
||
assert.NotContains(t, normalized, "and u.ts >=")
|
||
assert.NotContains(t, normalized, "and u.ts <=")
|
||
assert.NotContains(t, normalized, " or ")
|
||
assert.NotContains(t, normalized, "display_name")
|
||
assert.NotContains(t, normalized, "first_message")
|
||
assert.NotContains(t, normalized, "cost_status")
|
||
assert.NotContains(t, normalized, "cost_source")
|
||
assert.NotContains(t, normalized, "user_message_count")
|
||
assert.NotContains(t, normalized, "session_activity_at")
|
||
assert.NotContains(t, normalized, " as started_at")
|
||
assert.NotContains(t, normalized, "u.machine")
|
||
assert.Contains(t, normalized, "message_timestamp_rows as materialized")
|
||
assert.Contains(t, normalized, "usage_event_timestamp_rows as materialized")
|
||
assert.Contains(t, normalized, "from message_timestamp_rows m\njoin sessions s")
|
||
assert.Contains(t, normalized, "from usage_event_timestamp_rows ue\njoin sessions s")
|
||
assert.Contains(t, normalized, "m.timestamp is not null")
|
||
assert.Contains(t, normalized, "m.timestamp != ''")
|
||
assert.Contains(t, normalized, "ue.occurred_at is not null")
|
||
assert.Contains(t, normalized, "nullif(m.timestamp, '') is null")
|
||
assert.Contains(t, normalized, "ue.occurred_at is null")
|
||
assert.Contains(t, normalized, "m.timestamp >= ?")
|
||
assert.Contains(t, normalized, "ue.occurred_at >= ?")
|
||
assert.Contains(t, normalized, "s.started_at >= ?")
|
||
assert.Contains(t, normalized, "m.timestamp <= ?")
|
||
assert.Contains(t, normalized, "ue.occurred_at <= ?")
|
||
assert.Contains(t, normalized, "s.started_at <= ?")
|
||
require.Len(t, args, 8)
|
||
assert.Equal(t, "2024-05-31T10:00:00Z", args[0])
|
||
assert.Equal(t, "2024-07-01T13:59:59Z", args[1])
|
||
assert.Equal(t, "2024-05-31T10:00:00Z", args[2])
|
||
assert.Equal(t, "2024-07-01T13:59:59Z", args[3])
|
||
assert.Equal(t, "2024-05-31T10:00:00Z", args[4])
|
||
assert.Equal(t, "2024-07-01T13:59:59Z", args[5])
|
||
assert.Equal(t, "2024-05-31T10:00:00Z", args[6])
|
||
assert.Equal(t, "2024-07-01T13:59:59Z", args[7])
|
||
}
|
||
|
||
func TestTopSessionsUsageRowQueryUsesNarrowScan(t *testing.T) {
|
||
query, args := topSessionsUsageRowQuery(UsageFilter{
|
||
From: "2024-06-01",
|
||
To: "2024-06-30",
|
||
})
|
||
|
||
normalized := strings.ToLower(query)
|
||
assert.NotContains(t, normalized, "display_name")
|
||
assert.NotContains(t, normalized, "first_message")
|
||
assert.NotContains(t, normalized, "cost_status")
|
||
assert.NotContains(t, normalized, "cost_source")
|
||
assert.NotContains(t, normalized, "user_message_count")
|
||
assert.NotContains(t, normalized, "session_activity_at")
|
||
assert.NotContains(t, normalized, " as started_at")
|
||
assert.NotContains(t, normalized, "u.machine")
|
||
assert.Contains(t, normalized, "m.timestamp is not null")
|
||
assert.Contains(t, normalized, "m.timestamp != ''")
|
||
assert.Contains(t, normalized, "ue.occurred_at is not null")
|
||
assert.Contains(t, normalized, "nullif(m.timestamp, '') is null")
|
||
assert.Contains(t, normalized, "ue.occurred_at is null")
|
||
assert.Contains(t, normalized, "m.timestamp >= ?")
|
||
assert.Contains(t, normalized, "ue.occurred_at >= ?")
|
||
assert.Contains(t, normalized,
|
||
"nullif(m.timestamp, '') is null\n\tand s.started_at >= ?")
|
||
assert.Contains(t, normalized,
|
||
"ue.occurred_at is null\n\tand s.started_at >= ?")
|
||
assert.Contains(t, normalized, "m.timestamp <= ?")
|
||
assert.Contains(t, normalized, "ue.occurred_at <= ?")
|
||
require.Len(t, args, 8)
|
||
assert.Equal(t, "2024-05-31T10:00:00Z", args[0])
|
||
assert.Equal(t, "2024-07-01T13:59:59Z", args[1])
|
||
assert.Equal(t, "2024-05-31T10:00:00Z", args[2])
|
||
assert.Equal(t, "2024-07-01T13:59:59Z", args[3])
|
||
assert.Equal(t, "2024-05-31T10:00:00Z", args[4])
|
||
assert.Equal(t, "2024-07-01T13:59:59Z", args[5])
|
||
assert.Equal(t, "2024-05-31T10:00:00Z", args[6])
|
||
assert.Equal(t, "2024-07-01T13:59:59Z", args[7])
|
||
}
|
||
|
||
func TestUsageEventsReplaceAndList(t *testing.T) {
|
||
d := testDB(t)
|
||
ctx := context.Background()
|
||
|
||
insertSession(t, d, "hermes:event", "proj", func(s *Session) {
|
||
s.Agent = "hermes"
|
||
s.StartedAt = new("2026-05-14T10:00:00Z")
|
||
s.UserMessageCount = 2
|
||
})
|
||
|
||
cost := 0.02
|
||
ordinal := 3
|
||
events := []UsageEvent{{
|
||
SessionID: "hermes:event",
|
||
MessageOrdinal: &ordinal,
|
||
Source: "session",
|
||
Model: "gpt-5.4",
|
||
InputTokens: 100,
|
||
OutputTokens: 50,
|
||
CacheCreationInputTokens: 7,
|
||
CacheReadInputTokens: 11,
|
||
ReasoningTokens: 13,
|
||
CostUSD: &cost,
|
||
CostStatus: "estimated",
|
||
CostSource: "hermes",
|
||
OccurredAt: "2026-05-14T10:05:00Z",
|
||
DedupKey: "session:hermes:event",
|
||
}}
|
||
err := d.ReplaceSessionUsageEvents("hermes:event", events)
|
||
require.NoError(t, err, "ReplaceSessionUsageEvents")
|
||
|
||
got, err := d.GetUsageEvents(ctx, "hermes:event")
|
||
require.NoError(t, err, "GetUsageEvents")
|
||
require.Len(t, got, 1, "len")
|
||
require.Equal(t, 100, got[0].InputTokens,
|
||
"InputTokens (token fields not round-tripped: %#v)", got[0])
|
||
require.Equal(t, 50, got[0].OutputTokens,
|
||
"OutputTokens (token fields not round-tripped: %#v)", got[0])
|
||
require.Equal(t, 7, got[0].CacheCreationInputTokens,
|
||
"CacheCreationInputTokens (token fields not round-tripped: %#v)", got[0])
|
||
require.Equal(t, 11, got[0].CacheReadInputTokens,
|
||
"CacheReadInputTokens (token fields not round-tripped: %#v)", got[0])
|
||
require.Equal(t, 13, got[0].ReasoningTokens,
|
||
"ReasoningTokens (token fields not round-tripped: %#v)", got[0])
|
||
require.NotNil(t, got[0].MessageOrdinal, "MessageOrdinal want 3")
|
||
require.Equal(t, 3, *got[0].MessageOrdinal, "MessageOrdinal")
|
||
require.NotNil(t, got[0].CostUSD, "CostUSD want %v", cost)
|
||
require.Equal(t, cost, *got[0].CostUSD, "CostUSD")
|
||
require.Equal(t, "session:hermes:event", got[0].DedupKey, "DedupKey")
|
||
fps, err := d.UsageEventFingerprints([]string{"hermes:event", "missing"})
|
||
require.NoError(t, err, "UsageEventFingerprints")
|
||
require.NotEmpty(t, fps["hermes:event"],
|
||
"expected non-empty usage event fingerprint")
|
||
require.Equal(t, "", fps["missing"], "missing fingerprint")
|
||
|
||
err = d.ReplaceSessionUsageEvents("hermes:event", nil)
|
||
require.NoError(t, err, "ReplaceSessionUsageEvents clear")
|
||
got, err = d.GetUsageEvents(ctx, "hermes:event")
|
||
require.NoError(t, err, "GetUsageEvents after clear")
|
||
require.Len(t, got, 0, "usage events after clear =")
|
||
}
|
||
|
||
func TestGetDailyUsageWithData(t *testing.T) {
|
||
d := testDB(t)
|
||
ctx := context.Background()
|
||
|
||
err := d.UpsertModelPricing([]ModelPricing{{
|
||
ModelPattern: "claude-sonnet-4-20250514",
|
||
InputPerMTok: 3.0,
|
||
OutputPerMTok: 15.0,
|
||
CacheCreationPerMTok: 3.75,
|
||
CacheReadPerMTok: 0.30,
|
||
}})
|
||
requireNoError(t, err, "UpsertModelPricing")
|
||
|
||
insertSession(t, d, "sess1", "proj1", func(s *Session) {
|
||
s.Agent = "claude"
|
||
s.StartedAt = new("2024-06-15T10:00:00Z")
|
||
s.EndedAt = new("2024-06-15T11:00:00Z")
|
||
})
|
||
|
||
tokenUsage := `{
|
||
"input_tokens": 1000,
|
||
"output_tokens": 500,
|
||
"cache_creation_input_tokens": 200,
|
||
"cache_read_input_tokens": 300
|
||
}`
|
||
insertMessages(t, d, Message{
|
||
SessionID: "sess1",
|
||
Ordinal: 0,
|
||
Role: "assistant",
|
||
Timestamp: "2024-06-15T10:30:00Z",
|
||
Model: "claude-sonnet-4-20250514",
|
||
TokenUsage: json.RawMessage(tokenUsage),
|
||
})
|
||
|
||
result, err := d.GetDailyUsage(ctx, UsageFilter{
|
||
From: "2024-06-01",
|
||
To: "2024-06-30",
|
||
})
|
||
requireNoError(t, err, "GetDailyUsage")
|
||
|
||
require.Len(t, result.Daily, 1, "got")
|
||
|
||
day := result.Daily[0]
|
||
assert.Equal(t, "2024-06-15", day.Date, "Date")
|
||
assert.Equal(t, 1000, day.InputTokens, "InputTokens")
|
||
assert.Equal(t, 500, day.OutputTokens, "OutputTokens")
|
||
assert.Equal(t, 200, day.CacheCreationTokens, "CacheCreationTokens")
|
||
assert.Equal(t, 300, day.CacheReadTokens, "CacheReadTokens")
|
||
|
||
// Cost = (1000*3.0 + 500*15.0 + 200*3.75 + 300*0.30) / 1_000_000
|
||
// = (3000 + 7500 + 750 + 90) / 1_000_000
|
||
// = 11340 / 1_000_000
|
||
// = 0.01134
|
||
wantCost := 0.01134
|
||
assert.InDelta(t, wantCost, day.TotalCost, 1e-9, "TotalCost")
|
||
|
||
assert.Equal(t, []string{"claude-sonnet-4-20250514"},
|
||
day.ModelsUsed, "ModelsUsed")
|
||
|
||
// Totals should match single day
|
||
assert.Equal(t, 1000, result.Totals.InputTokens, "Totals.InputTokens")
|
||
assert.InDelta(t, wantCost, result.Totals.TotalCost, 1e-9,
|
||
"Totals.TotalCost")
|
||
}
|
||
|
||
func TestUsageRowsHandleBlankMessageTimestampWithoutSessionStart(t *testing.T) {
|
||
d := testDB(t)
|
||
ctx := context.Background()
|
||
|
||
insertSession(t, d, "blank-ts", "proj", func(s *Session) {
|
||
s.Agent = "claude"
|
||
s.MessageCount = 1
|
||
s.StartedAt = nil
|
||
})
|
||
insertMessages(t, d, Message{
|
||
SessionID: "blank-ts",
|
||
Ordinal: 0,
|
||
Role: "assistant",
|
||
Timestamp: "",
|
||
Model: "claude-sonnet-4-20250514",
|
||
TokenUsage: json.RawMessage(`{"input_tokens":100,"output_tokens":50}`),
|
||
})
|
||
|
||
daily, err := d.GetDailyUsage(ctx, UsageFilter{})
|
||
requireNoError(t, err, "GetDailyUsage")
|
||
assert.Equal(t, 100, daily.Totals.InputTokens)
|
||
assert.Equal(t, 50, daily.Totals.OutputTokens)
|
||
|
||
usage, err := d.GetSessionUsage(ctx, "blank-ts", true)
|
||
requireNoError(t, err, "GetSessionUsage")
|
||
require.NotNil(t, usage)
|
||
assert.Equal(t, []string{"claude-sonnet-4-20250514"}, usage.Models)
|
||
}
|
||
|
||
func TestUsagePreservesSessionSummaryUsageEventTokens(t *testing.T) {
|
||
d := testDB(t)
|
||
ctx := context.Background()
|
||
rawInput := MaxPlausibleTokens + 250_000
|
||
rawOutput := MaxPlausibleTokens + 500_000
|
||
|
||
requireNoError(t, d.UpsertModelPricing([]ModelPricing{{
|
||
ModelPattern: "gpt-5.4",
|
||
InputPerMTok: 1.0,
|
||
OutputPerMTok: 2.0,
|
||
}}), "UpsertModelPricing")
|
||
|
||
insertSession(t, d, "hermes:summary", "proj", func(s *Session) {
|
||
s.Agent = "hermes"
|
||
s.StartedAt = new("2026-05-14T10:00:00Z")
|
||
s.UserMessageCount = 2
|
||
s.TotalOutputTokens = rawOutput
|
||
s.PeakContextTokens = rawInput
|
||
s.HasTotalOutputTokens = true
|
||
s.HasPeakContextTokens = true
|
||
})
|
||
requireNoError(t, d.ReplaceSessionUsageEvents(
|
||
"hermes:summary",
|
||
[]UsageEvent{{
|
||
SessionID: "hermes:summary",
|
||
Source: "session",
|
||
Model: "gpt-5.4",
|
||
InputTokens: rawInput,
|
||
OutputTokens: rawOutput,
|
||
OccurredAt: "2026-05-14T10:05:00Z",
|
||
DedupKey: "session:hermes:summary",
|
||
}},
|
||
), "ReplaceSessionUsageEvents")
|
||
|
||
daily, err := d.GetDailyUsage(ctx, UsageFilter{
|
||
From: "2026-05-14",
|
||
To: "2026-05-14",
|
||
})
|
||
requireNoError(t, err, "GetDailyUsage")
|
||
require.Len(t, daily.Daily, 1, "daily entries")
|
||
assert.Equal(t, rawInput, daily.Totals.InputTokens, "daily input")
|
||
assert.Equal(t, rawOutput, daily.Totals.OutputTokens, "daily output")
|
||
|
||
usage, err := d.GetSessionUsage(ctx, "hermes:summary", true)
|
||
requireNoError(t, err, "GetSessionUsage")
|
||
require.NotNil(t, usage, "session usage")
|
||
assert.Equal(t, rawOutput, usage.TotalOutputTokens, "session output total")
|
||
assert.Equal(t, rawInput, usage.PeakContextTokens, "session peak context")
|
||
require.True(t, usage.HasCost, "HasCost")
|
||
wantCost := (float64(rawInput)*1.0 + float64(rawOutput)*2.0) / 1_000_000
|
||
assert.InDelta(t, wantCost, usage.CostUSD, 1e-9, "session usage cost")
|
||
}
|
||
|
||
func TestGetDailyUsageFallsBackForEmptyMessageTimestamp(t *testing.T) {
|
||
d := testDB(t)
|
||
ctx := context.Background()
|
||
|
||
requireNoError(t, d.UpsertModelPricing([]ModelPricing{{
|
||
ModelPattern: "claude-sonnet-4-20250514",
|
||
InputPerMTok: 3.0,
|
||
OutputPerMTok: 15.0,
|
||
}}), "UpsertModelPricing")
|
||
|
||
insertSession(t, d, "empty-ts", "proj1", func(s *Session) {
|
||
s.Agent = "claude"
|
||
s.StartedAt = new("2024-06-15T10:00:00Z")
|
||
})
|
||
insertMessages(t, d, Message{
|
||
SessionID: "empty-ts",
|
||
Ordinal: 0,
|
||
Role: "assistant",
|
||
Timestamp: "",
|
||
Model: "claude-sonnet-4-20250514",
|
||
TokenUsage: json.RawMessage(
|
||
`{"input_tokens":1000,"output_tokens":500}`,
|
||
),
|
||
})
|
||
|
||
result, err := d.GetDailyUsage(ctx, UsageFilter{
|
||
From: "2024-06-15",
|
||
To: "2024-06-15",
|
||
})
|
||
requireNoError(t, err, "GetDailyUsage")
|
||
|
||
require.Len(t, result.Daily, 1, "daily entries")
|
||
assert.Equal(t, "2024-06-15", result.Daily[0].Date, "Date")
|
||
assert.Equal(t, 1000, result.Totals.InputTokens, "InputTokens")
|
||
assert.Equal(t, 500, result.Totals.OutputTokens, "OutputTokens")
|
||
}
|
||
|
||
func TestUsageQueriesUnionMessageAndUsageEvents(t *testing.T) {
|
||
d := testDB(t)
|
||
ctx := context.Background()
|
||
|
||
insertSession(t, d, "claude:msg", "proj-a", func(s *Session) {
|
||
s.Agent = "claude"
|
||
s.StartedAt = new("2026-05-14T09:00:00Z")
|
||
s.UserMessageCount = 2
|
||
})
|
||
insertMessages(t, d, Message{
|
||
SessionID: "claude:msg",
|
||
Ordinal: 0,
|
||
Role: "assistant",
|
||
Timestamp: "2026-05-14T09:05:00Z",
|
||
Model: "claude-sonnet-4-20250514",
|
||
TokenUsage: json.RawMessage(
|
||
`{"input_tokens":100,"output_tokens":40}`,
|
||
),
|
||
})
|
||
|
||
insertSession(t, d, "hermes:event", "proj-b", func(s *Session) {
|
||
s.Agent = "hermes"
|
||
s.StartedAt = new("2026-05-14T10:00:00Z")
|
||
s.UserMessageCount = 2
|
||
})
|
||
requireNoError(t, d.ReplaceSessionUsageEvents(
|
||
"hermes:event",
|
||
[]UsageEvent{{
|
||
SessionID: "hermes:event",
|
||
Source: "session",
|
||
Model: "gpt-5.4",
|
||
InputTokens: 300,
|
||
OutputTokens: 70,
|
||
CacheReadInputTokens: 20,
|
||
DedupKey: "shared-key",
|
||
}},
|
||
), "replace hermes usage event")
|
||
insertSession(t, d, "hermes:event-2", "proj-b", func(s *Session) {
|
||
s.Agent = "hermes"
|
||
s.StartedAt = new("2026-05-14T10:10:00Z")
|
||
s.UserMessageCount = 2
|
||
})
|
||
requireNoError(t, d.ReplaceSessionUsageEvents(
|
||
"hermes:event-2",
|
||
[]UsageEvent{{
|
||
SessionID: "hermes:event-2",
|
||
Source: "session",
|
||
Model: "gpt-5.4",
|
||
InputTokens: 50,
|
||
OutputTokens: 5,
|
||
DedupKey: "shared-key",
|
||
}},
|
||
), "replace second hermes usage event")
|
||
|
||
filter := UsageFilter{
|
||
From: "2026-05-14",
|
||
To: "2026-05-14",
|
||
Breakdowns: true,
|
||
}
|
||
daily, err := d.GetDailyUsage(ctx, filter)
|
||
requireNoError(t, err, "GetDailyUsage")
|
||
require.Equal(t, 450, daily.Totals.InputTokens,
|
||
"daily totals: %#v", daily.Totals)
|
||
require.Equal(t, 115, daily.Totals.OutputTokens,
|
||
"daily totals: %#v", daily.Totals)
|
||
require.Equal(t, 20, daily.Totals.CacheReadTokens,
|
||
"daily totals: %#v", daily.Totals)
|
||
require.Len(t, daily.Daily, 1, "daily entries =")
|
||
require.Len(t, daily.Daily[0].AgentBreakdowns, 2,
|
||
"agent breakdowns: %#v", daily.Daily[0].AgentBreakdowns)
|
||
|
||
top, err := d.GetTopSessionsByCost(ctx, filter, 10)
|
||
requireNoError(t, err, "GetTopSessionsByCost")
|
||
topByID := make(map[string]TopSessionEntry, len(top))
|
||
for _, entry := range top {
|
||
topByID[entry.SessionID] = entry
|
||
}
|
||
require.Equal(t, 140, topByID["claude:msg"].TotalTokens,
|
||
"claude top tokens: %#v", topByID["claude:msg"])
|
||
require.Equal(t, 390, topByID["hermes:event"].TotalTokens,
|
||
"hermes top tokens: %#v", topByID["hermes:event"])
|
||
require.Equal(t, 55, topByID["hermes:event-2"].TotalTokens,
|
||
"second hermes top tokens: %#v", topByID["hermes:event-2"])
|
||
|
||
counts, err := d.GetUsageSessionCounts(ctx, filter)
|
||
requireNoError(t, err, "GetUsageSessionCounts")
|
||
require.Equal(t, 3, counts.Total, "counts: %#v", counts)
|
||
require.Equal(t, 1, counts.ByAgent["claude"], "counts: %#v", counts)
|
||
require.Equal(t, 2, counts.ByAgent["hermes"], "counts: %#v", counts)
|
||
require.Equal(t, 1, counts.ByProject["proj-a"], "counts: %#v", counts)
|
||
require.Equal(t, 2, counts.ByProject["proj-b"], "counts: %#v", counts)
|
||
}
|
||
|
||
func TestGetDailyUsageIncludesCursorUsageEvents(t *testing.T) {
|
||
d := testDB(t)
|
||
ctx := context.Background()
|
||
|
||
require.NoError(t, d.InsertCursorUsageEvents([]CursorUsageEvent{{
|
||
OccurredAt: "2026-05-14T10:05:00Z",
|
||
Model: "claude-4.6-opus-high-thinking",
|
||
Kind: "USAGE_EVENT_KIND_USAGE_BASED",
|
||
InputTokens: 1234,
|
||
OutputTokens: 567,
|
||
CacheWriteTokens: 0,
|
||
CacheReadTokens: 8901,
|
||
ChargedCents: 15.66,
|
||
CursorTokenFee: 3.32,
|
||
UserID: "152683922",
|
||
UserEmail: "member@example.com",
|
||
IsHeadless: false,
|
||
}}), "InsertCursorUsageEvents")
|
||
|
||
result, err := d.GetDailyUsage(ctx, UsageFilter{
|
||
From: "2026-05-14",
|
||
To: "2026-05-14",
|
||
Breakdowns: true,
|
||
})
|
||
require.NoError(t, err, "GetDailyUsage cursor")
|
||
require.Len(t, result.Daily, 1, "daily len =")
|
||
|
||
day := result.Daily[0]
|
||
assert.Equal(t, "2026-05-14", day.Date, "Date")
|
||
assert.Equal(t, 1234, day.InputTokens, "InputTokens")
|
||
assert.Equal(t, 567, day.OutputTokens, "OutputTokens")
|
||
assert.Equal(t, 0, day.CacheCreationTokens, "CacheCreationTokens")
|
||
assert.Equal(t, 8901, day.CacheReadTokens, "CacheReadTokens")
|
||
assert.InDelta(t, 0.1566, day.TotalCost, 1e-9, "TotalCost")
|
||
require.Equal(t, []string{"claude-4.6-opus-high-thinking"}, day.ModelsUsed)
|
||
require.Len(t, day.AgentBreakdowns, 1)
|
||
assert.Equal(t, "cursor", day.AgentBreakdowns[0].Agent)
|
||
assert.InDelta(t, 0.1566, day.AgentBreakdowns[0].Cost, 1e-9)
|
||
assert.Empty(t, result.Projects, "cursor-only usage should not emit project identities")
|
||
assert.NotContains(t, result.Projects, "")
|
||
assert.Equal(t, 0, result.SessionCounts.Total, "cursor rows should not count as sessions")
|
||
}
|
||
|
||
func TestGetDailyUsageIncludesCursorUsageEventsWithSessionDefaults(t *testing.T) {
|
||
d := testDB(t)
|
||
ctx := context.Background()
|
||
|
||
require.NoError(t, d.InsertCursorUsageEvents([]CursorUsageEvent{{
|
||
OccurredAt: "2026-05-14T10:05:00Z",
|
||
Model: "claude-4.6-opus-high-thinking",
|
||
Kind: "USAGE_EVENT_KIND_USAGE_BASED",
|
||
InputTokens: 1234,
|
||
OutputTokens: 567,
|
||
CacheWriteTokens: 0,
|
||
CacheReadTokens: 8901,
|
||
ChargedCents: 15.66,
|
||
CursorTokenFee: 3.32,
|
||
UserID: "152683922",
|
||
UserEmail: "member@example.com",
|
||
IsHeadless: false,
|
||
}}), "InsertCursorUsageEvents")
|
||
|
||
result, err := d.GetDailyUsage(ctx, UsageFilter{
|
||
From: "2026-05-14",
|
||
To: "2026-05-14",
|
||
Breakdowns: true,
|
||
ExcludeAutomated: true,
|
||
})
|
||
require.NoError(t, err, "GetDailyUsage cursor with defaults")
|
||
require.Len(t, result.Daily, 1, "daily len =")
|
||
assert.Equal(t, 1234, result.Daily[0].InputTokens, "InputTokens")
|
||
assert.Equal(t, 0, result.SessionCounts.Total, "cursor rows should not count as sessions")
|
||
}
|
||
|
||
func TestGetDailyUsageSkipsCursorUsageEventsForExcludeOneShot(t *testing.T) {
|
||
d := testDB(t)
|
||
ctx := context.Background()
|
||
|
||
require.NoError(t, d.InsertCursorUsageEvents([]CursorUsageEvent{{
|
||
OccurredAt: "2026-05-14T10:05:00Z",
|
||
Model: "claude-4.6-opus-high-thinking",
|
||
Kind: "USAGE_EVENT_KIND_USAGE_BASED",
|
||
InputTokens: 1234,
|
||
OutputTokens: 567,
|
||
CacheWriteTokens: 0,
|
||
CacheReadTokens: 8901,
|
||
ChargedCents: 15.66,
|
||
CursorTokenFee: 3.32,
|
||
UserID: "152683922",
|
||
UserEmail: "member@example.com",
|
||
IsHeadless: false,
|
||
}}), "InsertCursorUsageEvents")
|
||
|
||
result, err := d.GetDailyUsage(ctx, UsageFilter{
|
||
From: "2026-05-14",
|
||
To: "2026-05-14",
|
||
Breakdowns: true,
|
||
ExcludeOneShot: true,
|
||
})
|
||
require.NoError(t, err, "GetDailyUsage cursor exclude one-shot")
|
||
assert.Empty(t, result.Daily, "daily entries should be empty")
|
||
assert.Zero(t, result.Totals.InputTokens, "InputTokens")
|
||
assert.Zero(t, result.SessionCounts.Total, "cursor rows should not count as sessions")
|
||
}
|
||
|
||
func TestGetDailyUsageSkipsCursorUsageEventsForTerminationFilter(t *testing.T) {
|
||
d := testDB(t)
|
||
ctx := context.Background()
|
||
|
||
requireNoError(t, d.UpsertModelPricing([]ModelPricing{{
|
||
ModelPattern: "claude-sonnet-4-20250514",
|
||
InputPerMTok: 3.0, OutputPerMTok: 15.0,
|
||
}}), "UpsertModelPricing")
|
||
|
||
insertSession(t, d, "clean-session", "proj", func(s *Session) {
|
||
s.Agent = "claude"
|
||
s.StartedAt = new("2026-05-14T10:00:00Z")
|
||
s.TerminationStatus = new("clean")
|
||
})
|
||
insertMessages(t, d, Message{
|
||
SessionID: "clean-session",
|
||
Ordinal: 0,
|
||
Role: "assistant",
|
||
Timestamp: "2026-05-14T10:30:00Z",
|
||
Model: "claude-sonnet-4-20250514",
|
||
TokenUsage: json.RawMessage(
|
||
`{"input_tokens":100,"output_tokens":40}`,
|
||
),
|
||
})
|
||
require.NoError(t, d.InsertCursorUsageEvents([]CursorUsageEvent{{
|
||
OccurredAt: "2026-05-14T10:05:00Z",
|
||
Model: "claude-4.6-opus-high-thinking",
|
||
Kind: "USAGE_EVENT_KIND_USAGE_BASED",
|
||
InputTokens: 1234,
|
||
OutputTokens: 567,
|
||
CacheReadTokens: 8901,
|
||
ChargedCents: 15.66,
|
||
CursorTokenFee: 3.32,
|
||
UserID: "152683922",
|
||
UserEmail: "member@example.com",
|
||
}}), "InsertCursorUsageEvents")
|
||
|
||
result, err := d.GetDailyUsage(ctx, UsageFilter{
|
||
From: "2026-05-14",
|
||
To: "2026-05-14",
|
||
Termination: "clean",
|
||
})
|
||
require.NoError(t, err, "GetDailyUsage clean termination")
|
||
require.Len(t, result.Daily, 1, "daily len =")
|
||
assert.Equal(t, 100, result.Totals.InputTokens, "InputTokens")
|
||
assert.Equal(t, 40, result.Totals.OutputTokens, "OutputTokens")
|
||
assert.Equal(t, 1, result.SessionCounts.Total, "SessionCounts.Total")
|
||
}
|
||
|
||
func TestInsertCursorUsageEventsDedupesByFingerprint(t *testing.T) {
|
||
d := testDB(t)
|
||
|
||
event := CursorUsageEvent{
|
||
OccurredAt: "2026-05-14T10:05:00Z",
|
||
Model: "claude-4.6-opus-high-thinking",
|
||
Kind: "USAGE_EVENT_KIND_USAGE_BASED",
|
||
InputTokens: 1234,
|
||
OutputTokens: 567,
|
||
CacheWriteTokens: 0,
|
||
CacheReadTokens: 8901,
|
||
ChargedCents: 15.66,
|
||
CursorTokenFee: 3.32,
|
||
UserID: "152683922",
|
||
UserEmail: "member@example.com",
|
||
IsHeadless: false,
|
||
}
|
||
require.NoError(t, d.InsertCursorUsageEvents([]CursorUsageEvent{event}))
|
||
require.NoError(t, d.InsertCursorUsageEvents([]CursorUsageEvent{event}))
|
||
|
||
var count int
|
||
require.NoError(t, d.getReader().QueryRow(
|
||
"SELECT count(*) FROM cursor_usage_events",
|
||
).Scan(&count))
|
||
assert.Equal(t, 1, count, "duplicate fingerprint should be ignored")
|
||
}
|
||
|
||
// TestGetDailyUsage_CacheSavingsUsesPerModelRates pins down
|
||
// that totals.CacheSavings is computed from each row's actual
|
||
// per-model pricing, not a hard-coded proxy. A hard-coded
|
||
// Sonnet rate would misreport an Opus-heavy workload because
|
||
// Opus rates are roughly 5x Sonnet on both sides.
|
||
func TestGetDailyUsage_CacheSavingsUsesPerModelRates(t *testing.T) {
|
||
d := testDB(t)
|
||
ctx := context.Background()
|
||
|
||
requireNoError(t, d.UpsertModelPricing([]ModelPricing{
|
||
{
|
||
ModelPattern: "claude-opus-4-6",
|
||
InputPerMTok: 15.0,
|
||
OutputPerMTok: 75.0,
|
||
CacheCreationPerMTok: 18.75,
|
||
CacheReadPerMTok: 1.50,
|
||
},
|
||
{
|
||
ModelPattern: "claude-sonnet-4-20250514",
|
||
InputPerMTok: 3.0,
|
||
OutputPerMTok: 15.0,
|
||
CacheCreationPerMTok: 3.75,
|
||
CacheReadPerMTok: 0.30,
|
||
},
|
||
}), "UpsertModelPricing")
|
||
|
||
// Same 1M/1M mix of cache read + cache creation tokens
|
||
// on both models so the per-model rate difference is the
|
||
// only thing that can move the result.
|
||
tokens := json.RawMessage(
|
||
`{"input_tokens":0,"output_tokens":0,` +
|
||
`"cache_creation_input_tokens":1000000,` +
|
||
`"cache_read_input_tokens":1000000}`)
|
||
|
||
insertSession(t, d, "s-opus", "proj", func(s *Session) {
|
||
s.Agent = "claude"
|
||
s.StartedAt = new("2024-06-15T10:00:00Z")
|
||
})
|
||
insertMessages(t, d, Message{
|
||
SessionID: "s-opus", Ordinal: 0,
|
||
Role: "assistant", Timestamp: "2024-06-15T10:30:00Z",
|
||
Model: "claude-opus-4-6", TokenUsage: tokens,
|
||
})
|
||
|
||
insertSession(t, d, "s-sonnet", "proj", func(s *Session) {
|
||
s.Agent = "claude"
|
||
s.StartedAt = new("2024-06-15T10:05:00Z")
|
||
})
|
||
insertMessages(t, d, Message{
|
||
SessionID: "s-sonnet", Ordinal: 0,
|
||
Role: "assistant", Timestamp: "2024-06-15T10:35:00Z",
|
||
Model: "claude-sonnet-4-20250514", TokenUsage: tokens,
|
||
})
|
||
|
||
result, err := d.GetDailyUsage(ctx, UsageFilter{
|
||
From: "2024-06-01", To: "2024-06-30",
|
||
})
|
||
requireNoError(t, err, "GetDailyUsage")
|
||
|
||
// Opus per-token delta: read earns (15 - 1.50) = 13.50,
|
||
// creation earns (15 - 18.75) = -3.75.
|
||
// Opus savings on 1M + 1M = 13.50 + (-3.75) = 9.75.
|
||
// Sonnet per-token delta: read earns (3 - 0.30) = 2.70,
|
||
// creation earns (3 - 3.75) = -0.75.
|
||
// Sonnet savings on 1M + 1M = 2.70 + (-0.75) = 1.95.
|
||
// Net total savings = 9.75 + 1.95 = 11.70.
|
||
wantSavings := 11.70
|
||
assert.InDelta(t, wantSavings, result.Totals.CacheSavings, 1e-9,
|
||
"Totals.CacheSavings")
|
||
|
||
// Falsification: if the code had used Sonnet rates for
|
||
// both rows the total would be 2 * 1.95 = 3.90, which
|
||
// differs from wantSavings by >$7. Assert we're nowhere
|
||
// near that value so a regression to a single-rate path
|
||
// trips the test.
|
||
assert.Greater(t, math.Abs(result.Totals.CacheSavings-3.90), 0.1,
|
||
"CacheSavings looks like single-rate path; expected per-model math")
|
||
}
|
||
|
||
func TestGetDailyUsageAgentFilter(t *testing.T) {
|
||
d := testDB(t)
|
||
ctx := context.Background()
|
||
|
||
err := d.UpsertModelPricing([]ModelPricing{{
|
||
ModelPattern: "claude-sonnet-4-20250514",
|
||
InputPerMTok: 3.0,
|
||
OutputPerMTok: 15.0,
|
||
CacheCreationPerMTok: 3.75,
|
||
CacheReadPerMTok: 0.30,
|
||
}})
|
||
requireNoError(t, err, "UpsertModelPricing")
|
||
|
||
// Claude session
|
||
insertSession(t, d, "sess-claude", "proj1", func(s *Session) {
|
||
s.Agent = "claude"
|
||
s.StartedAt = new("2024-06-15T10:00:00Z")
|
||
})
|
||
insertMessages(t, d, Message{
|
||
SessionID: "sess-claude",
|
||
Ordinal: 0,
|
||
Role: "assistant",
|
||
Timestamp: "2024-06-15T10:30:00Z",
|
||
Model: "claude-sonnet-4-20250514",
|
||
TokenUsage: json.RawMessage(`{"input_tokens":1000,"output_tokens":500}`),
|
||
})
|
||
|
||
// Codex session
|
||
insertSession(t, d, "sess-codex", "proj1", func(s *Session) {
|
||
s.Agent = "codex"
|
||
s.StartedAt = new("2024-06-15T10:00:00Z")
|
||
})
|
||
insertMessages(t, d, Message{
|
||
SessionID: "sess-codex",
|
||
Ordinal: 0,
|
||
Role: "assistant",
|
||
Timestamp: "2024-06-15T10:30:00Z",
|
||
Model: "claude-sonnet-4-20250514",
|
||
TokenUsage: json.RawMessage(`{"input_tokens":2000,"output_tokens":1000}`),
|
||
})
|
||
|
||
result, err := d.GetDailyUsage(ctx, UsageFilter{
|
||
From: "2024-06-01",
|
||
To: "2024-06-30",
|
||
Agent: "claude",
|
||
})
|
||
requireNoError(t, err, "GetDailyUsage agent filter")
|
||
|
||
require.Len(t, result.Daily, 1, "got")
|
||
|
||
day := result.Daily[0]
|
||
assert.Equal(t, 1000, day.InputTokens, "InputTokens")
|
||
assert.Equal(t, 500, day.OutputTokens, "OutputTokens")
|
||
}
|
||
|
||
func TestGetDailyUsageMultipleDaysAndModels(t *testing.T) {
|
||
d := testDB(t)
|
||
ctx := context.Background()
|
||
|
||
err := d.UpsertModelPricing([]ModelPricing{
|
||
{
|
||
ModelPattern: "model-a",
|
||
InputPerMTok: 2.0,
|
||
OutputPerMTok: 10.0,
|
||
},
|
||
{
|
||
ModelPattern: "model-b",
|
||
InputPerMTok: 4.0,
|
||
OutputPerMTok: 20.0,
|
||
},
|
||
})
|
||
requireNoError(t, err, "UpsertModelPricing")
|
||
|
||
// Day 1: two models
|
||
insertSession(t, d, "sess-d1", "proj1", func(s *Session) {
|
||
s.Agent = "claude"
|
||
s.StartedAt = new("2024-06-10T08:00:00Z")
|
||
})
|
||
insertMessages(t, d,
|
||
Message{
|
||
SessionID: "sess-d1",
|
||
Ordinal: 0,
|
||
Role: "assistant",
|
||
Timestamp: "2024-06-10T08:30:00Z",
|
||
Model: "model-a",
|
||
TokenUsage: json.RawMessage(`{"input_tokens":100,"output_tokens":50}`),
|
||
},
|
||
Message{
|
||
SessionID: "sess-d1",
|
||
Ordinal: 1,
|
||
Role: "assistant",
|
||
Timestamp: "2024-06-10T09:00:00Z",
|
||
Model: "model-b",
|
||
TokenUsage: json.RawMessage(`{"input_tokens":200,"output_tokens":100}`),
|
||
},
|
||
)
|
||
|
||
// Day 2: one model
|
||
insertSession(t, d, "sess-d2", "proj1", func(s *Session) {
|
||
s.Agent = "claude"
|
||
s.StartedAt = new("2024-06-11T08:00:00Z")
|
||
})
|
||
insertMessages(t, d, Message{
|
||
SessionID: "sess-d2",
|
||
Ordinal: 0,
|
||
Role: "assistant",
|
||
Timestamp: "2024-06-11T08:30:00Z",
|
||
Model: "model-a",
|
||
TokenUsage: json.RawMessage(`{"input_tokens":300,"output_tokens":150}`),
|
||
})
|
||
|
||
result, err := d.GetDailyUsage(ctx, UsageFilter{
|
||
From: "2024-06-01",
|
||
To: "2024-06-30",
|
||
})
|
||
requireNoError(t, err, "GetDailyUsage multi")
|
||
|
||
require.Len(t, result.Daily, 2, "got")
|
||
|
||
// Day 1: check totals
|
||
d1 := result.Daily[0]
|
||
assert.Equal(t, "2024-06-10", d1.Date, "day1 Date")
|
||
assert.Equal(t, 300, d1.InputTokens, "day1 InputTokens")
|
||
assert.Equal(t, 150, d1.OutputTokens, "day1 OutputTokens")
|
||
assert.Len(t, d1.ModelsUsed, 2, "day1 ModelsUsed count")
|
||
|
||
// Day 2
|
||
d2 := result.Daily[1]
|
||
assert.Equal(t, "2024-06-11", d2.Date, "day2 Date")
|
||
assert.Equal(t, 300, d2.InputTokens, "day2 InputTokens")
|
||
|
||
// Totals should sum both days
|
||
wantTotalInput := 600
|
||
assert.Equal(t, wantTotalInput, result.Totals.InputTokens, "Totals.InputTokens")
|
||
wantTotalOutput := 300
|
||
assert.Equal(t, wantTotalOutput, result.Totals.OutputTokens, "Totals.OutputTokens")
|
||
|
||
// Cost check: day1 model-a = (100*2+50*10)/1e6 = 0.0007
|
||
// day1 model-b = (200*4+100*20)/1e6 = 0.0028
|
||
// day2 model-a = (300*2+150*10)/1e6 = 0.0021
|
||
// total = 0.0056
|
||
wantTotalCost := 0.0056
|
||
assert.InDelta(t, wantTotalCost, result.Totals.TotalCost, 1e-9,
|
||
"Totals.TotalCost")
|
||
}
|
||
|
||
func TestGetDailyUsageNoPricing(t *testing.T) {
|
||
d := openDailyUsageFixtureDB(t)
|
||
ctx := context.Background()
|
||
|
||
result, err := d.GetDailyUsage(ctx, UsageFilter{
|
||
From: "2024-07-01",
|
||
To: "2024-07-31",
|
||
})
|
||
requireNoError(t, err, "GetDailyUsage no pricing")
|
||
|
||
require.Len(t, result.Daily, 1, "got")
|
||
|
||
day := result.Daily[0]
|
||
assert.Equal(t, 500, day.InputTokens, "InputTokens")
|
||
assert.Equal(t, 250, day.OutputTokens, "OutputTokens")
|
||
assert.Equal(t, 0.0, day.TotalCost, "TotalCost")
|
||
assert.Equal(t, []string{"unknown-model"}, day.ModelsUsed,
|
||
"ModelsUsed")
|
||
}
|
||
|
||
func TestGetDailyUsageCostsMessageReasoningTokens(t *testing.T) {
|
||
d := testDB(t)
|
||
ctx := context.Background()
|
||
|
||
require.NoError(t, d.UpsertModelPricing([]ModelPricing{{
|
||
ModelPattern: "gpt-5.4",
|
||
InputPerMTok: 1,
|
||
OutputPerMTok: 2,
|
||
}}))
|
||
insertSession(t, d, "reasoning-message", "proj", func(s *Session) {
|
||
s.Agent = "codex"
|
||
s.StartedAt = Ptr("2026-05-14T10:00:00Z")
|
||
})
|
||
insertMessages(t, d, Message{
|
||
SessionID: "reasoning-message",
|
||
Ordinal: 0,
|
||
Role: "assistant",
|
||
Timestamp: "2026-05-14T10:30:00Z",
|
||
Model: "gpt-5.4",
|
||
TokenUsage: json.RawMessage(
|
||
`{"input_tokens":1000,"output_tokens":0,"reasoning_tokens":500}`),
|
||
})
|
||
|
||
result, err := d.GetDailyUsage(ctx, UsageFilter{
|
||
From: "2026-05-14", To: "2026-05-14", Timezone: "UTC",
|
||
})
|
||
require.NoError(t, err)
|
||
require.Len(t, result.Daily, 1)
|
||
assert.Equal(t, 1000, result.Totals.InputTokens)
|
||
assert.Zero(t, result.Totals.OutputTokens)
|
||
assert.InDelta(t, 0.002, result.Totals.TotalCost, 1e-12)
|
||
assert.InDelta(t, 0.002, result.Daily[0].TotalCost, 1e-12)
|
||
}
|
||
|
||
// TestGetDailyUsageTruncatedTokenJSON documents what happens when
|
||
// a message lands in the DB with truncated token_usage. The hot
|
||
// aggregation counter is intentionally permissive and still extracts
|
||
// leading fields, so the valid data is preserved. This is why we don't
|
||
// require fully valid JSON on the hot aggregation path: the realistic
|
||
// corruption modes reachable from our parsers don't produce silent zeros.
|
||
func TestGetDailyUsageTruncatedTokenJSON(t *testing.T) {
|
||
d := testDB(t)
|
||
ctx := context.Background()
|
||
|
||
requireNoError(t, d.UpsertModelPricing([]ModelPricing{{
|
||
ModelPattern: "claude-sonnet-4-20250514",
|
||
InputPerMTok: 3.0,
|
||
OutputPerMTok: 15.0,
|
||
}}), "UpsertModelPricing")
|
||
|
||
insertSession(t, d, "sess1", "proj1", func(s *Session) {
|
||
s.Agent = "claude"
|
||
s.StartedAt = new("2024-06-15T10:00:00Z")
|
||
})
|
||
|
||
insertMessages(t, d,
|
||
Message{
|
||
SessionID: "sess1", Ordinal: 0,
|
||
Role: "assistant",
|
||
Timestamp: "2024-06-15T10:30:00Z",
|
||
Model: "claude-sonnet-4-20250514",
|
||
TokenUsage: json.RawMessage(
|
||
`{"input_tokens":1000,"output_tokens":500}`),
|
||
},
|
||
Message{
|
||
SessionID: "sess1", Ordinal: 1,
|
||
Role: "assistant",
|
||
Timestamp: "2024-06-15T10:31:00Z",
|
||
Model: "claude-sonnet-4-20250514",
|
||
// Truncated mid-key. The usage counter still finds
|
||
// the two leading numeric fields and extracts them.
|
||
TokenUsage: json.RawMessage(
|
||
`{"input_tokens":9999,"output_tokens":4242,"ca`),
|
||
},
|
||
)
|
||
|
||
result, err := d.GetDailyUsage(ctx, UsageFilter{
|
||
From: "2024-06-01",
|
||
To: "2024-06-30",
|
||
})
|
||
requireNoError(t, err, "GetDailyUsage truncated")
|
||
|
||
require.Len(t, result.Daily, 1, "got")
|
||
day := result.Daily[0]
|
||
// 1000 (valid row) + 9999 (truncated but still parseable)
|
||
assert.Equal(t, 10999, day.InputTokens,
|
||
"InputTokens want 10999 "+
|
||
"(counter should extract leading fields from truncated JSON)")
|
||
assert.Equal(t, 4742, day.OutputTokens, "OutputTokens")
|
||
}
|
||
|
||
func TestParseUsageTokenCounters(t *testing.T) {
|
||
in, out, cacheCreate, cacheRead := parseUsageTokenCounters(
|
||
`{"input_tokens":100,"output_tokens":50,` +
|
||
`"cache_creation_input_tokens":20,` +
|
||
`"cache_read_input_tokens":300,` +
|
||
`"reasoning_tokens":75}`,
|
||
)
|
||
assert.Equal(t, 100, in)
|
||
assert.Equal(t, 50, out)
|
||
assert.Equal(t, 20, cacheCreate)
|
||
assert.Equal(t, 300, cacheRead)
|
||
|
||
in, out, cacheCreate, cacheRead, reasoning := parseUsageTokenCountersWithReasoning(
|
||
`{"input_tokens":100,"output_tokens":50,` +
|
||
`"cache_creation_input_tokens":20,` +
|
||
`"cache_read_input_tokens":300,` +
|
||
`"reasoning_tokens":75}`,
|
||
)
|
||
assert.Equal(t, 100, in)
|
||
assert.Equal(t, 50, out)
|
||
assert.Equal(t, 20, cacheCreate)
|
||
assert.Equal(t, 300, cacheRead)
|
||
assert.Equal(t, 75, reasoning)
|
||
|
||
in, out, cacheCreate, cacheRead = parseUsageTokenCounters(
|
||
`{"input_tokens":9999,"output_tokens":4242,"ca`,
|
||
)
|
||
assert.Equal(t, 9999, in)
|
||
assert.Equal(t, 4242, out)
|
||
assert.Zero(t, cacheCreate)
|
||
assert.Zero(t, cacheRead)
|
||
|
||
in, out, cacheCreate, cacheRead = parseUsageTokenCounters(
|
||
`{"input_tokens":"-5","cache_read_input_tokens":"100",` +
|
||
`"output_tokens":"42"}`,
|
||
)
|
||
assert.Equal(t, -5, in)
|
||
assert.Equal(t, 42, out)
|
||
assert.Zero(t, cacheCreate)
|
||
assert.Equal(t, 100, cacheRead)
|
||
|
||
in, out, cacheCreate, cacheRead = parseUsageTokenCounters(
|
||
`{"metadata":{"input_tokens":999},` +
|
||
`"note":"\"output_tokens\":777",` +
|
||
`"output_tokens":42}`,
|
||
)
|
||
assert.Zero(t, in)
|
||
assert.Equal(t, 42, out)
|
||
assert.Zero(t, cacheCreate)
|
||
assert.Zero(t, cacheRead)
|
||
|
||
in, out, cacheCreate, cacheRead = parseUsageTokenCounters(
|
||
`{"metadata":{"url":"https:\/\/x"},"output_tokens":42}`,
|
||
)
|
||
assert.Zero(t, in)
|
||
assert.Equal(t, 42, out)
|
||
assert.Zero(t, cacheCreate)
|
||
assert.Zero(t, cacheRead)
|
||
}
|
||
|
||
func TestUsageAggregationClampsMessageTokenJSON(t *testing.T) {
|
||
d := testDB(t)
|
||
ctx := context.Background()
|
||
const maxTokens = 2_000_000
|
||
|
||
requireNoError(t, d.UpsertModelPricing([]ModelPricing{{
|
||
ModelPattern: "claude-sonnet-4-20250514",
|
||
InputPerMTok: 1.0,
|
||
OutputPerMTok: 2.0,
|
||
CacheCreationPerMTok: 3.0,
|
||
CacheReadPerMTok: 4.0,
|
||
}}), "UpsertModelPricing")
|
||
|
||
insertSession(t, d, "sess1", "proj1", func(s *Session) {
|
||
s.Agent = "claude"
|
||
s.StartedAt = new("2024-06-15T10:00:00Z")
|
||
s.TotalOutputTokens = maxTokens
|
||
s.PeakContextTokens = maxTokens
|
||
s.HasTotalOutputTokens = true
|
||
s.HasPeakContextTokens = true
|
||
})
|
||
insertMessages(t, d, Message{
|
||
SessionID: "sess1", Ordinal: 0,
|
||
Role: "assistant",
|
||
Timestamp: "2024-06-15T10:30:00Z",
|
||
Model: "claude-sonnet-4-20250514",
|
||
TokenUsage: json.RawMessage(
|
||
`{"input_tokens":9999999999,` +
|
||
`"output_tokens":9999999999,` +
|
||
`"cache_creation_input_tokens":9999999999,` +
|
||
`"cache_read_input_tokens":9999999999}`),
|
||
ContextTokens: maxTokens,
|
||
OutputTokens: maxTokens,
|
||
HasContextTokens: true,
|
||
HasOutputTokens: true,
|
||
})
|
||
|
||
result, err := d.GetDailyUsage(ctx, UsageFilter{
|
||
From: "2024-06-01",
|
||
To: "2024-06-30",
|
||
})
|
||
requireNoError(t, err, "GetDailyUsage")
|
||
|
||
assert.Equal(t, maxTokens, result.Totals.InputTokens, "InputTokens")
|
||
assert.Equal(t, maxTokens, result.Totals.OutputTokens, "OutputTokens")
|
||
assert.Equal(t, maxTokens, result.Totals.CacheCreationTokens,
|
||
"CacheCreationTokens")
|
||
assert.Equal(t, maxTokens, result.Totals.CacheReadTokens,
|
||
"CacheReadTokens")
|
||
assert.InDelta(t, 20.0, result.Totals.TotalCost, 1e-9,
|
||
"TotalCost")
|
||
|
||
usage, err := d.GetSessionUsage(ctx, "sess1", true)
|
||
requireNoError(t, err, "GetSessionUsage")
|
||
require.NotNil(t, usage, "session usage")
|
||
require.True(t, usage.HasCost, "HasCost")
|
||
assert.InDelta(t, 20.0, usage.CostUSD, 1e-9, "CostUSD")
|
||
}
|
||
|
||
func TestGetDailyUsage_DedupesByClaudeMessageAndRequestID(t *testing.T) {
|
||
d := testDB(t)
|
||
require.NoError(t, d.UpsertModelPricing([]ModelPricing{{
|
||
ModelPattern: "claude-opus-4-6",
|
||
InputPerMTok: 15.0,
|
||
OutputPerMTok: 75.0,
|
||
CacheCreationPerMTok: 18.75,
|
||
CacheReadPerMTok: 1.50,
|
||
}}), "seed pricing")
|
||
|
||
mustExec := func(q string, args ...any) {
|
||
t.Helper()
|
||
_, err := d.getWriter().Exec(q, args...)
|
||
require.NoError(t, err, "exec %q", q)
|
||
}
|
||
mustExec(`INSERT INTO sessions (id, project, machine, agent, started_at, ended_at)
|
||
VALUES (?, ?, 'local', 'claude', ?, ?)`,
|
||
"s-main", "proj", "2026-04-10T10:00:00Z", "2026-04-10T10:05:00Z")
|
||
mustExec(`INSERT INTO sessions (id, project, machine, agent, started_at, ended_at, parent_session_id, relationship_type)
|
||
VALUES (?, ?, 'local', 'claude', ?, ?, 's-main', 'fork')`,
|
||
"s-fork", "proj", "2026-04-10T10:01:00Z", "2026-04-10T10:06:00Z")
|
||
|
||
shared := `{"input_tokens":100,"output_tokens":500,"cache_creation_input_tokens":1000,"cache_read_input_tokens":50000}`
|
||
unique := `{"input_tokens":20,"output_tokens":80,"cache_creation_input_tokens":200,"cache_read_input_tokens":5000}`
|
||
|
||
for _, row := range []struct {
|
||
sid, ts, usage, mid, rid string
|
||
ord int
|
||
}{
|
||
{"s-main", "2026-04-10T10:02:00Z", shared, "msg_dup", "req_dup", 0},
|
||
{"s-fork", "2026-04-10T10:02:00Z", shared, "msg_dup", "req_dup", 0},
|
||
{"s-fork", "2026-04-10T10:03:00Z", unique, "msg_uniq", "req_uniq", 1},
|
||
} {
|
||
mustExec(`INSERT INTO messages
|
||
(session_id, ordinal, role, content, timestamp,
|
||
model, token_usage,
|
||
claude_message_id, claude_request_id,
|
||
has_output_tokens, has_context_tokens)
|
||
VALUES (?, ?, 'assistant', '', ?, 'claude-opus-4-6', ?, ?, ?, 1, 1)`,
|
||
row.sid, row.ord, row.ts, row.usage, row.mid, row.rid)
|
||
}
|
||
|
||
result, err := d.GetDailyUsage(context.Background(), UsageFilter{
|
||
From: "2026-04-10", To: "2026-04-10", Timezone: "UTC",
|
||
})
|
||
require.NoError(t, err, "GetDailyUsage")
|
||
require.Len(t, result.Daily, 1, "daily entries =")
|
||
day := result.Daily[0]
|
||
assert.Equal(t, 120, day.InputTokens, "input")
|
||
assert.Equal(t, 580, day.OutputTokens, "output")
|
||
assert.Equal(t, 1200, day.CacheCreationTokens, "cache_cr")
|
||
assert.Equal(t, 55000, day.CacheReadTokens, "cache_rd")
|
||
}
|
||
|
||
func TestGetDailyUsage_DedupKeyVariants(t *testing.T) {
|
||
d := testDB(t)
|
||
require.NoError(t, d.UpsertModelPricing([]ModelPricing{{
|
||
ModelPattern: "claude-opus-4-6",
|
||
InputPerMTok: 15.0,
|
||
OutputPerMTok: 75.0,
|
||
CacheCreationPerMTok: 18.75,
|
||
CacheReadPerMTok: 1.50,
|
||
}}), "seed pricing")
|
||
|
||
insertSession(t, d, "source-main", "proj", func(s *Session) {
|
||
s.Agent = "claude"
|
||
s.StartedAt = new("2026-04-10T10:00:00Z")
|
||
s.EndedAt = new("2026-04-10T10:05:00Z")
|
||
})
|
||
insertSession(t, d, "source-fork", "proj", func(s *Session) {
|
||
s.Agent = "claude"
|
||
s.StartedAt = new("2026-04-10T10:01:00Z")
|
||
s.EndedAt = new("2026-04-10T10:06:00Z")
|
||
s.ParentSessionID = new("source-main")
|
||
s.RelationshipType = "fork"
|
||
})
|
||
insertSession(t, d, "missing-keys", "proj", func(s *Session) {
|
||
s.Agent = "claude"
|
||
s.StartedAt = new("2026-04-11T10:00:00Z")
|
||
s.EndedAt = new("2026-04-11T10:05:00Z")
|
||
})
|
||
|
||
shared := json.RawMessage(`{"input_tokens":100,"output_tokens":500,"cache_creation_input_tokens":1000,"cache_read_input_tokens":50000}`)
|
||
unique := json.RawMessage(`{"input_tokens":20,"output_tokens":80,"cache_creation_input_tokens":200,"cache_read_input_tokens":5000}`)
|
||
missingKeysUsage := json.RawMessage(`{"input_tokens":0,"output_tokens":10,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}`)
|
||
|
||
insertMessages(t, d,
|
||
Message{
|
||
SessionID: "source-main", Ordinal: 0,
|
||
Role: "assistant", Timestamp: "2026-04-10T10:02:00Z",
|
||
Model: "claude-opus-4-6", TokenUsage: shared, HasOutputTokens: true,
|
||
ClaudeMessageID: "msg_dup", SourceUUID: "source_dup",
|
||
},
|
||
Message{
|
||
SessionID: "source-fork", Ordinal: 0,
|
||
Role: "assistant", Timestamp: "2026-04-10T10:02:00Z",
|
||
Model: "claude-opus-4-6", TokenUsage: shared, HasOutputTokens: true,
|
||
ClaudeMessageID: "msg_dup", SourceUUID: "source_dup",
|
||
},
|
||
Message{
|
||
SessionID: "source-fork", Ordinal: 1,
|
||
Role: "assistant", Timestamp: "2026-04-10T10:03:00Z",
|
||
Model: "claude-opus-4-6", TokenUsage: unique, HasOutputTokens: true,
|
||
ClaudeMessageID: "msg_uniq", SourceUUID: "source_uniq",
|
||
},
|
||
Message{
|
||
SessionID: "missing-keys", Ordinal: 0,
|
||
Role: "assistant", Timestamp: "2026-04-11T10:02:00Z",
|
||
Model: "claude-opus-4-6", TokenUsage: missingKeysUsage,
|
||
HasOutputTokens: true,
|
||
},
|
||
Message{
|
||
SessionID: "missing-keys", Ordinal: 1,
|
||
Role: "assistant", Timestamp: "2026-04-11T10:02:00Z",
|
||
Model: "claude-opus-4-6", TokenUsage: missingKeysUsage,
|
||
HasOutputTokens: true,
|
||
},
|
||
)
|
||
|
||
t.Run("dedupes by source uuid when claude pair incomplete", func(t *testing.T) {
|
||
result, err := d.GetDailyUsage(context.Background(), UsageFilter{
|
||
From: "2026-04-10", To: "2026-04-10", Timezone: "UTC",
|
||
})
|
||
require.NoError(t, err, "GetDailyUsage")
|
||
require.Len(t, result.Daily, 1, "daily entries =")
|
||
day := result.Daily[0]
|
||
assert.Equal(t, 120, day.InputTokens, "input")
|
||
assert.Equal(t, 580, day.OutputTokens, "output")
|
||
assert.Equal(t, 1200, day.CacheCreationTokens, "cache_cr")
|
||
assert.Equal(t, 55000, day.CacheReadTokens, "cache_rd")
|
||
})
|
||
|
||
t.Run("missing dedup keys counted every time", func(t *testing.T) {
|
||
result, err := d.GetDailyUsage(context.Background(), UsageFilter{
|
||
From: "2026-04-11", To: "2026-04-11", Timezone: "UTC",
|
||
})
|
||
require.NoError(t, err, "GetDailyUsage")
|
||
require.Len(t, result.Daily, 1,
|
||
"output want 20 (both no-key rows counted): %v", result.Daily)
|
||
assert.Equal(t, 20, result.Daily[0].OutputTokens,
|
||
"output want 20 (both no-key rows counted): %v", result.Daily)
|
||
})
|
||
}
|
||
|
||
func TestGetDailyUsageLongLivedSession(t *testing.T) {
|
||
d := testDB(t)
|
||
ctx := context.Background()
|
||
|
||
requireNoError(t, d.UpsertModelPricing([]ModelPricing{{
|
||
ModelPattern: "claude-sonnet-4-6",
|
||
InputPerMTok: 3.0,
|
||
OutputPerMTok: 15.0,
|
||
}}), "upsert pricing")
|
||
|
||
// Session started on Apr 1 but has messages on Apr 10.
|
||
requireNoError(t, d.UpsertSession(Session{
|
||
ID: "long-lived", Project: "proj", Machine: "local",
|
||
Agent: "claude",
|
||
StartedAt: new("2026-04-01T10:00:00Z"),
|
||
}), "upsert session")
|
||
|
||
insertMessages(t, d,
|
||
Message{
|
||
SessionID: "long-lived", Ordinal: 0,
|
||
Role: "assistant", Content: "early",
|
||
ContentLength: 5,
|
||
Timestamp: "2026-04-01T10:00:00Z",
|
||
Model: "claude-sonnet-4-6",
|
||
TokenUsage: json.RawMessage(
|
||
`{"input_tokens":100,"output_tokens":50}`),
|
||
ContextTokens: 100,
|
||
OutputTokens: 50,
|
||
HasContextTokens: true,
|
||
HasOutputTokens: true,
|
||
},
|
||
Message{
|
||
SessionID: "long-lived", Ordinal: 1,
|
||
Role: "assistant", Content: "late",
|
||
ContentLength: 4,
|
||
Timestamp: "2026-04-10T14:00:00Z",
|
||
Model: "claude-sonnet-4-6",
|
||
TokenUsage: json.RawMessage(
|
||
`{"input_tokens":2000,"output_tokens":500}`),
|
||
ContextTokens: 2000,
|
||
OutputTokens: 500,
|
||
HasContextTokens: true,
|
||
HasOutputTokens: true,
|
||
},
|
||
)
|
||
|
||
// Query Apr 10 only — should include the late message even
|
||
// though the session started on Apr 1.
|
||
result, err := d.GetDailyUsage(ctx, UsageFilter{
|
||
From: "2026-04-10",
|
||
To: "2026-04-10",
|
||
Timezone: "UTC",
|
||
})
|
||
requireNoError(t, err, "GetDailyUsage long-lived")
|
||
|
||
require.Len(t, result.Daily, 1, "expected 1 day")
|
||
assert.Equal(t, 2000, result.Daily[0].InputTokens, "InputTokens")
|
||
}
|
||
|
||
func TestGetDailyUsageProjectFilter(t *testing.T) {
|
||
|
||
d := openDailyUsageFixtureDB(t)
|
||
ctx := context.Background()
|
||
|
||
result, err := d.GetDailyUsage(ctx, UsageFilter{
|
||
From: "2024-06-01",
|
||
To: "2024-06-30",
|
||
Project: "proj-a",
|
||
})
|
||
requireNoError(t, err, "GetDailyUsage project filter")
|
||
|
||
require.Len(t, result.Daily, 1, "got")
|
||
day := result.Daily[0]
|
||
assert.Equal(t, 4000, day.InputTokens, "InputTokens")
|
||
assert.Equal(t, 4000, result.Totals.InputTokens, "Totals.InputTokens")
|
||
}
|
||
|
||
func TestGetDailyUsageModelFilter(t *testing.T) {
|
||
|
||
d := openDailyUsageFixtureDB(t)
|
||
ctx := context.Background()
|
||
|
||
result, err := d.GetDailyUsage(ctx, UsageFilter{
|
||
From: "2024-06-01",
|
||
To: "2024-06-30",
|
||
Model: "gpt-5",
|
||
})
|
||
requireNoError(t, err, "GetDailyUsage model filter")
|
||
|
||
require.Len(t, result.Daily, 1, "got")
|
||
day := result.Daily[0]
|
||
assert.Equal(t, 4000, day.InputTokens, "InputTokens")
|
||
assert.Equal(t, []string{"gpt-5"}, day.ModelsUsed, "ModelsUsed")
|
||
}
|
||
|
||
func TestGetDailyUsageProjectBreakdowns(t *testing.T) {
|
||
|
||
d := openDailyUsageFixtureDB(t)
|
||
ctx := context.Background()
|
||
|
||
result, err := d.GetDailyUsage(ctx, UsageFilter{
|
||
From: "2024-06-01",
|
||
To: "2024-06-30",
|
||
Breakdowns: true,
|
||
})
|
||
requireNoError(t, err, "GetDailyUsage project breakdowns")
|
||
|
||
require.Len(t, result.Daily, 1, "got")
|
||
day := result.Daily[0]
|
||
require.Len(t, day.ProjectBreakdowns, 2, "ProjectBreakdowns len")
|
||
|
||
projMap := make(map[string]ProjectBreakdown)
|
||
var projCostSum float64
|
||
for _, pb := range day.ProjectBreakdowns {
|
||
projMap[pb.Project] = pb
|
||
projCostSum += pb.Cost
|
||
}
|
||
for _, name := range []string{"proj-a", "proj-b"} {
|
||
pb, ok := projMap[name]
|
||
if !assert.Truef(t, ok,
|
||
"missing ProjectBreakdown for %s", name) {
|
||
continue
|
||
}
|
||
assert.Equal(t, 4000, pb.InputTokens,
|
||
"%s InputTokens", name)
|
||
}
|
||
assert.InDelta(t, day.TotalCost, projCostSum, 1e-9,
|
||
"sum(ProjectBreakdowns.Cost) want TotalCost")
|
||
}
|
||
|
||
func TestDailyUsageEntryBreakdownSlicesMarshalAsEmptyArrays(t *testing.T) {
|
||
data, err := json.Marshal(DailyUsageEntry{
|
||
Date: "2026-07-03",
|
||
ModelsUsed: []string{},
|
||
})
|
||
require.NoError(t, err)
|
||
|
||
var got map[string]any
|
||
require.NoError(t, json.Unmarshal(data, &got))
|
||
assert.Equal(t, []any{}, got["modelBreakdowns"])
|
||
assert.Equal(t, []any{}, got["projectBreakdowns"])
|
||
assert.Equal(t, []any{}, got["agentBreakdowns"])
|
||
}
|
||
|
||
func TestGetDailyUsageAgentBreakdowns(t *testing.T) {
|
||
|
||
d := openDailyUsageFixtureDB(t)
|
||
ctx := context.Background()
|
||
|
||
result, err := d.GetDailyUsage(ctx, UsageFilter{
|
||
From: "2024-06-01",
|
||
To: "2024-06-30",
|
||
Breakdowns: true,
|
||
})
|
||
requireNoError(t, err, "GetDailyUsage agent breakdowns")
|
||
|
||
require.Len(t, result.Daily, 1, "got")
|
||
day := result.Daily[0]
|
||
require.Len(t, day.AgentBreakdowns, 2, "AgentBreakdowns len")
|
||
|
||
agentMap := make(map[string]AgentBreakdown)
|
||
var agentCostSum float64
|
||
for _, ab := range day.AgentBreakdowns {
|
||
agentMap[ab.Agent] = ab
|
||
agentCostSum += ab.Cost
|
||
}
|
||
for _, name := range []string{"claude", "codex"} {
|
||
ab, ok := agentMap[name]
|
||
if !assert.Truef(t, ok,
|
||
"missing AgentBreakdown for %s", name) {
|
||
continue
|
||
}
|
||
assert.Equal(t, 4000, ab.InputTokens,
|
||
"%s InputTokens", name)
|
||
}
|
||
assert.InDelta(t, day.TotalCost, agentCostSum, 1e-9,
|
||
"sum(AgentBreakdowns.Cost) want TotalCost")
|
||
}
|
||
|
||
func TestGetDailyUsageBreakdownInvariant(t *testing.T) {
|
||
|
||
d := openDailyUsageFixtureDB(t)
|
||
ctx := context.Background()
|
||
|
||
result, err := d.GetDailyUsage(ctx, UsageFilter{
|
||
From: "2024-06-01",
|
||
To: "2024-06-30",
|
||
Breakdowns: true,
|
||
})
|
||
requireNoError(t, err, "GetDailyUsage breakdown invariant")
|
||
|
||
require.Len(t, result.Daily, 1, "got")
|
||
day := result.Daily[0]
|
||
|
||
var modelCostSum float64
|
||
for _, mb := range day.ModelBreakdowns {
|
||
modelCostSum += mb.Cost
|
||
}
|
||
var projectCostSum float64
|
||
for _, pb := range day.ProjectBreakdowns {
|
||
projectCostSum += pb.Cost
|
||
}
|
||
var agentCostSum float64
|
||
for _, ab := range day.AgentBreakdowns {
|
||
agentCostSum += ab.Cost
|
||
}
|
||
|
||
assert.InDelta(t, day.TotalCost, modelCostSum, 1e-9,
|
||
"sum(ModelBreakdowns.Cost) want TotalCost")
|
||
assert.InDelta(t, day.TotalCost, projectCostSum, 1e-9,
|
||
"sum(ProjectBreakdowns.Cost) want TotalCost")
|
||
assert.InDelta(t, day.TotalCost, agentCostSum, 1e-9,
|
||
"sum(AgentBreakdowns.Cost) want TotalCost")
|
||
assert.InDelta(t, projectCostSum, modelCostSum, 1e-9,
|
||
"model cost sum != project cost sum")
|
||
assert.InDelta(t, agentCostSum, modelCostSum, 1e-9,
|
||
"model cost sum != agent cost sum")
|
||
}
|
||
|
||
// BenchmarkGetDailyUsage measures the hot-path scan over a realistic
|
||
// synthetic dataset. The baseline number (captured against the commit
|
||
// that introduces this benchmark) is the non-regression budget for all
|
||
// subsequent changes to GetDailyUsage: new code must land within +10%.
|
||
//
|
||
// See docs/specs/2026-04-12-token-usage-ui-design.md for the full
|
||
// non-destructive benchmark procedure.
|
||
func TestGetTopSessionsByCost(t *testing.T) {
|
||
d := testDB(t)
|
||
ctx := context.Background()
|
||
|
||
requireNoError(t, d.UpsertModelPricing([]ModelPricing{{
|
||
ModelPattern: "claude-sonnet",
|
||
InputPerMTok: 3.0,
|
||
OutputPerMTok: 15.0,
|
||
CacheCreationPerMTok: 3.75,
|
||
CacheReadPerMTok: 0.30,
|
||
}}), "UpsertModelPricing")
|
||
|
||
// Expensive session
|
||
insertSession(t, d, "sBig", "proj-a", func(s *Session) {
|
||
s.Agent = "claude"
|
||
s.SessionName = new("Big Session")
|
||
s.StartedAt = new("2024-06-15T10:00:00Z")
|
||
})
|
||
insertMessages(t, d, Message{
|
||
SessionID: "sBig", Ordinal: 0,
|
||
Role: "assistant", Timestamp: "2024-06-15T10:30:00Z",
|
||
Model: "claude-sonnet",
|
||
TokenUsage: json.RawMessage(
|
||
`{"input_tokens":5000,"output_tokens":2000,` +
|
||
`"cache_creation_input_tokens":1000,` +
|
||
`"cache_read_input_tokens":3000}`),
|
||
})
|
||
|
||
// Cheap session
|
||
insertSession(t, d, "sSmall", "proj-b", func(s *Session) {
|
||
s.Agent = "codex"
|
||
s.SessionName = new("Small Session")
|
||
s.StartedAt = new("2024-06-15T11:00:00Z")
|
||
})
|
||
insertMessages(t, d, Message{
|
||
SessionID: "sSmall", Ordinal: 0,
|
||
Role: "assistant", Timestamp: "2024-06-15T11:30:00Z",
|
||
Model: "claude-sonnet",
|
||
TokenUsage: json.RawMessage(
|
||
`{"input_tokens":100,"output_tokens":50,` +
|
||
`"cache_creation_input_tokens":10,` +
|
||
`"cache_read_input_tokens":20}`),
|
||
})
|
||
|
||
top, err := d.GetTopSessionsByCost(ctx, UsageFilter{
|
||
From: "2024-06-01",
|
||
To: "2024-06-30",
|
||
}, 20)
|
||
requireNoError(t, err, "GetTopSessionsByCost")
|
||
|
||
require.Len(t, top, 2, "len")
|
||
|
||
// Ordered cost desc — sBig first
|
||
assert.Equal(t, "sBig", top[0].SessionID, "top[0].SessionID")
|
||
assert.Equal(t, "Big Session", top[0].DisplayName, "top[0].DisplayName")
|
||
assert.Equal(t, "proj-a", top[0].Project, "top[0].Project")
|
||
assert.Equal(t, "claude", top[0].Agent, "top[0].Agent")
|
||
// TotalTokens = 5000 + 2000 + 1000 + 3000 = 11000
|
||
assert.Equal(t, 11000, top[0].TotalTokens, "top[0].TotalTokens")
|
||
assert.Greater(t, top[0].Cost, 0.0, "top[0].Cost want > 0")
|
||
|
||
assert.Equal(t, "sSmall", top[1].SessionID, "top[1].SessionID")
|
||
assert.Greater(t, top[0].Cost, top[1].Cost,
|
||
"top[0].Cost should be > top[1].Cost")
|
||
}
|
||
|
||
func TestGetTopSessionsByCost_DisplayNameFallback(t *testing.T) {
|
||
d := testDB(t)
|
||
ctx := context.Background()
|
||
|
||
requireNoError(t, d.UpsertModelPricing([]ModelPricing{{
|
||
ModelPattern: "claude-sonnet",
|
||
InputPerMTok: 3.0,
|
||
OutputPerMTok: 15.0,
|
||
CacheCreationPerMTok: 3.75,
|
||
CacheReadPerMTok: 0.30,
|
||
}}), "UpsertModelPricing")
|
||
|
||
tokenJSON := `{"input_tokens":100,"output_tokens":50,` +
|
||
`"cache_creation_input_tokens":0,"cache_read_input_tokens":0}`
|
||
|
||
// Session with session_name set — should use session_name via COALESCE.
|
||
insertSession(t, d, "s-dn", "proj-a", func(s *Session) {
|
||
s.Agent = "claude"
|
||
s.SessionName = new("My Custom Name")
|
||
s.FirstMessage = new("some first message")
|
||
s.StartedAt = new("2024-06-15T10:00:00Z")
|
||
})
|
||
insertMessages(t, d, Message{
|
||
SessionID: "s-dn", Ordinal: 0,
|
||
Role: "assistant", Timestamp: "2024-06-15T10:01:00Z",
|
||
Model: "claude-sonnet",
|
||
TokenUsage: json.RawMessage(tokenJSON),
|
||
})
|
||
|
||
// Session with no display_name — should fall back to first_message.
|
||
insertSession(t, d, "s-fm", "proj-a", func(s *Session) {
|
||
s.Agent = "claude"
|
||
s.FirstMessage = new("fix the login bug")
|
||
s.StartedAt = new("2024-06-15T11:00:00Z")
|
||
})
|
||
insertMessages(t, d, Message{
|
||
SessionID: "s-fm", Ordinal: 0,
|
||
Role: "assistant", Timestamp: "2024-06-15T11:01:00Z",
|
||
Model: "claude-sonnet",
|
||
TokenUsage: json.RawMessage(tokenJSON),
|
||
})
|
||
|
||
// Session with no display_name and no first_message — should
|
||
// fall back to project.
|
||
insertSession(t, d, "s-proj", "my-project", func(s *Session) {
|
||
s.Agent = "claude"
|
||
s.StartedAt = new("2024-06-15T12:00:00Z")
|
||
})
|
||
insertMessages(t, d, Message{
|
||
SessionID: "s-proj", Ordinal: 0,
|
||
Role: "assistant", Timestamp: "2024-06-15T12:01:00Z",
|
||
Model: "claude-sonnet",
|
||
TokenUsage: json.RawMessage(tokenJSON),
|
||
})
|
||
|
||
// Session with no display_name, no first_message, and empty
|
||
// project — should fall back to session ID.
|
||
insertSession(t, d, "s-id", "", func(s *Session) {
|
||
s.Agent = "claude"
|
||
s.StartedAt = new("2024-06-15T13:00:00Z")
|
||
})
|
||
insertMessages(t, d, Message{
|
||
SessionID: "s-id", Ordinal: 0,
|
||
Role: "assistant", Timestamp: "2024-06-15T13:01:00Z",
|
||
Model: "claude-sonnet",
|
||
TokenUsage: json.RawMessage(tokenJSON),
|
||
})
|
||
|
||
top, err := d.GetTopSessionsByCost(ctx, UsageFilter{
|
||
From: "2024-06-01",
|
||
To: "2024-06-30",
|
||
}, 20)
|
||
requireNoError(t, err, "GetTopSessionsByCost fallback")
|
||
|
||
require.Len(t, top, 4, "len")
|
||
|
||
// Build a map for easy lookup (order is by cost, all equal
|
||
// here so secondary sort is by session ID).
|
||
byID := make(map[string]TopSessionEntry)
|
||
for _, e := range top {
|
||
byID[e.SessionID] = e
|
||
}
|
||
|
||
assert.Equal(t, "My Custom Name", byID["s-dn"].DisplayName,
|
||
"s-dn DisplayName")
|
||
assert.Equal(t, "fix the login bug", byID["s-fm"].DisplayName,
|
||
"s-fm DisplayName")
|
||
assert.Equal(t, "my-project", byID["s-proj"].DisplayName,
|
||
"s-proj DisplayName")
|
||
assert.Equal(t, "s-id", byID["s-id"].DisplayName,
|
||
"s-id DisplayName")
|
||
}
|
||
|
||
// TestGetTopSessionsByCost_DedupesByClaudeMessageAndRequestID
|
||
// mirrors TestGetDailyUsage_DedupesByClaudeMessageAndRequestID
|
||
// for the top-sessions query: a parent session and a forked
|
||
// session that both replay the same Claude message should only
|
||
// count that message once in the per-session totals. The
|
||
// earliest-timestamp session wins the credit.
|
||
func TestGetTopSessionsByCost_DedupesByClaudeMessageAndRequestID(
|
||
t *testing.T,
|
||
) {
|
||
d := testDB(t)
|
||
ctx := context.Background()
|
||
|
||
requireNoError(t, d.UpsertModelPricing([]ModelPricing{{
|
||
ModelPattern: "claude-sonnet",
|
||
InputPerMTok: 3.0,
|
||
OutputPerMTok: 15.0,
|
||
CacheCreationPerMTok: 3.75,
|
||
CacheReadPerMTok: 0.30,
|
||
}}), "UpsertModelPricing")
|
||
|
||
// Parent session starts first.
|
||
insertSession(t, d, "s-parent", "proj", func(s *Session) {
|
||
s.Agent = "claude"
|
||
s.StartedAt = new("2024-06-15T10:00:00Z")
|
||
})
|
||
// Forked session starts a minute later.
|
||
insertSession(t, d, "s-fork", "proj", func(s *Session) {
|
||
s.Agent = "claude"
|
||
s.StartedAt = new("2024-06-15T10:01:00Z")
|
||
s.ParentSessionID = new("s-parent")
|
||
s.RelationshipType = "fork"
|
||
})
|
||
|
||
shared := json.RawMessage(
|
||
`{"input_tokens":1000,"output_tokens":500,` +
|
||
`"cache_creation_input_tokens":200,` +
|
||
`"cache_read_input_tokens":3000}`)
|
||
unique := json.RawMessage(
|
||
`{"input_tokens":10,"output_tokens":20,` +
|
||
`"cache_creation_input_tokens":0,` +
|
||
`"cache_read_input_tokens":0}`)
|
||
|
||
// The shared message exists on both sessions with the same
|
||
// Claude IDs; the parent's timestamp is earlier so it should
|
||
// win the dedup.
|
||
insertMessages(t, d, Message{
|
||
SessionID: "s-parent", Ordinal: 0,
|
||
Role: "assistant", Timestamp: "2024-06-15T10:02:00Z",
|
||
Model: "claude-sonnet", TokenUsage: shared,
|
||
ClaudeMessageID: "msg_dup", ClaudeRequestID: "req_dup",
|
||
})
|
||
insertMessages(t, d, Message{
|
||
SessionID: "s-fork", Ordinal: 0,
|
||
Role: "assistant", Timestamp: "2024-06-15T10:03:00Z",
|
||
Model: "claude-sonnet", TokenUsage: shared,
|
||
ClaudeMessageID: "msg_dup", ClaudeRequestID: "req_dup",
|
||
})
|
||
// Plus a unique fork-only message so the fork still appears.
|
||
insertMessages(t, d, Message{
|
||
SessionID: "s-fork", Ordinal: 1,
|
||
Role: "assistant", Timestamp: "2024-06-15T10:04:00Z",
|
||
Model: "claude-sonnet", TokenUsage: unique,
|
||
ClaudeMessageID: "msg_uniq", ClaudeRequestID: "req_uniq",
|
||
})
|
||
|
||
top, err := d.GetTopSessionsByCost(ctx, UsageFilter{
|
||
From: "2024-06-15", To: "2024-06-15", Timezone: "UTC",
|
||
}, 20)
|
||
requireNoError(t, err, "GetTopSessionsByCost")
|
||
|
||
require.Len(t, top, 2, "len")
|
||
|
||
byID := map[string]TopSessionEntry{}
|
||
for _, e := range top {
|
||
byID[e.SessionID] = e
|
||
}
|
||
|
||
parent, ok := byID["s-parent"]
|
||
require.True(t, ok, "s-parent missing from top sessions")
|
||
// Parent owns shared: 1000+500+200+3000 = 4700 tokens.
|
||
assert.Equal(t, 4700, parent.TotalTokens, "parent.TotalTokens")
|
||
|
||
fork, ok := byID["s-fork"]
|
||
require.True(t, ok, "s-fork missing from top sessions")
|
||
// Fork should only own the unique message: 10+20 = 30
|
||
// tokens. If the dedup were missing, the shared row would
|
||
// be counted again and this would jump to 4730.
|
||
assert.Equal(t, 30, fork.TotalTokens,
|
||
"fork.TotalTokens want 30 "+
|
||
"(shared message should be deduped)")
|
||
|
||
// Total across both entries must equal the undeduped
|
||
// message sum: parent 4700 + fork 30 = 4730.
|
||
total := parent.TotalTokens + fork.TotalTokens
|
||
assert.Equal(t, 4730, total, "sum of per-session totals")
|
||
}
|
||
|
||
func TestGetTopSessionsByCost_DedupesBySourceUUIDWhenClaudePairIncomplete(
|
||
t *testing.T,
|
||
) {
|
||
d := testDB(t)
|
||
ctx := context.Background()
|
||
|
||
requireNoError(t, d.UpsertModelPricing([]ModelPricing{{
|
||
ModelPattern: "claude-sonnet",
|
||
InputPerMTok: 3.0,
|
||
OutputPerMTok: 15.0,
|
||
CacheCreationPerMTok: 3.75,
|
||
CacheReadPerMTok: 0.30,
|
||
}}), "UpsertModelPricing")
|
||
|
||
insertSession(t, d, "s-parent", "proj", func(s *Session) {
|
||
s.Agent = "claude"
|
||
s.StartedAt = new("2024-06-15T10:00:00Z")
|
||
})
|
||
insertSession(t, d, "s-fork", "proj", func(s *Session) {
|
||
s.Agent = "claude"
|
||
s.StartedAt = new("2024-06-15T10:01:00Z")
|
||
s.ParentSessionID = new("s-parent")
|
||
s.RelationshipType = "fork"
|
||
})
|
||
|
||
shared := json.RawMessage(
|
||
`{"input_tokens":1000,"output_tokens":500,` +
|
||
`"cache_creation_input_tokens":200,` +
|
||
`"cache_read_input_tokens":3000}`)
|
||
unique := json.RawMessage(
|
||
`{"input_tokens":10,"output_tokens":20,` +
|
||
`"cache_creation_input_tokens":0,` +
|
||
`"cache_read_input_tokens":0}`)
|
||
|
||
insertMessages(t, d, Message{
|
||
SessionID: "s-parent", Ordinal: 0,
|
||
Role: "assistant", Timestamp: "2024-06-15T10:02:00Z",
|
||
Model: "claude-sonnet", TokenUsage: shared,
|
||
ClaudeMessageID: "msg_dup", SourceUUID: "source_dup",
|
||
})
|
||
insertMessages(t, d, Message{
|
||
SessionID: "s-fork", Ordinal: 0,
|
||
Role: "assistant", Timestamp: "2024-06-15T10:03:00Z",
|
||
Model: "claude-sonnet", TokenUsage: shared,
|
||
ClaudeMessageID: "msg_dup", SourceUUID: "source_dup",
|
||
})
|
||
insertMessages(t, d, Message{
|
||
SessionID: "s-fork", Ordinal: 1,
|
||
Role: "assistant", Timestamp: "2024-06-15T10:04:00Z",
|
||
Model: "claude-sonnet", TokenUsage: unique,
|
||
ClaudeMessageID: "msg_uniq", SourceUUID: "source_uniq",
|
||
})
|
||
|
||
top, err := d.GetTopSessionsByCost(ctx, UsageFilter{
|
||
From: "2024-06-15", To: "2024-06-15", Timezone: "UTC",
|
||
}, 20)
|
||
requireNoError(t, err, "GetTopSessionsByCost")
|
||
|
||
require.Len(t, top, 2, "len")
|
||
byID := map[string]TopSessionEntry{}
|
||
for _, e := range top {
|
||
byID[e.SessionID] = e
|
||
}
|
||
|
||
parent, ok := byID["s-parent"]
|
||
require.True(t, ok, "s-parent missing from top sessions")
|
||
assert.Equal(t, 4700, parent.TotalTokens, "parent.TotalTokens")
|
||
|
||
fork, ok := byID["s-fork"]
|
||
require.True(t, ok, "s-fork missing from top sessions")
|
||
assert.Equal(t, 30, fork.TotalTokens, "fork.TotalTokens want 30")
|
||
}
|
||
|
||
func TestGetTopSessionsByCostLimit(t *testing.T) {
|
||
d := testDB(t)
|
||
ctx := context.Background()
|
||
|
||
requireNoError(t, d.UpsertModelPricing([]ModelPricing{{
|
||
ModelPattern: "claude-sonnet",
|
||
InputPerMTok: 3.0,
|
||
OutputPerMTok: 15.0,
|
||
}}), "UpsertModelPricing")
|
||
|
||
for i := range 5 {
|
||
sid := "sess-" + strconv.Itoa(i)
|
||
insertSession(t, d, sid, "proj", func(s *Session) {
|
||
s.Agent = "claude"
|
||
s.StartedAt = new("2024-06-15T10:00:00Z")
|
||
})
|
||
insertMessages(t, d, Message{
|
||
SessionID: sid, Ordinal: 0,
|
||
Role: "assistant", Timestamp: "2024-06-15T10:30:00Z",
|
||
Model: "claude-sonnet",
|
||
TokenUsage: json.RawMessage(
|
||
`{"input_tokens":1000,"output_tokens":500}`),
|
||
})
|
||
}
|
||
|
||
top, err := d.GetTopSessionsByCost(ctx, UsageFilter{
|
||
From: "2024-06-01",
|
||
To: "2024-06-30",
|
||
}, 3)
|
||
requireNoError(t, err, "GetTopSessionsByCost limit")
|
||
|
||
require.Len(t, top, 3, "len")
|
||
}
|
||
|
||
func TestGetUsageSessionCounts(t *testing.T) {
|
||
d := testDB(t)
|
||
ctx := context.Background()
|
||
|
||
// s1: proj-a / claude — TWO messages across TWO days
|
||
insertSession(t, d, "s1", "proj-a", func(s *Session) {
|
||
s.Agent = "claude"
|
||
s.StartedAt = new("2024-06-15T10:00:00Z")
|
||
})
|
||
insertMessages(t, d,
|
||
Message{
|
||
SessionID: "s1", Ordinal: 0,
|
||
Role: "assistant", Timestamp: "2024-06-15T10:30:00Z",
|
||
Model: "claude-sonnet",
|
||
TokenUsage: json.RawMessage(
|
||
`{"input_tokens":100,"output_tokens":50}`),
|
||
},
|
||
Message{
|
||
SessionID: "s1", Ordinal: 1,
|
||
Role: "assistant", Timestamp: "2024-06-16T10:30:00Z",
|
||
Model: "claude-sonnet",
|
||
TokenUsage: json.RawMessage(
|
||
`{"input_tokens":200,"output_tokens":100}`),
|
||
},
|
||
)
|
||
|
||
// s2: proj-a / codex
|
||
insertSession(t, d, "s2", "proj-a", func(s *Session) {
|
||
s.Agent = "codex"
|
||
s.StartedAt = new("2024-06-15T11:00:00Z")
|
||
})
|
||
insertMessages(t, d, Message{
|
||
SessionID: "s2", Ordinal: 0,
|
||
Role: "assistant", Timestamp: "2024-06-15T11:30:00Z",
|
||
Model: "claude-sonnet",
|
||
TokenUsage: json.RawMessage(
|
||
`{"input_tokens":100,"output_tokens":50}`),
|
||
})
|
||
|
||
// s3: proj-b / claude
|
||
insertSession(t, d, "s3", "proj-b", func(s *Session) {
|
||
s.Agent = "claude"
|
||
s.StartedAt = new("2024-06-15T12:00:00Z")
|
||
})
|
||
insertMessages(t, d, Message{
|
||
SessionID: "s3", Ordinal: 0,
|
||
Role: "assistant", Timestamp: "2024-06-15T12:30:00Z",
|
||
Model: "claude-sonnet",
|
||
TokenUsage: json.RawMessage(
|
||
`{"input_tokens":100,"output_tokens":50}`),
|
||
})
|
||
|
||
counts, err := d.GetUsageSessionCounts(ctx, UsageFilter{
|
||
From: "2024-06-01",
|
||
To: "2024-06-30",
|
||
})
|
||
requireNoError(t, err, "GetUsageSessionCounts")
|
||
|
||
assert.Equal(t, 3, counts.Total, "Total")
|
||
assert.Equal(t, 2, counts.ByProject["proj-a"], "ByProject[proj-a]")
|
||
assert.Equal(t, 1, counts.ByProject["proj-b"], "ByProject[proj-b]")
|
||
assert.Equal(t, 2, counts.ByAgent["claude"], "ByAgent[claude]")
|
||
assert.Equal(t, 1, counts.ByAgent["codex"], "ByAgent[codex]")
|
||
|
||
daily, err := d.GetDailyUsage(ctx, UsageFilter{
|
||
From: "2024-06-01",
|
||
To: "2024-06-30",
|
||
})
|
||
requireNoError(t, err, "GetDailyUsage")
|
||
assert.Equal(t, counts.Total, daily.SessionCounts.Total)
|
||
assert.Equal(t, counts.ByAgent, daily.SessionCounts.ByAgent)
|
||
projectCounts := make(map[string]int, len(daily.Projects))
|
||
for key, project := range daily.Projects {
|
||
projectCounts[project.DisplayLabel] = daily.SessionCounts.ByProject[key]
|
||
assert.NotContains(t, key, project.DisplayLabel)
|
||
}
|
||
assert.Equal(t, counts.ByProject, projectCounts)
|
||
|
||
dailyNoCounts, err := d.GetDailyUsage(ctx, UsageFilter{
|
||
From: "2024-06-01",
|
||
To: "2024-06-30",
|
||
SkipSessionCounts: true,
|
||
})
|
||
requireNoError(t, err, "GetDailyUsage skip counts")
|
||
assert.Equal(t, daily.Daily, dailyNoCounts.Daily)
|
||
assert.Equal(t, daily.Totals, dailyNoCounts.Totals)
|
||
assert.Zero(t, dailyNoCounts.SessionCounts.Total)
|
||
assert.Nil(t, dailyNoCounts.SessionCounts.ByProject)
|
||
assert.Nil(t, dailyNoCounts.SessionCounts.ByAgent)
|
||
}
|
||
|
||
func TestGetUsageMatchingSessionCount(t *testing.T) {
|
||
d := testDB(t)
|
||
ctx := context.Background()
|
||
|
||
insertSession(t, d, "copilot-empty", "proj-a", func(s *Session) {
|
||
s.Agent = "copilot"
|
||
s.StartedAt = new("2024-06-15T10:00:00Z")
|
||
s.EndedAt = new("2024-06-15T10:00:00Z")
|
||
})
|
||
insertMessages(t, d, Message{
|
||
SessionID: "copilot-empty", Ordinal: 0,
|
||
Role: "assistant", Timestamp: "2024-06-15T10:00:00Z",
|
||
Model: "gpt-5.3-codex",
|
||
})
|
||
|
||
insertSession(t, d, "claude-usage", "proj-a", func(s *Session) {
|
||
s.Agent = "claude"
|
||
s.StartedAt = new("2024-06-15T11:00:00Z")
|
||
s.EndedAt = new("2024-06-15T11:00:00Z")
|
||
})
|
||
insertMessages(t, d, Message{
|
||
SessionID: "claude-usage", Ordinal: 0,
|
||
Role: "assistant", Timestamp: "2024-06-15T11:00:00Z",
|
||
Model: "claude-sonnet",
|
||
TokenUsage: json.RawMessage(
|
||
`{"input_tokens":100,"output_tokens":50}`),
|
||
})
|
||
|
||
tests := []struct {
|
||
name string
|
||
filter UsageFilter
|
||
want int
|
||
}{
|
||
{
|
||
name: "counts copilot sessions without usage rows",
|
||
filter: UsageFilter{
|
||
From: "2024-06-15", To: "2024-06-15",
|
||
Timezone: "UTC", Agent: "copilot",
|
||
},
|
||
want: 1,
|
||
},
|
||
{
|
||
name: "respects model filters from session messages",
|
||
filter: UsageFilter{
|
||
From: "2024-06-15", To: "2024-06-15",
|
||
Timezone: "UTC", Agent: "copilot", Model: "gpt-5.3-codex",
|
||
},
|
||
want: 1,
|
||
},
|
||
{
|
||
name: "excludes sessions when model is excluded",
|
||
filter: UsageFilter{
|
||
From: "2024-06-15", To: "2024-06-15",
|
||
Timezone: "UTC", Agent: "copilot", ExcludeModel: "gpt-5.3-codex",
|
||
},
|
||
want: 0,
|
||
},
|
||
{
|
||
name: "respects date range",
|
||
filter: UsageFilter{
|
||
From: "2024-06-16", To: "2024-06-16",
|
||
Timezone: "UTC", Agent: "copilot",
|
||
},
|
||
want: 0,
|
||
},
|
||
}
|
||
|
||
for _, tt := range tests {
|
||
t.Run(tt.name, func(t *testing.T) {
|
||
got, err := d.GetUsageMatchingSessionCount(ctx, tt.filter)
|
||
requireNoError(t, err, "GetUsageMatchingSessionCount")
|
||
assert.Equal(t, tt.want, got)
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestGetUsageMatchingSessionCount_UsesMessageTimestampNotSessionActivity
|
||
// guards against regressing to session-activity bounding: a Copilot
|
||
// session whose started_at/ended_at fall outside the requested window but
|
||
// whose message is timestamped inside it must still be counted, because
|
||
// GetDailyUsage and GetUsageSessionCounts already bound on message/event
|
||
// timestamps, not session activity.
|
||
func TestGetUsageMatchingSessionCount_UsesMessageTimestampNotSessionActivity(
|
||
t *testing.T,
|
||
) {
|
||
d := testDB(t)
|
||
ctx := context.Background()
|
||
|
||
insertSession(t, d, "copilot-late-message", "proj-a", func(s *Session) {
|
||
s.Agent = "copilot"
|
||
s.StartedAt = new("2026-02-08T10:00:00Z")
|
||
s.EndedAt = new("2026-02-08T10:00:00Z")
|
||
})
|
||
insertMessages(t, d, Message{
|
||
SessionID: "copilot-late-message", Ordinal: 0,
|
||
Role: "assistant", Timestamp: "2026-02-10T12:00:00Z",
|
||
Model: "gpt-5.3-codex",
|
||
TokenUsage: nil,
|
||
})
|
||
|
||
insertSession(t, d, "copilot-out-of-range", "proj-a", func(s *Session) {
|
||
s.Agent = "copilot"
|
||
s.StartedAt = new("2026-02-08T10:00:00Z")
|
||
s.EndedAt = new("2026-02-08T10:00:00Z")
|
||
})
|
||
insertMessages(t, d, Message{
|
||
SessionID: "copilot-out-of-range", Ordinal: 0,
|
||
Role: "assistant", Timestamp: "2026-02-08T10:00:00Z",
|
||
Model: "gpt-5.3-codex",
|
||
TokenUsage: nil,
|
||
})
|
||
|
||
filter := UsageFilter{
|
||
From: "2026-02-10", To: "2026-02-10",
|
||
Timezone: "UTC", Agent: "copilot",
|
||
}
|
||
|
||
got, err := d.GetUsageMatchingSessionCount(ctx, filter)
|
||
requireNoError(t, err, "GetUsageMatchingSessionCount")
|
||
assert.Equal(t, 1, got)
|
||
}
|
||
|
||
// TestGetUsageMatchingSessionCount_ModelFilterAppliesToBoundedRow guards
|
||
// against the model/exclude-model predicate being applied session-wide
|
||
// instead of to the in-range message/event row: a session with an
|
||
// out-of-range message on the filtered model but an in-range message on a
|
||
// different model must not match a Model filter for the out-of-range
|
||
// model, and must not be excluded by an ExcludeModel filter for the
|
||
// out-of-range model either.
|
||
func TestGetUsageMatchingSessionCount_ModelFilterAppliesToBoundedRow(
|
||
t *testing.T,
|
||
) {
|
||
d := testDB(t)
|
||
ctx := context.Background()
|
||
|
||
insertSession(t, d, "copilot-mixed-model", "proj-a", func(s *Session) {
|
||
s.Agent = "copilot"
|
||
s.StartedAt = new("2026-02-08T10:00:00Z")
|
||
s.EndedAt = new("2026-02-10T12:00:00Z")
|
||
})
|
||
insertMessages(t, d,
|
||
Message{
|
||
SessionID: "copilot-mixed-model", Ordinal: 0,
|
||
Role: "assistant", Timestamp: "2026-02-08T10:00:00Z",
|
||
Model: "gpt-5.3-codex",
|
||
TokenUsage: nil,
|
||
},
|
||
Message{
|
||
SessionID: "copilot-mixed-model", Ordinal: 1,
|
||
Role: "assistant", Timestamp: "2026-02-10T12:00:00Z",
|
||
Model: "claude-sonnet",
|
||
TokenUsage: nil,
|
||
},
|
||
)
|
||
|
||
inRangeFilter := UsageFilter{
|
||
From: "2026-02-10", To: "2026-02-10",
|
||
Timezone: "UTC", Agent: "copilot",
|
||
}
|
||
|
||
got, err := d.GetUsageMatchingSessionCount(ctx, UsageFilter{
|
||
From: inRangeFilter.From, To: inRangeFilter.To,
|
||
Timezone: inRangeFilter.Timezone, Agent: inRangeFilter.Agent,
|
||
Model: "gpt-5.3-codex",
|
||
})
|
||
requireNoError(t, err, "GetUsageMatchingSessionCount with Model")
|
||
assert.Equal(t, 0, got,
|
||
"out-of-range message's model must not match the bounded window")
|
||
|
||
got, err = d.GetUsageMatchingSessionCount(ctx, UsageFilter{
|
||
From: inRangeFilter.From, To: inRangeFilter.To,
|
||
Timezone: inRangeFilter.Timezone, Agent: inRangeFilter.Agent,
|
||
ExcludeModel: "gpt-5.3-codex",
|
||
})
|
||
requireNoError(t, err, "GetUsageMatchingSessionCount with ExcludeModel")
|
||
assert.Equal(t, 1, got,
|
||
"in-range message's model is not excluded, so the session must still count")
|
||
}
|
||
|
||
// TestGetUsageMatchingSessionCount_CountsAssistantMessageWithNoModel
|
||
// guards against gating matching-session eligibility on m.model != ”:
|
||
// some Copilot assistant messages parse before a model name is known, so
|
||
// an assistant message with an empty Model must still count toward the
|
||
// matching-session total when no Model/ExcludeModel filter narrows it.
|
||
func TestGetUsageMatchingSessionCount_CountsAssistantMessageWithNoModel(
|
||
t *testing.T,
|
||
) {
|
||
d := testDB(t)
|
||
ctx := context.Background()
|
||
|
||
insertSession(t, d, "copilot-no-model", "proj-a", func(s *Session) {
|
||
s.Agent = "copilot"
|
||
s.StartedAt = new("2026-02-10T10:00:00Z")
|
||
s.EndedAt = new("2026-02-10T10:00:00Z")
|
||
})
|
||
insertMessages(t, d, Message{
|
||
SessionID: "copilot-no-model", Ordinal: 0,
|
||
Role: "assistant", Timestamp: "2026-02-10T10:00:00Z",
|
||
Model: "",
|
||
TokenUsage: nil,
|
||
})
|
||
|
||
got, err := d.GetUsageMatchingSessionCount(ctx, UsageFilter{
|
||
From: "2026-02-10", To: "2026-02-10",
|
||
Timezone: "UTC", Agent: "copilot",
|
||
})
|
||
requireNoError(t, err, "GetUsageMatchingSessionCount")
|
||
assert.Equal(t, 1, got,
|
||
"assistant message with no model must still count without a model filter")
|
||
}
|
||
|
||
// TestGetUsageMatchingSessionCount_UnboundedMatchesBoundedSemantics guards
|
||
// against the unbounded (no From/To) branch drifting from the bounded
|
||
// branch: both must require an assistant, non-synthetic message (or a
|
||
// usage_events row with a model), both must admit empty-model assistant
|
||
// messages, and Model/ExcludeModel must narrow the same rows.
|
||
func TestGetUsageMatchingSessionCount_UnboundedMatchesBoundedSemantics(
|
||
t *testing.T,
|
||
) {
|
||
d := testDB(t)
|
||
ctx := context.Background()
|
||
|
||
insertSession(t, d, "copilot-user-only", "proj-a", func(s *Session) {
|
||
s.Agent = "copilot"
|
||
s.StartedAt = new("2026-03-01T10:00:00Z")
|
||
s.EndedAt = new("2026-03-01T10:00:00Z")
|
||
})
|
||
insertMessages(t, d, Message{
|
||
SessionID: "copilot-user-only", Ordinal: 0,
|
||
Role: "user", Timestamp: "2026-03-01T10:00:00Z",
|
||
})
|
||
|
||
insertSession(t, d, "copilot-synthetic-only", "proj-a", func(s *Session) {
|
||
s.Agent = "copilot"
|
||
s.StartedAt = new("2026-03-01T11:00:00Z")
|
||
s.EndedAt = new("2026-03-01T11:00:00Z")
|
||
})
|
||
insertMessages(t, d, Message{
|
||
SessionID: "copilot-synthetic-only", Ordinal: 0,
|
||
Role: "assistant", Timestamp: "2026-03-01T11:00:00Z",
|
||
Model: "<synthetic>",
|
||
})
|
||
|
||
insertSession(t, d, "copilot-no-model-msg", "proj-a", func(s *Session) {
|
||
s.Agent = "copilot"
|
||
s.StartedAt = new("2026-03-01T12:00:00Z")
|
||
s.EndedAt = new("2026-03-01T12:00:00Z")
|
||
})
|
||
insertMessages(t, d, Message{
|
||
SessionID: "copilot-no-model-msg", Ordinal: 0,
|
||
Role: "assistant", Timestamp: "2026-03-01T12:00:00Z",
|
||
Model: "",
|
||
})
|
||
|
||
insertSession(t, d, "copilot-no-messages", "proj-a", func(s *Session) {
|
||
s.Agent = "copilot"
|
||
s.StartedAt = new("2026-03-01T13:00:00Z")
|
||
s.EndedAt = new("2026-03-01T13:00:00Z")
|
||
})
|
||
|
||
tests := []struct {
|
||
name string
|
||
filter UsageFilter
|
||
want int
|
||
}{
|
||
{
|
||
name: "unbounded requires assistant or event activity",
|
||
filter: UsageFilter{Timezone: "UTC", Agent: "copilot"},
|
||
// Only copilot-no-model-msg has a qualifying assistant
|
||
// message; user-only, synthetic-only, and message-less
|
||
// sessions must not count.
|
||
want: 1,
|
||
},
|
||
{
|
||
name: "unbounded exclude-model keeps empty-model assistant messages",
|
||
filter: UsageFilter{
|
||
Timezone: "UTC", Agent: "copilot",
|
||
ExcludeModel: "gpt-5.3-codex",
|
||
},
|
||
want: 1,
|
||
},
|
||
{
|
||
name: "unbounded model filter narrows to matching rows",
|
||
filter: UsageFilter{
|
||
Timezone: "UTC", Agent: "copilot", Model: "gpt-5.3-codex",
|
||
},
|
||
want: 0,
|
||
},
|
||
}
|
||
|
||
for _, tt := range tests {
|
||
t.Run(tt.name, func(t *testing.T) {
|
||
got, err := d.GetUsageMatchingSessionCount(ctx, tt.filter)
|
||
requireNoError(t, err, "GetUsageMatchingSessionCount")
|
||
assert.Equal(t, tt.want, got)
|
||
|
||
bounded := tt.filter
|
||
bounded.From = "2026-03-01"
|
||
bounded.To = "2026-03-01"
|
||
boundedGot, err := d.GetUsageMatchingSessionCount(ctx, bounded)
|
||
requireNoError(t, err, "GetUsageMatchingSessionCount bounded")
|
||
assert.Equal(t, got, boundedGot,
|
||
"bounded and unbounded requests must match the same sessions")
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestNewUsageSessionCounts(t *testing.T) {
|
||
counts := NewUsageSessionCounts(map[string]UsageSessionInfo{
|
||
"s1": {Project: "proj-a", Agent: "claude"},
|
||
"s2": {Project: "proj-a", Agent: "codex"},
|
||
"s3": {Project: "proj-b", Agent: "claude"},
|
||
})
|
||
|
||
assert.Equal(t, 3, counts.Total, "Total")
|
||
assert.Equal(t, map[string]int{
|
||
"proj-a": 2,
|
||
"proj-b": 1,
|
||
}, counts.ByProject, "ByProject")
|
||
assert.Equal(t, map[string]int{
|
||
"claude": 2,
|
||
"codex": 1,
|
||
}, counts.ByAgent, "ByAgent")
|
||
}
|
||
|
||
// TestGetUsageSessionCounts_DedupesByClaudeMessageAndRequestID
|
||
// mirrors the dedup regression coverage on the other two usage
|
||
// queries. A fork session whose only qualifying messages are
|
||
// replays of its parent's (same claude_message_id +
|
||
// claude_request_id) contributes zero cost after dedup in
|
||
// GetDailyUsage, so it must also NOT be counted in
|
||
// GetUsageSessionCounts — otherwise the summary cards disagree
|
||
// with the charts.
|
||
func TestGetUsageSessionCounts_DedupesByClaudeMessageAndRequestID(
|
||
t *testing.T,
|
||
) {
|
||
d := testDB(t)
|
||
ctx := context.Background()
|
||
|
||
// Parent starts first.
|
||
insertSession(t, d, "s-parent", "proj", func(s *Session) {
|
||
s.Agent = "claude"
|
||
s.StartedAt = new("2024-06-15T10:00:00Z")
|
||
})
|
||
// Fork starts a minute later.
|
||
insertSession(t, d, "s-fork", "proj", func(s *Session) {
|
||
s.Agent = "claude"
|
||
s.StartedAt = new("2024-06-15T10:01:00Z")
|
||
s.ParentSessionID = new("s-parent")
|
||
s.RelationshipType = "fork"
|
||
})
|
||
|
||
shared := json.RawMessage(
|
||
`{"input_tokens":100,"output_tokens":50}`)
|
||
|
||
// Parent has one unique message.
|
||
insertMessages(t, d, Message{
|
||
SessionID: "s-parent", Ordinal: 0,
|
||
Role: "assistant", Timestamp: "2024-06-15T10:02:00Z",
|
||
Model: "claude-sonnet", TokenUsage: shared,
|
||
ClaudeMessageID: "msg_dup", ClaudeRequestID: "req_dup",
|
||
})
|
||
// Fork's ONLY qualifying message is a replay of the parent
|
||
// row — same claude IDs. After dedup the fork contributes
|
||
// nothing and must not be counted.
|
||
insertMessages(t, d, Message{
|
||
SessionID: "s-fork", Ordinal: 0,
|
||
Role: "assistant", Timestamp: "2024-06-15T10:03:00Z",
|
||
Model: "claude-sonnet", TokenUsage: shared,
|
||
ClaudeMessageID: "msg_dup", ClaudeRequestID: "req_dup",
|
||
})
|
||
|
||
counts, err := d.GetUsageSessionCounts(ctx, UsageFilter{
|
||
From: "2024-06-15", To: "2024-06-15", Timezone: "UTC",
|
||
})
|
||
requireNoError(t, err, "GetUsageSessionCounts")
|
||
|
||
assert.Equal(t, 1, counts.Total,
|
||
"Total want 1 (fork should dedup out)")
|
||
assert.Equal(t, 1, counts.ByProject["proj"], "ByProject[proj]")
|
||
assert.Equal(t, 1, counts.ByAgent["claude"], "ByAgent[claude]")
|
||
}
|
||
|
||
func TestGetUsageSessionCounts_DedupesBySourceUUIDWhenClaudePairIncomplete(
|
||
t *testing.T,
|
||
) {
|
||
d := testDB(t)
|
||
ctx := context.Background()
|
||
|
||
insertSession(t, d, "s-parent", "proj", func(s *Session) {
|
||
s.Agent = "claude"
|
||
s.StartedAt = new("2024-06-15T10:00:00Z")
|
||
})
|
||
insertSession(t, d, "s-fork", "proj", func(s *Session) {
|
||
s.Agent = "claude"
|
||
s.StartedAt = new("2024-06-15T10:01:00Z")
|
||
s.ParentSessionID = new("s-parent")
|
||
s.RelationshipType = "fork"
|
||
})
|
||
|
||
shared := json.RawMessage(`{"input_tokens":100,"output_tokens":50}`)
|
||
|
||
insertMessages(t, d, Message{
|
||
SessionID: "s-parent", Ordinal: 0,
|
||
Role: "assistant", Timestamp: "2024-06-15T10:02:00Z",
|
||
Model: "claude-sonnet", TokenUsage: shared,
|
||
ClaudeMessageID: "msg_dup", SourceUUID: "source_dup",
|
||
})
|
||
insertMessages(t, d, Message{
|
||
SessionID: "s-fork", Ordinal: 0,
|
||
Role: "assistant", Timestamp: "2024-06-15T10:03:00Z",
|
||
Model: "claude-sonnet", TokenUsage: shared,
|
||
ClaudeMessageID: "msg_dup", SourceUUID: "source_dup",
|
||
})
|
||
|
||
counts, err := d.GetUsageSessionCounts(ctx, UsageFilter{
|
||
From: "2024-06-15", To: "2024-06-15", Timezone: "UTC",
|
||
})
|
||
requireNoError(t, err, "GetUsageSessionCounts")
|
||
|
||
assert.Equal(t, 1, counts.Total, "Total want 1 (fork should dedup out)")
|
||
assert.Equal(t, 1, counts.ByProject["proj"], "ByProject[proj]")
|
||
assert.Equal(t, 1, counts.ByAgent["claude"], "ByAgent[claude]")
|
||
}
|
||
|
||
// TestUsageQueryEligibilityParity seeds messages that fail each
|
||
// disqualification predicate and asserts all three usage queries
|
||
// ignore them. Guardrail against drift between usage queries.
|
||
func TestUsageQueryEligibilityParity(t *testing.T) {
|
||
d := testDB(t)
|
||
ctx := context.Background()
|
||
|
||
requireNoError(t, d.UpsertModelPricing([]ModelPricing{{
|
||
ModelPattern: "claude-sonnet",
|
||
InputPerMTok: 3.0,
|
||
OutputPerMTok: 15.0,
|
||
}}), "UpsertModelPricing")
|
||
|
||
// Good session — should be visible to all queries.
|
||
insertSession(t, d, "good", "proj", func(s *Session) {
|
||
s.Agent = "claude"
|
||
s.StartedAt = new("2024-06-15T10:00:00Z")
|
||
})
|
||
insertMessages(t, d, Message{
|
||
SessionID: "good", Ordinal: 0,
|
||
Role: "assistant", Timestamp: "2024-06-15T10:30:00Z",
|
||
Model: "claude-sonnet",
|
||
TokenUsage: json.RawMessage(
|
||
`{"input_tokens":1000,"output_tokens":500}`),
|
||
})
|
||
|
||
// Bad: empty token_usage
|
||
insertSession(t, d, "bad-empty", "proj", func(s *Session) {
|
||
s.Agent = "claude"
|
||
s.StartedAt = new("2024-06-15T10:00:00Z")
|
||
})
|
||
insertMessages(t, d, Message{
|
||
SessionID: "bad-empty", Ordinal: 0,
|
||
Role: "assistant", Timestamp: "2024-06-15T10:30:00Z",
|
||
Model: "claude-sonnet",
|
||
TokenUsage: json.RawMessage(""),
|
||
})
|
||
|
||
// Bad: synthetic model
|
||
insertSession(t, d, "bad-synth", "proj", func(s *Session) {
|
||
s.Agent = "claude"
|
||
s.StartedAt = new("2024-06-15T10:00:00Z")
|
||
})
|
||
insertMessages(t, d, Message{
|
||
SessionID: "bad-synth", Ordinal: 0,
|
||
Role: "assistant", Timestamp: "2024-06-15T10:30:00Z",
|
||
Model: "<synthetic>",
|
||
TokenUsage: json.RawMessage(
|
||
`{"input_tokens":999,"output_tokens":999}`),
|
||
})
|
||
|
||
// Bad: soft-deleted session
|
||
insertSession(t, d, "bad-deleted", "proj", func(s *Session) {
|
||
s.Agent = "claude"
|
||
s.StartedAt = new("2024-06-15T10:00:00Z")
|
||
})
|
||
insertMessages(t, d, Message{
|
||
SessionID: "bad-deleted", Ordinal: 0,
|
||
Role: "assistant", Timestamp: "2024-06-15T10:30:00Z",
|
||
Model: "claude-sonnet",
|
||
TokenUsage: json.RawMessage(
|
||
`{"input_tokens":999,"output_tokens":999}`),
|
||
})
|
||
requireNoError(t,
|
||
d.SoftDeleteSession("bad-deleted"),
|
||
"SoftDeleteSession")
|
||
|
||
filter := UsageFilter{
|
||
From: "2024-06-01",
|
||
To: "2024-06-30",
|
||
Breakdowns: true,
|
||
}
|
||
|
||
// GetDailyUsage
|
||
daily, err := d.GetDailyUsage(ctx, filter)
|
||
requireNoError(t, err, "GetDailyUsage parity")
|
||
assert.Equal(t, 1000, daily.Totals.InputTokens, "GetDailyUsage InputTokens")
|
||
|
||
// GetUsageSessionCounts
|
||
counts, err := d.GetUsageSessionCounts(ctx, filter)
|
||
requireNoError(t, err, "GetUsageSessionCounts parity")
|
||
assert.Equal(t, 1, counts.Total, "GetUsageSessionCounts Total")
|
||
|
||
// GetTopSessionsByCost
|
||
top, err := d.GetTopSessionsByCost(ctx, filter, 20)
|
||
requireNoError(t, err, "GetTopSessionsByCost parity")
|
||
require.Len(t, top, 1, "GetTopSessionsByCost len")
|
||
assert.Equal(t, "good", top[0].SessionID,
|
||
"GetTopSessionsByCost[0].SessionID")
|
||
}
|
||
|
||
// TestExcludeProjectFilter verifies that ExcludeProject removes
|
||
// matching projects from all three usage queries.
|
||
func TestExcludeProjectFilter(t *testing.T) {
|
||
d := testDB(t)
|
||
ctx := context.Background()
|
||
|
||
requireNoError(t, d.UpsertModelPricing([]ModelPricing{{
|
||
ModelPattern: "claude-sonnet",
|
||
InputPerMTok: 3.0,
|
||
OutputPerMTok: 15.0,
|
||
}}), "UpsertModelPricing")
|
||
|
||
insertSession(t, d, "sA", "proj-a", func(s *Session) {
|
||
s.Agent = "claude"
|
||
s.StartedAt = new("2024-06-15T10:00:00Z")
|
||
})
|
||
insertSession(t, d, "sB", "proj-b", func(s *Session) {
|
||
s.Agent = "claude"
|
||
s.StartedAt = new("2024-06-15T10:00:00Z")
|
||
})
|
||
insertSession(t, d, "sC", "proj-c", func(s *Session) {
|
||
s.Agent = "claude"
|
||
s.StartedAt = new("2024-06-15T10:00:00Z")
|
||
})
|
||
|
||
usage := `{"input_tokens":1000,"output_tokens":500}`
|
||
insertMessages(t, d,
|
||
Message{SessionID: "sA", Ordinal: 0, Role: "assistant",
|
||
Timestamp: "2024-06-15T10:30:00Z", Model: "claude-sonnet",
|
||
TokenUsage: json.RawMessage(usage)},
|
||
Message{SessionID: "sB", Ordinal: 0, Role: "assistant",
|
||
Timestamp: "2024-06-15T10:30:00Z", Model: "claude-sonnet",
|
||
TokenUsage: json.RawMessage(usage)},
|
||
Message{SessionID: "sC", Ordinal: 0, Role: "assistant",
|
||
Timestamp: "2024-06-15T10:30:00Z", Model: "claude-sonnet",
|
||
TokenUsage: json.RawMessage(usage)},
|
||
)
|
||
|
||
base := UsageFilter{From: "2024-06-01", To: "2024-06-30"}
|
||
|
||
// Exclude one project.
|
||
f1 := base
|
||
f1.ExcludeProject = "proj-b"
|
||
daily, err := d.GetDailyUsage(ctx, f1)
|
||
requireNoError(t, err, "GetDailyUsage exclude one")
|
||
assert.Equal(t, 2000, daily.Totals.InputTokens, "exclude proj-b: InputTokens")
|
||
|
||
// Exclude two projects (comma-separated).
|
||
f2 := base
|
||
f2.ExcludeProject = "proj-a,proj-c"
|
||
daily, err = d.GetDailyUsage(ctx, f2)
|
||
requireNoError(t, err, "GetDailyUsage exclude two")
|
||
assert.Equal(t, 1000, daily.Totals.InputTokens, "exclude a+c: InputTokens")
|
||
|
||
// GetTopSessionsByCost with exclude.
|
||
top, err := d.GetTopSessionsByCost(ctx, f1, 10)
|
||
requireNoError(t, err, "GetTopSessionsByCost exclude")
|
||
require.Len(t, top, 2, "exclude proj-b: top len =")
|
||
for _, ts := range top {
|
||
assert.NotEqual(t, "proj-b", ts.Project,
|
||
"excluded proj-b still in top sessions")
|
||
}
|
||
|
||
// GetUsageSessionCounts with exclude.
|
||
counts, err := d.GetUsageSessionCounts(ctx, f1)
|
||
requireNoError(t, err, "GetUsageSessionCounts exclude")
|
||
assert.Equal(t, 2, counts.Total, "exclude proj-b: Total")
|
||
assert.Equal(t, 0, counts.ByProject["proj-b"], "excluded proj-b count")
|
||
}
|
||
|
||
func TestUsageSessionFilters(t *testing.T) {
|
||
d := testDB(t)
|
||
ctx := context.Background()
|
||
|
||
requireNoError(t, d.UpsertModelPricing([]ModelPricing{{
|
||
ModelPattern: "claude-sonnet",
|
||
InputPerMTok: 3.0,
|
||
OutputPerMTok: 15.0,
|
||
}}), "UpsertModelPricing")
|
||
|
||
tokenUsage := json.RawMessage(
|
||
`{"input_tokens":1000,"output_tokens":500}`,
|
||
)
|
||
|
||
insertSession(t, d, "usage-filter-keep", "proj", func(s *Session) {
|
||
s.Machine = "host-a"
|
||
s.Agent = "claude"
|
||
s.MessageCount = 4
|
||
s.UserMessageCount = 3
|
||
s.StartedAt = new("2024-06-15T10:00:00Z")
|
||
})
|
||
insertSession(t, d, "usage-filter-machine", "proj", func(s *Session) {
|
||
s.Machine = "host-b"
|
||
s.Agent = "claude"
|
||
s.MessageCount = 4
|
||
s.UserMessageCount = 3
|
||
s.StartedAt = new("2024-06-15T10:00:00Z")
|
||
})
|
||
insertSession(t, d, "usage-filter-prompts", "proj", func(s *Session) {
|
||
s.Machine = "host-a"
|
||
s.Agent = "claude"
|
||
s.MessageCount = 4
|
||
s.UserMessageCount = 1
|
||
s.StartedAt = new("2024-06-15T10:00:00Z")
|
||
})
|
||
insertSession(t, d, "usage-filter-one-shot", "proj", func(s *Session) {
|
||
s.Machine = "host-a"
|
||
s.Agent = "claude"
|
||
s.MessageCount = 1
|
||
s.UserMessageCount = 1
|
||
s.StartedAt = new("2024-06-15T10:00:00Z")
|
||
})
|
||
insertSession(t, d, "usage-filter-automated", "proj", func(s *Session) {
|
||
s.Machine = "host-a"
|
||
s.Agent = "claude"
|
||
s.MessageCount = 4
|
||
s.UserMessageCount = 3
|
||
s.StartedAt = new("2024-06-15T10:00:00Z")
|
||
})
|
||
_, err := d.getWriter().Exec(
|
||
"UPDATE sessions SET is_automated = 1 WHERE id = ?",
|
||
"usage-filter-automated",
|
||
)
|
||
require.NoError(t, err, "patch automated fixture")
|
||
|
||
for _, sid := range []string{
|
||
"usage-filter-keep",
|
||
"usage-filter-machine",
|
||
"usage-filter-prompts",
|
||
"usage-filter-one-shot",
|
||
"usage-filter-automated",
|
||
} {
|
||
insertMessages(t, d, Message{
|
||
SessionID: sid,
|
||
Ordinal: 0,
|
||
Role: "assistant",
|
||
Timestamp: "2024-06-15T10:30:00Z",
|
||
Model: "claude-sonnet",
|
||
TokenUsage: tokenUsage,
|
||
})
|
||
}
|
||
|
||
filter := UsageFilter{
|
||
From: "2024-06-01",
|
||
To: "2024-06-30",
|
||
Machine: "host-a",
|
||
MinUserMessages: 2,
|
||
ExcludeOneShot: true,
|
||
ExcludeAutomated: true,
|
||
}
|
||
|
||
daily, err := d.GetDailyUsage(ctx, filter)
|
||
requireNoError(t, err, "GetDailyUsage session filters")
|
||
assert.Equal(t, 1000, daily.Totals.InputTokens, "InputTokens")
|
||
|
||
top, err := d.GetTopSessionsByCost(ctx, filter, 10)
|
||
requireNoError(t, err, "GetTopSessionsByCost session filters")
|
||
require.Len(t, top, 1,
|
||
"top sessions want only usage-filter-keep: %+v", top)
|
||
require.Equal(t, "usage-filter-keep", top[0].SessionID,
|
||
"top sessions want only usage-filter-keep: %+v", top)
|
||
|
||
counts, err := d.GetUsageSessionCounts(ctx, filter)
|
||
requireNoError(t, err, "GetUsageSessionCounts session filters")
|
||
assert.Equal(t, 1, counts.Total, "counts.Total")
|
||
}
|
||
|
||
func TestUsageTerminationFilter(t *testing.T) {
|
||
d := testDB(t)
|
||
ctx := context.Background()
|
||
|
||
requireNoError(t, d.UpsertModelPricing([]ModelPricing{{
|
||
ModelPattern: "claude-sonnet",
|
||
InputPerMTok: 3.0,
|
||
OutputPerMTok: 15.0,
|
||
}}), "UpsertModelPricing")
|
||
|
||
clean := "clean"
|
||
unclean := "tool_call_pending"
|
||
tokenUsage := json.RawMessage(
|
||
`{"input_tokens":1000,"output_tokens":500}`,
|
||
)
|
||
insertSession(t, d, "usage-filter-clean", "proj", func(s *Session) {
|
||
s.MessageCount = 4
|
||
s.UserMessageCount = 3
|
||
s.TerminationStatus = &clean
|
||
s.StartedAt = new("2024-06-15T10:00:00Z")
|
||
})
|
||
insertSession(t, d, "usage-filter-unclean", "proj", func(s *Session) {
|
||
s.MessageCount = 4
|
||
s.UserMessageCount = 3
|
||
s.TerminationStatus = &unclean
|
||
s.StartedAt = new("2024-06-15T10:00:00Z")
|
||
})
|
||
for _, sid := range []string{
|
||
"usage-filter-clean",
|
||
"usage-filter-unclean",
|
||
} {
|
||
insertMessages(t, d, Message{
|
||
SessionID: sid,
|
||
Ordinal: 0,
|
||
Role: "assistant",
|
||
Timestamp: "2024-06-15T10:30:00Z",
|
||
Model: "claude-sonnet",
|
||
TokenUsage: tokenUsage,
|
||
})
|
||
}
|
||
|
||
filter := UsageFilter{
|
||
From: "2024-06-01",
|
||
To: "2024-06-30",
|
||
Termination: "clean",
|
||
}
|
||
daily, err := d.GetDailyUsage(ctx, filter)
|
||
requireNoError(t, err, "GetDailyUsage termination filter")
|
||
if daily.Totals.InputTokens != 1000 {
|
||
t.Errorf("InputTokens = %d, want 1000",
|
||
daily.Totals.InputTokens)
|
||
}
|
||
|
||
top, err := d.GetTopSessionsByCost(ctx, filter, 10)
|
||
requireNoError(t, err, "GetTopSessionsByCost termination filter")
|
||
if len(top) != 1 || top[0].SessionID != "usage-filter-clean" {
|
||
t.Fatalf("top sessions = %+v, want only usage-filter-clean", top)
|
||
}
|
||
|
||
counts, err := d.GetUsageSessionCounts(ctx, filter)
|
||
requireNoError(t, err, "GetUsageSessionCounts termination filter")
|
||
if counts.Total != 1 {
|
||
t.Errorf("counts.Total = %d, want 1", counts.Total)
|
||
}
|
||
}
|
||
|
||
// TestUsageActivityFallbackEmptyEndedAt guards the SQLite usage activity-time
|
||
// fallback. A session whose ended_at was never persisted is stored as the empty
|
||
// string, so COALESCE(s.ended_at, ...) returned ” and strftime('%s', ”)
|
||
// yielded NULL, silently dropping it from active_since and the active/stale/
|
||
// unclean termination filters. NULLIF must let the fallback reach started_at.
|
||
func TestUsageActivityFallbackEmptyEndedAt(t *testing.T) {
|
||
d := testDB(t)
|
||
ctx := context.Background()
|
||
|
||
require.NoError(t, d.UpsertModelPricing([]ModelPricing{{
|
||
ModelPattern: "claude-sonnet",
|
||
InputPerMTok: 3.0,
|
||
OutputPerMTok: 15.0,
|
||
}}), "UpsertModelPricing")
|
||
|
||
flagged := "tool_call_pending"
|
||
// started_at set; ended_at persisted as the empty string, the legacy
|
||
// state every other read query guards with NULLIF(ended_at, '').
|
||
insertSession(t, d, "untimed", "proj", func(s *Session) {
|
||
s.MessageCount = 4
|
||
s.UserMessageCount = 3
|
||
s.TerminationStatus = &flagged
|
||
s.StartedAt = new("2024-06-15T10:00:00Z")
|
||
s.EndedAt = new("")
|
||
})
|
||
insertMessages(t, d, Message{
|
||
SessionID: "untimed",
|
||
Ordinal: 0,
|
||
Role: "assistant",
|
||
Timestamp: "2024-06-15T10:30:00Z",
|
||
Model: "claude-sonnet",
|
||
TokenUsage: json.RawMessage(`{"input_tokens":1000,"output_tokens":500}`),
|
||
})
|
||
|
||
// active_since before started_at must keep the session via the fallback.
|
||
activeCounts, err := d.GetUsageSessionCounts(ctx, UsageFilter{
|
||
From: "2024-06-01",
|
||
To: "2024-06-30",
|
||
ActiveSince: "2024-06-01T00:00:00Z",
|
||
})
|
||
require.NoError(t, err, "GetUsageSessionCounts active_since")
|
||
assert.Equal(t, 1, activeCounts.Total,
|
||
"active_since must match empty-ended_at session via started_at")
|
||
|
||
// The unclean filter evaluates the activity epoch expression; the flagged
|
||
// session with an old started_at must be matched.
|
||
uncleanCounts, err := d.GetUsageSessionCounts(ctx, UsageFilter{
|
||
From: "2024-06-01",
|
||
To: "2024-06-30",
|
||
Termination: "unclean",
|
||
})
|
||
require.NoError(t, err, "GetUsageSessionCounts unclean")
|
||
assert.Equal(t, 1, uncleanCounts.Total,
|
||
"unclean must match flagged empty-ended_at session via started_at")
|
||
}
|
||
|
||
func TestUsageExcludeOneShotUsesUserMessageCount(t *testing.T) {
|
||
d := testDB(t)
|
||
ctx := context.Background()
|
||
|
||
requireNoError(t, d.UpsertModelPricing([]ModelPricing{{
|
||
ModelPattern: "claude-sonnet",
|
||
InputPerMTok: 3.0,
|
||
OutputPerMTok: 15.0,
|
||
}}), "UpsertModelPricing")
|
||
|
||
tokenUsage := json.RawMessage(
|
||
`{"input_tokens":1000,"output_tokens":500}`,
|
||
)
|
||
|
||
insertSession(t, d, "usage-one-user-message", "proj", func(s *Session) {
|
||
s.Agent = "claude"
|
||
s.MessageCount = 2
|
||
s.UserMessageCount = 1
|
||
s.StartedAt = new("2024-06-15T10:00:00Z")
|
||
})
|
||
insertSession(t, d, "usage-two-user-messages", "proj", func(s *Session) {
|
||
s.Agent = "claude"
|
||
s.MessageCount = 3
|
||
s.UserMessageCount = 2
|
||
s.StartedAt = new("2024-06-15T10:00:00Z")
|
||
})
|
||
|
||
for _, sid := range []string{
|
||
"usage-one-user-message",
|
||
"usage-two-user-messages",
|
||
} {
|
||
insertMessages(t, d, Message{
|
||
SessionID: sid,
|
||
Ordinal: 0,
|
||
Role: "assistant",
|
||
Timestamp: "2024-06-15T10:30:00Z",
|
||
Model: "claude-sonnet",
|
||
TokenUsage: tokenUsage,
|
||
})
|
||
}
|
||
|
||
filter := UsageFilter{
|
||
From: "2024-06-01",
|
||
To: "2024-06-30",
|
||
ExcludeOneShot: true,
|
||
}
|
||
|
||
daily, err := d.GetDailyUsage(ctx, filter)
|
||
requireNoError(t, err, "GetDailyUsage exclude one-shot")
|
||
assert.Equal(t, 1000, daily.Totals.InputTokens, "InputTokens")
|
||
|
||
top, err := d.GetTopSessionsByCost(ctx, filter, 10)
|
||
requireNoError(t, err, "GetTopSessionsByCost exclude one-shot")
|
||
require.Len(t, top, 1,
|
||
"top sessions want only usage-two-user-messages: %+v", top)
|
||
require.Equal(t, "usage-two-user-messages", top[0].SessionID,
|
||
"top sessions want only usage-two-user-messages: %+v", top)
|
||
|
||
counts, err := d.GetUsageSessionCounts(ctx, filter)
|
||
requireNoError(t, err, "GetUsageSessionCounts exclude one-shot")
|
||
assert.Equal(t, 1, counts.Total, "counts.Total")
|
||
}
|
||
|
||
// TestExcludeAgentFilter verifies ExcludeAgent on GetDailyUsage.
|
||
func TestExcludeAgentFilter(t *testing.T) {
|
||
d := testDB(t)
|
||
ctx := context.Background()
|
||
|
||
requireNoError(t, d.UpsertModelPricing([]ModelPricing{{
|
||
ModelPattern: "claude-sonnet",
|
||
InputPerMTok: 3.0,
|
||
OutputPerMTok: 15.0,
|
||
}}), "UpsertModelPricing")
|
||
|
||
insertSession(t, d, "s1", "proj", func(s *Session) {
|
||
s.Agent = "claude"
|
||
s.StartedAt = new("2024-06-15T10:00:00Z")
|
||
})
|
||
insertSession(t, d, "s2", "proj", func(s *Session) {
|
||
s.Agent = "codex"
|
||
s.StartedAt = new("2024-06-15T10:00:00Z")
|
||
})
|
||
|
||
usage := `{"input_tokens":1000,"output_tokens":500}`
|
||
insertMessages(t, d,
|
||
Message{SessionID: "s1", Ordinal: 0, Role: "assistant",
|
||
Timestamp: "2024-06-15T10:30:00Z", Model: "claude-sonnet",
|
||
TokenUsage: json.RawMessage(usage)},
|
||
Message{SessionID: "s2", Ordinal: 0, Role: "assistant",
|
||
Timestamp: "2024-06-15T10:30:00Z", Model: "claude-sonnet",
|
||
TokenUsage: json.RawMessage(usage)},
|
||
)
|
||
|
||
f := UsageFilter{
|
||
From: "2024-06-01",
|
||
To: "2024-06-30",
|
||
ExcludeAgent: "codex",
|
||
}
|
||
daily, err := d.GetDailyUsage(ctx, f)
|
||
requireNoError(t, err, "GetDailyUsage exclude agent")
|
||
assert.Equal(t, 1000, daily.Totals.InputTokens, "exclude codex: InputTokens")
|
||
}
|
||
|
||
// TestExcludeModelFilter verifies ExcludeModel on GetDailyUsage.
|
||
func TestExcludeModelFilter(t *testing.T) {
|
||
d := testDB(t)
|
||
ctx := context.Background()
|
||
|
||
requireNoError(t, d.UpsertModelPricing([]ModelPricing{
|
||
{ModelPattern: "sonnet", InputPerMTok: 3.0,
|
||
OutputPerMTok: 15.0},
|
||
{ModelPattern: "opus", InputPerMTok: 15.0,
|
||
OutputPerMTok: 75.0},
|
||
}), "UpsertModelPricing")
|
||
|
||
insertSession(t, d, "s1", "proj", func(s *Session) {
|
||
s.Agent = "claude"
|
||
s.StartedAt = new("2024-06-15T10:00:00Z")
|
||
})
|
||
|
||
insertMessages(t, d,
|
||
Message{SessionID: "s1", Ordinal: 0, Role: "assistant",
|
||
Timestamp: "2024-06-15T10:30:00Z", Model: "sonnet",
|
||
TokenUsage: json.RawMessage(
|
||
`{"input_tokens":1000,"output_tokens":500}`)},
|
||
Message{SessionID: "s1", Ordinal: 1, Role: "assistant",
|
||
Timestamp: "2024-06-15T11:30:00Z", Model: "opus",
|
||
TokenUsage: json.RawMessage(
|
||
`{"input_tokens":1000,"output_tokens":500}`)},
|
||
)
|
||
|
||
f := UsageFilter{
|
||
From: "2024-06-01",
|
||
To: "2024-06-30",
|
||
ExcludeModel: "opus",
|
||
}
|
||
daily, err := d.GetDailyUsage(ctx, f)
|
||
requireNoError(t, err, "GetDailyUsage exclude model")
|
||
assert.Equal(t, 1000, daily.Totals.InputTokens, "exclude opus: InputTokens")
|
||
require.Len(t, daily.Daily, 1, "daily len =")
|
||
for _, mb := range daily.Daily[0].ModelBreakdowns {
|
||
assert.NotEqual(t, "opus", mb.ModelName,
|
||
"excluded model opus still in breakdowns")
|
||
}
|
||
}
|
||
|
||
func BenchmarkGetDailyUsage(b *testing.B) {
|
||
d := testDB(b)
|
||
ctx := context.Background()
|
||
origLog := log.Writer()
|
||
log.SetOutput(io.Discard)
|
||
defer log.SetOutput(origLog)
|
||
|
||
if err := d.UpsertModelPricing([]ModelPricing{
|
||
{ModelPattern: "claude-sonnet-4-20250514",
|
||
InputPerMTok: 3.0, OutputPerMTok: 15.0,
|
||
CacheCreationPerMTok: 3.75, CacheReadPerMTok: 0.30},
|
||
{ModelPattern: "claude-opus-4-20250514",
|
||
InputPerMTok: 15.0, OutputPerMTok: 75.0,
|
||
CacheCreationPerMTok: 18.75, CacheReadPerMTok: 1.50},
|
||
{ModelPattern: "gpt-5",
|
||
InputPerMTok: 2.5, OutputPerMTok: 10.0,
|
||
CacheCreationPerMTok: 2.5, CacheReadPerMTok: 0.25},
|
||
{ModelPattern: "gemini-2.5-pro",
|
||
InputPerMTok: 1.25, OutputPerMTok: 5.0,
|
||
CacheCreationPerMTok: 1.25, CacheReadPerMTok: 0.125},
|
||
}); err != nil {
|
||
b.Fatalf("UpsertModelPricing: %v", err)
|
||
}
|
||
|
||
projects := []string{
|
||
"agentsview", "quokka", "arrow-rs", "side-quests",
|
||
"infrastructure", "blog", "experiments", "docs",
|
||
"dotfiles", "playground",
|
||
}
|
||
agents := []string{"claude", "codex", "openhands"}
|
||
models := []string{
|
||
"claude-sonnet-4-20250514",
|
||
"claude-opus-4-20250514",
|
||
"gpt-5",
|
||
"gemini-2.5-pro",
|
||
}
|
||
|
||
// 500 sessions × 200 messages each = 100k rows.
|
||
const sessionCount = 500
|
||
const msgsPerSession = 200
|
||
|
||
tokenUsage := `{"input_tokens":1200,"output_tokens":480,` +
|
||
`"cache_creation_input_tokens":300,` +
|
||
`"cache_read_input_tokens":2400}`
|
||
|
||
// Pre-parse the anchor timestamp once; the seed loop offsets from it.
|
||
startTime, err := time.Parse(time.RFC3339, "2024-06-01T00:00:00Z")
|
||
if err != nil {
|
||
b.Fatalf("parsing start time: %v", err)
|
||
}
|
||
|
||
for i := range sessionCount {
|
||
id := "bench-sess-" + strconv.Itoa(i)
|
||
project := projects[i%len(projects)]
|
||
agent := agents[i%len(agents)]
|
||
// Spread sessions across a 60-day window.
|
||
dayOffset := i % 60
|
||
s := Session{
|
||
ID: id,
|
||
Project: project,
|
||
Machine: defaultMachine,
|
||
Agent: agent,
|
||
MessageCount: msgsPerSession,
|
||
StartedAt: new(startTime.Format(time.RFC3339)),
|
||
}
|
||
if err := d.UpsertSession(s); err != nil {
|
||
b.Fatalf("UpsertSession: %v", err)
|
||
}
|
||
msgs := make([]Message, msgsPerSession)
|
||
for j := range msgsPerSession {
|
||
msgs[j] = Message{
|
||
SessionID: id,
|
||
Ordinal: j,
|
||
Role: "assistant",
|
||
Timestamp: startTime.AddDate(0, 0, dayOffset).Format(time.RFC3339),
|
||
Model: models[(i+j)%len(models)],
|
||
TokenUsage: json.RawMessage(tokenUsage),
|
||
}
|
||
}
|
||
if err := d.InsertMessages(msgs); err != nil {
|
||
b.Fatalf("InsertMessages: %v", err)
|
||
}
|
||
}
|
||
|
||
log.SetOutput(origLog)
|
||
b.ResetTimer()
|
||
b.ReportAllocs()
|
||
for i := 0; i < b.N; i++ {
|
||
_, err := d.GetDailyUsage(ctx, UsageFilter{
|
||
From: "2024-06-01",
|
||
To: "2024-08-01",
|
||
})
|
||
if err != nil {
|
||
b.Fatalf("GetDailyUsage: %v", err)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestGetDailyUsage_PricingPrecedence(t *testing.T) {
|
||
d := testDB(t)
|
||
ctx := context.Background()
|
||
|
||
requireNoError(t, d.UpsertModelPricing([]ModelPricing{
|
||
{
|
||
ModelPattern: "db-only-model",
|
||
InputPerMTok: 1.0, OutputPerMTok: 4.0,
|
||
},
|
||
{
|
||
ModelPattern: "custom-overrides-model",
|
||
InputPerMTok: 1.0, OutputPerMTok: 4.0,
|
||
},
|
||
{
|
||
ModelPattern: "db-model",
|
||
InputPerMTok: 3.0, OutputPerMTok: 10.0,
|
||
},
|
||
}), "UpsertModelPricing")
|
||
d.SetCustomPricing(map[string]config.CustomModelRate{
|
||
"custom-overrides-model": {Input: 2.0, Output: 8.0},
|
||
"my-custom-model": {Input: 1.5, Output: 6.0},
|
||
"other-model": {Input: 99.0, Output: 99.0},
|
||
})
|
||
|
||
tests := []struct {
|
||
name string
|
||
model string
|
||
input int // input tokens
|
||
output int // output tokens
|
||
wantCost float64
|
||
}{
|
||
{
|
||
name: "db pricing only",
|
||
model: "db-only-model",
|
||
input: 1_000_000,
|
||
output: 100_000,
|
||
wantCost: 1.4, // 1M*$1/M + 100k*$4/M
|
||
},
|
||
{
|
||
name: "custom overrides db for same model",
|
||
model: "custom-overrides-model",
|
||
input: 1_000_000,
|
||
output: 100_000,
|
||
wantCost: 2.8, // 1M*$2/M + 100k*$8/M
|
||
},
|
||
{
|
||
name: "custom for unknown model, no db entry",
|
||
model: "my-custom-model",
|
||
input: 500_000,
|
||
output: 50_000,
|
||
wantCost: 1.05, // 500k*$1.5/M + 50k*$6/M
|
||
},
|
||
{
|
||
name: "no pricing at all yields zero cost",
|
||
model: "unknown-model",
|
||
input: 1_000_000,
|
||
output: 100_000,
|
||
wantCost: 0.0,
|
||
},
|
||
{
|
||
name: "custom only affects targeted model",
|
||
model: "db-model",
|
||
input: 1_000_000,
|
||
output: 100_000,
|
||
wantCost: 4.0, // 1M*$3/M + 100k*$10/M -- db rates, not custom
|
||
},
|
||
}
|
||
|
||
for i, tt := range tests {
|
||
sessionID := "pricing-" + strconv.Itoa(i)
|
||
insertSession(t, d, sessionID, "proj", func(s *Session) {
|
||
s.StartedAt = new("2024-06-15T10:00:00Z")
|
||
})
|
||
insertMessages(t, d, Message{
|
||
SessionID: sessionID,
|
||
Ordinal: 0,
|
||
Role: "assistant",
|
||
Timestamp: "2024-06-15T10:30:00Z",
|
||
Model: tt.model,
|
||
TokenUsage: json.RawMessage(
|
||
`{"input_tokens":` + strconv.Itoa(tt.input) +
|
||
`,"output_tokens":` + strconv.Itoa(tt.output) + `}`,
|
||
),
|
||
})
|
||
}
|
||
|
||
for _, tt := range tests {
|
||
t.Run(tt.name, func(t *testing.T) {
|
||
result, err := d.GetDailyUsage(ctx, UsageFilter{
|
||
From: "2024-06-01",
|
||
To: "2024-06-30",
|
||
Model: tt.model,
|
||
})
|
||
requireNoError(t, err, "GetDailyUsage")
|
||
|
||
assert.Equal(t, tt.input, result.Totals.InputTokens,
|
||
"InputTokens")
|
||
assert.Equal(t, tt.output, result.Totals.OutputTokens,
|
||
"OutputTokens")
|
||
assert.InDelta(t, tt.wantCost, result.Totals.TotalCost, 0.01,
|
||
"TotalCost")
|
||
})
|
||
}
|
||
}
|
||
|
||
func seedOpusPricing(t *testing.T, d *DB) {
|
||
t.Helper()
|
||
require.NoError(t, d.UpsertModelPricing([]ModelPricing{{
|
||
ModelPattern: "claude-opus-4-6",
|
||
InputPerMTok: 5.0, OutputPerMTok: 25.0,
|
||
CacheCreationPerMTok: 6.25, CacheReadPerMTok: 0.5,
|
||
}}), "UpsertModelPricing")
|
||
}
|
||
|
||
func TestGetSessionUsage_PricedModel(t *testing.T) {
|
||
d := testDB(t)
|
||
ctx := context.Background()
|
||
seedOpusPricing(t, d)
|
||
|
||
insertSession(t, d, "claude:s1", "proj", func(s *Session) {
|
||
s.Agent = "claude-code"
|
||
s.StartedAt = new("2026-05-20T10:00:00Z")
|
||
s.TotalOutputTokens = 500
|
||
s.PeakContextTokens = 1200
|
||
s.HasTotalOutputTokens = true
|
||
s.HasPeakContextTokens = true
|
||
})
|
||
insertMessages(t, d, Message{
|
||
SessionID: "claude:s1", Ordinal: 0, Role: "assistant",
|
||
Timestamp: "2026-05-20T10:30:00Z", Model: "claude-opus-4-6",
|
||
TokenUsage: json.RawMessage(
|
||
`{"input_tokens":1000,"output_tokens":500}`),
|
||
})
|
||
|
||
u, err := d.GetSessionUsage(ctx, "claude:s1", true)
|
||
requireNoError(t, err, "GetSessionUsage")
|
||
require.NotNil(t, u, "usage is nil")
|
||
require.True(t, u.HasCost, "HasCost = false, want true")
|
||
assert.InDelta(t, 0.0175, u.CostUSD, 1e-9, "CostUSD")
|
||
assert.Equal(t, 500, u.TotalOutputTokens,
|
||
"TotalOutputTokens want 500")
|
||
assert.Equal(t, 1200, u.PeakContextTokens,
|
||
"PeakContextTokens want 1200")
|
||
assert.Equal(t, []string{"claude-opus-4-6"}, u.Models, "Models")
|
||
assert.Empty(t, u.UnpricedModels, "UnpricedModels")
|
||
require.Len(t, u.Breakdown, 1, "Breakdown")
|
||
entry := u.Breakdown[0]
|
||
assert.Equal(t, 1, entry.Ordinal, "Breakdown ordinal")
|
||
require.NotNil(t, entry.MessageOrdinal, "MessageOrdinal")
|
||
assert.Equal(t, 0, *entry.MessageOrdinal, "MessageOrdinal")
|
||
assert.Equal(t, "message", entry.Source, "Source")
|
||
assert.Equal(t, "Prompt 1", entry.Label, "Label")
|
||
assert.Equal(t, "2026-05-20T10:30:00Z", entry.Timestamp, "Timestamp")
|
||
assert.Equal(t, "claude-opus-4-6", entry.Model, "Model")
|
||
assert.Equal(t, 1000, entry.InputTokens, "InputTokens")
|
||
assert.Equal(t, 500, entry.OutputTokens, "OutputTokens")
|
||
assert.InDelta(t, 0.0175, entry.CostUSD, 1e-9, "entry CostUSD")
|
||
assert.True(t, entry.HasCost, "entry HasCost")
|
||
}
|
||
|
||
func TestGetSessionUsage_UnpricedModel(t *testing.T) {
|
||
d := testDB(t)
|
||
ctx := context.Background()
|
||
insertSession(t, d, "claude:s2", "proj", func(s *Session) {
|
||
s.Agent = "claude-code"
|
||
s.StartedAt = new("2026-05-20T10:00:00Z")
|
||
s.TotalOutputTokens = 500
|
||
s.HasTotalOutputTokens = true
|
||
})
|
||
insertMessages(t, d, Message{
|
||
SessionID: "claude:s2", Ordinal: 0, Role: "assistant",
|
||
Timestamp: "2026-05-20T10:30:00Z", Model: "local-llama-99",
|
||
TokenUsage: json.RawMessage(
|
||
`{"input_tokens":1000,"output_tokens":500}`),
|
||
})
|
||
|
||
u, err := d.GetSessionUsage(ctx, "claude:s2", true)
|
||
requireNoError(t, err, "GetSessionUsage")
|
||
assert.False(t, u.HasCost, "HasCost = true, want false (unpriced)")
|
||
assert.Equal(t, 0.0, u.CostUSD, "CostUSD")
|
||
assert.Equal(t, []string{"local-llama-99"}, u.UnpricedModels,
|
||
"UnpricedModels")
|
||
}
|
||
|
||
func TestGetSessionUsage_MixedPricedUnpriced(t *testing.T) {
|
||
d := testDB(t)
|
||
ctx := context.Background()
|
||
seedOpusPricing(t, d)
|
||
insertSession(t, d, "claude:s3", "proj", func(s *Session) {
|
||
s.Agent = "claude-code"
|
||
s.StartedAt = new("2026-05-20T10:00:00Z")
|
||
})
|
||
insertMessages(t, d,
|
||
Message{
|
||
SessionID: "claude:s3", Ordinal: 0, Role: "assistant",
|
||
Timestamp: "2026-05-20T10:30:00Z", Model: "claude-opus-4-6",
|
||
TokenUsage: json.RawMessage(
|
||
`{"input_tokens":1000,"output_tokens":500}`),
|
||
},
|
||
Message{
|
||
SessionID: "claude:s3", Ordinal: 1, Role: "assistant",
|
||
Timestamp: "2026-05-20T10:31:00Z", Model: "local-llama-99",
|
||
TokenUsage: json.RawMessage(
|
||
`{"input_tokens":1000,"output_tokens":500}`),
|
||
},
|
||
)
|
||
|
||
u, err := d.GetSessionUsage(ctx, "claude:s3", true)
|
||
requireNoError(t, err, "GetSessionUsage")
|
||
assert.False(t, u.HasCost, "HasCost = true, want false (mixed)")
|
||
assert.Equal(t, 0.0, u.CostUSD, "CostUSD")
|
||
assert.Equal(t, []string{"local-llama-99"}, u.UnpricedModels,
|
||
"UnpricedModels")
|
||
}
|
||
|
||
func TestGetSessionUsage_ExplicitCostOnly(t *testing.T) {
|
||
d := testDB(t)
|
||
ctx := context.Background()
|
||
insertSession(t, d, "hermes:s4", "proj", func(s *Session) {
|
||
s.Agent = "hermes"
|
||
s.StartedAt = new("2026-05-20T10:00:00Z")
|
||
})
|
||
cost := 0.02
|
||
require.NoError(t, d.ReplaceSessionUsageEvents("hermes:s4", []UsageEvent{{
|
||
SessionID: "hermes:s4", Source: "session", Model: "gpt-5.4",
|
||
InputTokens: 100, OutputTokens: 50,
|
||
CostUSD: &cost, CostStatus: "estimated", CostSource: "hermes",
|
||
OccurredAt: "2026-05-20T10:05:00Z", DedupKey: "session:hermes:s4",
|
||
}}), "ReplaceSessionUsageEvents")
|
||
|
||
u, err := d.GetSessionUsage(ctx, "hermes:s4", true)
|
||
requireNoError(t, err, "GetSessionUsage")
|
||
assert.True(t, u.HasCost, "HasCost = false, want true (explicit cost)")
|
||
assert.InDelta(t, 0.02, u.CostUSD, 1e-9, "CostUSD")
|
||
assert.Equal(t, []string{"gpt-5.4"}, u.Models, "Models")
|
||
require.Len(t, u.Breakdown, 1, "Breakdown")
|
||
entry := u.Breakdown[0]
|
||
assert.Nil(t, entry.MessageOrdinal, "MessageOrdinal")
|
||
assert.Equal(t, "session", entry.Source, "Source")
|
||
assert.Equal(t, "session", entry.Label, "Label")
|
||
assert.Equal(t, 100, entry.InputTokens, "InputTokens")
|
||
assert.Equal(t, 50, entry.OutputTokens, "OutputTokens")
|
||
assert.True(t, entry.HasCost, "entry HasCost")
|
||
assert.InDelta(t, 0.02, entry.CostUSD, 1e-9, "entry CostUSD")
|
||
}
|
||
|
||
func TestGetSessionUsage_BreakdownOrderingAndBuckets(t *testing.T) {
|
||
d := testDB(t)
|
||
ctx := context.Background()
|
||
seedOpusPricing(t, d)
|
||
insertSession(t, d, "claude:s-breakdown", "proj", func(s *Session) {
|
||
s.Agent = "claude-code"
|
||
s.StartedAt = new("2026-05-20T10:00:00Z")
|
||
s.TotalOutputTokens = 650
|
||
s.PeakContextTokens = 1500
|
||
s.HasTotalOutputTokens = true
|
||
s.HasPeakContextTokens = true
|
||
})
|
||
insertMessages(t, d,
|
||
Message{
|
||
SessionID: "claude:s-breakdown", Ordinal: 1, Role: "assistant",
|
||
Timestamp: "2026-05-20T10:31:00Z", Model: "claude-opus-4-6",
|
||
TokenUsage: json.RawMessage(
|
||
`{"input_tokens":100,"output_tokens":50,` +
|
||
`"cache_creation_input_tokens":10,` +
|
||
`"cache_read_input_tokens":20}`),
|
||
},
|
||
Message{
|
||
SessionID: "claude:s-breakdown", Ordinal: 0, Role: "assistant",
|
||
Timestamp: "2026-05-20T10:30:00Z", Model: "claude-opus-4-6",
|
||
TokenUsage: json.RawMessage(
|
||
`{"input_tokens":200,"output_tokens":60,` +
|
||
`"cache_creation_input_tokens":0,` +
|
||
`"cache_read_input_tokens":40}`),
|
||
},
|
||
)
|
||
|
||
u, err := d.GetSessionUsage(ctx, "claude:s-breakdown", true)
|
||
requireNoError(t, err, "GetSessionUsage")
|
||
require.Len(t, u.Breakdown, 2, "Breakdown")
|
||
assert.Equal(t, 650, u.TotalOutputTokens, "TotalOutputTokens")
|
||
assert.Equal(t, 1500, u.PeakContextTokens, "PeakContextTokens")
|
||
|
||
first := u.Breakdown[0]
|
||
require.NotNil(t, first.MessageOrdinal, "first MessageOrdinal")
|
||
assert.Equal(t, 0, *first.MessageOrdinal, "first MessageOrdinal")
|
||
assert.Equal(t, "Prompt 1", first.Label, "first Label")
|
||
assert.Equal(t, 200, first.InputTokens, "first InputTokens")
|
||
assert.Equal(t, 60, first.OutputTokens, "first OutputTokens")
|
||
assert.Equal(t, 0, first.CacheCreationInputTokens, "first CacheCreationInputTokens")
|
||
assert.Equal(t, 40, first.CacheReadInputTokens, "first CacheReadInputTokens")
|
||
|
||
second := u.Breakdown[1]
|
||
require.NotNil(t, second.MessageOrdinal, "second MessageOrdinal")
|
||
assert.Equal(t, 1, *second.MessageOrdinal, "second MessageOrdinal")
|
||
assert.Equal(t, "Prompt 2", second.Label, "second Label")
|
||
assert.Equal(t, 100, second.InputTokens, "second InputTokens")
|
||
assert.Equal(t, 50, second.OutputTokens, "second OutputTokens")
|
||
assert.Equal(t, 10, second.CacheCreationInputTokens, "second CacheCreationInputTokens")
|
||
assert.Equal(t, 20, second.CacheReadInputTokens, "second CacheReadInputTokens")
|
||
|
||
breakdownCost := first.CostUSD + second.CostUSD
|
||
assert.InDelta(t, breakdownCost, u.CostUSD, 1e-9,
|
||
"breakdown cost should sum to total cost")
|
||
}
|
||
|
||
func TestGetSessionUsage_DedupesDuplicateClaudeRows(t *testing.T) {
|
||
d := testDB(t)
|
||
ctx := context.Background()
|
||
seedOpusPricing(t, d)
|
||
insertSession(t, d, "claude:s6", "proj", func(s *Session) {
|
||
s.Agent = "claude-code"
|
||
s.StartedAt = new("2026-05-20T10:00:00Z")
|
||
})
|
||
// Two rows sharing the same claude message+request id (a
|
||
// fork/replay) must be counted once, not doubled.
|
||
insertMessages(t, d,
|
||
Message{
|
||
SessionID: "claude:s6", Ordinal: 0, Role: "assistant",
|
||
Timestamp: "2026-05-20T10:30:00Z", Model: "claude-opus-4-6",
|
||
ClaudeMessageID: "msg-1", ClaudeRequestID: "req-1",
|
||
TokenUsage: json.RawMessage(`{"input_tokens":1000,"output_tokens":500}`),
|
||
},
|
||
Message{
|
||
SessionID: "claude:s6", Ordinal: 1, Role: "assistant",
|
||
Timestamp: "2026-05-20T10:31:00Z", Model: "claude-opus-4-6",
|
||
ClaudeMessageID: "msg-1", ClaudeRequestID: "req-1",
|
||
TokenUsage: json.RawMessage(`{"input_tokens":1000,"output_tokens":500}`),
|
||
},
|
||
)
|
||
u, err := d.GetSessionUsage(ctx, "claude:s6", true)
|
||
requireNoError(t, err, "GetSessionUsage")
|
||
// One row priced at 1000*5/1e6 + 500*25/1e6 = 0.0175; deduped, not 0.035.
|
||
assert.InDelta(t, 0.0175, u.CostUSD, 1e-9, "CostUSD want 0.0175 (deduped)")
|
||
assert.True(t, u.HasCost, "HasCost = false, want true")
|
||
require.Len(t, u.Breakdown, 1, "Breakdown")
|
||
require.NotNil(t, u.Breakdown[0].MessageOrdinal, "MessageOrdinal")
|
||
assert.Equal(t, 0, *u.Breakdown[0].MessageOrdinal, "MessageOrdinal")
|
||
}
|
||
|
||
func TestGetSessionUsage_DedupesBySourceUUIDWhenClaudePairIncomplete(t *testing.T) {
|
||
d := testDB(t)
|
||
ctx := context.Background()
|
||
seedOpusPricing(t, d)
|
||
insertSession(t, d, "claude:s7", "proj", func(s *Session) {
|
||
s.Agent = "claude-code"
|
||
s.StartedAt = new("2026-05-20T10:00:00Z")
|
||
})
|
||
insertMessages(t, d,
|
||
Message{
|
||
SessionID: "claude:s7", Ordinal: 0, Role: "assistant",
|
||
Timestamp: "2026-05-20T10:30:00Z", Model: "claude-opus-4-6",
|
||
ClaudeMessageID: "msg-1", SourceUUID: "source-1",
|
||
TokenUsage: json.RawMessage(`{"input_tokens":1000,"output_tokens":500}`),
|
||
},
|
||
Message{
|
||
SessionID: "claude:s7", Ordinal: 1, Role: "assistant",
|
||
Timestamp: "2026-05-20T10:31:00Z", Model: "claude-opus-4-6",
|
||
ClaudeMessageID: "msg-1", SourceUUID: "source-1",
|
||
TokenUsage: json.RawMessage(`{"input_tokens":1000,"output_tokens":500}`),
|
||
},
|
||
)
|
||
u, err := d.GetSessionUsage(ctx, "claude:s7", true)
|
||
requireNoError(t, err, "GetSessionUsage")
|
||
assert.InDelta(t, 0.0175, u.CostUSD, 1e-9, "CostUSD want 0.0175 (deduped)")
|
||
assert.True(t, u.HasCost, "HasCost = false, want true")
|
||
}
|
||
|
||
func TestGetSessionUsage_NoTokenRowsKeepsMetadata(t *testing.T) {
|
||
d := testDB(t)
|
||
ctx := context.Background()
|
||
insertSession(t, d, "claude:s5", "proj", func(s *Session) {
|
||
s.Agent = "claude-code"
|
||
s.StartedAt = new("2026-05-20T10:00:00Z")
|
||
s.TotalOutputTokens = 700
|
||
s.PeakContextTokens = 3000
|
||
s.HasTotalOutputTokens = true
|
||
s.HasPeakContextTokens = true
|
||
})
|
||
|
||
u, err := d.GetSessionUsage(ctx, "claude:s5", true)
|
||
requireNoError(t, err, "GetSessionUsage")
|
||
require.NotNil(t, u, "usage is nil")
|
||
assert.Equal(t, 700, u.TotalOutputTokens,
|
||
"TotalOutputTokens want 700")
|
||
assert.Equal(t, 3000, u.PeakContextTokens,
|
||
"PeakContextTokens want 3000")
|
||
assert.True(t, u.HasTokenData, "HasTokenData = false, want true")
|
||
assert.False(t, u.HasCost, "HasCost = true, want false (no cost rows)")
|
||
assert.NotNil(t, u.Models, "Models = nil, want non-nil empty slice")
|
||
assert.Empty(t, u.Breakdown, "Breakdown")
|
||
}
|
||
|
||
func TestGetSessionUsage_NotFound(t *testing.T) {
|
||
d := testDB(t)
|
||
u, err := d.GetSessionUsage(context.Background(), "nope:x", true)
|
||
requireNoError(t, err, "GetSessionUsage")
|
||
assert.Nil(t, u, "usage")
|
||
}
|
||
|
||
func TestGetSessionUsage_AICreditsCapability(t *testing.T) {
|
||
parsertest.StubAgentDefs(t, parser.AgentDef{
|
||
Type: parser.AgentType("ai-credit-agent"),
|
||
DisplayName: "AI Credit Agent",
|
||
Usage: parser.UsageCapabilities{
|
||
AICreditsDenominated: true,
|
||
},
|
||
})
|
||
|
||
d := testDB(t)
|
||
ctx := context.Background()
|
||
seedOpusPricing(t, d)
|
||
|
||
insertSession(t, d, "ai-credit-agent:s1", "proj", func(s *Session) {
|
||
s.Agent = "ai-credit-agent"
|
||
s.StartedAt = new("2026-05-20T10:00:00Z")
|
||
})
|
||
insertMessages(t, d, Message{
|
||
SessionID: "ai-credit-agent:s1",
|
||
Ordinal: 0,
|
||
Role: "assistant",
|
||
Timestamp: "2026-05-20T10:30:00Z",
|
||
Model: "claude-opus-4-6",
|
||
TokenUsage: json.RawMessage(
|
||
`{"input_tokens":1000,"output_tokens":500}`),
|
||
})
|
||
|
||
u, err := d.GetSessionUsage(ctx, "ai-credit-agent:s1", true)
|
||
requireNoError(t, err, "GetSessionUsage")
|
||
require.NotNil(t, u, "usage is nil")
|
||
assert.True(t, u.HasCost, "HasCost = false, want true")
|
||
assert.InDelta(t, 0.0175, u.CostUSD, 1e-9, "CostUSD")
|
||
assert.InDelta(t, 1.75, u.AICredits, 1e-9, "AICredits")
|
||
}
|
||
|
||
// TestGetDailyUsage_CopilotAICredits verifies AI credits are computed from
|
||
// agents with the parser AI-credit capability: costUSD / 0.01.
|
||
func TestGetDailyUsage_CopilotAICredits(t *testing.T) {
|
||
parsertest.StubAgentDefs(t, parser.AgentDef{
|
||
Type: parser.AgentType("ai-credit-agent"),
|
||
DisplayName: "AI Credit Agent",
|
||
Usage: parser.UsageCapabilities{
|
||
AICreditsDenominated: true,
|
||
},
|
||
})
|
||
|
||
d := testDB(t)
|
||
ctx := context.Background()
|
||
|
||
require.NoError(t, d.UpsertModelPricing([]ModelPricing{
|
||
{
|
||
ModelPattern: "gpt-4",
|
||
InputPerMTok: 15.0,
|
||
OutputPerMTok: 60.0,
|
||
CacheCreationPerMTok: 15.0,
|
||
CacheReadPerMTok: 6.0,
|
||
},
|
||
{
|
||
ModelPattern: "claude-opus-4-6",
|
||
InputPerMTok: 3.0,
|
||
OutputPerMTok: 15.0,
|
||
CacheCreationPerMTok: 3.75,
|
||
CacheReadPerMTok: 0.30,
|
||
},
|
||
}))
|
||
|
||
tests := []struct {
|
||
name string
|
||
sessionID string
|
||
agent string
|
||
model string
|
||
inputRate float64
|
||
outputRate float64
|
||
wantCredits bool
|
||
}{
|
||
{
|
||
name: "copilot credits computed",
|
||
sessionID: "copilot:aicredits",
|
||
agent: "copilot",
|
||
model: "gpt-4",
|
||
inputRate: 15.0,
|
||
outputRate: 60.0,
|
||
wantCredits: true,
|
||
},
|
||
{
|
||
name: "non copilot capability credits computed",
|
||
sessionID: "ai-credit-agent:aicredits",
|
||
agent: "ai-credit-agent",
|
||
model: "gpt-4",
|
||
inputRate: 15.0,
|
||
outputRate: 60.0,
|
||
wantCredits: true,
|
||
},
|
||
{
|
||
name: "non copilot has no credits",
|
||
sessionID: "claude:nocredits",
|
||
agent: "claude-code",
|
||
model: "claude-opus-4-6",
|
||
inputRate: 3.0,
|
||
outputRate: 15.0,
|
||
},
|
||
}
|
||
|
||
for _, tt := range tests {
|
||
insertSession(t, d, tt.sessionID, "proj", func(s *Session) {
|
||
s.Agent = tt.agent
|
||
s.StartedAt = new("2024-06-15T10:00:00Z")
|
||
s.EndedAt = new("2024-06-15T11:00:00Z")
|
||
})
|
||
insertMessages(t, d, Message{
|
||
SessionID: tt.sessionID,
|
||
Ordinal: 0,
|
||
Role: "assistant",
|
||
Timestamp: "2024-06-15T10:30:00Z",
|
||
Model: tt.model,
|
||
TokenUsage: json.RawMessage(`{
|
||
"input_tokens": 1000,
|
||
"output_tokens": 500
|
||
}`),
|
||
})
|
||
}
|
||
|
||
for _, tt := range tests {
|
||
t.Run(tt.name, func(t *testing.T) {
|
||
result, err := d.GetDailyUsage(ctx, UsageFilter{
|
||
From: "2024-06-01",
|
||
To: "2024-06-30",
|
||
Agent: tt.agent,
|
||
})
|
||
requireNoError(t, err, "GetDailyUsage")
|
||
|
||
wantCost := (1000*tt.inputRate + 500*tt.outputRate) / 1_000_000
|
||
wantCredits := 0.0
|
||
if tt.wantCredits {
|
||
wantCredits = wantCost / 0.01
|
||
}
|
||
assert.InDelta(t, wantCost, result.Totals.TotalCost, 1e-9,
|
||
"TotalCost")
|
||
assert.InDelta(t, wantCredits, result.Totals.CopilotAICredits,
|
||
1e-6, "CopilotAICredits")
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestAICreditsFromCost(t *testing.T) {
|
||
cases := []struct {
|
||
name string
|
||
agent string
|
||
cost float64
|
||
want float64
|
||
}{
|
||
{"copilot converts at a cent per credit", "copilot", 0.42, 42},
|
||
{"zero cost yields zero credits", "copilot", 0, 0},
|
||
{"non-credit agent yields zero", "claude", 3.5, 0},
|
||
{"unknown agent yields zero", "unknown-agent", 3.5, 0},
|
||
}
|
||
for _, tc := range cases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
assert.InDelta(t, tc.want,
|
||
AICreditsFromCost(tc.agent, tc.cost), 1e-9)
|
||
})
|
||
}
|
||
}
|