Files
magpie/usage_cli.go
T
yetone d924802de5 usage: a streamed reply's time to its first token is kept, and its first text's (#196: usage 记录首字延迟(TTFT),用于计算模型的真实生成速度) — usage.jsonl had only a call's whole ms and its out tokens, so how fast a model writes couldn't be told from how long it waited or thought. The reply is now read as it goes to the agent, in the agent's protocol whatever the vendor spoke (Anthropic content_block_delta text/thinking/input_json and a tool_use's start, Chat content/reasoning_content/reasoning/tool_calls, Responses output_text/reasoning/function_call_arguments deltas and a function call's start, Gemini parts), and each record gets ttft_ms to the first content and first_text_ms to the first text, counted from the request as ms is, so ms − ttft_ms is the writing time; a reply that isn't streamed gets neither, and omitempty leaves old records as they were. Only the stream's events up to its first text are read (a JSON reply, or past 8 MB, not at all). Each try in the routing trace keeps its own from when it was sent, so a group that fails over shows which member was slow, and the route, the request log and the Codex backend's relay keep them too. Shown on the Routing page (TTFT in the list, first token / first text / tok/s in a try's story), in the Gateway's request log, and on the Usage page and in magpie usage per model as the mean TTFT and tok/s = out ÷ (ms − ttft) over the answered streamed calls. Tested with fake vendors streaming a gap apart for Anthropic, Chat and Responses, as they came and translated (Chat from Anthropic, Responses from Chat), the ChatGPT backend without a Content-Type, a whole reply, a group whose first member fails (its try untimed, the second's timed from its own start, the request's including the failed try), each protocol's events and split writes, and the summary's sums; the GUI's browser tests pass
2026-09-29 16:40:03 +08:00

151 lines
4.0 KiB
Go

package main
import (
"fmt"
"strings"
"github.com/yetone/magpie/internal/agent"
stats "github.com/yetone/magpie/internal/usage"
)
// usageCmd: `magpie usage [today|7d|30d|all]` — tokens and cost per agent,
// model and session
func usageCmd(args []string) error {
period := stats.Month
if len(args) > 1 {
switch strings.ToLower(args[1]) {
case "today", "day":
period = stats.Today
case "7d", "week":
period = stats.Week
case "30d", "month":
period = stats.Month
case "all":
period = stats.All
default:
return fmt.Errorf("usage: magpie usage [today|7d|30d|all]")
}
}
s := stats.Summarize(period)
title := map[stats.Period]string{stats.Today: "today", stats.Week: "last 7 days", stats.Month: "last 30 days", stats.All: "all time"}[s.Period]
if s.Calls == 0 {
fmt.Println(muted.Render("no calls "+title+" ·"), "route an agent through magpie and its usage shows up here")
fmt.Println(faint.Render(" " + stats.Path()))
return nil
}
fmt.Println(bold.Render(fmtTokens(s.Tokens())+" tokens"), muted.Render(title+" ·"), plural(s.Calls, "call"), muted.Render("·"), cost(s.Totals))
fmt.Println(muted.Render(" in "+fmtTokens(s.Input)+" out "+fmtTokens(s.Output)+" cache read "+fmtTokens(s.CacheRead)+" cache write "+fmtTokens(s.CacheWrite)+" reasoning "+fmtTokens(s.Reasoning)),
func() string {
if s.Errors > 0 {
return muted.Render(" · ") + plural(s.Errors, "error")
}
return ""
}())
names := map[string]string{}
for _, a := range agent.All() {
names[a.ID] = a.Name
}
table := func(head string, gs []stats.Group, name func(stats.Group) string) {
fmt.Println()
fmt.Println(faint.Render(" " + head))
w := 0
for _, g := range gs {
w = max(w, len(name(g)))
}
for _, g := range gs {
share := ""
if s.Tokens() > 0 {
share = fmt.Sprintf("%3.0f%%", 100*float64(g.Tokens())/float64(s.Tokens()))
}
fmt.Println(" "+pad(name(g), w), muted.Render(share), pad(fmtTokens(g.Tokens()), 7), faint.Render(pad(plural(g.Calls, "call"), 10)), cost(g.Totals), faint.Render(speed(g.Totals)))
}
}
table("agents", s.Agents, func(g stats.Group) string {
if n := names[g.ID]; n != "" {
return n
}
return g.ID
})
table("models", s.Models, func(g stats.Group) string { return g.ID })
if len(s.Sessions) > 0 {
top := s.Sessions[:min(len(s.Sessions), 10)]
head := "sessions"
if len(s.Sessions) > len(top) {
head = fmt.Sprintf("sessions · top %d of %d", len(top), len(s.Sessions))
}
table(head, top, func(g stats.Group) string {
n := names[g.Agent]
if n == "" {
n = g.Agent
}
return n + " " + g.ID
})
}
fmt.Println(faint.Render(" " + stats.Path()))
return nil
}
// speed is how long the timed calls took to their first token, and how
// fast they wrote after it (#196); "" when none was timed.
func speed(t stats.Totals) string {
if t.Timed == 0 {
return ""
}
out := "ttft " + fmtMs(t.MeanTTFT())
if v := t.Speed(); v > 0 {
out += fmt.Sprintf(" · %.0f tok/s", v)
}
return out
}
func fmtMs(ms int64) string {
if ms < 1000 {
return fmt.Sprintf("%d ms", ms)
}
return fmt.Sprintf("%.1f s", float64(ms)/1000)
}
func plural(n int, unit string) string {
if n == 1 {
return "1 " + unit
}
return fmt.Sprintf("%d %ss", n, unit)
}
func fmtTokens(n int) string {
switch {
case n >= 1_000_000_000:
return fmt.Sprintf("%.2fB", float64(n)/1e9)
case n >= 10_000_000:
return fmt.Sprintf("%.0fM", float64(n)/1e6)
case n >= 1_000_000:
return fmt.Sprintf("%.1fM", float64(n)/1e6)
case n >= 100_000:
return fmt.Sprintf("%.0fK", float64(n)/1e3)
case n >= 1_000:
return fmt.Sprintf("%.1fK", float64(n)/1e3)
}
return fmt.Sprint(n)
}
// cost renders list-price cost, saying when some calls could not be priced.
func cost(t stats.Totals) string {
if t.Cost == 0 && t.Unpriced > 0 {
return faint.Render("no price")
}
var s string
switch {
case t.Cost >= 100:
s = fmt.Sprintf("$%.0f", t.Cost)
case t.Cost >= 1:
s = fmt.Sprintf("$%.2f", t.Cost)
default:
s = fmt.Sprintf("$%.3f", t.Cost)
}
if t.Unpriced > 0 {
s += muted.Render("+")
}
return green.Render("≈" + s)
}