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
291 lines
8.1 KiB
Go
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()
|
|
}
|