Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:30:36 +08:00

2820 lines
78 KiB
Go

package db
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"slices"
"sort"
"strconv"
"strings"
"time"
"go.kenn.io/agentsview/internal/export"
"go.kenn.io/agentsview/internal/parser"
pricingpkg "go.kenn.io/agentsview/internal/pricing"
)
// aiCreditUSD is the USD value of one AI credit for agents whose cost
// is denominated in AI credits (the AICreditsDenominated capability).
const aiCreditUSD = 0.01
// AICreditsFromCost converts a USD cost into AI credits when the
// agent's cost is denominated in AI credits, and returns 0 otherwise.
// It is the single home of the credit conversion shared by the SQLite,
// PostgreSQL, and DuckDB usage paths; a per-agent credit rate would
// slot in here rather than at each accumulation site.
func AICreditsFromCost(agent string, costUSD float64) float64 {
if costUSD == 0 || !parser.AgentNameUsesAICredits(agent) {
return 0
}
return costUSD / aiCreditUSD
}
// NoTokenData reports whether a daily-usage total carries neither token
// data nor cost: every token counter, the cost total, and any Copilot AI
// credits are zero. It distinguishes a window whose sessions simply do not
// record token usage from one that genuinely has no sessions.
func NoTokenData(t UsageTotals) bool {
return t.InputTokens == 0 &&
t.OutputTokens == 0 &&
t.CacheCreationTokens == 0 &&
t.CacheReadTokens == 0 &&
t.TotalCost == 0 &&
t.CopilotAICredits == 0
}
// UsageFilter controls the date range, agent, and timezone
// for daily usage aggregation queries.
type UsageFilter struct {
From string // YYYY-MM-DD, inclusive
To string // YYYY-MM-DD, inclusive
Agent string // "" for all; supports comma-separated
Project string // "" for all; supports comma-separated
Machine string // "" for all; supports comma-separated
// ProjectLabels and ExcludeProjectLabels preserve exact internal labels
// that may themselves contain commas. A non-nil slice takes precedence
// over the legacy comma-separated string field.
ProjectLabels []string
ExcludeProjectLabels []string
// GitBranch is a branchListSep-joined list of opaque (project, branch) tokens (EncodeBranchFilterToken).
GitBranch string
Model string // "" for all; supports comma-separated
ExcludeProject string // comma-separated projects to exclude
ExcludeAgent string // comma-separated agents to exclude
ExcludeModel string // comma-separated models to exclude
Timezone string // IANA timezone, "" for UTC
MinUserMessages int // user_message_count >= N
ExcludeOneShot bool // user_message_count > 1
ExcludeAutomated bool // is_automated = false
AutomatedScope string // "", "human", "all", or "automated"
ActiveSince string // RFC3339 session recency cutoff
Termination string // "", "clean", "unclean", "active", or "stale"
Breakdowns bool // populate Project/AgentBreakdowns per day
SkipSessionCounts bool // skip distinct session counts when callers do not need them
}
// ProjectFilterLabels returns exact include labels when present, otherwise it
// decodes the legacy comma-separated project filter.
func (f UsageFilter) ProjectFilterLabels() []string {
if f.ProjectLabels != nil {
return f.ProjectLabels
}
if f.Project == "" {
return nil
}
return strings.Split(f.Project, ",")
}
// ExcludedProjectFilterLabels returns exact exclude labels when present,
// otherwise it decodes the legacy comma-separated project filter.
func (f UsageFilter) ExcludedProjectFilterLabels() []string {
if f.ExcludeProjectLabels != nil {
return f.ExcludeProjectLabels
}
if f.ExcludeProject == "" {
return nil
}
return strings.Split(f.ExcludeProject, ",")
}
func (f UsageFilter) appendUsageBranchFilterClauses(
where string, args []any, modelCol string,
) (string, []any) {
where, args = f.appendUsageSourceFilterClauses(where, args, modelCol)
return f.appendUsageSessionFilterClauses(where, args)
}
func (f UsageFilter) appendUsageSourceFilterClauses(
where string, args []any, modelCol string,
) (string, []any) {
appendCSV := func(
q string, a []any, col, csv string, include bool,
) (string, []any) {
if csv == "" {
return q, a
}
vals := strings.Split(csv, ",")
op := "IN"
if !include {
op = "NOT IN"
}
if len(vals) == 1 {
if include {
q += "\n\tAND " + col + " = ?"
} else {
q += "\n\tAND " + col + " != ?"
}
a = append(a, vals[0])
} else {
ph := make([]string, len(vals))
for i, v := range vals {
ph[i] = "?"
a = append(a, v)
}
q += "\n\tAND " + col + " " + op +
" (" + strings.Join(ph, ",") + ")"
}
return q, a
}
where, args = appendCSV(where, args, modelCol, f.Model, true)
where, args = appendCSV(where, args, modelCol, f.ExcludeModel, false)
return where, args
}
func (f UsageFilter) appendUsageSessionFilterClauses(
where string, args []any,
) (string, []any) {
appendValues := func(
q string, a []any, col string, vals []string, include bool,
) (string, []any) {
if len(vals) == 0 {
return q, a
}
op := "IN"
if !include {
op = "NOT IN"
}
if len(vals) == 1 {
if include {
q += "\n\tAND " + col + " = ?"
} else {
q += "\n\tAND " + col + " != ?"
}
a = append(a, vals[0])
} else {
ph := make([]string, len(vals))
for i, v := range vals {
ph[i] = "?"
a = append(a, v)
}
q += "\n\tAND " + col + " " + op +
" (" + strings.Join(ph, ",") + ")"
}
return q, a
}
appendCSV := func(
q string, a []any, col, csv string, include bool,
) (string, []any) {
if csv == "" {
return q, a
}
return appendValues(q, a, col, strings.Split(csv, ","), include)
}
where, args = appendCSV(where, args, "s.agent", f.Agent, true)
where, args = appendValues(
where, args, "s.project", f.ProjectFilterLabels(), true,
)
where, args = appendCSV(where, args, "s.machine", f.Machine, true)
if f.GitBranch != "" {
var clause string
clause, args = BranchPairClauseArgs("s.project", "s.git_branch", f.GitBranch, args)
where += "\n\tAND " + clause
}
where, args = appendValues(
where, args, "s.project", f.ExcludedProjectFilterLabels(), false,
)
where, args = appendCSV(where, args, "s.agent", f.ExcludeAgent, false)
if f.MinUserMessages > 0 {
where += "\n\tAND s.user_message_count >= ?"
args = append(args, f.MinUserMessages)
}
scope := normalizeAutomatedScope(f.AutomatedScope, f.ExcludeAutomated)
if f.ExcludeOneShot {
if scope == "human" {
where += "\n\tAND s.user_message_count > 1"
} else {
where += "\n\tAND (s.user_message_count > 1 OR COALESCE(s.is_automated, 0) = 1)"
}
}
if pred := automatedScopePredicate(scope, "COALESCE(s.is_automated, 0)"); pred != "" {
where += "\n\tAND " + pred
}
if f.ActiveSince != "" {
where += "\n\tAND COALESCE(NULLIF(s.ended_at, ''), NULLIF(s.started_at, ''), s.created_at) >= ?"
args = append(args, f.ActiveSince)
}
if pred, pargs := buildUsageTerminationPredSQLite(f.Termination); pred != "" {
where += "\n\tAND " + pred
args = append(args, pargs...)
}
return where, args
}
// appendUsageMatchingActivityClauses requires the session to have at
// least one row that GetUsageMatchingSessionCount's bounded branch would
// count: an assistant, non-synthetic message (model optional — some
// Copilot assistant messages parse before a model name is known) or a
// usage_events row with a model. Model/ExcludeModel narrow those same
// rows. Seeding the EXISTS subqueries with the matching eligibility
// predicates keeps the unbounded branch's semantics aligned with the
// bounded branch's per-row predicates, so the same filter matches the
// same sessions whether or not a date range is set.
func (f UsageFilter) appendUsageMatchingActivityClauses(
where string, args []any,
) (string, []any) {
var messageArgs []any
messageWhere, messageArgs := f.appendUsageSourceFilterClauses(
usageMatchingMessageSourceEligibility, messageArgs, "m.model",
)
var eventArgs []any
eventWhere, eventArgs := f.appendUsageSourceFilterClauses(
usageEventSourceEligibility, eventArgs, "ue.model",
)
where += `
AND (
EXISTS (
SELECT 1
FROM messages m
WHERE m.session_id = s.id
AND ` + messageWhere + `
)
OR EXISTS (
SELECT 1
FROM usage_events ue
WHERE ue.session_id = s.id
AND ` + eventWhere + `
)
)`
args = append(args, messageArgs...)
args = append(args, eventArgs...)
return where, args
}
func buildUsageTerminationPredSQLite(status string) (string, []any) {
if status == "" || status == "all" {
return "", nil
}
now := time.Now().Unix()
activeCutoff := now - int64(activeWindow.Seconds())
staleCutoff := now - int64(staleWindow.Seconds())
const activityExpr = "CAST(strftime('%s', COALESCE(NULLIF(s.ended_at, ''), NULLIF(s.started_at, ''), s.created_at)) AS INTEGER)"
const flagged = "s.termination_status IN ('tool_call_pending', 'truncated')"
parts := strings.Split(status, ",")
preds := make([]string, 0, len(parts))
args := make([]any, 0, len(parts)*2)
for _, p := range parts {
switch strings.TrimSpace(p) {
case "active":
preds = append(preds, activityExpr+" > ?")
args = append(args, activeCutoff)
case "stale":
preds = append(preds, "("+activityExpr+" > ? AND "+
activityExpr+" <= ? AND "+flagged+")")
args = append(args, staleCutoff, activeCutoff)
case "unclean":
preds = append(preds, "("+activityExpr+" <= ? AND "+flagged+")")
args = append(args, staleCutoff)
case "clean":
preds = append(preds, "s.termination_status = 'clean'")
case "awaiting_user":
preds = append(preds, "s.termination_status = 'awaiting_user'")
}
}
if len(preds) == 0 {
return "", nil
}
if len(preds) == 1 {
return preds[0], args
}
return "(" + strings.Join(preds, " OR ") + ")", args
}
// location loads the timezone or returns the system local timezone.
func (f UsageFilter) location() *time.Location {
if f.Timezone == "" {
return time.Local
}
loc, err := time.LoadLocation(f.Timezone)
if err != nil {
return time.Local
}
return loc
}
// usageMessageEligibility is the WHERE-clause fragment that selects
// messages eligible for usage / cost aggregation. Every usage query
// (GetDailyUsage, GetUsageSessionCounts, GetTopSessionsByCost) MUST
// reference this constant so the set of counted messages stays
// identical across queries. Drift here is the bug that makes
// sessionCounts and daily totals disagree.
//
// Note: this does NOT filter by s.relationship_type. Duplicate
// messages across fork/subagent boundaries are handled by the
// per-query usage dedup in GetDailyUsage, which prefers the
// Claude message/request pair and falls back to persisted source
// identity when the pair is incomplete. That is more precise than
// a blanket exclusion: a fork session can legitimately contribute
// unique-keyed messages that should still be counted (see
// TestGetDailyUsage_DedupesByClaudeMessageAndRequestID).
const usageMessageEligibility = `
m.token_usage != ''
AND m.model != ''
AND m.model != '<synthetic>'
AND s.deleted_at IS NULL`
const usageMessageSourceEligibility = `
m.token_usage != ''
AND m.model != ''
AND m.model != '<synthetic>'`
// usageMatchingMessageEligibility is usageMessageEligibility with the
// token-presence requirement removed and the model-presence requirement
// relaxed to a role check. GetUsageMatchingSessionCount counts sessions
// that have usage-shaped activity even when the agent (e.g. Copilot)
// never records per-message tokens or, for some assistant messages, a
// model name, so it must not gate on m.token_usage or m.model != ” the
// way every token/cost query does; Model/ExcludeModel filters are applied
// separately and still narrow the match when set. Do not reuse this for
// usageRowQuery or its callers — see the usageMessageEligibility doc
// comment above.
const usageMatchingMessageEligibility = `
m.role = 'assistant'
AND m.model != '<synthetic>'
AND s.deleted_at IS NULL`
const usageMatchingMessageSourceEligibility = `
m.role = 'assistant'
AND m.model != '<synthetic>'`
const usageEventEligibility = `
ue.model != ''
AND s.deleted_at IS NULL`
const usageEventSourceEligibility = `
ue.model != ''`
const usageSessionEligibility = `s.deleted_at IS NULL`
const usageRowsSQLTemplate = `
SELECT
m.session_id,
m.ordinal AS message_ordinal,
'message' AS usage_source,
COALESCE(NULLIF(m.timestamp, ''), s.started_at, '') AS ts,
m.model,
m.token_usage,
0 AS input_tokens,
0 AS output_tokens,
0 AS cache_creation_input_tokens,
0 AS cache_read_input_tokens,
CASE
WHEN json_valid(m.token_usage) THEN COALESCE(CAST(json_extract(m.token_usage, '$.reasoning_tokens') AS INTEGER), 0)
ELSE 0
END AS reasoning_tokens,
NULL AS cost_usd,
'' AS cost_status,
'' AS cost_source,
m.claude_message_id,
m.claude_request_id,
m.source_uuid,
'' AS usage_dedup_key,
s.project,
s.agent,
s.machine,
s.user_message_count,
COALESCE(s.is_automated, 0) AS is_automated,
COALESCE(NULLIF(s.ended_at, ''), NULLIF(s.started_at, ''), s.created_at) AS session_activity_at,
COALESCE(s.termination_status, '') AS termination_status,
COALESCE(NULLIF(COALESCE(s.display_name, s.session_name), ''), NULLIF(s.first_message, ''), NULLIF(s.project, ''), s.id) AS display_name,
COALESCE(s.started_at, '') AS started_at
FROM messages m
JOIN sessions s ON m.session_id = s.id
WHERE %s
UNION ALL
SELECT
ue.session_id,
ue.message_ordinal,
ue.source AS usage_source,
COALESCE(ue.occurred_at, s.started_at, '') AS ts,
ue.model,
'' AS token_usage,
ue.input_tokens,
ue.output_tokens,
ue.cache_creation_input_tokens,
ue.cache_read_input_tokens,
ue.reasoning_tokens,
ue.cost_usd,
ue.cost_status,
ue.cost_source,
'' AS claude_message_id,
'' AS claude_request_id,
'' AS source_uuid,
CASE
WHEN ue.dedup_key != '' THEN ue.session_id || ':' || ue.source || ':' || ue.dedup_key
ELSE ue.session_id || ':' || ue.source || ':id:' || ue.id
END AS usage_dedup_key,
s.project,
s.agent,
s.machine,
s.user_message_count,
COALESCE(s.is_automated, 0) AS is_automated,
COALESCE(NULLIF(s.ended_at, ''), NULLIF(s.started_at, ''), s.created_at) AS session_activity_at,
COALESCE(s.termination_status, '') AS termination_status,
COALESCE(NULLIF(COALESCE(s.display_name, s.session_name), ''), NULLIF(s.first_message, ''), NULLIF(s.project, ''), s.id) AS display_name,
COALESCE(s.started_at, '') AS started_at
FROM usage_events ue
JOIN sessions s ON s.id = ue.session_id
WHERE %s`
func usageRowsSQLWithWhere(
messageWhere, usageEventWhere string,
) string {
return fmt.Sprintf(
usageRowsSQLTemplate,
messageWhere,
usageEventWhere,
)
}
const dailyUsageRowsSQLTemplate = `
SELECT
m.session_id,
m.ordinal AS message_ordinal,
'message' AS usage_source,
COALESCE(NULLIF(m.timestamp, ''), s.started_at, '') AS ts,
m.model,
m.token_usage,
0 AS input_tokens,
0 AS output_tokens,
0 AS cache_creation_input_tokens,
0 AS cache_read_input_tokens,
CASE
WHEN json_valid(m.token_usage) THEN COALESCE(CAST(json_extract(m.token_usage, '$.reasoning_tokens') AS INTEGER), 0)
ELSE 0
END AS reasoning_tokens,
NULL AS cost_usd,
m.claude_message_id,
m.claude_request_id,
m.source_uuid,
'' AS usage_dedup_key,
s.project,
s.agent
FROM messages m
JOIN sessions s ON m.session_id = s.id
WHERE %s
UNION ALL
SELECT
ue.session_id,
ue.message_ordinal,
ue.source AS usage_source,
COALESCE(ue.occurred_at, s.started_at, '') AS ts,
ue.model,
'' AS token_usage,
ue.input_tokens,
ue.output_tokens,
ue.cache_creation_input_tokens,
ue.cache_read_input_tokens,
ue.reasoning_tokens,
ue.cost_usd,
'' AS claude_message_id,
'' AS claude_request_id,
'' AS source_uuid,
CASE
WHEN ue.dedup_key != '' THEN ue.session_id || ':' || ue.source || ':' || ue.dedup_key
ELSE ue.session_id || ':' || ue.source || ':id:' || ue.id
END AS usage_dedup_key,
s.project,
s.agent
FROM usage_events ue
JOIN sessions s ON s.id = ue.session_id
WHERE %s`
const dailyUsageMessageRowsSQLTemplate = `
SELECT
m.session_id,
m.ordinal AS message_ordinal,
'message' AS usage_source,
COALESCE(NULLIF(m.timestamp, ''), s.started_at, '') AS ts,
m.model,
m.token_usage,
0 AS input_tokens,
0 AS output_tokens,
0 AS cache_creation_input_tokens,
0 AS cache_read_input_tokens,
CASE
WHEN json_valid(m.token_usage) THEN COALESCE(CAST(json_extract(m.token_usage, '$.reasoning_tokens') AS INTEGER), 0)
ELSE 0
END AS reasoning_tokens,
NULL AS cost_usd,
m.claude_message_id,
m.claude_request_id,
m.source_uuid,
'' AS usage_dedup_key,
s.project,
s.agent
FROM %s m
JOIN sessions s ON m.session_id = s.id
WHERE %s`
const dailyUsageEventRowsSQLTemplate = `
SELECT
ue.session_id,
ue.message_ordinal,
ue.source AS usage_source,
COALESCE(ue.occurred_at, s.started_at, '') AS ts,
ue.model,
'' AS token_usage,
ue.input_tokens,
ue.output_tokens,
ue.cache_creation_input_tokens,
ue.cache_read_input_tokens,
ue.reasoning_tokens,
ue.cost_usd,
'' AS claude_message_id,
'' AS claude_request_id,
'' AS source_uuid,
CASE
WHEN ue.dedup_key != '' THEN ue.session_id || ':' || ue.source || ':' || ue.dedup_key
ELSE ue.session_id || ':' || ue.source || ':id:' || ue.id
END AS usage_dedup_key,
s.project,
s.agent
FROM %s ue
JOIN sessions s ON s.id = ue.session_id
WHERE %s`
func dailyUsageRowsSQLWithWhere(
messageWhere, usageEventWhere string,
) string {
return fmt.Sprintf(
dailyUsageRowsSQLTemplate,
messageWhere,
usageEventWhere,
)
}
func dailyUsageRowsSQLWithTimestampCTEs(
messageTimestampWhere, eventTimestampWhere string,
messageTimestampJoinWhere, eventTimestampJoinWhere string,
messageFallbackWhere, eventFallbackWhere string,
) string {
return `
WITH
message_timestamp_rows AS MATERIALIZED (
SELECT
m.session_id,
m.ordinal,
NULLIF(m.timestamp, '') AS timestamp,
m.model,
m.token_usage,
m.claude_message_id,
m.claude_request_id,
m.source_uuid
FROM messages m
WHERE ` + messageTimestampWhere + `
),
usage_event_timestamp_rows AS MATERIALIZED (
SELECT
ue.id,
ue.session_id,
ue.message_ordinal,
ue.source,
ue.occurred_at,
ue.model,
ue.input_tokens,
ue.output_tokens,
ue.cache_creation_input_tokens,
ue.cache_read_input_tokens,
ue.reasoning_tokens,
ue.cost_usd,
ue.dedup_key
FROM usage_events ue
WHERE ` + eventTimestampWhere + `
)
` + fmt.Sprintf(
dailyUsageMessageRowsSQLTemplate,
"message_timestamp_rows",
messageTimestampJoinWhere,
) + `
UNION ALL
` + fmt.Sprintf(
dailyUsageEventRowsSQLTemplate,
"usage_event_timestamp_rows",
eventTimestampJoinWhere,
) + `
UNION ALL
` + fmt.Sprintf(
dailyUsageMessageRowsSQLTemplate,
"messages",
messageFallbackWhere,
) + `
UNION ALL
` + fmt.Sprintf(
dailyUsageEventRowsSQLTemplate,
"usage_events",
eventFallbackWhere,
)
}
type usageScanRow struct {
sessionID string
messageOrdinal sql.NullInt64
usageSource string
ts string
model string
tokenJSON string
inputTokens int
outputTokens int
cacheCreationInputTokens int
cacheReadInputTokens int
reasoningTokens int
costUSD sql.NullFloat64
costStatus string
costSource string
claudeMessageID string
claudeRequestID string
sourceUUID string
usageDedupKey string
project string
agent string
machine string
userMessageCount int
isAutomated int
sessionActivityAt string
terminationStatus string
displayName string
startedAt string
}
type dailyUsageScanRow struct {
sessionID string
messageOrdinal sql.NullInt64
usageSource string
ts string
model string
tokenJSON string
inputTokens int
outputTokens int
cacheCreationInputTokens int
cacheReadInputTokens int
reasoningTokens int
costUSD sql.NullFloat64
claudeMessageID string
claudeRequestID string
sourceUUID string
usageDedupKey string
project string
agent string
}
type topSessionMetadata struct {
displayName string
agent string
project string
startedAt string
}
func usageRowSelectFromRows(rowsSQL string) string {
return `
SELECT
u.session_id,
u.message_ordinal,
u.usage_source,
u.ts,
u.model,
u.token_usage,
u.input_tokens,
u.output_tokens,
u.cache_creation_input_tokens,
u.cache_read_input_tokens,
u.reasoning_tokens,
u.cost_usd,
u.cost_status,
u.cost_source,
u.claude_message_id,
u.claude_request_id,
u.source_uuid,
u.usage_dedup_key,
u.project,
u.agent,
u.machine,
u.user_message_count,
u.is_automated,
u.session_activity_at,
u.termination_status,
u.display_name,
u.started_at
FROM (` + rowsSQL + `) u
WHERE 1=1`
}
func usageRowSelect() string {
return usageRowSelectFromRows(usageRowsSQLWithWhere(
usageMessageEligibility,
usageEventEligibility,
))
}
func dailyUsageRowSelectFromRows(rowsSQL string) string {
return `
SELECT
u.session_id,
u.message_ordinal,
u.usage_source,
u.ts,
u.model,
u.token_usage,
u.input_tokens,
u.output_tokens,
u.cache_creation_input_tokens,
u.cache_read_input_tokens,
u.reasoning_tokens,
u.cost_usd,
u.claude_message_id,
u.claude_request_id,
u.source_uuid,
u.usage_dedup_key,
u.project,
u.agent
FROM (` + rowsSQL + `) u
WHERE 1=1`
}
type usageBounds struct {
from string
to string
}
func (b usageBounds) bounded() bool {
return b.from != "" || b.to != ""
}
func usageBoundsForFilter(f UsageFilter) usageBounds {
var b usageBounds
if f.From != "" {
b.from = paddedUTCBound(f.From+"T00:00:00Z", -14)
}
if f.To != "" {
b.to = paddedUTCBound(f.To+"T23:59:59Z", 14)
}
return b
}
func appendUsageColumnBounds(
where, col string, b usageBounds, args []any,
) (string, []any) {
if b.from != "" {
where += "\n\tAND " + col + " >= ?"
args = append(args, b.from)
}
if b.to != "" {
where += "\n\tAND " + col + " <= ?"
args = append(args, b.to)
}
return where, args
}
func usageRowsSQLForBounds(
f UsageFilter, b usageBounds,
) (string, []any) {
if !b.bounded() {
var messageArgs []any
messageWhere, messageArgs := f.appendUsageBranchFilterClauses(
usageMessageEligibility, messageArgs, "m.model")
var eventArgs []any
eventWhere, eventArgs := f.appendUsageBranchFilterClauses(
usageEventEligibility, eventArgs, "ue.model")
rowsSQL := dailyUsageRowsSQLWithWhere(messageWhere, eventWhere)
args := make([]any, 0, len(messageArgs)+len(eventArgs))
args = append(args, messageArgs...)
args = append(args, eventArgs...)
return rowsSQL, args
}
return usageBoundedRowsSQL(
f, b, usageMessageSourceEligibility, usageMessageEligibility)
}
// usageBoundedRowsSQL builds the bounded-branch CTE row source shared by
// usageRowsSQLForBounds (token-eligible rows) and
// usageMatchingSessionRowsSQLForBounds (relaxed matching rows). The two
// callers differ only in the message eligibility predicates.
func usageBoundedRowsSQL(
f UsageFilter, b usageBounds,
messageSourceEligibility, messageEligibility string,
) (string, []any) {
messageTimestampSourceWhere := messageSourceEligibility +
"\n\tAND m.timestamp IS NOT NULL" +
"\n\tAND m.timestamp != ''"
var messageTimestampArgs []any
messageTimestampSourceWhere, messageTimestampArgs =
f.appendUsageSourceFilterClauses(
messageTimestampSourceWhere, messageTimestampArgs, "m.model")
messageTimestampSourceWhere, messageTimestampArgs = appendUsageColumnBounds(
messageTimestampSourceWhere, "m.timestamp", b, messageTimestampArgs)
var messageTimestampJoinArgs []any
messageTimestampJoinWhere, messageTimestampJoinArgs :=
f.appendUsageSessionFilterClauses(
usageSessionEligibility, messageTimestampJoinArgs)
eventTimestampSourceWhere := usageEventSourceEligibility +
"\n\tAND ue.occurred_at IS NOT NULL"
var eventTimestampArgs []any
eventTimestampSourceWhere, eventTimestampArgs =
f.appendUsageSourceFilterClauses(
eventTimestampSourceWhere, eventTimestampArgs, "ue.model")
eventTimestampSourceWhere, eventTimestampArgs = appendUsageColumnBounds(
eventTimestampSourceWhere, "ue.occurred_at", b, eventTimestampArgs)
var eventTimestampJoinArgs []any
eventTimestampJoinWhere, eventTimestampJoinArgs :=
f.appendUsageSessionFilterClauses(
usageSessionEligibility, eventTimestampJoinArgs)
messageFallbackWhere := messageEligibility +
"\n\tAND NULLIF(m.timestamp, '') IS NULL"
var messageFallbackArgs []any
messageFallbackWhere, messageFallbackArgs =
f.appendUsageBranchFilterClauses(
messageFallbackWhere, messageFallbackArgs, "m.model")
messageFallbackWhere, messageFallbackArgs = appendUsageColumnBounds(
messageFallbackWhere, "s.started_at", b, messageFallbackArgs)
eventFallbackWhere := usageEventEligibility +
"\n\tAND ue.occurred_at IS NULL"
var eventFallbackArgs []any
eventFallbackWhere, eventFallbackArgs =
f.appendUsageBranchFilterClauses(
eventFallbackWhere, eventFallbackArgs, "ue.model")
eventFallbackWhere, eventFallbackArgs = appendUsageColumnBounds(
eventFallbackWhere, "s.started_at", b, eventFallbackArgs)
rowsSQL := dailyUsageRowsSQLWithTimestampCTEs(
messageTimestampSourceWhere,
eventTimestampSourceWhere,
messageTimestampJoinWhere,
eventTimestampJoinWhere,
messageFallbackWhere,
eventFallbackWhere,
)
args := make(
[]any, 0,
len(messageTimestampArgs)+len(eventTimestampArgs)+
len(messageTimestampJoinArgs)+len(eventTimestampJoinArgs)+
len(messageFallbackArgs)+len(eventFallbackArgs),
)
args = append(args, messageTimestampArgs...)
args = append(args, eventTimestampArgs...)
args = append(args, messageTimestampJoinArgs...)
args = append(args, eventTimestampJoinArgs...)
args = append(args, messageFallbackArgs...)
args = append(args, eventFallbackArgs...)
return rowsSQL, args
}
// usageMatchingSessionRowsSQLForBounds is usageRowsSQLForBounds's bounded
// branch built from the relaxed usageMatchingMessageEligibility predicates,
// so GetUsageMatchingSessionCount only relaxes the token-usage and
// model-presence requirements and keeps the same per-row
// Model/ExcludeModel filtering as the normal bounded path.
func usageMatchingSessionRowsSQLForBounds(
f UsageFilter, b usageBounds,
) (string, []any) {
return usageBoundedRowsSQL(
f, b,
usageMatchingMessageSourceEligibility, usageMatchingMessageEligibility)
}
func usageRowQuery(f UsageFilter) (string, []any) {
rowsSQL, args := usageRowsSQLForBounds(f, usageBoundsForFilter(f))
query := dailyUsageRowSelectFromRows(rowsSQL)
return query, args
}
func topSessionsUsageRowQuery(f UsageFilter) (string, []any) {
return usageRowQuery(f)
}
const dailyCursorUsageRowsSQLTemplate = `
SELECT
'' AS session_id,
NULL AS message_ordinal,
'cursor' AS usage_source,
cu.occurred_at AS ts,
cu.model,
'' AS token_usage,
cu.input_tokens,
cu.output_tokens,
cu.cache_write_tokens AS cache_creation_input_tokens,
cu.cache_read_tokens AS cache_read_input_tokens,
0 AS reasoning_tokens,
cu.charged_cents / 100.0 AS cost_usd,
'' AS claude_message_id,
'' AS claude_request_id,
'' AS source_uuid,
cu.dedup_key AS usage_dedup_key,
'' AS project,
'cursor' AS agent
FROM cursor_usage_events cu
WHERE %s`
func cursorUsageRowsSQLForBounds(
f UsageFilter, b usageBounds,
) (string, []any, bool) {
termPred, _ := buildUsageTerminationPredSQLite(f.Termination)
// Cursor usage rows carry no project or git branch and bypass the session
// filter, so any filter they cannot satisfy (project, machine, branch)
// must exclude them entirely rather than let them leak into totals.
if len(f.ProjectFilterLabels()) > 0 ||
len(f.ExcludedProjectFilterLabels()) > 0 ||
f.Machine != "" || f.GitBranch != "" || f.MinUserMessages > 0 ||
f.ExcludeOneShot || termPred != "" ||
f.ActiveSince != "" {
return "", nil, false
}
if f.Agent != "" {
vals := strings.Split(f.Agent, ",")
for i := range vals {
vals[i] = strings.TrimSpace(vals[i])
}
if !slices.Contains(vals, "cursor") {
return "", nil, false
}
}
if f.ExcludeAgent != "" {
vals := strings.Split(f.ExcludeAgent, ",")
for i := range vals {
vals[i] = strings.TrimSpace(vals[i])
}
if slices.Contains(vals, "cursor") {
return "", nil, false
}
}
where := "cu.model != ''"
var args []any
scope := normalizeAutomatedScope(f.AutomatedScope, f.ExcludeAutomated)
if pred := automatedScopePredicate(scope, "cu.is_headless"); pred != "" {
where += "\n\tAND " + pred
}
where, args = f.appendUsageSourceFilterClauses(
where, args, "cu.model",
)
where, args = appendUsageColumnBounds(where, "cu.occurred_at", b, args)
rowsSQL := fmt.Sprintf(dailyCursorUsageRowsSQLTemplate, where)
return rowsSQL, args, true
}
func dailyUsageRowsSQLForBounds(
f UsageFilter, b usageBounds, hasCursorTable bool,
) (string, []any) {
sessionRowsSQL, sessionArgs := usageRowsSQLForBounds(f, b)
if !hasCursorTable {
return sessionRowsSQL, sessionArgs
}
cursorRowsSQL, cursorArgs, ok := cursorUsageRowsSQLForBounds(f, b)
if !ok {
return sessionRowsSQL, sessionArgs
}
rowsSQL := sessionRowsSQL + "\n\nUNION ALL\n\n" + cursorRowsSQL
args := make([]any, 0, len(sessionArgs)+len(cursorArgs))
args = append(args, sessionArgs...)
args = append(args, cursorArgs...)
return rowsSQL, args
}
func scanUsageRow(rows *sql.Rows) (usageScanRow, error) {
var r usageScanRow
err := rows.Scan(
&r.sessionID,
&r.messageOrdinal,
&r.usageSource,
&r.ts,
&r.model,
&r.tokenJSON,
&r.inputTokens,
&r.outputTokens,
&r.cacheCreationInputTokens,
&r.cacheReadInputTokens,
&r.reasoningTokens,
&r.costUSD,
&r.costStatus,
&r.costSource,
&r.claudeMessageID,
&r.claudeRequestID,
&r.sourceUUID,
&r.usageDedupKey,
&r.project,
&r.agent,
&r.machine,
&r.userMessageCount,
&r.isAutomated,
&r.sessionActivityAt,
&r.terminationStatus,
&r.displayName,
&r.startedAt,
)
return r, err
}
func scanDailyUsageRow(rows *sql.Rows) (dailyUsageScanRow, error) {
var r dailyUsageScanRow
err := rows.Scan(
&r.sessionID,
&r.messageOrdinal,
&r.usageSource,
&r.ts,
&r.model,
&r.tokenJSON,
&r.inputTokens,
&r.outputTokens,
&r.cacheCreationInputTokens,
&r.cacheReadInputTokens,
&r.reasoningTokens,
&r.costUSD,
&r.claudeMessageID,
&r.claudeRequestID,
&r.sourceUUID,
&r.usageDedupKey,
&r.project,
&r.agent,
)
return r, err
}
func parseUsageTokenCounters(
tokenJSON string,
) (inputTok, outputTok, cacheCrTok, cacheRdTok int) {
inputTok, outputTok, cacheCrTok, cacheRdTok, _ =
parseUsageTokenCountersWithReasoning(tokenJSON)
return
}
func parseUsageTokenCountersWithReasoning(
tokenJSON string,
) (inputTok, outputTok, cacheCrTok, cacheRdTok, reasoningTok int) {
i := skipJSONSpace(tokenJSON, 0)
if i >= len(tokenJSON) || tokenJSON[i] != '{' {
return
}
i++
for i < len(tokenJSON) {
i = skipJSONSpace(tokenJSON, i)
if i >= len(tokenJSON) || tokenJSON[i] == '}' {
break
}
if tokenJSON[i] == ',' {
i++
continue
}
if tokenJSON[i] != '"' {
next, ok := skipJSONValue(tokenJSON, i)
if !ok || next <= i {
i++
} else {
i = next
}
continue
}
key, next, ok := parseJSONString(tokenJSON, i)
if !ok {
break
}
i = skipJSONSpace(tokenJSON, next)
if i >= len(tokenJSON) || tokenJSON[i] != ':' {
continue
}
i = skipJSONSpace(tokenJSON, i+1)
if isUsageTokenCounterKey(key) {
value, valueNext, ok := parseUsageTokenInt(tokenJSON, i)
if ok {
switch key {
case "input_tokens":
inputTok = value
case "output_tokens":
outputTok = value
case "cache_creation_input_tokens":
cacheCrTok = value
case "cache_read_input_tokens":
cacheRdTok = value
case "reasoning_tokens":
reasoningTok = value
}
}
if valueNext <= i {
i++
} else {
i = valueNext
}
continue
}
valueNext, ok := skipJSONValue(tokenJSON, i)
if !ok {
break
}
i = valueNext
}
return
}
func isUsageTokenCounterKey(key string) bool {
switch key {
case "input_tokens", "output_tokens",
"cache_creation_input_tokens", "cache_read_input_tokens",
"reasoning_tokens":
return true
default:
return false
}
}
func skipJSONSpace(tokenJSON string, i int) int {
for i < len(tokenJSON) && isJSONSpace(tokenJSON[i]) {
i++
}
return i
}
func parseJSONString(tokenJSON string, i int) (string, int, bool) {
if i >= len(tokenJSON) || tokenJSON[i] != '"' {
return "", i, false
}
for j := i + 1; j < len(tokenJSON); j++ {
switch tokenJSON[j] {
case '\\':
if j+1 >= len(tokenJSON) {
return "", len(tokenJSON), false
}
j++
case '"':
raw := tokenJSON[i : j+1]
var value string
err := json.Unmarshal([]byte(raw), &value)
if err != nil {
return "", j + 1, false
}
return value, j + 1, true
}
}
return "", len(tokenJSON), false
}
func parseUsageTokenInt(tokenJSON string, i int) (int, int, bool) {
if i >= len(tokenJSON) {
return 0, i, false
}
if tokenJSON[i] == '"' {
value, next, ok := parseJSONString(tokenJSON, i)
if !ok {
return 0, next, false
}
parsed, ok := parseUsageTokenIntLiteral(strings.TrimSpace(value))
return parsed, next, ok
}
start := i
if tokenJSON[i] == '-' {
i++
}
digitStart := i
for i < len(tokenJSON) && tokenJSON[i] >= '0' && tokenJSON[i] <= '9' {
i++
}
if i == digitStart {
next, ok := skipJSONValue(tokenJSON, start)
if ok {
return 0, next, false
}
return 0, start, false
}
parsed, ok := parseUsageTokenIntLiteral(tokenJSON[start:i])
return parsed, i, ok
}
func parseUsageTokenIntLiteral(value string) (int, bool) {
parsed, err := strconv.ParseInt(value, 10, 0)
if err == nil {
return int(parsed), true
}
if numErr, ok := err.(*strconv.NumError); ok && numErr.Err == strconv.ErrRange {
if strings.HasPrefix(value, "-") {
return -int(^uint(0)>>1) - 1, true
}
return int(^uint(0) >> 1), true
}
return 0, false
}
func skipJSONValue(tokenJSON string, i int) (int, bool) {
i = skipJSONSpace(tokenJSON, i)
if i >= len(tokenJSON) {
return i, false
}
switch tokenJSON[i] {
case '"':
_, next, ok := parseJSONString(tokenJSON, i)
return next, ok
case '{', '[':
return skipJSONComposite(tokenJSON, i)
case 't':
if strings.HasPrefix(tokenJSON[i:], "true") {
return i + len("true"), true
}
case 'f':
if strings.HasPrefix(tokenJSON[i:], "false") {
return i + len("false"), true
}
case 'n':
if strings.HasPrefix(tokenJSON[i:], "null") {
return i + len("null"), true
}
default:
return skipJSONNumber(tokenJSON, i)
}
return i, false
}
func skipJSONComposite(tokenJSON string, i int) (int, bool) {
var stack []byte
switch tokenJSON[i] {
case '{':
stack = append(stack, '}')
case '[':
stack = append(stack, ']')
default:
return i, false
}
i++
for i < len(tokenJSON) {
switch tokenJSON[i] {
case '"':
_, next, ok := parseJSONString(tokenJSON, i)
if !ok {
return next, false
}
i = next
case '{':
stack = append(stack, '}')
i++
case '[':
stack = append(stack, ']')
i++
case '}', ']':
if len(stack) == 0 || tokenJSON[i] != stack[len(stack)-1] {
return i + 1, false
}
stack = stack[:len(stack)-1]
i++
if len(stack) == 0 {
return i, true
}
default:
i++
}
}
return len(tokenJSON), false
}
func skipJSONNumber(tokenJSON string, i int) (int, bool) {
start := i
if tokenJSON[i] == '-' {
i++
}
digitStart := i
for i < len(tokenJSON) && tokenJSON[i] >= '0' && tokenJSON[i] <= '9' {
i++
}
if i == digitStart {
return start, false
}
if i < len(tokenJSON) && tokenJSON[i] == '.' {
i++
fracStart := i
for i < len(tokenJSON) && tokenJSON[i] >= '0' && tokenJSON[i] <= '9' {
i++
}
if i == fracStart {
return start, false
}
}
if i < len(tokenJSON) && (tokenJSON[i] == 'e' || tokenJSON[i] == 'E') {
i++
if i < len(tokenJSON) && (tokenJSON[i] == '+' || tokenJSON[i] == '-') {
i++
}
expStart := i
for i < len(tokenJSON) && tokenJSON[i] >= '0' && tokenJSON[i] <= '9' {
i++
}
if i == expStart {
return start, false
}
}
return i, true
}
func isJSONSpace(b byte) bool {
return b == ' ' || b == '\n' || b == '\r' || b == '\t'
}
func clampedUsageRowTokens(
inputTokens, outputTokens, cacheCreationInputTokens,
cacheReadInputTokens int,
) (inputTok, outputTok, cacheCrTok, cacheRdTok int) {
return ClampPlausibleTokens(int64(inputTokens)),
ClampPlausibleTokens(int64(outputTokens)),
ClampPlausibleTokens(int64(cacheCreationInputTokens)),
ClampPlausibleTokens(int64(cacheReadInputTokens))
}
func usageEventRowTokens(
source string,
inputTokens, outputTokens, cacheCreationInputTokens,
cacheReadInputTokens int,
) (inputTok, outputTok, cacheCrTok, cacheRdTok int) {
if source == "session" {
return floorNegativeTokens(inputTokens),
floorNegativeTokens(outputTokens),
floorNegativeTokens(cacheCreationInputTokens),
floorNegativeTokens(cacheReadInputTokens)
}
return clampedUsageRowTokens(
inputTokens, outputTokens,
cacheCreationInputTokens, cacheReadInputTokens)
}
func floorNegativeTokens(v int) int {
if v < 0 {
return 0
}
return v
}
func clampedUsageTokenCounters(
tokenJSON string,
) (inputTok, outputTok, cacheCrTok, cacheRdTok int) {
inputTok, outputTok, cacheCrTok, cacheRdTok, _ =
parseUsageTokenCountersWithReasoning(tokenJSON)
return ClampPlausibleTokens(int64(inputTok)),
ClampPlausibleTokens(int64(outputTok)),
ClampPlausibleTokens(int64(cacheCrTok)),
ClampPlausibleTokens(int64(cacheRdTok))
}
func clampedUsageTokenCountersWithReasoning(
tokenJSON string,
) (inputTok, outputTok, cacheCrTok, cacheRdTok, reasoningTok int) {
inputTok, outputTok, cacheCrTok, cacheRdTok, reasoningTok =
parseUsageTokenCountersWithReasoning(tokenJSON)
return ClampPlausibleTokens(int64(inputTok)),
ClampPlausibleTokens(int64(outputTok)),
ClampPlausibleTokens(int64(cacheCrTok)),
ClampPlausibleTokens(int64(cacheRdTok)),
ClampPlausibleTokens(int64(reasoningTok))
}
func dailyUsageAmounts(
r dailyUsageScanRow, pricing *export.PricingResolver,
) (inputTok, outputTok, cacheCrTok, cacheRdTok int, cost, savings float64) {
reasoningTok := r.reasoningTokens
if r.usageSource == "message" {
inputTok, outputTok, cacheCrTok, cacheRdTok, reasoningTok =
clampedUsageTokenCountersWithReasoning(r.tokenJSON)
} else {
inputTok, outputTok, cacheCrTok, cacheRdTok =
usageEventRowTokens(
r.usageSource,
r.inputTokens, r.outputTokens,
r.cacheCreationInputTokens, r.cacheReadInputTokens)
}
lookup := pricing.Lookup(r.model)
rates := lookup.Rates
if r.costUSD.Valid {
cost = r.costUSD.Float64
pricing.RecordReported(r.model, lookup)
} else {
cost = rates.CostForTokens(
inputTok, outputTok, reasoningTok, cacheCrTok, cacheRdTok)
pricing.RecordComputed(r.model, lookup)
}
readDelta := float64(cacheRdTok) *
(rates.InputPerMTok - rates.CacheReadPerMTok) / 1_000_000
crDelta := float64(cacheCrTok) *
(rates.InputPerMTok - rates.CacheWritePerMTok) / 1_000_000
savings = readDelta + crDelta
return
}
type usageDedupToken struct {
kind string
value string
}
func usageDedupTokenForRow(
usageSource, agent, claudeMessageID, claudeRequestID, sourceUUID, usageDedupKey string,
) (usageDedupToken, bool) {
if claudeMessageID != "" && claudeRequestID != "" {
return usageDedupToken{
kind: "claude",
value: claudeMessageID + ":" + claudeRequestID,
}, true
}
if usageSource == "message" && agent != "" && sourceUUID != "" {
return usageDedupToken{
kind: "source",
value: agent + ":" + sourceUUID,
}, true
}
if usageDedupKey != "" {
return usageDedupToken{
kind: "usage",
value: usageDedupKey,
}, true
}
return usageDedupToken{}, false
}
func (db *DB) loadTopSessionMetadata(
ctx context.Context, sessionIDs []string,
) (map[string]topSessionMetadata, error) {
out := make(map[string]topSessionMetadata, len(sessionIDs))
if len(sessionIDs) == 0 {
return out, nil
}
placeholders := make([]string, len(sessionIDs))
args := make([]any, len(sessionIDs))
for i, id := range sessionIDs {
placeholders[i] = "?"
args[i] = id
}
query := `
SELECT
id,
COALESCE(NULLIF(COALESCE(display_name, session_name), ''), NULLIF(first_message, ''), NULLIF(project, ''), id) AS display_name,
agent,
project,
COALESCE(started_at, '') AS started_at
FROM sessions
WHERE id IN (` + strings.Join(placeholders, ",") + `)`
rows, err := db.getReader().QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("querying top session metadata: %w", err)
}
defer rows.Close()
for rows.Next() {
var id string
var meta topSessionMetadata
if err := rows.Scan(
&id,
&meta.displayName,
&meta.agent,
&meta.project,
&meta.startedAt,
); err != nil {
return nil, fmt.Errorf("scanning top session metadata: %w", err)
}
out[id] = meta
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterating top session metadata: %w", err)
}
return out, nil
}
// DailyUsageEntry holds token counts and cost for one day.
type DailyUsageEntry struct {
Date string `json:"date"`
InputTokens int `json:"inputTokens"`
OutputTokens int `json:"outputTokens"`
CacheCreationTokens int `json:"cacheCreationTokens"`
CacheReadTokens int `json:"cacheReadTokens"`
TotalCost float64 `json:"totalCost"`
ModelsUsed []string `json:"modelsUsed"`
ModelBreakdowns []ModelBreakdown `json:"modelBreakdowns"`
ProjectBreakdowns []ProjectBreakdown `json:"projectBreakdowns"`
AgentBreakdowns []AgentBreakdown `json:"agentBreakdowns"`
}
func (e DailyUsageEntry) MarshalJSON() ([]byte, error) {
type alias DailyUsageEntry
out := alias(e)
if out.ModelsUsed == nil {
out.ModelsUsed = []string{}
}
if out.ModelBreakdowns == nil {
out.ModelBreakdowns = []ModelBreakdown{}
}
if out.ProjectBreakdowns == nil {
out.ProjectBreakdowns = []ProjectBreakdown{}
}
if out.AgentBreakdowns == nil {
out.AgentBreakdowns = []AgentBreakdown{}
}
return json.Marshal(out)
}
// ModelBreakdown holds per-model token and cost breakdown.
type ModelBreakdown struct {
ModelName string `json:"modelName"`
InputTokens int `json:"inputTokens"`
OutputTokens int `json:"outputTokens"`
CacheCreationTokens int `json:"cacheCreationTokens"`
CacheReadTokens int `json:"cacheReadTokens"`
Cost float64 `json:"cost"`
}
// ProjectBreakdown is the per-project slice of a day's usage.
type ProjectBreakdown struct {
ProjectKey string `json:"project_key"`
Project string `json:"project"`
InputTokens int `json:"inputTokens"`
OutputTokens int `json:"outputTokens"`
CacheCreationTokens int `json:"cacheCreationTokens"`
CacheReadTokens int `json:"cacheReadTokens"`
Cost float64 `json:"cost"`
}
// AgentBreakdown is the per-agent slice of a day's usage.
type AgentBreakdown struct {
Agent string `json:"agent"`
InputTokens int `json:"inputTokens"`
OutputTokens int `json:"outputTokens"`
CacheCreationTokens int `json:"cacheCreationTokens"`
CacheReadTokens int `json:"cacheReadTokens"`
Cost float64 `json:"cost"`
}
// UsageTotals holds aggregate token and cost totals.
type UsageTotals struct {
InputTokens int `json:"inputTokens"`
OutputTokens int `json:"outputTokens"`
CacheCreationTokens int `json:"cacheCreationTokens"`
CacheReadTokens int `json:"cacheReadTokens"`
TotalCost float64 `json:"totalCost"`
CopilotAICredits float64 `json:"copilotAICredits,omitempty"`
// CacheSavings is the net dollar delta vs an uncached run:
// cache reads save (input_rate - cache_read_rate) per token,
// cache creations cost (input_rate - cache_creation_rate)
// per token (usually negative because creation is billed
// above the input rate). Computed from per-model rates so
// mixed-model workloads get the right number, not a fixed
// Sonnet proxy.
CacheSavings float64 `json:"cacheSavings"`
}
// DailyUsageResult wraps the daily entries and totals.
type DailyUsageResult struct {
SchemaVersion int `json:"schema_version,omitempty"`
Pricing *export.PricingBlock `json:"pricing,omitempty"`
Projects map[string]export.ProjectMapEntry `json:"projects"`
Daily []DailyUsageEntry `json:"daily"`
Totals UsageTotals `json:"totals"`
SessionCounts UsageSessionCounts `json:"sessionCounts,omitempty"`
}
func SanitizeDailyUsageProjectLabelsWithCatalog(
result *DailyUsageResult,
projects map[string]export.ProjectMapEntry,
) {
for i := range result.Daily {
for j := range result.Daily[i].ProjectBreakdowns {
raw := result.Daily[i].ProjectBreakdowns[j].Project
result.Daily[i].ProjectBreakdowns[j].ProjectKey =
export.ProjectKeyForEntry(projects[raw])
result.Daily[i].ProjectBreakdowns[j].Project =
export.SafeProjectDisplayLabel(raw)
}
}
if result.SessionCounts.ByProject != nil {
byProject := make(map[string]int, len(result.SessionCounts.ByProject))
for raw, count := range result.SessionCounts.ByProject {
key := export.ProjectKeyForEntry(projects[raw])
if key != "" {
byProject[key] += count
}
}
result.SessionCounts.ByProject = byProject
}
}
// loadPricingMap reads the model_pricing table into a map for
// in-memory joins. This is much faster than a SQL LEFT JOIN
// on every row of the daily usage scan, since the pricing
// table is tiny and repeated resolver lookups are cached.
func (db *DB) loadPricingMap(
ctx context.Context,
) ([]export.EffectivePricingRow, error) {
return db.loadPricingMapFrom(ctx, db.getReader())
}
func (db *DB) loadPricingMapFrom(
ctx context.Context, q sessionExportQuerier,
) ([]export.EffectivePricingRow, error) {
rows, err := q.QueryContext(ctx,
`SELECT model_pattern,
input_per_mtok, output_per_mtok,
cache_creation_per_mtok, cache_read_per_mtok,
updated_at
FROM model_pricing
WHERE model_pattern NOT LIKE '\_%' ESCAPE '\'`)
if err != nil {
return nil, err
}
defer rows.Close()
fallback := fallbackRateMap()
out := make(map[string]export.ModelRates)
for rows.Next() {
var p ModelPricing
if err := rows.Scan(
&p.ModelPattern,
&p.InputPerMTok, &p.OutputPerMTok,
&p.CacheCreationPerMTok, &p.CacheReadPerMTok,
&p.UpdatedAt,
); err != nil {
return nil, err
}
rates := modelPricingRates(p)
rates.Source = modelPricingSource(p, fallback)
out[p.ModelPattern] = rates
}
if err := rows.Err(); err != nil {
return nil, err
}
for model, cp := range db.customPricing {
rates := export.ModelRates{
InputPerMTok: cp.Input,
OutputPerMTok: cp.Output,
CacheWritePerMTok: cp.CacheCreation,
CacheReadPerMTok: cp.CacheRead,
}
rates.Source = customPricingSource()
if source, ok := db.customPricingSources[model]; ok {
rates.Source = source
}
out[model] = rates
}
return pricingMapRows(out), nil
}
func customPricingSource() export.PricingRowSource {
return export.PricingRowSourceCustom
}
func fallbackRateMap() map[string]export.ModelRates {
fallback := pricingpkg.FallbackPricing()
out := make(map[string]export.ModelRates, len(fallback))
for _, p := range fallback {
rates := export.ModelRates{
InputPerMTok: p.InputPerMTok,
OutputPerMTok: p.OutputPerMTok,
CacheWritePerMTok: p.CacheCreationPerMTok,
CacheReadPerMTok: p.CacheReadPerMTok,
Source: export.PricingRowSourceEmbedded,
}
out[p.ModelPattern] = rates
}
return out
}
func modelPricingRates(p ModelPricing) export.ModelRates {
var updatedAt *time.Time
if p.UpdatedAt != "" {
if parsed, err := time.Parse(time.RFC3339Nano, p.UpdatedAt); err == nil {
t := parsed.UTC()
updatedAt = &t
}
}
return export.ModelRates{
InputPerMTok: p.InputPerMTok,
OutputPerMTok: p.OutputPerMTok,
CacheWritePerMTok: p.CacheCreationPerMTok,
CacheReadPerMTok: p.CacheReadPerMTok,
UpdatedAt: updatedAt,
}
}
func modelPricingSource(
p ModelPricing, fallback map[string]export.ModelRates,
) export.PricingRowSource {
if rates, ok := fallback[p.ModelPattern]; ok &&
rates.InputPerMTok == p.InputPerMTok &&
rates.OutputPerMTok == p.OutputPerMTok &&
rates.CacheWritePerMTok == p.CacheCreationPerMTok &&
rates.CacheReadPerMTok == p.CacheReadPerMTok {
return export.PricingRowSourceEmbedded
}
return export.PricingRowSourceFetched
}
func pricingMapRows(
in map[string]export.ModelRates,
) []export.EffectivePricingRow {
out := make([]export.EffectivePricingRow, 0, len(in))
for pattern, rates := range in {
out = append(out, export.EffectivePricingRow{
ModelPattern: pattern,
Rates: rates,
})
}
return out
}
// paddedUTCBound pads a UTC timestamp by hours to cover timezone
// offsets. Positive hours pad forward, negative pad backward.
func paddedUTCBound(ts string, hours int) string {
t, err := time.Parse(time.RFC3339, ts)
if err != nil {
return ts
}
return t.Add(time.Duration(hours) * time.Hour).Format(time.RFC3339)
}
// GetDailyUsage returns token usage and cost aggregated by day.
// It scans messages with non-empty token_usage JSON blobs,
// parses them in Go (faster than SQLite's json_extract per row),
// joins against an in-memory pricing map, and buckets by
// local date.
func (db *DB) GetDailyUsage(
ctx context.Context, f UsageFilter,
) (DailyUsageResult, error) {
loc := f.location()
pricing, err := db.loadPricingMap(ctx)
if err != nil {
return DailyUsageResult{},
fmt.Errorf("loading pricing: %w", err)
}
rateResolver := export.NewPricingResolver(pricing)
// Filter on usage timestamp (not only session started_at) so
// long-lived sessions that span date boundaries are included.
// Pad by +/-14h to cover all timezone offsets; the actual
// date filtering happens post-query via localDate.
query, args := dailyUsageRowsSQLForBounds(f, usageBoundsForFilter(f), db.hasCursorUsageTable())
query = dailyUsageRowSelectFromRows(query)
query += ` ORDER BY u.ts ASC, u.session_id ASC,
COALESCE(u.message_ordinal, -1) ASC`
rows, err := db.getReader().QueryContext(ctx, query, args...)
if err != nil {
return DailyUsageResult{},
fmt.Errorf("querying daily usage: %w", err)
}
defer rows.Close()
// 4-tuple key for per-(date, project, agent, model) accumulation.
type accumKey struct {
date string
project string
agent string
model string
}
type bucket struct {
inputTok int
outputTok int
cacheCr int
cacheRd int
cost float64
}
accum := make(map[accumKey]*bucket)
seen := make(map[usageDedupToken]struct{})
var seenSessions map[string]UsageSessionInfo
if !f.SkipSessionCounts {
seenSessions = make(map[string]UsageSessionInfo)
}
projectLabels := make(map[string]struct{})
// totalSavings is the running sum of per-message cache
// savings using each row's actual per-model rates. We sum
// at the message level instead of deriving from totals
// later because the rate mix varies per workload and a
// single fallback rate would misreport mixed-model periods.
var totalSavings float64
for rows.Next() {
r, scanErr := scanDailyUsageRow(rows)
if scanErr != nil {
return DailyUsageResult{},
fmt.Errorf("scanning daily usage row: %w", scanErr)
}
date := localDate(r.ts, loc)
if f.From != "" && date < f.From {
continue
}
if f.To != "" && date > f.To {
continue
}
// Dedup AFTER the date filter so out-of-range rows
// (pulled in by the ±14h timezone padding) don't mark
// a key as seen and suppress the in-range duplicate.
if key, ok := usageDedupTokenForRow(
r.usageSource, r.agent, r.claudeMessageID,
r.claudeRequestID, r.sourceUUID, r.usageDedupKey,
); ok {
if _, dup := seen[key]; dup {
continue
}
seen[key] = struct{}{}
}
if seenSessions != nil && r.usageSource != "cursor" {
if _, ok := seenSessions[r.sessionID]; !ok {
seenSessions[r.sessionID] = UsageSessionInfo{
Project: r.project,
Agent: r.agent,
}
}
}
if r.project != "" {
projectLabels[r.project] = struct{}{}
}
inputTok, outputTok, cacheCrTok, cacheRdTok, cost, savings :=
dailyUsageAmounts(r, rateResolver)
totalSavings += savings
key := accumKey{
date: date, project: r.project,
agent: r.agent, model: r.model,
}
b, ok := accum[key]
if !ok {
b = &bucket{}
accum[key] = b
}
b.inputTok += inputTok
b.outputTok += outputTok
b.cacheCr += cacheCrTok
b.cacheRd += cacheRdTok
b.cost += cost
}
if err := rows.Err(); err != nil {
return DailyUsageResult{},
fmt.Errorf("iterating daily usage rows: %w", err)
}
// Two paths: without breakdowns (CLI, fast) and with breakdowns
// (web UI). The fast path uses the original (date, model)
// grouping with no extra column reads. The breakdown path adds
// project/agent dimensions and builds three decomposition slices.
if !f.Breakdowns {
// Fast path: group by (date, model) only.
type dateModelKey struct {
date string
model string
}
type modelAccum struct {
inputTok int
outputTok int
cacheCr int
cacheRd int
cost float64
}
dm := make(map[dateModelKey]*modelAccum)
for key, b := range accum {
dmk := dateModelKey{date: key.date, model: key.model}
ma, ok := dm[dmk]
if !ok {
ma = &modelAccum{}
dm[dmk] = ma
}
ma.inputTok += b.inputTok
ma.outputTok += b.outputTok
ma.cacheCr += b.cacheCr
ma.cacheRd += b.cacheRd
ma.cost += b.cost
}
type dayData struct {
models map[string]*modelAccum
}
days := make(map[string]*dayData)
for key, ma := range dm {
dd, ok := days[key.date]
if !ok {
dd = &dayData{
models: make(map[string]*modelAccum),
}
days[key.date] = dd
}
dd.models[key.model] = ma
}
dateKeys := make([]string, 0, len(days))
for d := range days {
dateKeys = append(dateKeys, d)
}
sort.Strings(dateKeys)
daily := make([]DailyUsageEntry, 0, len(dateKeys))
var totals UsageTotals
for _, date := range dateKeys {
dd, ok := days[date]
if !ok || dd == nil {
continue
}
var entry DailyUsageEntry
entry.Date = date
modelNames := make([]string, 0, len(dd.models))
for m := range dd.models {
modelNames = append(modelNames, m)
}
sort.Slice(modelNames, func(i, j int) bool {
left := dd.models[modelNames[i]]
right := dd.models[modelNames[j]]
if left == nil || right == nil {
return left != nil
}
ci := left.cost
cj := right.cost
if ci != cj {
return ci > cj
}
return modelNames[i] < modelNames[j]
})
entry.ModelsUsed = modelNames
mbd := make(
[]ModelBreakdown, 0, len(modelNames),
)
for _, m := range modelNames {
ma, ok := dd.models[m]
if !ok || ma == nil {
continue
}
entry.InputTokens += ma.inputTok
entry.OutputTokens += ma.outputTok
entry.CacheCreationTokens += ma.cacheCr
entry.CacheReadTokens += ma.cacheRd
entry.TotalCost += ma.cost
mbd = append(mbd, ModelBreakdown{
ModelName: m,
InputTokens: ma.inputTok,
OutputTokens: ma.outputTok,
CacheCreationTokens: ma.cacheCr,
CacheReadTokens: ma.cacheRd,
Cost: ma.cost,
})
}
entry.ModelBreakdowns = mbd
daily = append(daily, entry)
totals.InputTokens += entry.InputTokens
totals.OutputTokens += entry.OutputTokens
totals.CacheCreationTokens += entry.CacheCreationTokens
totals.CacheReadTokens += entry.CacheReadTokens
totals.TotalCost += entry.TotalCost
}
if daily == nil {
daily = []DailyUsageEntry{}
}
totals.CacheSavings = totalSavings
var aiCredits float64
for key, b := range accum {
aiCredits += AICreditsFromCost(key.agent, b.cost)
}
if aiCredits > 0 {
totals.CopilotAICredits = aiCredits
}
var sessionCounts UsageSessionCounts
if seenSessions != nil {
sessionCounts = NewUsageSessionCounts(seenSessions)
}
projects, err := db.BuildProjectIdentityMap(ctx,
sortedSetKeys(projectLabels))
if err != nil {
return DailyUsageResult{}, err
}
projectRows := DailyUsageResult{Daily: daily, SessionCounts: sessionCounts}
SanitizeDailyUsageProjectLabelsWithCatalog(&projectRows, projects)
daily = projectRows.Daily
sessionCounts = projectRows.SessionCounts
pricingBlock, err := rateResolver.BuildBlock()
if err != nil {
return DailyUsageResult{}, fmt.Errorf(
"building pricing block: %w", err)
}
return DailyUsageResult{
SchemaVersion: export.UsageDailySchemaVersion,
Pricing: &pricingBlock,
Projects: export.ProjectMapForWire(projects),
Daily: daily,
Totals: totals,
SessionCounts: sessionCounts,
}, nil
}
// Breakdown path: single walk builds model/project/agent maps.
type dayMaps struct {
models map[string]bucket
projects map[string]bucket
agents map[string]bucket
}
days := make(map[string]*dayMaps, 64)
for key, b := range accum {
dm, ok := days[key.date]
if !ok {
dm = &dayMaps{
models: make(map[string]bucket, 4),
projects: make(map[string]bucket, 8),
agents: make(map[string]bucket, 4),
}
days[key.date] = dm
}
cur := dm.models[key.model]
cur.inputTok += b.inputTok
cur.outputTok += b.outputTok
cur.cacheCr += b.cacheCr
cur.cacheRd += b.cacheRd
cur.cost += b.cost
dm.models[key.model] = cur
cur = dm.projects[key.project]
cur.inputTok += b.inputTok
cur.outputTok += b.outputTok
cur.cacheCr += b.cacheCr
cur.cacheRd += b.cacheRd
cur.cost += b.cost
dm.projects[key.project] = cur
cur = dm.agents[key.agent]
cur.inputTok += b.inputTok
cur.outputTok += b.outputTok
cur.cacheCr += b.cacheCr
cur.cacheRd += b.cacheRd
cur.cost += b.cost
dm.agents[key.agent] = cur
}
dateKeys := make([]string, 0, len(days))
for d := range days {
dateKeys = append(dateKeys, d)
}
sort.Strings(dateKeys)
daily := make([]DailyUsageEntry, 0, len(dateKeys))
var totals UsageTotals
for _, date := range dateKeys {
dm, ok := days[date]
if !ok || dm == nil {
continue
}
var entry DailyUsageEntry
entry.Date = date
modelNames := make([]string, 0, len(dm.models))
for m := range dm.models {
modelNames = append(modelNames, m)
}
sort.Slice(modelNames, func(i, j int) bool {
left := dm.models[modelNames[i]]
right := dm.models[modelNames[j]]
ci := left.cost
cj := right.cost
if ci != cj {
return ci > cj
}
return modelNames[i] < modelNames[j]
})
entry.ModelsUsed = modelNames
mbd := make(
[]ModelBreakdown, 0, len(modelNames),
)
for _, m := range modelNames {
b, ok := dm.models[m]
if !ok {
continue
}
entry.InputTokens += b.inputTok
entry.OutputTokens += b.outputTok
entry.CacheCreationTokens += b.cacheCr
entry.CacheReadTokens += b.cacheRd
entry.TotalCost += b.cost
mbd = append(mbd, ModelBreakdown{
ModelName: m,
InputTokens: b.inputTok,
OutputTokens: b.outputTok,
CacheCreationTokens: b.cacheCr,
CacheReadTokens: b.cacheRd,
Cost: b.cost,
})
}
entry.ModelBreakdowns = mbd
pbd := make(
[]ProjectBreakdown, 0, len(dm.projects),
)
for p, b := range dm.projects {
pbd = append(pbd, ProjectBreakdown{
Project: p,
InputTokens: b.inputTok,
OutputTokens: b.outputTok,
CacheCreationTokens: b.cacheCr,
CacheReadTokens: b.cacheRd,
Cost: b.cost,
})
}
sort.Slice(pbd, func(i, j int) bool {
if pbd[i].Cost != pbd[j].Cost {
return pbd[i].Cost > pbd[j].Cost
}
return pbd[i].Project < pbd[j].Project
})
entry.ProjectBreakdowns = pbd
abd := make(
[]AgentBreakdown, 0, len(dm.agents),
)
for a, b := range dm.agents {
abd = append(abd, AgentBreakdown{
Agent: a,
InputTokens: b.inputTok,
OutputTokens: b.outputTok,
CacheCreationTokens: b.cacheCr,
CacheReadTokens: b.cacheRd,
Cost: b.cost,
})
}
sort.Slice(abd, func(i, j int) bool {
if abd[i].Cost != abd[j].Cost {
return abd[i].Cost > abd[j].Cost
}
return abd[i].Agent < abd[j].Agent
})
entry.AgentBreakdowns = abd
daily = append(daily, entry)
totals.InputTokens += entry.InputTokens
totals.OutputTokens += entry.OutputTokens
totals.CacheCreationTokens += entry.CacheCreationTokens
totals.CacheReadTokens += entry.CacheReadTokens
totals.TotalCost += entry.TotalCost
}
if daily == nil {
daily = []DailyUsageEntry{}
}
totals.CacheSavings = totalSavings
var aiCredits float64
for _, d := range daily {
for _, ab := range d.AgentBreakdowns {
aiCredits += AICreditsFromCost(ab.Agent, ab.Cost)
}
}
if aiCredits > 0 {
totals.CopilotAICredits = aiCredits
}
var sessionCounts UsageSessionCounts
if seenSessions != nil {
sessionCounts = NewUsageSessionCounts(seenSessions)
}
projects, err := db.BuildProjectIdentityMap(ctx, sortedSetKeys(projectLabels))
if err != nil {
return DailyUsageResult{}, err
}
projectRows := DailyUsageResult{Daily: daily, SessionCounts: sessionCounts}
SanitizeDailyUsageProjectLabelsWithCatalog(&projectRows, projects)
daily = projectRows.Daily
sessionCounts = projectRows.SessionCounts
pricingBlock, err := rateResolver.BuildBlock()
if err != nil {
return DailyUsageResult{}, fmt.Errorf(
"building pricing block: %w", err)
}
return DailyUsageResult{
SchemaVersion: export.UsageDailySchemaVersion,
Pricing: &pricingBlock,
Projects: export.ProjectMapForWire(projects),
Daily: daily,
Totals: totals,
SessionCounts: sessionCounts,
}, nil
}
// TopSessionEntry is one row in the "top sessions by cost" result.
type TopSessionEntry struct {
SessionID string `json:"sessionId"`
DisplayName string `json:"displayName"`
Agent string `json:"agent"`
Project string `json:"project"`
StartedAt string `json:"startedAt"`
TotalTokens int `json:"totalTokens"`
Cost float64 `json:"cost"`
}
// GetTopSessionsByCost returns sessions ranked by total cost
// over the filter range. Default limit 20, max 100.
func (db *DB) GetTopSessionsByCost(
ctx context.Context, f UsageFilter, limit int,
) ([]TopSessionEntry, error) {
if limit <= 0 {
limit = 20
}
if limit > 100 {
limit = 100
}
pricing, err := db.loadPricingMap(ctx)
if err != nil {
return nil,
fmt.Errorf("loading pricing: %w", err)
}
rateResolver := export.NewPricingResolver(pricing)
query, args := topSessionsUsageRowQuery(f)
// Deterministic order so the dedup "winner" (the session
// that gets credit for a duplicate message.id + request.id
// pair) is stable across runs: earliest timestamp wins,
// then session_id, then message ordinal.
query += ` ORDER BY u.ts ASC, u.session_id ASC,
COALESCE(u.message_ordinal, -1) ASC`
rows, err := db.getReader().QueryContext(ctx, query, args...)
if err != nil {
return nil,
fmt.Errorf("querying top sessions: %w", err)
}
defer rows.Close()
loc := f.location()
type sessAccum struct {
totalTokens int
cost float64
}
accum := make(map[string]*sessAccum)
// Track insertion order for stable iteration.
var order []string
// Dedup duplicate usage rows across fork/subagent
// boundaries so per-session totals match the aggregate
// totals from GetDailyUsage. Same key and ordering rules.
seen := make(map[usageDedupToken]struct{})
for rows.Next() {
r, err := scanDailyUsageRow(rows)
if err != nil {
return nil,
fmt.Errorf("scanning top sessions row: %w", err)
}
// Post-query date filter (same as GetDailyUsage).
date := localDate(r.ts, loc)
if f.From != "" && date < f.From {
continue
}
if f.To != "" && date > f.To {
continue
}
// Dedup AFTER the date filter, matching GetDailyUsage,
// so out-of-range rows pulled in by the ±14h padding
// don't claim a key and suppress the in-range duplicate.
if key, ok := usageDedupTokenForRow(
r.usageSource, r.agent, r.claudeMessageID,
r.claudeRequestID, r.sourceUUID, r.usageDedupKey,
); ok {
if _, dup := seen[key]; dup {
continue
}
seen[key] = struct{}{}
}
inputTok, outputTok, cacheCrTok, cacheRdTok, cost, _ :=
dailyUsageAmounts(r, rateResolver)
sa, ok := accum[r.sessionID]
if !ok {
sa = &sessAccum{}
accum[r.sessionID] = sa
order = append(order, r.sessionID)
}
sa.totalTokens += inputTok + outputTok +
cacheCrTok + cacheRdTok
sa.cost += cost
}
if err := rows.Err(); err != nil {
return nil,
fmt.Errorf("iterating top sessions rows: %w", err)
}
result := make([]TopSessionEntry, 0, len(order))
for _, id := range order {
sa, ok := accum[id]
if !ok || sa == nil {
continue
}
result = append(result, TopSessionEntry{
SessionID: id,
DisplayName: id,
TotalTokens: sa.totalTokens,
Cost: sa.cost,
})
}
sort.Slice(result, func(i, j int) bool {
if result[i].Cost != result[j].Cost {
return result[i].Cost > result[j].Cost
}
return result[i].SessionID < result[j].SessionID
})
if len(result) > limit {
result = result[:limit]
}
sessionIDs := make([]string, len(result))
for i := range result {
sessionIDs[i] = result[i].SessionID
}
metadata, err := db.loadTopSessionMetadata(ctx, sessionIDs)
if err != nil {
return nil, err
}
for i := range result {
if meta, ok := metadata[result[i].SessionID]; ok {
result[i].DisplayName = meta.displayName
result[i].Agent = meta.agent
result[i].Project = meta.project
result[i].StartedAt = meta.startedAt
}
}
return result, nil
}
// SessionUsage is the per-session token + cost summary returned by
// the `session usage` command. Cost is an estimate from the
// model_pricing catalog unless an agent reported cost directly
// (usage_events.cost_usd). CostUSD is non-zero only when HasCost is
// true; a partial total (some models unpriced) is never emitted.
type SessionUsage struct {
SessionID string `json:"session_id"`
Agent string `json:"agent"`
Project string `json:"project"`
TotalOutputTokens int `json:"total_output_tokens"`
PeakContextTokens int `json:"peak_context_tokens"`
HasTokenData bool `json:"has_token_data"`
CostUSD float64 `json:"cost_usd"`
HasCost bool `json:"has_cost"`
AICredits float64 `json:"ai_credits,omitempty"`
Models []string `json:"models"`
UnpricedModels []string `json:"unpriced_models,omitempty"`
BreakdownCount int `json:"breakdown_count"`
Breakdown []SessionUsageBreakdownEntry `json:"breakdown"`
}
type SessionUsageBreakdownEntry struct {
Ordinal int `json:"ordinal"`
MessageOrdinal *int `json:"message_ordinal,omitempty"`
Source string `json:"source"`
Label string `json:"label"`
Timestamp string `json:"timestamp"`
Model string `json:"model"`
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
CacheCreationInputTokens int `json:"cache_creation_input_tokens"`
CacheReadInputTokens int `json:"cache_read_input_tokens"`
CostUSD float64 `json:"cost_usd"`
HasCost bool `json:"has_cost"`
}
// sessionRowCost computes one usage row's cost and reports whether
// it was priced and whether it contributes to the estimate. A row
// contributes when it carries an explicit cost or any tokens. It does
// an explicit map lookup so callers can distinguish "unpriced" from
// "$0".
func sessionRowCost(
r usageScanRow, pricing *export.PricingResolver,
) (cost float64, priced, contributes bool) {
var inTok, outTok, crTok, rdTok int
reasoningTok := r.reasoningTokens
if r.usageSource == "message" {
inTok, outTok, crTok, rdTok, reasoningTok =
clampedUsageTokenCountersWithReasoning(r.tokenJSON)
} else {
inTok, outTok, crTok, rdTok = usageEventRowTokens(
r.usageSource,
r.inputTokens, r.outputTokens,
r.cacheCreationInputTokens, r.cacheReadInputTokens)
}
if r.costUSD.Valid {
pricing.RecordReported(r.model, pricing.Lookup(r.model))
return r.costUSD.Float64, true, true
}
if inTok == 0 && outTok == 0 && reasoningTok == 0 &&
crTok == 0 && rdTok == 0 {
return 0, true, false
}
lookup := pricing.Lookup(r.model)
if !lookup.OK {
pricing.RecordComputed(r.model, lookup)
return 0, false, true
}
cost = lookup.Rates.CostForTokens(
inTok, outTok, reasoningTok, crTok, rdTok)
pricing.RecordComputed(r.model, lookup)
return cost, true, true
}
func sessionUsageBreakdownEntry(
r usageScanRow,
ordinal int,
cost float64,
priced bool,
) SessionUsageBreakdownEntry {
var inTok, outTok, crTok, rdTok int
if r.usageSource == "message" {
inTok, outTok, crTok, rdTok =
clampedUsageTokenCounters(r.tokenJSON)
} else {
inTok, outTok, crTok, rdTok = usageEventRowTokens(
r.usageSource,
r.inputTokens, r.outputTokens,
r.cacheCreationInputTokens, r.cacheReadInputTokens)
}
entry := SessionUsageBreakdownEntry{
Ordinal: ordinal,
Source: r.usageSource,
Label: sessionUsageBreakdownLabel(r),
Timestamp: r.ts,
Model: r.model,
InputTokens: inTok,
OutputTokens: outTok,
CacheCreationInputTokens: crTok,
CacheReadInputTokens: rdTok,
CostUSD: cost,
HasCost: priced,
}
if r.messageOrdinal.Valid {
messageOrdinal := int(r.messageOrdinal.Int64)
entry.MessageOrdinal = &messageOrdinal
}
return entry
}
func sessionUsageBreakdownLabel(r usageScanRow) string {
if r.messageOrdinal.Valid {
if r.usageSource == "message" {
return fmt.Sprintf("Prompt %d", r.messageOrdinal.Int64+1)
}
return fmt.Sprintf("Step %d", r.messageOrdinal.Int64+1)
}
if r.usageSource != "" {
return r.usageSource
}
return "usage"
}
// GetSessionUsage returns one session's token totals and cost
// estimate. It starts from GetSession (so metadata and session-level
// token aggregates are reported even when there are no per-message
// usage rows), then aggregates cost over the session's own usage
// rows. Dedup is intra-session only; this reports the session's own
// usage, which can diverge from the dashboard's cross-session
// credited total for fork/subagent sessions. Returns (nil, nil) when
// the session does not exist. BreakdownCount is always populated;
// per-row Breakdown entries are built only when includeBreakdown is
// true so callers that need just the totals avoid the row payload.
func (db *DB) GetSessionUsage(
ctx context.Context, sessionID string, includeBreakdown bool,
) (*SessionUsage, error) {
sess, err := db.GetSession(ctx, sessionID)
if err != nil {
return nil, err
}
if sess == nil {
return nil, nil
}
pricing, err := db.loadPricingMap(ctx)
if err != nil {
return nil, fmt.Errorf("loading pricing: %w", err)
}
rateResolver := export.NewPricingResolver(pricing)
query := usageRowSelect() + ` AND u.session_id = ?
ORDER BY u.ts ASC, u.session_id ASC,
COALESCE(u.message_ordinal, -1) ASC,
u.usage_source ASC,
COALESCE(u.usage_dedup_key, '') ASC`
rows, err := db.getReader().QueryContext(ctx, query, sessionID)
if err != nil {
return nil, fmt.Errorf("querying session usage: %w", err)
}
defer rows.Close()
var cost float64
contributing := false
allPriced := true
modelsSet := make(map[string]struct{})
unpricedSet := make(map[string]struct{})
breakdown := make([]SessionUsageBreakdownEntry, 0)
breakdownCount := 0
seen := make(map[usageDedupToken]struct{})
for rows.Next() {
r, scanErr := scanUsageRow(rows)
if scanErr != nil {
return nil, fmt.Errorf("scanning session usage row: %w", scanErr)
}
if key, ok := usageDedupTokenForRow(
r.usageSource, r.agent, r.claudeMessageID,
r.claudeRequestID, r.sourceUUID, r.usageDedupKey,
); ok {
if _, dup := seen[key]; dup {
continue
}
seen[key] = struct{}{}
}
c, priced, contributes := sessionRowCost(r, rateResolver)
if !contributes {
continue
}
contributing = true
modelsSet[r.model] = struct{}{}
if priced {
cost += c
} else {
allPriced = false
unpricedSet[r.model] = struct{}{}
}
breakdownCount++
if includeBreakdown {
breakdown = append(breakdown, sessionUsageBreakdownEntry(
r, breakdownCount, c, priced))
}
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterating session usage rows: %w", err)
}
out := &SessionUsage{
SessionID: sess.ID,
Agent: sess.Agent,
Project: sess.Project,
TotalOutputTokens: sess.TotalOutputTokens,
PeakContextTokens: sess.PeakContextTokens,
HasTokenData: sess.HasTotalOutputTokens || sess.HasPeakContextTokens,
Models: sortedSetKeys(modelsSet),
HasCost: contributing && allPriced,
BreakdownCount: breakdownCount,
Breakdown: breakdown,
}
if out.HasCost {
out.CostUSD = cost
out.AICredits = AICreditsFromCost(sess.Agent, cost)
}
if len(unpricedSet) > 0 {
out.UnpricedModels = sortedSetKeys(unpricedSet)
}
return out, nil
}
// sortedSetKeys returns the map keys sorted; never nil so JSON
// renders "[]" rather than "null".
func sortedSetKeys(set map[string]struct{}) []string {
out := make([]string, 0, len(set))
for k := range set {
out = append(out, k)
}
sort.Strings(out)
return out
}
// UsageSessionCounts holds distinct session counts grouped by
// project and agent over a filter range.
type UsageSessionCounts struct {
Total int `json:"total"`
ByProject map[string]int `json:"byProject"`
ByAgent map[string]int `json:"byAgent"`
}
type UsageSessionInfo struct {
Project string
Agent string
}
func NewUsageSessionCounts(
seen map[string]UsageSessionInfo,
) UsageSessionCounts {
out := UsageSessionCounts{
Total: len(seen),
ByProject: make(map[string]int),
ByAgent: make(map[string]int),
}
for _, info := range seen {
out.ByProject[info.Project]++
out.ByAgent[info.Agent]++
}
return out
}
// GetUsageSessionCounts returns distinct session counts grouped
// by project and agent. Sessions spanning multiple days count
// once. Soft-deleted sessions are excluded via
// usageMessageEligibility.
//
// Like GetDailyUsage and GetTopSessionsByCost, this query pads
// the UTC bounds by +/-14h and applies a post-query localDate
// filter so timezone-boundary messages are counted correctly.
func (db *DB) GetUsageSessionCounts(
ctx context.Context, f UsageFilter,
) (UsageSessionCounts, error) {
query, args := usageRowQuery(f)
// Deterministic ordering so the Claude dedup winner — the
// session that "owns" a shared message — is stable across
// runs. Matches GetDailyUsage / GetTopSessionsByCost so all
// three queries agree on which session gets credit.
query += ` ORDER BY u.ts ASC, u.session_id ASC,
COALESCE(u.message_ordinal, -1) ASC`
rows, err := db.getReader().QueryContext(ctx, query, args...)
if err != nil {
return UsageSessionCounts{},
fmt.Errorf("querying session counts: %w", err)
}
defer rows.Close()
loc := f.location()
// Track which sessions pass the localDate filter via a
// set of seen session IDs. Each session is counted once
// regardless of how many qualifying messages it has.
type sessInfo struct {
project string
agent string
}
seen := make(map[string]sessInfo)
// Usage dedup mirrors GetDailyUsage: if a session only
// qualifies because of rows that duplicate an earlier
// session's usage (fork/subagent replays), that session
// should NOT be counted. Otherwise sessionCounts would
// disagree with the deduped token totals.
dedup := make(map[usageDedupToken]struct{})
for rows.Next() {
r, err := scanDailyUsageRow(rows)
if err != nil {
return UsageSessionCounts{},
fmt.Errorf("scanning session counts: %w", err)
}
// Post-query date filter (same as GetDailyUsage).
date := localDate(r.ts, loc)
if f.From != "" && date < f.From {
continue
}
if f.To != "" && date > f.To {
continue
}
// Dedup AFTER the date filter, matching the other two
// queries so ±14h padding rows don't claim keys.
if key, ok := usageDedupTokenForRow(
r.usageSource, r.agent, r.claudeMessageID,
r.claudeRequestID, r.sourceUUID, r.usageDedupKey,
); ok {
if _, dup := dedup[key]; dup {
continue
}
dedup[key] = struct{}{}
}
if _, ok := seen[r.sessionID]; !ok {
seen[r.sessionID] = sessInfo{
project: r.project,
agent: r.agent,
}
}
}
if err := rows.Err(); err != nil {
return UsageSessionCounts{},
fmt.Errorf("iterating session counts: %w", err)
}
out := UsageSessionCounts{
Total: len(seen),
ByProject: make(map[string]int),
ByAgent: make(map[string]int),
}
for _, info := range seen {
out.ByProject[info.project]++
out.ByAgent[info.agent]++
}
return out, nil
}
// GetUsageMatchingSessionCount counts sessions that match the usage filter
// even when they have no token-bearing usage rows. Bounded ranges are
// resolved against the timestamps of the sessions' messages/usage_events
// rows (falling back to s.started_at for rows with no timestamp of their
// own), the same shape usageRowsSQLForBounds uses, so a session whose
// started_at/ended_at fall outside the window but whose message activity
// falls inside it is still counted.
func (db *DB) GetUsageMatchingSessionCount(
ctx context.Context, f UsageFilter,
) (int, error) {
bounds := usageBoundsForFilter(f)
if !bounds.bounded() {
where, args := f.appendUsageSessionFilterClauses(usageSessionEligibility, nil)
where, args = f.appendUsageMatchingActivityClauses(where, args)
var count int
err := db.getReader().QueryRowContext(ctx, `
SELECT COUNT(*)
FROM sessions s WHERE `+where, args...).Scan(&count)
if err != nil {
return 0, fmt.Errorf("querying matching usage sessions: %w", err)
}
return count, nil
}
rowsSQL, args := usageMatchingSessionRowsSQLForBounds(f, bounds)
rows, err := db.getReader().QueryContext(
ctx, dailyUsageRowSelectFromRows(rowsSQL), args...)
if err != nil {
return 0, fmt.Errorf("querying matching usage sessions: %w", err)
}
defer rows.Close()
loc := f.location()
seen := make(map[string]struct{})
for rows.Next() {
r, err := scanDailyUsageRow(rows)
if err != nil {
return 0, fmt.Errorf("scanning matching usage session: %w", err)
}
date := localDate(r.ts, loc)
if date == "" {
continue
}
if f.From != "" && date < f.From {
continue
}
if f.To != "" && date > f.To {
continue
}
seen[r.sessionID] = struct{}{}
}
if err := rows.Err(); err != nil {
return 0, fmt.Errorf("iterating matching usage sessions: %w", err)
}
return len(seen), nil
}