Files
wehub-resource-sync a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

291 lines
8.1 KiB
Go

package savings
import (
"bufio"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"sort"
"strings"
"time"
)
// Event is a single source-reading observation. The dashboard reads the
// event history (Store.EventsSince) to compute the Today / Last 7 days
// buckets the cumulative totals can't reconstruct on their own.
//
// Fields use compact JSON keys — they are also the line schema of the
// flat-file era's savings.jsonl log, which LoadEvents still parses for
// the one-shot legacy import.
type Event struct {
TS time.Time `json:"ts"`
SessionID string `json:"session,omitempty"`
Repo string `json:"repo,omitempty"`
Language string `json:"lang,omitempty"`
Tool string `json:"tool,omitempty"`
// Model is the LLM model that drove the call when the host surfaced
// it (via the model-hint bridge); Client is the MCP client app from
// the initialize handshake. Both omit when unknown.
Model string `json:"model,omitempty"`
Client string `json:"client,omitempty"`
Returned int64 `json:"returned"`
Saved int64 `json:"saved"`
}
// DimTotal is one row of a per-dimension breakdown (per-model or
// per-client), carrying the dimension value plus its rolled-up totals.
type DimTotal struct {
Name string
Totals
}
// AggregateByModel folds events into a per-model breakdown sorted by
// tokens-saved descending. Events with no attributed model are skipped,
// so the result is the "per known model" view (rows need not sum to the
// grand total).
func AggregateByModel(events []Event) []DimTotal {
return aggregateByDim(events, func(e Event) string { return e.Model })
}
// AggregateByClient folds events into a per-MCP-client breakdown sorted
// by tokens-saved descending. Events with no client are skipped.
func AggregateByClient(events []Event) []DimTotal {
return aggregateByDim(events, func(e Event) string { return e.Client })
}
// aggregateByDim is the shared fold for the per-model / per-client
// breakdowns. The empty key is dropped so unattributed calls don't
// masquerade as a named bucket.
func aggregateByDim(events []Event, key func(Event) string) []DimTotal {
per := make(map[string]*Totals)
for _, ev := range events {
name := key(ev)
if name == "" {
continue
}
t := per[name]
if t == nil {
t = &Totals{}
per[name] = t
}
t.TokensSaved += ev.Saved
t.TokensReturned += ev.Returned
t.CallsCounted++
}
rows := make([]DimTotal, 0, len(per))
for name, t := range per {
rows = append(rows, DimTotal{Name: name, Totals: *t})
}
sort.Slice(rows, func(i, j int) bool {
if a, b := rows[i].TokensSaved, rows[j].TokensSaved; a != b {
return a > b
}
return rows[i].Name < rows[j].Name
})
return rows
}
// LoadEvents reads a flat-file era JSONL log at path and returns events
// with ts >= since. since=zero returns everything. Returned events are in
// file order (oldest first). Malformed lines are skipped silently — they
// only happen when a previous gortex process crashed mid-write and the
// legacy import should keep working anyway.
func LoadEvents(path string, since time.Time) ([]Event, error) {
if path == "" {
return nil, nil
}
f, err := os.Open(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
return nil, fmt.Errorf("open events log: %w", err)
}
defer f.Close()
out := make([]Event, 0, 64)
r := bufio.NewReaderSize(f, 64*1024)
for {
line, err := r.ReadBytes('\n')
if len(line) > 0 {
// Strip trailing newline before unmarshal; tolerate
// CRLF too in case the file was edited on Windows.
line = trimNewline(line)
if len(line) > 0 {
var ev Event
if jerr := json.Unmarshal(line, &ev); jerr == nil {
if since.IsZero() || !ev.TS.Before(since) {
out = append(out, ev)
}
}
}
}
if err != nil {
if errors.Is(err, io.EOF) {
break
}
return out, fmt.Errorf("read events log: %w", err)
}
}
return out, nil
}
// trimNewline strips at most one trailing \n and one trailing \r so
// parsers see the bare JSON object.
func trimNewline(b []byte) []byte {
if n := len(b); n > 0 && b[n-1] == '\n' {
b = b[:n-1]
}
if n := len(b); n > 0 && b[n-1] == '\r' {
b = b[:n-1]
}
return b
}
// Bucket is a windowed roll-up of events: top-line totals plus an optional
// per-tool breakdown sorted by tokens-saved descending. Used by the
// `gortex savings` dashboard.
type Bucket struct {
Label string
Totals Totals
PerTool []ToolTotal // nil when no events fell in this bucket
}
// ToolTotal is one row of the per-tool breakdown.
type ToolTotal struct {
Tool string
Totals
}
// AggregateByTool folds events into a top-line Totals + a sorted per-tool
// breakdown. Tool keys are normalized: empty tool names group under
// "(unknown)" so they're still visible in --verbose.
func AggregateByTool(events []Event) (Totals, []ToolTotal) {
var total Totals
per := make(map[string]*Totals)
for _, ev := range events {
total.TokensSaved += ev.Saved
total.TokensReturned += ev.Returned
total.CallsCounted++
name := ev.Tool
if name == "" {
name = "(unknown)"
}
t := per[name]
if t == nil {
t = &Totals{}
per[name] = t
}
t.TokensSaved += ev.Saved
t.TokensReturned += ev.Returned
t.CallsCounted++
}
rows := make([]ToolTotal, 0, len(per))
for name, t := range per {
rows = append(rows, ToolTotal{Tool: name, Totals: *t})
}
sort.Slice(rows, func(i, j int) bool {
if a, b := rows[i].TokensSaved, rows[j].TokensSaved; a != b {
return a > b
}
return rows[i].Tool < rows[j].Tool
})
return total, rows
}
// FilterSince returns the subset of events whose TS is >= since.
func FilterSince(events []Event, since time.Time) []Event {
if since.IsZero() {
return events
}
out := make([]Event, 0, len(events))
for _, ev := range events {
if !ev.TS.Before(since) {
out = append(out, ev)
}
}
return out
}
// FilterDay returns events whose TS falls on the given calendar day in loc.
func FilterDay(events []Event, day time.Time, loc *time.Location) []Event {
if loc == nil {
loc = time.UTC
}
y, m, d := day.In(loc).Date()
out := make([]Event, 0, len(events))
for _, ev := range events {
ey, em, ed := ev.TS.In(loc).Date()
if ey == y && em == m && ed == d {
out = append(out, ev)
}
}
return out
}
// BuildDashboard returns the three canonical buckets (Today / Last 7 days /
// All time) from the last week's events (oldest first), using `now` as the
// reference clock and `loc` as the calendar for the "Today" boundary.
// storeAllTime supplies the All-time totals and allPerTool its per-tool
// breakdown — both come from the ledger's aggregates, so callers never
// materialize the full event history just to render the dashboard.
func BuildDashboard(weekEvents []Event, storeAllTime Totals, allPerTool []ToolTotal, now time.Time, loc *time.Location) []Bucket {
if loc == nil {
loc = time.Local
}
weekEvents = FilterSince(weekEvents, now.Add(-7*24*time.Hour))
todayEvents := FilterDay(weekEvents, now, loc)
todayTotals, todayPerTool := AggregateByTool(todayEvents)
weekTotals, weekPerTool := AggregateByTool(weekEvents)
return []Bucket{
{Label: "Today", Totals: todayTotals, PerTool: todayPerTool},
{Label: "Last 7 days", Totals: weekTotals, PerTool: weekPerTool},
{Label: "All time", Totals: storeAllTime, PerTool: allPerTool},
}
}
// SavingsPercent returns the percentage of "full file size" tokens that
// were avoided, clamped to [0, 100]. A bucket with no calls returns 0.
func SavingsPercent(t Totals) float64 {
denom := t.TokensSaved + t.TokensReturned
if denom <= 0 {
return 0
}
pct := float64(t.TokensSaved) / float64(denom) * 100.0
if pct < 0 {
pct = 0
}
if pct > 100 {
pct = 100
}
return pct
}
// BarString renders a 16-cell █/░ bar for pct in [0, 100]. The cell count
// is configurable so tests and future widths don't hardcode 16.
func BarString(pct float64, cells int) string {
if cells <= 0 {
cells = 16
}
if pct < 0 {
pct = 0
}
if pct > 100 {
pct = 100
}
filled := min(int(pct/100.0*float64(cells)+0.5), cells)
var sb strings.Builder
sb.Grow(cells * 3)
for range filled {
sb.WriteString("█")
}
for range cells - filled {
sb.WriteString("░")
}
return sb.String()
}