Files
magpie/internal/usage/ledger.go
T
yetone 0879894af3 gateway: each gateway key can have its own limit, a token total and/or an estimated cost per day, week or month, and a spent key is refused before any provider is asked (margbug01 on #585, v0.1.671: 每个网关密钥都能设置自己的用量上限) — a gateway key named who called and the Usage page grouped its calls, but nothing held a key to an amount, so one client shared on the network could spend a subscription's whole window. A key now carries limit {period day|week|month, tokens, cost, cacheReads} in caller-keys.json, set with the new "limit-key" change. Windows are calendar ones in local time (from midnight, from Monday, from the 1st) and a call counts in the window it started in. Tokens are a call's uncached input, output and cache writes, plus cache reads when the limit says so; cost is an estimate at the Usage page's effective prices (usage.NewCoster), a call with no known price adding none and counted as unpriced. What a key used is summed from usage.jsonl (usage.Visit), so it survives a restart; the new internal/budget keeps the sums in memory per window and appendUsage writes a key's record through budget.Append, which appends and counts under one lock so a window read meanwhile never counts a call twice. Before a keyed POST reaches the mux, keyLimited reserves for it: its body's size in tokens (4 bytes a token) plus the key's mean output per call this window, at the window's cost per token or the named model's input price; it is let in while used + reserved is under the cap, so requests sent at once overshoot by about one call, and the reservation is released when the request ends, a streamed reply being settled then with the usage its vendor reported. A spent key gets a 429 in its API's error shape (Anthropic, OpenAI chat/responses, Gemini) naming the key, the cap and the reset time, with Retry-After, X-Should-Retry: false and X-Magpie-Limit-Reset, recorded as a rejected call (gateway_key_limit); other keys go on. Requests from this computer without a gateway key are not limited; a key used from this computer is. count_tokens and bridge callbacks are never held. GET /v1/magpie/limit tells a key its own status. Gateway keys rows show a Limit badge (what is used of it, per what, Spent in red) and a line under a limited key (used of limit, left, resets / refused until, in flight; unpriced calls in its title); Limit opens an editor under the row (window, tokens with k/m/万/亿, cost in US$, Count cache reads too, what is counted) staged until Save, marked unsaved, Cancel/Escape dropping it, a bad amount disabling Save, No limit removing it; en and zh; no left-border accent; no click scrolls. The CLI gains magpie gateway-key limit <id> [off | day|week|month --tokens N --cost USD --cache-reads] and a LIMIT column in list. TestKeyLimitRefusesBeforeUpstream (two calls within 700 tokens, the third a 429 the fake upstream never sees, message/type/headers, the rejected record, Anthropic's shape on /v1/messages, another key and a keyless loopback request let in, still refused after budget.Forget as after a restart, a raised and a removed limit let in, /v1/magpie/limit), TestKeyLimitHoldsParallelRequests (20 at once against 2000 tokens: some refused, at most one call over, no reservation left; without the reservation all 20 got in and 7350 were used), TestKeyLimitWindowResets, TestLimitWindow, TestLimitValid and TestGatewayKeyLimitCLI are new; gateway-key-limit.test.cjs (WebKit, en and zh) is new and passes, as do gateway-caller-keys, gateway-connect-keys, api-key-theme and click-scroll (whose request-pick case flaked once under a parallel run and passes alone, as on main). go vet ./internal/..., go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui -count=1 ./... under a temp HOME pass (TestFXInState's known TempDir-cleanup flake aside).
2026-10-03 00:18:16 +08:00

668 lines
22 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 the effective price — to set beside a vendor's own bill.
import (
"encoding/csv"
"io"
"slices"
"sort"
"strconv"
"strings"
"time"
"github.com/yetone/magpie/internal/catalog"
"github.com/yetone/magpie/internal/provider"
"github.com/yetone/magpie/internal/sessions"
"github.com/yetone/magpie/internal/settings"
)
// Row is one call as the ledger lists it.
type Row struct {
Record
// Cost is the call's effective 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"`
// Routed marks a remote magpie group answered by one of its members.
Routed bool `json:"routed,omitempty"`
// Source is "log" for a call read from an agent's own session file,
// which the gateway never saw: no status, no provider, no model asked
// for, but what the file says of the call. "" for one the gateway logged.
Source string `json:"source,omitempty"`
}
// Failed is whether the call failed: the agent was answered an error status,
// or, for a call read from a session file, which has no status, the file
// says an error ended it.
func (r Record) Failed() bool { return r.Status >= 400 || r.Error != "" }
// Filter narrows the ledger: to a route, one agent (its id, as AgentOf gives it), to
// one provider, to the failed calls, and to the rows whose models, provider, host or
// session hold Query (any case).
type Filter struct {
CallerKey string
RouteID int64
Model string // exact model selected in the ranking
Agent string
Provider string // a provider's id, as the ledger's rows have it
// Account narrows to the calls a subscription account answered, by its
// name as Record.Account gives it (#557)
Account string
Failed bool
Query string
// Computer narrows to the calls of one computer (#542): ThisComputer,
// OtherComputers, or another's id; "" is every computer's
Computer string
}
// OtherComputers is the Filter.Computer of every other computer's calls.
const OtherComputers = "others"
// computer is whether r is of the computers Computer picks.
func (f Filter) computer(r Record) bool {
switch f.Computer {
case "":
return true
case ThisComputer:
return r.Computer == ""
case OtherComputers:
return r.Computer != ""
}
return r.Computer == f.Computer
}
func (f Filter) keeps(r Record) bool {
if f.CallerKey != "" && r.CallerKeyID != f.CallerKey {
return false
}
if f.RouteID != 0 && r.RouteID != f.RouteID {
return false
}
if f.Model != "" && r.Model != f.Model {
return false
}
if f.Agent != "" && AgentOf(r.Agent) != f.Agent {
return false
}
if f.Provider != "" && r.Provider != f.Provider {
return false
}
if f.Account != "" && r.Account() != f.Account {
return false
}
if !f.computer(r) {
return false
}
if f.Failed && !r.Failed() {
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, r.SessionProvider, r.SessionAccount, r.ProviderKeyID, r.ProviderKeyName, r.Account(), r.CallerKeyID, r.CallerKeyName}, 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{}
}
// LogCalls reads the calls the agents' own session files record, which the
// ledger adds to those the gateway logged. A variable for the tests.
var LogCalls func(time.Time) []sessions.Call
// Ledgered is a period's calls that a filter keeps, newest first, with their
// sum, and the agents and providers that made any call in the period (their
// ids, for a filter to offer).
type Ledgered struct {
Rows []Row
Sum Totals
Agents []string
Providers []string
}
// LedgerOf is the calls of a period that the filter keeps. The gateway's log
// comes with the calls the agents' session files record that the gateway did
// not see.
func LedgerOf(p Period, f Filter) Ledgered {
since := p.Since(time.Now())
gatewaySince := since
if !since.IsZero() {
gatewaySince = since.Add(-24 * time.Hour)
} // calls ending across the period boundary
reader := LogCalls
if reader == nil {
reader = sessions.Calls
}
rows, sum, agents, providers := ledgerWithShared(since, f, Load(gatewaySince), reader(since), sharedRecords(since))
return Ledgered{rows, sum, agents, providers}
}
// Filtered narrows one snapshot without reloading either log or repricing rows.
func (l Ledgered) Filtered(f Filter) Ledgered {
if f == (Filter{}) {
return l
}
out := Ledgered{Rows: []Row{}, Agents: l.Agents, Providers: l.Providers}
for _, r := range l.Rows {
if f.keeps(r.Record) {
out.Rows = append(out.Rows, r)
if !r.IsRejected() {
out.Sum.addRow(r)
}
}
}
return out
}
// Ledger is LedgerOf's rows, sum and agents.
//
// A row's swapped is judged by the upstream names in force now, not by the
// one its call went out under: the names are read from the settings here,
// once, so a name given or changed since a call was recorded re-judges that
// call — one recorded under the name magpie knows the model by, and read
// after a name was set for it, reads as a swap although the vendor answered
// with the very model that was asked for. A record keeps the canonical id
// and the name the vendor's own reply gave (served_model) and not the name
// the request was sent under, so this is the only name there is to judge by
// — read as the name that went out, which the effort a record keeps of
// makes exact: an Antigravity account is asked for the variant of the model
// that level picks, so a reply naming that one is the model that was asked
// for and not another, whatever the family it is a level of.
// It is how ledgerWith's loop already treats the rest of a row: a provider
// renamed since is put back to the id it has now, and the names in force now
// are what the catalog lists the models under as well.
//
// Storing the name that was sent would judge each call by what it was
// really sent under, and it is the better answer, but it is not this
// function's to make: it is a field on every record the gateway writes, and
// so a change to the file's format — a column a row written by an older
// magpie has no value for, and a decision about whether an empty one means
// "no upstream name" or "unknown" — rather than a row of a report. The rows
// here are what a reader sets beside a bill, and a name in force today is
// the one whose reply a bill's model column is read against.
//
// The names are read here, once for the lot, rather than asked for a row
// at a time, so a caller already holding them — a report over the
// provider/model it was given — reads them the same way and reaches the
// same judgement: what a row says is the same however the rows were asked
// for.
func Ledger(p Period, f Filter) (rows []Row, sum Totals, agents []string) {
l := LedgerOf(p, f)
return l.Rows, l.Sum, l.Agents
}
func ledger(since time.Time, f Filter, recs []Record) (rows []Row, sum Totals, agents []string) {
rows, sum, agents, _ = ledgerWith(since, f, recs, nil)
return rows, sum, agents
}
// UnknownProvider groups local session calls without claiming a supplier.
// Recorded provider IDs and matched creator identities stay in their own fields.
const UnknownProvider = "session-unknown"
// logRecord is a call read from a session file, as a record: the upstream is
// unknown, and what the file doesn't say is left out. Nothing
// stood between the agent and the vendor, so what was sent is what the agent
// asked for. Claude Code's file names the model it asked for (as it runs) and
// the one the API answered with, which is the served model; Codex's names the
// one it asked for.
func logRecord(c sessions.Call) Record {
r := Record{Time: c.Time, Agent: c.Agent, Provider: UnknownProvider, Model: c.Model, Served: c.Model, Requested: c.Requested,
Input: c.Input, Output: c.Output, CacheRead: c.CacheRead, CacheWrite: c.CacheWrite, Reasoning: c.Reasoning, Effort: c.Effort,
Millis: c.Millis, TTFT: c.TTFT, Session: c.Session, RequestID: c.RequestID, Error: c.ErrorText, ErrType: c.Error}
if c.Agent == "codex" {
r.Requested, r.Served = c.Model, ""
}
if r.Requested != "" {
// sent as asked, less the size of its context Claude Code names it by
r.Model = contextTail.ReplaceAllString(r.Requested, "")
}
return r
}
// gatewayMatches correlates IDs first, then native session, equal token counts
// and an end within 2s. Fallback matches must be unique in both logs: an
// ambiguous direct call stays visible. One gateway entry consumes one file call.
func gatewayMatches(recs []Record, logs []sessions.Call) map[int]bool {
gateway, local := &rowChunk{}, &rowChunk{}
for i, r := range recs {
r.Agent = AgentOf(r.Agent)
gateway.add(Row{Record: r}, "", int64(i), false)
}
for i, c := range logs {
r := logRecord(c)
r.Agent = c.Agent // the native log already names its agent
local.add(Row{Record: r}, c.Msg, int64(i), c.Error != "")
}
matched := map[int]bool{}
for ref := range matchedBlocks([]*rowChunk{gateway}, []*rowChunk{local}, nil, time.Time{}, false) {
matched[ref.Index] = true
}
return matched
}
func ledgerWith(since time.Time, f Filter, recs []Record, logs []sessions.Call) (rows []Row, sum Totals, agents, providers []string) {
return ledgerWithShared(since, f, recs, logs, nil)
}
// ledgerWithShared is ledgerWith with the calls other computers made (#542),
// priced and judged here as this computer's are.
func ledgerWithShared(since time.Time, f Filter, recs []Record, logs []sessions.Call, others []SharedCall) (rows []Row, sum Totals, agents, providers []string) {
renamed := provider.Renamed()
// the upstream names in force now, read once for the lot: a row is
// judged by the names standing today, which is what Ledger says, and
// the rows here are what a reader compares a bill against
wires := settings.Load().ModelWires
priceOf := pricer()
rows = make([]Row, 0, len(recs)+len(logs))
agents, providers = []string{}, []string{}
matched := gatewayMatches(recs, logs)
identities := newSessionResolver(logs)
add := func(r Record, pr *catalog.Price, source string) {
if a := AgentOf(r.Agent); !slices.Contains(agents, a) {
agents = append(agents, a)
}
if r.Provider != "" && !slices.Contains(providers, r.Provider) {
providers = append(providers, r.Provider)
}
if !f.keeps(r) {
return
}
if !r.IsRejected() {
sum.add(r, pr)
}
sent := r.Model
if source == "" {
sent = provider.SentNameIn(wires, r.Provider, r.Model, r.Effort)
}
row := Row{Record: r, Swapped: r.Served != "" && Swapped(sent, r.Served), Routed: GroupRouted(sent, r.Served), Source: source}
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)
}
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
}
add(r, priceOf(r), "")
}
for i, c := range logs {
if (!since.IsZero() && c.Time.Before(since)) || matched[i] {
continue
}
// Session identity does not establish a billing provider; price the
// record through the same lookup without inferring one from the account.
r := logRecord(c)
r.SessionAccount = identities.resolve(c)
r.SessionOfficialLogin = identities.officialLogin(c, r.SessionAccount)
r.SessionProvider = c.Upstream
add(r, priceOf(r), "log")
}
for _, c := range others {
r := c.Record
if !since.IsZero() && r.Time.Before(since) {
continue
}
if id, ok := renamed[r.Provider]; ok {
r.Provider = id
}
add(r, priceOf(r), c.Source)
}
// the two logs, by when each call began
sort.SliceStable(rows, func(i, j int) bool { return rows[i].Time.After(rows[j].Time) })
slices.Sort(agents)
slices.Sort(providers)
return rows, sum, agents, providers
}
// pricer uses the same effective price as the model CLI and session totals:
// explicit provider/model or provider-wide prices (including zero), then the
// provider catalog, then the maker catalog. Settings are read once per query.
func pricer() func(Record) *catalog.Price {
prices := map[[2]string]*catalog.Price{}
s := settings.Load()
return func(r Record) *catalog.Price {
k := [2]string{r.Provider, r.Model}
if pr, ok := prices[k]; ok {
return pr
}
var pr *catalog.Price
v, ok := provider.EffectivePriceIn(s, r.Provider, r.Model)
if ok {
pr = &v
} else {
catalog.Missing() // models.dev may list it by now
}
prices[k] = pr
return pr
}
}
// NewPricer totals requests' token-bearing attempts at one snapshot of the
// effective prices used by the ledger. Reuse it for one page of routes;
// make it again on the next read so a changed tariff re-prices history.
func NewPricer() func([]Record) Totals {
priceOf, renamed := pricer(), provider.Renamed()
return func(recs []Record) Totals {
var sum Totals
for _, r := range recs {
if id, ok := renamed[r.Provider]; ok {
r.Provider = id
}
sum.add(r, priceOf(r))
}
return sum
}
}
// 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", "provider_key_id", "provider_key_name", "provider_account", "route_id",
"request_id", "endpoint", "error_message", "error_type", "source", "rejected", "session_provider", "session_account", "session_official_login", "caller_key_id", "caller_key_name"}
// WriteCSV writes rows as CSV, a header first: times in RFC 3339 with
// their offset, the cost in USD at the effective price (empty when unknown), error
// "true" for a call that failed: answered with a status of 400 or more, or
// ended by an error a session file tells.
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)
}
routeID := ""
if r.RouteID != 0 {
routeID = strconv.FormatInt(r.RouteID, 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.Failed()), r.Session, r.Kind, r.ProviderKeyID, r.ProviderKeyName, r.Account(), routeID,
r.RequestID, r.Endpoint, r.Error, r.ErrType, r.Source, strconv.FormatBool(r.IsRejected()), r.SessionProvider, r.SessionAccount, strconv.FormatBool(r.SessionOfficialLogin), r.CallerKeyID, r.CallerKeyName})
}
cw.Flush()
return cw.Error()
}
// timeline is a period's empty timeline as of now, so quiet stretches still
// take their place: a point per hour of today, per day of a week or a month,
// and per week (from a Monday) of a longer time, which starts at first, the
// time of the oldest call. since is when it starts.
func timeline(p Period, now, first time.Time) (since time.Time, bucket string, pts []Point) {
day := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
bucket, pts = "day", []Point{}
var n int
switch p {
case Today:
since, bucket = day, "hour"
case Week:
since, n = day.AddDate(0, 0, -6), 7
case Month:
since, n = day.AddDate(0, 0, -29), 30
default:
if !first.IsZero() {
f := first.In(now.Location())
since = time.Date(f.Year(), f.Month(), f.Day(), 0, 0, 0, 0, now.Location())
} else {
since = day
}
if n = calendarDays(since, day) + 1; n > 60 {
bucket = "week"
// start on the Monday of the first week
off := (int(since.Weekday()) + 6) % 7
since = since.AddDate(0, 0, -off)
n = calendarDays(since, day)/7 + 1
}
}
switch bucket {
case "hour":
for h := 0; day.Add(time.Duration(h) * time.Hour).Before(day.AddDate(0, 0, 1)); h++ {
t := day.Add(time.Duration(h) * time.Hour)
pts = append(pts, Point{Label: t.Format("15"), Time: t})
}
case "day":
for i := 0; i < n; i++ {
t := since.AddDate(0, 0, i)
pts = append(pts, Point{Label: t.Format("Jan 2"), Time: t})
}
case "week":
for i := 0; i < n; i++ {
t := since.AddDate(0, 0, 7*i)
pts = append(pts, Point{Label: t.Format("Jan 2"), Time: t})
}
}
return since, bucket, pts
}
func calendarDays(from, to time.Time) int {
date := func(t time.Time) time.Time { return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC) }
return int(date(to).Sub(date(from)).Hours() / 24)
}
func bucketIndex(bucket string, since, t time.Time) int {
switch bucket {
case "hour":
return int(t.Sub(since).Hours())
case "week":
return calendarDays(since, t) / 7
default:
return calendarDays(since, t)
}
}
// addRow adds a ledger row, at the cost it already has.
func (t *Totals) addRow(r Row) {
if r.TTFT > 0 && !r.Failed() {
t.Timed++
t.TTFT += r.TTFT
if r.Output > 0 && r.Millis > r.TTFT {
t.DecodeMs += r.Millis - r.TTFT
t.DecodeOut += r.Output
}
}
t.Calls++
if r.Failed() {
t.Errors++
}
t.Input += r.Input
t.Output += r.Output
t.CacheRead += r.CacheRead
t.CacheWrite += r.CacheWrite
t.Reasoning += r.Reasoning
switch {
case r.Priced:
t.Cost += r.Cost
case r.Input+r.Output > 0:
t.Unpriced++
}
}
// Dimensions are what calls are told apart by: the provider they went to,
// the agent that made them, the model.
var Dimensions = []string{"provider", "agent", "model"}
// key is the row's part of a dimension: its provider's id, its agent, its model.
func (r Row) key(by string) string {
switch by {
case "provider":
return r.Provider
case "agent":
return r.Agent
case "computer":
if r.Computer == "" {
return ThisComputer
}
return r.Computer
}
return r.Model
}
// AllTokens is what went in and out and through the cache.
func (t Totals) AllTokens() int { return t.Input + t.Output + t.CacheRead + t.CacheWrite }
// Share is one provider's, agent's or model's part of some rows.
type Share struct {
ID string `json:"id"`
Totals
}
// Breakdown sums rows by a dimension, the one with the most tokens first (then
// by calls, then by name).
func Breakdown(rows []Row, by string) []Share {
at := map[string]*Share{}
var out []*Share
for _, r := range rows {
if r.IsRejected() {
continue
}
k := r.key(by)
s := at[k]
if s == nil {
s = &Share{ID: k}
at[k], out = s, append(out, s)
}
s.addRow(r)
}
slices.SortFunc(out, func(a, b *Share) int {
switch {
case a.AllTokens() != b.AllTokens():
return b.AllTokens() - a.AllTokens()
case a.Calls != b.Calls:
return b.Calls - a.Calls
}
return strings.Compare(a.ID, b.ID)
})
res := make([]Share, len(out))
for i, s := range out {
res[i] = *s
}
return res
}
// Part is what one provider, agent or model had of a point of the timeline.
type Part struct {
Calls int `json:"calls"`
Tokens int `json:"tokens"`
Cost float64 `json:"cost"`
}
// SeriesPoint is a point of the timeline with its calls told apart, by each
// dimension, for a chart to stack them: the models of one point, say.
type SeriesPoint struct {
Point
By map[string]map[string]Part `json:"by"`
}
// seriesKeep is how many providers, agents or models a point tells apart,
// those with the most tokens over the period; the rest are what is left of
// the point's own sums.
const seriesKeep = 24
// LedgerSeries is the rows of a period, the ledger's or a filter's, summed by
// hour, day or week, for the chart over them. A row is of the point its
// time falls in; the rows are in any order.
func LedgerSeries(p Period, rows []Row) (bucket string, pts []SeriesPoint) {
now := time.Now()
var first time.Time
for _, r := range rows {
if r.IsRejected() {
continue
}
if first.IsZero() || r.Time.Before(first) {
first = r.Time
}
}
since, bucket, base := timeline(p, now, first)
pts = make([]SeriesPoint, len(base))
for i := range base {
pts[i] = SeriesPoint{Point: base[i], By: map[string]map[string]Part{}}
for _, d := range Dimensions {
pts[i].By[d] = map[string]Part{}
}
}
kept := map[string]map[string]bool{}
for _, d := range Dimensions {
kept[d] = map[string]bool{}
for i, s := range Breakdown(rows, d) {
if i < seriesKeep {
kept[d][s.ID] = true
}
}
}
for _, r := range rows {
if r.IsRejected() {
continue
}
t := r.Time.In(now.Location())
if t.Before(since) {
continue
}
i := bucketIndex(bucket, since, t)
if i < 0 || i >= len(pts) {
continue
}
pts[i].addRow(r)
tokens := r.Input + r.Output + r.CacheRead + r.CacheWrite
for _, d := range Dimensions {
if k := r.key(d); kept[d][k] {
part := pts[i].By[d][k]
part.Calls++
part.Tokens += tokens
if r.Priced {
part.Cost += r.Cost
}
pts[i].By[d][k] = part
}
}
}
return bucket, pts
}
// Visit calls fn with each record from since on, as the ledger's index
// reads usage.jsonl: only its blocks that reach since are decoded.
func Visit(since time.Time, fn func(Record)) { readLogSnapshot().visit(since, fn) }
// NewCoster prices one record at a time at the effective prices the
// ledger uses, read once: its cost in USD, and false for a call with
// tokens but no known price.
func NewCoster() func(Record) (float64, bool) {
priceOf, renamed := pricer(), provider.Renamed()
return func(r Record) (float64, bool) {
if id, ok := renamed[r.Provider]; ok {
r.Provider = id
}
var t Totals
t.add(r, priceOf(r))
return t.Cost, t.Unpriced == 0
}
}