Files
wehub-resource-sync f99010fae1
Desktop Artifacts / Desktop Build (Linux) (push) Waiting to run
Desktop Artifacts / Desktop Build (Windows) (push) Waiting to run
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Waiting to run
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Waiting to run
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Waiting to run
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
chore: import upstream snapshot with attribution
2026-07-13 12:30:36 +08:00

742 lines
25 KiB
Go

// ABOUTME: Usage-summary request/response types, validation, and the
// ABOUTME: fold/cache aggregation shared by both SessionService backends.
package service
import (
"context"
"sort"
"strings"
"time"
"go.kenn.io/agentsview/internal/db"
"go.kenn.io/agentsview/internal/export"
"go.kenn.io/agentsview/internal/parser"
"go.kenn.io/agentsview/internal/timeutil"
)
// UsageRequest is the transport-neutral usage-summary input. Fields use
// the include_* polarity of the HTTP query parameters; BuildUsageFilter
// inverts them to the db layer's exclude_* form.
type UsageRequest struct {
From string `json:"from,omitempty"`
To string `json:"to,omitempty"`
Timezone string `json:"timezone,omitempty"`
Agent string `json:"agent,omitempty"`
Project string `json:"project,omitempty"`
Machine string `json:"machine,omitempty"`
GitBranch string `json:"git_branch,omitempty"`
ExcludeProject string `json:"exclude_project,omitempty"`
ExcludeProjectKey string `json:"exclude_project_key,omitempty"`
ExcludeAgent string `json:"exclude_agent,omitempty"`
ExcludeModel string `json:"exclude_model,omitempty"`
Model string `json:"model,omitempty"`
MinUserMessages int `json:"min_user_messages,omitempty"`
ActiveSince string `json:"active_since,omitempty"`
Termination string `json:"termination,omitempty"`
IncludeOneShot bool `json:"include_one_shot,omitempty"`
IncludeAutomated bool `json:"include_automated,omitempty"`
NoDefaultRange bool `json:"no_default_range,omitempty"`
Breakdowns *bool `json:"breakdowns,omitempty"`
SessionCounts *bool `json:"session_counts,omitempty"`
// ProjectLabels and ExcludeProjectLabels carry exact internal labels
// resolved from opaque keys. Unlike the public string fields, they are
// never parsed as comma-separated transport input.
ProjectLabels []string `json:"-"`
ExcludeProjectLabels []string `json:"-"`
}
// ResolveUsageProjectKeys translates opaque project-label keys back to the
// source labels understood by storage queries. The translation stays inside
// the local data boundary; callers never need the raw label carried by an
// unsafe path-like project name.
func ResolveUsageProjectKeys(
ctx context.Context, store db.Store, req UsageRequest,
) (UsageRequest, error) {
if req.ExcludeProjectKey == "" {
return req, nil
}
resolved, err := resolveUsageProjectKeyLabels(
ctx, store, req.ExcludeProjectKey,
)
if err != nil {
return UsageRequest{}, err
}
req.ExcludeProjectLabels = append(req.ExcludeProjectLabels, resolved...)
req.ExcludeProjectKey = ""
return req, nil
}
func resolveUsageProjectKeyLabels(
ctx context.Context, store db.Store, keys string,
) ([]string, error) {
labels, err := store.GetActiveProjectLabels(ctx)
if err != nil {
return nil, err
}
catalog, err := store.BuildProjectIdentityMap(ctx, labels)
if err != nil {
return nil, err
}
byKey := make(map[string]string, len(catalog))
for label, entry := range catalog {
if entry.ProjectKey != "" {
byKey[entry.ProjectKey] = label
}
}
resolved := make([]string, 0)
for _, key := range splitCSVTokens(keys) {
label, ok := byKey[key]
if !ok {
return nil, &UsageInputError{
Code: UsageErrorCodeUnknownProjectKey,
Msg: "unknown project key",
}
}
resolved = append(resolved, label)
}
return resolved, nil
}
func ResolveUsagePairwiseProjectKeys(
ctx context.Context, store db.Store, req UsagePairwiseComparisonRequest,
) (UsagePairwiseComparisonRequest, error) {
resolvedBase, err := ResolveUsageProjectKeys(ctx, store, req.UsageRequest)
if err != nil {
return UsagePairwiseComparisonRequest{}, err
}
req.UsageRequest = resolvedBase
req.LeftProjectLabels, err = resolvePairwiseProjectLabels(
ctx, store, req.LeftDimension, req.LeftValue,
)
if err != nil {
return UsagePairwiseComparisonRequest{}, err
}
req.RightProjectLabels, err = resolvePairwiseProjectLabels(
ctx, store, req.RightDimension, req.RightValue,
)
if err != nil {
return UsagePairwiseComparisonRequest{}, err
}
return req, nil
}
func resolvePairwiseProjectLabels(
ctx context.Context, store db.Store, dimension, value string,
) ([]string, error) {
if dimension != "project" || !strings.HasPrefix(value, "pl1:sha256:") {
return nil, nil
}
return resolveUsageProjectKeyLabels(ctx, store, value)
}
// UsageInputError flags an invalid usage filter (bad timezone, date, or
// range). Transports map it to a 400-style client error; it mirrors
// db.SearchInputError so handlers can errors.As it.
const UsageErrorCodeUnknownProjectKey = "unknown_project_key"
type UsageInputError struct {
Code string
Msg string
}
func (e *UsageInputError) Error() string { return e.Msg }
// BuildUsageFilter validates a UsageRequest and maps it to a
// db.UsageFilter. It is the single source of truth for usage filter
// validation, shared by the usage summary seam method and the server's
// comparison/top-sessions handlers. Date defaulting matches the
// server's analytics range (last 30 days through today, UTC) unless
// NoDefaultRange is set.
func BuildUsageFilter(req UsageRequest) (db.UsageFilter, error) {
tz := req.Timezone
if tz == "" {
tz = "UTC"
}
if _, err := time.LoadLocation(tz); err != nil {
return db.UsageFilter{}, &UsageInputError{Msg: "invalid timezone: " + tz}
}
from, to := req.From, req.To
if !req.NoDefaultRange {
from, to = defaultUsageDateRange(req.From, req.To)
}
if (from != "" && !timeutil.IsValidDate(from)) ||
(to != "" && !timeutil.IsValidDate(to)) {
return db.UsageFilter{}, &UsageInputError{
Msg: "invalid date format: use YYYY-MM-DD",
}
}
if from != "" && to != "" && from > to {
return db.UsageFilter{}, &UsageInputError{Msg: "from must not be after to"}
}
if req.ActiveSince != "" && !timeutil.IsValidTimestamp(req.ActiveSince) {
return db.UsageFilter{}, &UsageInputError{
Msg: "invalid active_since: use RFC3339 timestamp",
}
}
breakdowns := true
if req.Breakdowns != nil {
breakdowns = *req.Breakdowns
}
sessionCounts := true
if req.SessionCounts != nil {
sessionCounts = *req.SessionCounts
}
return db.UsageFilter{
From: from,
To: to,
Agent: req.Agent,
Project: req.Project,
ProjectLabels: mergeResolvedProjectLabels(
req.Project, req.ProjectLabels,
),
Machine: req.Machine,
GitBranch: req.GitBranch,
ExcludeProject: req.ExcludeProject,
ExcludeProjectLabels: mergeResolvedProjectLabels(
req.ExcludeProject, req.ExcludeProjectLabels,
),
ExcludeAgent: req.ExcludeAgent,
ExcludeModel: req.ExcludeModel,
Model: req.Model,
Timezone: tz,
MinUserMessages: req.MinUserMessages,
ExcludeOneShot: !req.IncludeOneShot,
ExcludeAutomated: !req.IncludeAutomated,
ActiveSince: req.ActiveSince,
Termination: req.Termination,
Breakdowns: breakdowns,
SkipSessionCounts: !sessionCounts,
}, nil
}
// defaultUsageDateRange fills an empty from/to with the last 30 days
// through today (UTC). It mirrors the server's defaultDateRange so the
// seam and the analytics handlers default identically.
func defaultUsageDateRange(from, to string) (string, string) {
now := time.Now().UTC()
if to == "" {
to = now.Format("2006-01-02")
}
if from == "" {
t, err := time.Parse("2006-01-02", to)
if err != nil {
t = now
}
from = t.AddDate(0, 0, -30).Format("2006-01-02")
}
return from, to
}
func mergeResolvedProjectLabels(raw string, resolved []string) []string {
if resolved == nil {
return nil
}
return append(splitCSVTokens(raw), resolved...)
}
// ProjectTotal holds range-wide token and cost totals per project.
type ProjectTotal 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"`
}
// ModelTotal holds range-wide token and cost totals per model.
type ModelTotal struct {
Model string `json:"model"`
InputTokens int `json:"inputTokens"`
OutputTokens int `json:"outputTokens"`
CacheCreationTokens int `json:"cacheCreationTokens"`
CacheReadTokens int `json:"cacheReadTokens"`
Cost float64 `json:"cost"`
}
// AgentTotal holds range-wide token and cost totals per agent.
type AgentTotal 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"`
}
// CacheStats summarizes cache hit/miss for the period.
type CacheStats struct {
CacheReadTokens int `json:"cacheReadTokens"`
CacheCreationTokens int `json:"cacheCreationTokens"`
UncachedInputTokens int `json:"uncachedInputTokens"`
OutputTokens int `json:"outputTokens"`
HitRate float64 `json:"hitRate"`
SavingsVsUncached float64 `json:"savingsVsUncached"`
}
const UnsupportedUsageKindNoTokenData = "no-token-data"
const UnsupportedUsageKindCopilotNoTokenData = "copilot-no-token-data"
// UnsupportedUsageKindForAgentFilter returns the unsupported-usage
// kind for an agent filter whose agents record no per-message token
// data: the Copilot-specific kind when the filter selects only
// Copilot-family agents, and the generic kind otherwise. Copilot
// branding keys on agent identity, not on the AI-credits capability,
// so another credits-denominated agent degrades to the generic kind
// instead of being described as Copilot.
func UnsupportedUsageKindForAgentFilter(agentFilter string) string {
if parser.AgentFilterIsCopilot(agentFilter) {
return UnsupportedUsageKindCopilotNoTokenData
}
return UnsupportedUsageKindNoTokenData
}
type UnsupportedUsage struct {
Kind string `json:"kind"`
}
// UsageSummaryResult is the transport-neutral usage-summary response, the
// JSON shape served by GET /api/v1/usage/summary. The prior-period
// comparison is a separate endpoint, so it is intentionally absent here.
type UsageSummaryResult struct {
SchemaVersion int `json:"schema_version,omitempty"`
Pricing *export.PricingBlock `json:"pricing,omitempty"`
Projects map[string]export.ProjectMapEntry `json:"projects"`
From string `json:"from"`
To string `json:"to"`
Totals db.UsageTotals `json:"totals"`
Daily []db.DailyUsageEntry `json:"daily"`
ProjectTotals []ProjectTotal `json:"projectTotals"`
ModelTotals []ModelTotal `json:"modelTotals"`
AgentTotals []AgentTotal `json:"agentTotals"`
SessionCounts db.UsageSessionCounts `json:"sessionCounts"`
CacheStats CacheStats `json:"cacheStats"`
UnsupportedUsage *UnsupportedUsage `json:"unsupportedUsage,omitempty"`
}
// UsagePairwiseComparisonSide holds aggregate and derived
// metrics for one side of a pairwise comparison.
type UsagePairwiseComparisonSide struct {
TotalCost float64 `json:"totalCost"`
InputTokens int `json:"inputTokens"`
OutputTokens int `json:"outputTokens"`
CacheCreationTokens int `json:"cacheCreationTokens"`
CacheReadTokens int `json:"cacheReadTokens"`
TotalTokens int `json:"totalTokens"`
SessionCount int `json:"sessionCount"`
CostPerSession *float64 `json:"costPerSession,omitempty"`
TokensPerSession *float64 `json:"tokensPerSession,omitempty"`
}
// UsagePairwiseComparisonDelta reports absolute and relative differences
// for each metric between right and left sides.
type UsagePairwiseComparisonDelta struct {
TotalCostDelta float64 `json:"totalCostDelta"`
TotalCostDeltaRatio *float64 `json:"totalCostDeltaRatio"`
InputTokensDelta int `json:"inputTokensDelta"`
InputTokensDeltaRatio *float64 `json:"inputTokensDeltaRatio"`
OutputTokensDelta int `json:"outputTokensDelta"`
OutputTokensDeltaRatio *float64 `json:"outputTokensDeltaRatio"`
CacheCreationDelta int `json:"cacheCreationDelta"`
CacheCreationDeltaRatio *float64 `json:"cacheCreationDeltaRatio"`
CacheReadDelta int `json:"cacheReadDelta"`
CacheReadDeltaRatio *float64 `json:"cacheReadDeltaRatio"`
TotalTokensDelta int `json:"totalTokensDelta"`
TotalTokensDeltaRatio *float64 `json:"totalTokensDeltaRatio"`
SessionCountDelta int `json:"sessionCountDelta"`
SessionCountDeltaRatio *float64 `json:"sessionCountDeltaRatio"`
CostPerSessionDelta *float64 `json:"costPerSessionDelta"`
CostPerSessionRatio *float64 `json:"costPerSessionRatio"`
TokensPerSessionDelta *float64 `json:"tokensPerSessionDelta"`
TokensPerSessionRatio *float64 `json:"tokensPerSessionRatio"`
}
// UsagePairwiseComparisonResponse is the backend-computed response
// for model/project pairwise usage comparisons.
type UsagePairwiseComparisonResponse struct {
Left UsagePairwiseComparisonSide `json:"left"`
Right UsagePairwiseComparisonSide `json:"right"`
Deltas UsagePairwiseComparisonDelta `json:"deltas"`
}
// UsagePairwiseComparisonRequest holds shared usage filters plus one
// extra include filter per side.
type UsagePairwiseComparisonRequest struct {
UsageRequest
LeftDimension string `json:"left_dimension,omitempty"`
LeftValue string `json:"left_value,omitempty"`
RightDimension string `json:"right_dimension,omitempty"`
RightValue string `json:"right_value,omitempty"`
// Project label slices are populated only after resolving opaque keys and
// preserve exact labels that cannot round-trip through CSV strings.
LeftProjectLabels []string `json:"-"`
RightProjectLabels []string `json:"-"`
}
// buildUsageSummary assembles a UsageSummaryResult from a daily-usage
// query result over the [from, to] range.
func buildUsageSummary(
f db.UsageFilter, result db.DailyUsageResult,
) *UsageSummaryResult {
out := &UsageSummaryResult{
SchemaVersion: result.SchemaVersion,
Pricing: result.Pricing,
Projects: result.Projects,
From: f.From,
To: f.To,
Totals: result.Totals,
Daily: result.Daily,
SessionCounts: result.SessionCounts,
CacheStats: computeCacheStats(result.Totals),
}
if f.Breakdowns {
out.ProjectTotals = foldProjectTotals(result.Daily)
out.ModelTotals = foldModelTotals(result.Daily)
out.AgentTotals = foldAgentTotals(result.Daily)
} else {
out.ProjectTotals = []ProjectTotal{}
out.ModelTotals = []ModelTotal{}
out.AgentTotals = []AgentTotal{}
}
return out
}
// foldProjectTotals sums daily project breakdowns into range-wide totals
// sorted by cost descending.
func foldProjectTotals(daily []db.DailyUsageEntry) []ProjectTotal {
m := make(map[string]*ProjectTotal)
for _, d := range daily {
for _, pb := range d.ProjectBreakdowns {
pt, ok := m[pb.ProjectKey]
if !ok {
pt = &ProjectTotal{
ProjectKey: pb.ProjectKey,
Project: pb.Project,
}
m[pb.ProjectKey] = pt
}
pt.InputTokens += pb.InputTokens
pt.OutputTokens += pb.OutputTokens
pt.CacheCreationTokens += pb.CacheCreationTokens
pt.CacheReadTokens += pb.CacheReadTokens
pt.Cost += pb.Cost
}
}
out := make([]ProjectTotal, 0, len(m))
for _, v := range m {
out = append(out, *v)
}
sort.Slice(out, func(i, j int) bool {
if out[i].Cost != out[j].Cost {
return out[i].Cost > out[j].Cost
}
if out[i].ProjectKey != out[j].ProjectKey {
return out[i].ProjectKey < out[j].ProjectKey
}
return out[i].Project < out[j].Project
})
return out
}
// foldModelTotals sums daily model breakdowns into range-wide totals
// sorted by cost descending.
func foldModelTotals(daily []db.DailyUsageEntry) []ModelTotal {
m := make(map[string]*ModelTotal)
for _, d := range daily {
for _, mb := range d.ModelBreakdowns {
mt, ok := m[mb.ModelName]
if !ok {
mt = &ModelTotal{Model: mb.ModelName}
m[mb.ModelName] = mt
}
mt.InputTokens += mb.InputTokens
mt.OutputTokens += mb.OutputTokens
mt.CacheCreationTokens += mb.CacheCreationTokens
mt.CacheReadTokens += mb.CacheReadTokens
mt.Cost += mb.Cost
}
}
out := make([]ModelTotal, 0, len(m))
for _, v := range m {
out = append(out, *v)
}
sort.Slice(out, func(i, j int) bool {
if out[i].Cost != out[j].Cost {
return out[i].Cost > out[j].Cost
}
return out[i].Model < out[j].Model
})
return out
}
// foldAgentTotals sums daily agent breakdowns into range-wide totals
// sorted by cost descending.
func foldAgentTotals(daily []db.DailyUsageEntry) []AgentTotal {
m := make(map[string]*AgentTotal)
for _, d := range daily {
for _, ab := range d.AgentBreakdowns {
at, ok := m[ab.Agent]
if !ok {
at = &AgentTotal{Agent: ab.Agent}
m[ab.Agent] = at
}
at.InputTokens += ab.InputTokens
at.OutputTokens += ab.OutputTokens
at.CacheCreationTokens += ab.CacheCreationTokens
at.CacheReadTokens += ab.CacheReadTokens
at.Cost += ab.Cost
}
}
out := make([]AgentTotal, 0, len(m))
for _, v := range m {
out = append(out, *v)
}
sort.Slice(out, func(i, j int) bool {
if out[i].Cost != out[j].Cost {
return out[i].Cost > out[j].Cost
}
return out[i].Agent < out[j].Agent
})
return out
}
// computeCacheStats derives cache hit/miss metrics from totals.
// SavingsVsUncached passes through totals.CacheSavings, which the DB
// layer computes per-message using each row's actual per-model rates,
// so mixed-model periods report the right net delta instead of a single
// hard-coded proxy rate.
func computeCacheStats(t db.UsageTotals) CacheStats {
// Anthropic reports input_tokens as the NON-cached portion of the
// input (cache_read and cache_creation are separate fields), so
// UncachedInputTokens is just t.InputTokens directly.
cs := CacheStats{
CacheReadTokens: t.CacheReadTokens,
CacheCreationTokens: t.CacheCreationTokens,
UncachedInputTokens: t.InputTokens,
OutputTokens: t.OutputTokens,
SavingsVsUncached: t.CacheSavings,
}
denominator := t.CacheReadTokens + t.InputTokens
if denominator > 0 {
cs.HitRate = float64(t.CacheReadTokens) / float64(denominator)
}
return cs
}
func BuildUsagePairwiseComparisonResult(
left db.DailyUsageResult,
right db.DailyUsageResult,
) UsagePairwiseComparisonResponse {
leftSide := usagePairwiseSideFromResult(left)
rightSide := usagePairwiseSideFromResult(right)
return UsagePairwiseComparisonResponse{
Left: leftSide,
Right: rightSide,
Deltas: pairwiseDeltas(leftSide, rightSide),
}
}
func BuildUsagePairwiseFilters(
req UsagePairwiseComparisonRequest,
) (
db.UsageFilter,
bool,
db.UsageFilter,
bool,
error,
) {
base, err := BuildUsageFilter(req.UsageRequest)
if err != nil {
return db.UsageFilter{}, false, db.UsageFilter{}, false, err
}
left, err := applyPairwiseDimension(
base,
req.LeftDimension,
req.LeftValue,
req.LeftProjectLabels,
"left",
)
if err != nil {
return db.UsageFilter{}, false, db.UsageFilter{}, false, err
}
right, err := applyPairwiseDimension(
base,
req.RightDimension,
req.RightValue,
req.RightProjectLabels,
"right",
)
if err != nil {
return db.UsageFilter{}, false, db.UsageFilter{}, false, err
}
return left.filter, left.empty, right.filter, right.empty, nil
}
func intersectCSV(base, add string) (string, bool) {
out, ok := intersectValues(splitCSVTokens(base), splitCSVTokens(add))
return joinCSVTokens(out), ok
}
func intersectValues(base, add []string) ([]string, bool) {
if len(add) == 0 {
return base, len(base) > 0
}
if len(base) == 0 {
return append([]string(nil), add...), true
}
addSet := map[string]struct{}{}
for _, token := range add {
addSet[token] = struct{}{}
}
out := make([]string, 0)
seen := map[string]struct{}{}
for _, token := range base {
if _, ok := addSet[token]; !ok {
continue
}
if _, seenOk := seen[token]; seenOk {
continue
}
seen[token] = struct{}{}
out = append(out, token)
}
if len(out) == 0 {
return nil, false
}
return out, true
}
func splitCSVTokens(raw string) []string {
out := make([]string, 0)
for token := range strings.SplitSeq(raw, ",") {
trimmed := strings.TrimSpace(token)
if trimmed == "" {
continue
}
out = append(out, trimmed)
}
return out
}
func joinCSVTokens(tokens []string) string {
return strings.Join(tokens, ",")
}
type pairwiseFilterResult struct {
filter db.UsageFilter
empty bool
}
func applyPairwiseDimension(
base db.UsageFilter, dimension, value string, projectLabels []string,
label string,
) (pairwiseFilterResult, error) {
filter := base
if value == "" {
return pairwiseFilterResult{},
&UsageInputError{Msg: label + "_value is required"}
}
var ok bool
switch dimension {
case "model":
filter.Model, ok = intersectCSV(filter.Model, value)
return pairwiseFilterResult{filter: filter, empty: !ok}, nil
case "project":
if projectLabels == nil {
projectLabels = splitCSVTokens(value)
}
filter.ProjectLabels, ok = intersectValues(
filter.ProjectFilterLabels(), projectLabels,
)
filter.Project = joinCSVTokens(filter.ProjectLabels)
return pairwiseFilterResult{filter: filter, empty: !ok}, nil
case "":
return pairwiseFilterResult{},
&UsageInputError{Msg: label + "_dimension is required"}
default:
return pairwiseFilterResult{},
&UsageInputError{
Msg: label + "_dimension must be model or project",
}
}
}
func safePerTurnDenominator(count int) bool {
return count > 0
}
func maybeFloatRatio(left, delta float64) *float64 {
if left == 0 {
return nil
}
r := delta / left
return &r
}
func usagePairwiseSideFromResult(r db.DailyUsageResult) UsagePairwiseComparisonSide {
total := r.Totals
side := UsagePairwiseComparisonSide{
TotalCost: total.TotalCost,
InputTokens: total.InputTokens,
OutputTokens: total.OutputTokens,
CacheCreationTokens: total.CacheCreationTokens,
CacheReadTokens: total.CacheReadTokens,
SessionCount: r.SessionCounts.Total,
}
side.TotalTokens = side.InputTokens + side.OutputTokens +
side.CacheCreationTokens + side.CacheReadTokens
if safePerTurnDenominator(r.SessionCounts.Total) {
costPerSession := side.TotalCost / float64(r.SessionCounts.Total)
tokensPerSession := float64(side.TotalTokens) / float64(r.SessionCounts.Total)
side.CostPerSession = &costPerSession
side.TokensPerSession = &tokensPerSession
}
return side
}
func pairwiseDeltas(left, right UsagePairwiseComparisonSide) UsagePairwiseComparisonDelta {
costPerSessionDelta, costPerSessionRatio := deltaWithRatio(
left.CostPerSession, right.CostPerSession,
)
tokensPerSessionDelta, tokensPerSessionRatio := deltaWithRatio(
left.TokensPerSession, right.TokensPerSession,
)
totalCostDelta := right.TotalCost - left.TotalCost
inputTokensDelta := right.InputTokens - left.InputTokens
outputTokensDelta := right.OutputTokens - left.OutputTokens
cacheCreationDelta := right.CacheCreationTokens - left.CacheCreationTokens
cacheReadDelta := right.CacheReadTokens - left.CacheReadTokens
totalTokensDelta := right.TotalTokens - left.TotalTokens
sessionCountDelta := right.SessionCount - left.SessionCount
return UsagePairwiseComparisonDelta{
TotalCostDelta: totalCostDelta,
TotalCostDeltaRatio: maybeFloatRatio(left.TotalCost, totalCostDelta),
InputTokensDelta: inputTokensDelta,
InputTokensDeltaRatio: maybeFloatRatio(float64(left.InputTokens), float64(inputTokensDelta)),
OutputTokensDelta: outputTokensDelta,
OutputTokensDeltaRatio: maybeFloatRatio(float64(left.OutputTokens), float64(outputTokensDelta)),
CacheCreationDelta: cacheCreationDelta,
CacheCreationDeltaRatio: maybeFloatRatio(float64(left.CacheCreationTokens), float64(cacheCreationDelta)),
CacheReadDelta: cacheReadDelta,
CacheReadDeltaRatio: maybeFloatRatio(float64(left.CacheReadTokens), float64(cacheReadDelta)),
TotalTokensDelta: totalTokensDelta,
TotalTokensDeltaRatio: maybeFloatRatio(float64(left.TotalTokens), float64(totalTokensDelta)),
SessionCountDelta: sessionCountDelta,
SessionCountDeltaRatio: maybeFloatRatio(float64(left.SessionCount), float64(sessionCountDelta)),
CostPerSessionDelta: costPerSessionDelta,
CostPerSessionRatio: costPerSessionRatio,
TokensPerSessionDelta: tokensPerSessionDelta,
TokensPerSessionRatio: tokensPerSessionRatio,
}
}
func deltaWithRatio(left, right *float64) (*float64, *float64) {
if left == nil || right == nil {
return nil, nil
}
delta := *right - *left
return &delta, maybeFloatRatio(*left, delta)
}