162 lines
4.7 KiB
Go
162 lines
4.7 KiB
Go
package usage
|
|
|
|
// The ledger: every call of a period, one row each, newest first — what
|
|
// the agent asked for, where it went, what model answered, the tokens and
|
|
// what they cost at list price — to set beside a vendor's own bill.
|
|
|
|
import (
|
|
"encoding/csv"
|
|
"io"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/yetone/magpie/internal/catalog"
|
|
"github.com/yetone/magpie/internal/provider"
|
|
)
|
|
|
|
// Row is one call as the ledger lists it.
|
|
type Row struct {
|
|
Record
|
|
// Cost is the call's list price in USD, when its model's is known
|
|
// (Priced); Swapped: the reply named another model than Model
|
|
Cost float64 `json:"cost"`
|
|
Priced bool `json:"priced"`
|
|
Swapped bool `json:"swapped,omitempty"`
|
|
}
|
|
|
|
// Filter narrows the ledger: to one agent (its id, as AgentOf gives it),
|
|
// to the failed calls, and to the rows whose models, provider, host or
|
|
// session hold Query (any case).
|
|
type Filter struct {
|
|
Agent string
|
|
Failed bool
|
|
Query string
|
|
}
|
|
|
|
func (f Filter) keeps(r Record) bool {
|
|
if f.Agent != "" && AgentOf(r.Agent) != f.Agent {
|
|
return false
|
|
}
|
|
if f.Failed && r.Status < 400 {
|
|
return false
|
|
}
|
|
if q := strings.ToLower(strings.TrimSpace(f.Query)); q != "" {
|
|
return slices.ContainsFunc([]string{r.Requested, r.Model, r.Served, r.Provider, r.Host, r.Session, r.Effort}, func(s string) bool {
|
|
return strings.Contains(strings.ToLower(s), q)
|
|
})
|
|
}
|
|
return true
|
|
}
|
|
|
|
// Since is when the period began, as of now; zero for all.
|
|
func (p Period) Since(now time.Time) time.Time {
|
|
day := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
|
switch p {
|
|
case Today:
|
|
return day
|
|
case Week:
|
|
return day.AddDate(0, 0, -6)
|
|
case Month:
|
|
return day.AddDate(0, 0, -29)
|
|
}
|
|
return time.Time{}
|
|
}
|
|
|
|
// Ledger is the calls of a period that the filter keeps, newest first,
|
|
// with their sum, and the agents that made any call in the period (their
|
|
// ids, for a filter to offer).
|
|
func Ledger(p Period, f Filter) (rows []Row, sum Totals, agents []string) {
|
|
return ledger(p.Since(time.Now()), f, Load(time.Time{}))
|
|
}
|
|
|
|
func ledger(since time.Time, f Filter, recs []Record) (rows []Row, sum Totals, agents []string) {
|
|
renamed := provider.Renamed()
|
|
priceOf := pricer()
|
|
rows = []Row{}
|
|
agents = []string{}
|
|
for i := len(recs) - 1; i >= 0; i-- {
|
|
r := recs[i]
|
|
if !since.IsZero() && r.Time.Before(since) {
|
|
continue
|
|
}
|
|
if id, ok := renamed[r.Provider]; ok {
|
|
r.Provider = id
|
|
}
|
|
if a := AgentOf(r.Agent); !slices.Contains(agents, a) {
|
|
agents = append(agents, a)
|
|
}
|
|
if !f.keeps(r) {
|
|
continue
|
|
}
|
|
pr := priceOf(r)
|
|
sum.add(r, pr)
|
|
row := Row{Record: r, Swapped: r.Served != "" && Swapped(r.Model, r.Served)}
|
|
if pr != nil && r.Input+r.Output > 0 {
|
|
row.Cost, row.Priced = pr.Cost(r.Input, r.Output, r.CacheRead, r.CacheWrite), true
|
|
}
|
|
row.Agent = AgentOf(r.Agent)
|
|
rows = append(rows, row)
|
|
}
|
|
slices.Sort(agents)
|
|
return rows, sum, agents
|
|
}
|
|
|
|
// pricer looks up each call's list price, once per provider and model: its
|
|
// provider's models.dev entry's, else its maker's (a subscription's, #224).
|
|
func pricer() func(Record) *catalog.Price {
|
|
prices := map[string]*catalog.Price{}
|
|
all := provider.All()
|
|
return func(r Record) *catalog.Price {
|
|
k := r.Provider + "/" + r.Model
|
|
if pr, ok := prices[k]; ok {
|
|
return pr
|
|
}
|
|
var pr *catalog.Price
|
|
v, ok := provider.MakerPrice(r.Model)
|
|
for _, p := range all {
|
|
if p.ID == r.Provider {
|
|
v, ok = p.ListPrice(r.Model)
|
|
break
|
|
}
|
|
}
|
|
if ok {
|
|
pr = &v
|
|
} else {
|
|
catalog.Missing() // models.dev may list it by now
|
|
}
|
|
prices[k] = pr
|
|
return pr
|
|
}
|
|
}
|
|
|
|
// CSVHeader is the ledger's columns, as WriteCSV writes them.
|
|
var CSVHeader = []string{"time", "agent", "requested_model", "provider", "host", "model", "served_model", "swapped",
|
|
"effort", "input_tokens", "output_tokens", "cache_write_tokens", "cache_read_tokens", "reasoning_tokens",
|
|
"cost_usd", "duration_ms", "ttft_ms", "status", "error", "session", "kind"}
|
|
|
|
// WriteCSV writes rows as CSV, a header first: times in RFC 3339 with
|
|
// their offset, the cost in USD at list price (empty when unknown), error
|
|
// "true" for a call answered with a status of 400 or more.
|
|
func WriteCSV(w io.Writer, rows []Row) error {
|
|
cw := csv.NewWriter(w)
|
|
cw.Write(CSVHeader)
|
|
n := strconv.Itoa
|
|
for _, r := range rows {
|
|
cost := ""
|
|
if r.Priced {
|
|
cost = strconv.FormatFloat(r.Cost, 'f', 6, 64)
|
|
}
|
|
ttft := ""
|
|
if r.TTFT > 0 {
|
|
ttft = strconv.FormatInt(r.TTFT, 10)
|
|
}
|
|
cw.Write([]string{r.Time.Format(time.RFC3339), r.Agent, r.Requested, r.Provider, r.Host, r.Model, r.Served,
|
|
strconv.FormatBool(r.Swapped), r.Effort, n(r.Input), n(r.Output), n(r.CacheWrite), n(r.CacheRead), n(r.Reasoning),
|
|
cost, strconv.FormatInt(r.Millis, 10), ttft, n(r.Status), strconv.FormatBool(r.Status >= 400), r.Session, r.Kind})
|
|
}
|
|
cw.Flush()
|
|
return cw.Error()
|
|
}
|