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

This commit is contained in:
yetone committed 2026-09-29 16:40:03 +08:00
1 parent 30300c40d0
commit d924802de5
12 files changed
+638 -32

No files matched your search

+13 -7
View File
@@ -215,7 +215,8 @@ func (s *Server) codexUpstream(w http.ResponseWriter, r *http.Request, rest stri
// a turn goes in the Routing view's live trace as the others do, to
// the one it can go to: Codex's own sign-in, or its key
var tr *Route
end := func(status int, msg string, tokens int) {}
first := firstToken{start: start} // the reply's first tokens (#196), counted as ms are
end := func(status int, msg string, tokens, out int) {}
if rest == "/responses" {
who := "Codex's own sign-in"
if base == codexAPIBase {
@@ -225,11 +226,14 @@ func (s *Server) codexUpstream(w http.ResponseWriter, r *http.Request, rest stri
seat := Weighed{ID: "codex", Provider: "openai", Name: "OpenAI", Icon: "openai", Who: who, Kind: "account", Agent: "codex", Model: model}
tr = s.trace.begin(Route{Time: start, Agent: agentOf(r), Model: model, Provider: "openai",
Order: []Weighed{seat}, Tries: []Try{{ID: seat.ID, Model: model, Start: start}}})
end = func(status int, msg string, tokens int) {
end = func(status int, msg string, tokens, out int) {
ms := time.Since(start).Milliseconds()
ttft, text := first.ms()
s.trace.update(tr, func(t *Route) {
t.Tries[0].Done, t.Tries[0].Status, t.Tries[0].Millis, t.Tries[0].Error = true, status, ms, msg
t.Tries[0].TTFT, t.Tries[0].FirstText = ttft, text
t.Done, t.Status, t.Error, t.Millis, t.Tokens = true, status, msg, ms, tokens
t.Output, t.TTFT, t.FirstText = out, ttft, text
})
}
}
@@ -238,7 +242,7 @@ func (s *Server) codexUpstream(w http.ResponseWriter, r *http.Request, rest stri
req, err := http.NewRequestWithContext(r.Context(), r.Method, u, bytes.NewReader(body))
if err != nil {
writeError(w, provider.Responses, 502, err.Error())
end(502, err.Error(), 0)
end(502, err.Error(), 0, 0)
return
}
copyHeaders(req.Header, r.Header)
@@ -246,7 +250,7 @@ func (s *Server) codexUpstream(w http.ResponseWriter, r *http.Request, rest stri
req.Header.Del("Accept-Encoding")
if res, err = s.client.Do(req); err != nil {
writeError(w, provider.Responses, 502, "OpenAI: "+err.Error())
end(502, "OpenAI: "+err.Error(), 0)
end(502, "OpenAI: "+err.Error(), 0, 0)
return
}
if rest != "/responses" || tries >= 3 || (res.StatusCode != 400 && res.StatusCode != 404) {
@@ -275,7 +279,7 @@ func (s *Server) codexUpstream(w http.ResponseWriter, r *http.Request, rest stri
s.record(Call{Time: start, From: provider.Responses, To: provider.Responses, Model: modelOf(body),
Provider: "openai", Agent: agentOf(r), Status: res.StatusCode,
Millis: time.Since(start).Milliseconds(), Error: res.Status})
end(res.StatusCode, res.Status, 0)
end(res.StatusCode, res.Status, 0, 0)
return
}
for k, vs := range res.Header {
@@ -300,6 +304,7 @@ func (s *Server) codexUpstream(w http.ResponseWriter, r *http.Request, rest stri
if n > 0 {
if sniff != nil {
sniff.write(buf[:n])
first.see(buf[:n])
}
if _, werr := w.Write(buf[:n]); werr != nil {
break
@@ -318,17 +323,18 @@ func (s *Server) codexUpstream(w http.ResponseWriter, r *http.Request, rest stri
call := Call{Time: start, From: provider.Responses, To: provider.Responses, Model: modelOf(body),
Provider: "openai", Agent: agentOf(r), Status: res.StatusCode,
Millis: time.Since(start).Milliseconds()}
call.TTFT, call.FirstText = first.ms()
var uu Usage
uu.add(sniff.usage())
call.Usage = uu
if res.StatusCode >= 400 {
call.Error = res.Status
}
end(call.Status, call.Error, uu.Input+uu.Output+uu.CacheRead+uu.CacheWrite)
end(call.Status, call.Error, uu.Input+uu.Output+uu.CacheRead+uu.CacheWrite, uu.Output)
s.record(call)
usage.Append(usage.Record{Time: start, Agent: call.Agent, Provider: call.Provider, Host: provider.HostOf(base), Model: call.Model,
Input: uu.Input, Output: uu.Output, CacheRead: uu.CacheRead, CacheWrite: uu.CacheWrite,
Reasoning: uu.Reasoning, Millis: call.Millis, Status: call.Status, Session: sessionOf(r.Header)})
Reasoning: uu.Reasoning, Millis: call.Millis, TTFT: call.TTFT, FirstText: call.FirstText, Status: call.Status, Session: sessionOf(r.Header)})
}
// unreadableItem is the item OpenAI's refusal names: sealed content it
+4 -1
View File
@@ -474,10 +474,12 @@ type holdWriter struct {
ended bool // the stream's last event was written: the reply is whole
tail []byte // the end of the last write, for a marker split across two
first firstToken // when its first content and text came (#196)
}
func newHoldWriter(w http.ResponseWriter, hold bool) *holdWriter {
return &holdWriter{w: w, hold: hold, header: http.Header{}}
return &holdWriter{w: w, hold: hold, header: http.Header{}, first: firstToken{start: time.Now()}}
}
func (h *holdWriter) Header() http.Header { return h.header }
@@ -513,6 +515,7 @@ func (h *holdWriter) Write(b []byte) (int, error) {
h.WriteHeader(http.StatusOK)
}
h.see(b)
h.first.see(b)
if h.passing {
return h.w.Write(b)
}
+36 -16
View File
@@ -153,21 +153,26 @@ func Serving() (running, window bool) {
// Call is one request the gateway handled, for the status views.
type Call struct {
Time time.Time `json:"time"`
Agent string `json:"agent"` // who called, from the client's User-Agent
Model string `json:"model"`
Provider string `json:"provider"`
From provider.Protocol `json:"from"`
To provider.Protocol `json:"to"`
Status int `json:"status"`
Millis int64 `json:"ms"`
Error string `json:"error,omitempty"`
Fallback string `json:"fallback,omitempty"` // providers that failed first, and why
Usage Usage `json:"usage"`
RequestBody string `json:"requestBody,omitempty"`
ResponseBody string `json:"responseBody,omitempty"`
RequestTruncated bool `json:"requestTruncated,omitempty"`
ResponseTruncated bool `json:"responseTruncated,omitempty"`
Time time.Time `json:"time"`
Agent string `json:"agent"` // who called, from the client's User-Agent
Model string `json:"model"`
Provider string `json:"provider"`
From provider.Protocol `json:"from"`
To provider.Protocol `json:"to"`
Status int `json:"status"`
Millis int64 `json:"ms"`
// TTFT: ms from the request to its reply's first content — text,
// reasoning or a tool call — and FirstText to its first text, when
// it was streamed (#196)
TTFT int64 `json:"ttft,omitempty"`
FirstText int64 `json:"firstText,omitempty"`
Error string `json:"error,omitempty"`
Fallback string `json:"fallback,omitempty"` // providers that failed first, and why
Usage Usage `json:"usage"`
RequestBody string `json:"requestBody,omitempty"`
ResponseBody string `json:"responseBody,omitempty"`
RequestTruncated bool `json:"requestTruncated,omitempty"`
ResponseTruncated bool `json:"responseTruncated,omitempty"`
}
// Server is the gateway.
@@ -882,6 +887,7 @@ func (s *Server) serve(w http.ResponseWriter, r *http.Request, from provider.Pro
call.Provider, call.To, call.Usage = c.p.ID, "", Usage{}
where = c.p.Where()
began := time.Now()
hw.first.start = began
attemptBody := body
if from == provider.Responses {
// reasoning another sealed, which this one refused earlier in
@@ -945,6 +951,10 @@ func (s *Server) serve(w http.ResponseWriter, r *http.Request, from provider.Pro
call.Status, call.Error = hw.failure, c.p.Name+": "+hw.failMsg
}
try := Try{ID: c.rest, Model: c.model, Effort: sent, Picked: picked, Fixed: c.effort, Start: began, Done: true, Status: call.Status, Millis: time.Since(began).Milliseconds(), Error: call.Error}
try.TTFT, try.FirstText = hw.first.ms()
// the request's, from when it came as its ms are: the time before
// this try, the ones that failed first, is in it
call.TTFT, call.FirstText = sinceStart(began.Sub(start), hw.first.first), sinceStart(began.Sub(start), hw.first.text)
if r.Context().Err() != nil && !hw.ended {
// the agent went away: nobody failed, and nobody else is asked
call.Status, call.Error = 499, "the agent canceled the request"
@@ -1030,16 +1040,26 @@ func (s *Server) serve(w http.ResponseWriter, r *http.Request, from provider.Pro
s.trace.update(tr, func(t *Route) {
t.Done, t.Status, t.Error, t.Millis = true, call.Status, call.Error, call.Millis
t.Tokens = call.Usage.Input + call.Usage.Output + call.Usage.CacheRead + call.Usage.CacheWrite
t.Output, t.TTFT, t.FirstText = call.Usage.Output, call.TTFT, call.FirstText
})
s.record(call)
if call.To != "" {
usage.Append(usage.Record{Time: start, Agent: call.Agent, Provider: call.Provider, Host: where, Model: model,
Input: call.Usage.Input, Output: call.Usage.Output, CacheRead: call.Usage.CacheRead,
CacheWrite: call.Usage.CacheWrite, Reasoning: call.Usage.Reasoning, Effort: sent, Millis: call.Millis, Status: call.Status,
Session: sessionOf(r.Header)})
TTFT: call.TTFT, FirstText: call.FirstText, Session: sessionOf(r.Header)})
}
}
// sinceStart is a time d into a try that began before into the request,
// as ms from the request's start; 0 for none.
func sinceStart(before, d time.Duration) int64 {
if d <= 0 {
return 0
}
return (before + d).Milliseconds()
}
// attempt sends a request to one provider. call.To stays empty when the
// provider has no endpoint to send it to.
func (s *Server) attempt(w http.ResponseWriter, r *http.Request, from provider.Protocol, p provider.Provider, model string, body []byte, call *Call) (int, string) {
+14 -4
View File
@@ -43,6 +43,12 @@ type Route struct {
Error string `json:"error,omitempty"`
Millis int64 `json:"ms,omitempty"`
Tokens int `json:"tokens,omitempty"`
Output int `json:"out,omitempty"` // of Tokens, the reply's
// TTFT: ms from the request to its reply's first content (text,
// reasoning or a tool call), FirstText to its first text, as Millis
// counts: streamed replies only (#196)
TTFT int64 `json:"ttft,omitempty"`
FirstText int64 `json:"firstText,omitempty"`
}
// GroupRef is the routing group a request asked for.
@@ -145,10 +151,14 @@ type Try struct {
Done bool `json:"done"`
Status int `json:"status,omitempty"`
Millis int64 `json:"ms,omitempty"`
Fail string `json:"fail,omitempty"` // why it failed, as rest tells it
Error string `json:"error,omitempty"`
Rest *Rest `json:"rest,omitempty"` // how long it now sits out; none when it was the last to try
Again int64 `json:"again,omitempty"` // ms waited before it was tried again, the last one left
// TTFT: ms from Start to its reply's first content, FirstText to its
// first text, when it streamed any (#196)
TTFT int64 `json:"ttft,omitempty"`
FirstText int64 `json:"firstText,omitempty"`
Fail string `json:"fail,omitempty"` // why it failed, as rest tells it
Error string `json:"error,omitempty"`
Rest *Rest `json:"rest,omitempty"` // how long it now sits out; none when it was the last to try
Again int64 `json:"again,omitempty"` // ms waited before it was tried again, the last one left
}
type planned struct {
+198
View File
@@ -0,0 +1,198 @@
package gateway
// Time to first token (#196): how long a streamed reply took to begin —
// the first of its text, reasoning or a tool call — and to show its first
// text, so how fast a model writes can be told from how long it thought
// or waited. Read off the stream as it goes to the agent, in the agent's
// protocol, whichever the vendor spoke: one place for every way a reply
// comes, translated or not. A reply that isn't streamed isn't timed.
import (
"bytes"
"encoding/json"
"time"
)
// firstMost is the most of a stream read for its first tokens: a reply
// that is all tool calls has no text to wait for.
const firstMost = 8 << 20
// firstToken times a stream's first content and first text from start.
type firstToken struct {
start time.Time
first time.Duration // to the first content; 0 until it comes
text time.Duration // to the first text
off bool // not a stream, or read as far as it is
began bool
read int
pend []byte
}
// see reads what was written of the reply.
func (f *firstToken) see(b []byte) {
if f.off || len(b) == 0 {
return
}
if !f.began {
// a JSON reply is no stream
if t := bytes.TrimLeft(b, " \t\r\n"); len(t) == 0 {
return
} else if t[0] == '{' || t[0] == '[' {
f.off = true
return
}
f.began = true
}
if f.read += len(b); f.read > firstMost {
f.off, f.pend = true, nil
return
}
f.pend = append(f.pend, b...)
for !f.off {
end := eventEnd(f.pend)
if end < 0 {
break
}
content, text := firstKind(f.pend[:end])
f.pend = f.pend[end:]
if !content && !text {
continue
}
d := max(time.Since(f.start), time.Millisecond)
if f.first == 0 {
f.first = d
}
if text {
f.text = d
f.off, f.pend = true, nil // all there is to know
}
}
if len(f.pend) == 0 {
f.pend = nil
}
}
// ms are the two as milliseconds from start, 0 for none.
func (f *firstToken) ms() (first, text int64) {
return f.first.Milliseconds(), f.text.Milliseconds()
}
// firstKind says whether a server-sent event of a reply, in any protocol an
// agent speaks, carries content — text, reasoning or a tool call — and
// whether that is text the reader sees.
func firstKind(ev []byte) (content, text bool) {
var name string
var data []byte
for _, ln := range bytes.Split(ev, []byte("\n")) {
ln = bytes.TrimRight(ln, "\r")
switch {
case bytes.HasPrefix(ln, []byte("event:")):
name = string(bytes.TrimSpace(ln[6:]))
case bytes.HasPrefix(ln, []byte("data:")):
data = append(data, bytes.TrimSpace(ln[5:])...)
}
}
// only deltas, a tool call's start and Gemini's parts carry any: the
// rest go by unread
if len(data) == 0 || !(bytes.Contains(data, []byte("delta")) || bytes.Contains(data, []byte(`"parts"`)) ||
bytes.Contains(data, []byte("content_block_start")) || bytes.Contains(data, []byte("output_item.added"))) {
return false, false
}
var v struct {
Type string `json:"type"`
Delta json.RawMessage `json:"delta"` // Anthropic's an object, Responses' a string
ContentBlock *struct {
Type string `json:"type"`
} `json:"content_block"`
Item *struct {
Type string `json:"type"`
} `json:"item"`
Choices []struct {
Delta struct {
Content json.RawMessage `json:"content"`
Refusal json.RawMessage `json:"refusal"`
ReasoningContent json.RawMessage `json:"reasoning_content"`
Reasoning json.RawMessage `json:"reasoning"`
ToolCalls json.RawMessage `json:"tool_calls"`
FunctionCall json.RawMessage `json:"function_call"`
} `json:"delta"`
} `json:"choices"`
Candidates []struct {
Content struct {
Parts []struct {
Text string `json:"text"`
Thought bool `json:"thought"`
FunctionCall json.RawMessage `json:"functionCall"`
} `json:"parts"`
} `json:"content"`
} `json:"candidates"`
}
if json.Unmarshal(data, &v) != nil {
return false, false
}
typ := v.Type
if typ == "" {
typ = name
}
switch typ {
case "content_block_delta": // Anthropic
var d struct {
Type string `json:"type"`
Text string `json:"text"`
Thinking string `json:"thinking"`
PartialJSON string `json:"partial_json"`
}
json.Unmarshal(v.Delta, &d)
switch {
case d.Text != "":
return true, true
case d.Thinking != "", d.PartialJSON != "":
return true, false
}
return false, false
case "content_block_start":
if b := v.ContentBlock; b != nil && (b.Type == "tool_use" || b.Type == "server_tool_use") {
return true, false
}
return false, false
case "response.output_text.delta", "response.refusal.delta": // Responses
return filled(v.Delta), filled(v.Delta)
case "response.reasoning_summary_text.delta", "response.reasoning_text.delta",
"response.function_call_arguments.delta", "response.custom_tool_call_input.delta":
return filled(v.Delta), false
case "response.output_item.added":
if it := v.Item; it != nil && (it.Type == "function_call" || it.Type == "custom_tool_call") {
return true, false
}
return false, false
}
for _, c := range v.Choices { // Chat
d := c.Delta
if filled(d.Content) || filled(d.Refusal) {
return true, true
}
if filled(d.ReasoningContent) || filled(d.Reasoning) || filled(d.ToolCalls) || filled(d.FunctionCall) {
content = true
}
}
for _, c := range v.Candidates { // Gemini
for _, p := range c.Content.Parts {
switch {
case p.Text != "" && !p.Thought:
return true, true
case p.Text != "", filled(p.FunctionCall):
content = true
}
}
}
return content, false
}
// filled is a JSON value with something in it.
func filled(raw json.RawMessage) bool {
switch string(bytes.TrimSpace(raw)) {
case "", "null", `""`, "[]", "{}":
return false
}
return true
}
+257
View File
@@ -0,0 +1,257 @@
package gateway
import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/yetone/magpie/internal/provider"
"github.com/yetone/magpie/internal/usage"
)
// paced is a vendor that streams its events a gap apart, the first at
// once, or fails with a status after the gap.
type paced struct {
events []string
gap time.Duration
fail int
}
func (p *paced) ServeHTTP(w http.ResponseWriter, r *http.Request) {
io.ReadAll(r.Body)
if p.fail != 0 {
time.Sleep(p.gap)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(p.fail)
io.WriteString(w, `{"error":{"message":"slow down"}}`)
return
}
w.Header().Set("Content-Type", "text/event-stream")
f := w.(http.Flusher)
for i, ev := range p.events {
if i > 0 && strings.HasPrefix(ev, "~") { // a gap before it
time.Sleep(p.gap)
ev = ev[1:]
}
io.WriteString(w, ev+"\n\n")
f.Flush()
}
}
// Each: a lead, then reasoning a gap later, then text another gap later,
// then the end another gap later — 20 tokens written in all.
var (
pacedAnthropic = []string{
"event: message_start\ndata: " + `{"type":"message_start","message":{"id":"msg_1","type":"message","role":"assistant","model":"m1","content":[],"usage":{"input_tokens":5,"output_tokens":0}}}`,
"event: content_block_start\ndata: " + `{"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}`,
"event: ping\ndata: " + `{"type":"ping"}`,
"~event: content_block_delta\ndata: " + `{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"hmm"}}`,
"event: content_block_stop\ndata: " + `{"type":"content_block_stop","index":0}`,
"event: content_block_start\ndata: " + `{"type":"content_block_start","index":1,"content_block":{"type":"text","text":""}}`,
"~event: content_block_delta\ndata: " + `{"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"hi"}}`,
"event: content_block_stop\ndata: " + `{"type":"content_block_stop","index":1}`,
"~event: message_delta\ndata: " + `{"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":20}}`,
"event: message_stop\ndata: " + `{"type":"message_stop"}`,
}
pacedChat = []string{
`data: {"id":"c1","model":"m1","choices":[{"index":0,"delta":{"role":"assistant","content":""}}]}`,
`~data: {"id":"c1","model":"m1","choices":[{"index":0,"delta":{"reasoning_content":"hmm"}}]}`,
`~data: {"id":"c1","model":"m1","choices":[{"index":0,"delta":{"content":"hi"}}]}`,
`~data: {"id":"c1","model":"m1","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":5,"completion_tokens":20}}`,
`data: [DONE]`,
}
pacedResponses = []string{
"event: response.created\ndata: " + `{"type":"response.created","response":{"id":"r1","status":"in_progress","output":[]}}`,
"~event: response.reasoning_summary_text.delta\ndata: " + `{"type":"response.reasoning_summary_text.delta","item_id":"rs_1","output_index":0,"summary_index":0,"delta":"hmm"}`,
"~event: response.output_text.delta\ndata: " + `{"type":"response.output_text.delta","item_id":"msg_1","output_index":1,"content_index":0,"delta":"hi"}`,
"~event: response.completed\ndata: " + `{"type":"response.completed","response":{"id":"r1","status":"completed","output":[],"usage":{"input_tokens":5,"output_tokens":20}}}`,
}
)
const pacedGap = 80 * time.Millisecond
func lastUsage(t *testing.T) usage.Record {
t.Helper()
recs := usage.Load(time.Time{})
if len(recs) == 0 {
t.Fatal("no usage recorded")
}
return recs[len(recs)-1]
}
// A streamed reply's first content (reasoning here) and first text are
// timed from the request, whatever the vendor speaks and the agent asked
// in, and kept in the usage, the request's route and its try.
func TestUsageKeepsTimeToFirstToken(t *testing.T) {
gap := pacedGap.Milliseconds()
for _, c := range []struct {
name string
proto provider.Protocol
events []string
path string
body string
}{
{"anthropic", provider.Anthropic, pacedAnthropic, "/v1/messages", `{"model":"fake/m1","stream":true,"max_tokens":100,"messages":[{"role":"user","content":"hi"}]}`},
{"chat", provider.Chat, pacedChat, "/v1/chat/completions", `{"model":"fake/m1","stream":true,"messages":[{"role":"user","content":"hi"}]}`},
{"responses", provider.Responses, pacedResponses, "/v1/responses", `{"model":"fake/m1","stream":true,"input":"hi"}`},
// translated: the agent's Chat stream from the vendor's Anthropic one
{"chat from anthropic", provider.Anthropic, pacedAnthropic, "/v1/chat/completions", `{"model":"fake/m1","stream":true,"messages":[{"role":"user","content":"hi"}]}`},
// and its Responses stream from the vendor's Chat one
{"responses from chat", provider.Chat, pacedChat, "/v1/responses", `{"model":"fake/m1","stream":true,"input":"hi"}`},
} {
t.Run(c.name, func(t *testing.T) {
fresh(t)
up := httptest.NewServer(&paced{events: c.events, gap: pacedGap})
t.Cleanup(up.Close)
p := provider.Provider{ID: "fake", Name: "Fake", Key: "k", Models: []string{"m1"}}
switch c.proto {
case provider.Chat:
p.Chat = up.URL + "/v1"
case provider.Responses:
p.Responses = up.URL + "/v1"
case provider.Anthropic:
p.Anthropic = up.URL
}
if err := provider.Save(p); err != nil {
t.Fatal(err)
}
s := New()
rec := httptest.NewRecorder()
s.Handler().ServeHTTP(rec, httptest.NewRequest("POST", c.path, strings.NewReader(c.body)))
if rec.Code != 200 || !strings.Contains(rec.Body.String(), "hi") {
t.Fatalf("%d %s", rec.Code, rec.Body)
}
u := lastUsage(t)
if u.TTFT < gap || u.FirstText < 2*gap || u.FirstText-u.TTFT < gap/2 || u.Millis < u.FirstText+gap/2 || u.Output != 20 {
t.Errorf("usage ttft %d, first text %d, ms %d, out %d; want the reasoning after %d ms and the text after %d", u.TTFT, u.FirstText, u.Millis, u.Output, gap, 2*gap)
}
r := lastRoute(s)
if len(r.Tries) != 1 || r.TTFT != u.TTFT || r.FirstText != u.FirstText || r.Output != 20 ||
r.Tries[0].TTFT < gap || r.Tries[0].TTFT > u.TTFT || r.Tries[0].FirstText < 2*gap {
t.Errorf("route ttft %d/%d out %d, tries %+v", r.TTFT, r.FirstText, r.Output, r.Tries)
}
if calls := s.Recent(); len(calls) == 0 || calls[0].TTFT != u.TTFT {
t.Errorf("request log: %+v", calls)
}
})
}
}
// A reply that isn't streamed has no first token to time.
func TestUsageLeavesTTFTOfAWholeReply(t *testing.T) {
fresh(t)
serveOn(t, "a", "ka", []string{"m"}, &keyed{})
s := New()
if code, body := postAs(t, s, "", `{"model":"a/m","messages":[{"role":"user","content":"hi"}]}`); code != 200 {
t.Fatalf("%d %s", code, body)
}
if u := lastUsage(t); u.TTFT != 0 || u.FirstText != 0 || u.Output != 5 {
t.Errorf("usage %+v", u)
}
if r := lastRoute(s); r.TTFT != 0 || r.Tries[0].TTFT != 0 {
t.Errorf("route %+v", r)
}
}
// In a group that fails over, each try keeps its own time to its first
// token, from when it was sent, and the usage the request's, from when it
// came: the time the failed try took is in it, as it is in ms.
func TestGroupFailoverTimesEachTry(t *testing.T) {
fresh(t)
serveOn(t, "a", "ka", []string{"m"}, &paced{fail: 429, gap: pacedGap})
serveOn(t, "b", "kb", []string{"m"}, &paced{events: pacedChat, gap: pacedGap})
if err := provider.SaveGroup(provider.Group{Name: "Mine", Members: []string{"a/m", "b/m"}, Routing: provider.Ordered}); err != nil {
t.Fatal(err)
}
s := New()
code, body := postAs(t, s, "", `{"model":"group/mine","stream":true,"messages":[{"role":"user","content":"hi"}]}`)
if code != 200 || !strings.Contains(body, `"hi"`) {
t.Fatalf("%d %s", code, body)
}
r := lastRoute(s)
if len(r.Tries) != 2 || r.Tries[0].Status != 429 || r.Tries[1].Status != 200 {
t.Fatalf("tries %+v", r.Tries)
}
gap := pacedGap.Milliseconds()
failed, answered := r.Tries[0], r.Tries[1]
if failed.TTFT != 0 || failed.FirstText != 0 {
t.Errorf("the failed try timed: %+v", failed)
}
if answered.TTFT < gap || answered.FirstText < 2*gap || answered.TTFT >= answered.FirstText {
t.Errorf("the answering try: %+v", answered)
}
u := lastUsage(t)
if u.Provider != "b" || u.TTFT < answered.TTFT+failed.Millis || u.FirstText < answered.FirstText+failed.Millis || u.Millis <= u.FirstText {
t.Errorf("usage %+v; tries %+v", u, r.Tries)
}
}
// What counts as a reply's first content and first text, in each protocol,
// its events split across writes as they come.
func TestFirstTokenKinds(t *testing.T) {
for _, c := range []struct {
ev string
content, text bool
}{
{`data: {"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"t","name":"ls","input":{}}}`, true, false},
{`data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}`, false, false},
{`data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"a"}}`, true, false},
{`data: {"type":"content_block_delta","index":0,"delta":{"type":"signature_delta","signature":"x"}}`, false, false},
{`data: {"type":"message_delta","delta":{"stop_reason":"end_turn"}}`, false, false},
{`data: {"choices":[{"delta":{"role":"assistant","content":null}}]}`, false, false},
{`data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"name":"ls"}}]}}]}`, true, false},
{`data: {"choices":[{"delta":{"reasoning":"hm"}}]}`, true, false},
{`data: {"choices":[{"delta":{"content":"hi"}}]}`, true, true},
{"event: response.function_call_arguments.delta\ndata: {\"delta\":\"{\"}", true, false},
{`data: {"type":"response.output_item.added","item":{"type":"function_call","name":"ls"}}`, true, false},
{`data: {"type":"response.output_item.added","item":{"type":"message"}}`, false, false},
{`data: {"type":"response.output_text.delta","delta":""}`, false, false},
{`data: {"candidates":[{"content":{"parts":[{"text":"hm","thought":true}]}}]}`, true, false},
{`data: {"candidates":[{"content":{"parts":[{"functionCall":{"name":"ls"}}]}}]}`, true, false},
{`data: {"candidates":[{"content":{"parts":[{"text":"hi"}]}}]}`, true, true},
{`: keep-alive`, false, false},
{`data: [DONE]`, false, false},
} {
content, text := firstKind([]byte(c.ev))
if content != c.content || text != c.text {
t.Errorf("%s: content %v text %v", c.ev, content, text)
}
}
// split across writes, \r\n-ended
f := firstToken{start: time.Now().Add(-time.Second)}
stream := "data: {\"choices\":[{\"delta\":{\"role\":\"assistant\"}}]}\r\n\r\ndata: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0}]}}]}\r\n\r\ndata: {\"choices\":[{\"delta\":{\"content\":\"hi\"}}]}\r\n\r\n"
for i := 0; i < len(stream); i += 7 {
f.see([]byte(stream[i:min(i+7, len(stream))]))
}
if first, text := f.ms(); first < 1000 || text < first || !f.off || f.pend != nil {
t.Errorf("split: %d %d %+v", first, text, f)
}
// a JSON reply is not read
j := firstToken{start: time.Now()}
j.see([]byte(`{"choices":[{"message":{"content":"hi"}}]}` + "\n\n"))
if first, _ := j.ms(); first != 0 || !j.off {
t.Errorf("json: %+v", j)
}
}
// Codex's own models, relayed to the ChatGPT backend as they came, are
// timed too — its stream says no Content-Type.
func TestCodexBackendTimesFirstToken(t *testing.T) {
setup(t, provider.Chat, &fake{t: t})
p := &paced{events: pacedResponses, gap: pacedGap}
chatgpt(t, func(w http.ResponseWriter, r *http.Request) {
w.Header()["Content-Type"] = nil
p.ServeHTTP(w, r)
})
if code, body := codexPost(t, `{"model":"gpt-5.5","stream":true,"input":"hi"}`); code != 200 {
t.Fatalf("%d %s", code, body)
}
gap := pacedGap.Milliseconds()
if u := lastUsage(t); u.TTFT < gap || u.FirstText < 2*gap || u.Millis <= u.FirstText || u.Output != 20 {
t.Errorf("usage %+v", u)
}
}
+8 -1
View File
@@ -2111,7 +2111,7 @@ function renderActivity() {
r.append(el("span", "m", c.model));
r.append(el("span", "p", c.from === c.to ? c.from : `${c.from} → ${c.to}`));
r.append(el("span", "grow"));
r.append(el("span", "st", c.error ? `${c.status} ${c.error}` : `${c.status} · ${c.ms} ms`));
r.append(el("span", "st", c.error ? `${c.status} ${c.error}` : `${c.status} · ${c.ms} ms` + (c.ttft ? " · " + t("TTFT {ms}", { ms: `${c.ttft} ms` }) : "")));
r.title = open ? t("Hide request and response bodies") : t("Show request and response bodies");
const toggle = () => {
if (expandedCalls.has(id)) expandedCalls.delete(id); else expandedCalls.add(id);
@@ -5103,6 +5103,13 @@ function renderUsage() {
if (g.sub) sub.push(g.sub);
sub.push(t(g.calls === 1 ? "{n} call" : "{n} calls", { n: g.calls }));
if (g.errors) sub.push(t("{n} failed", { n: g.errors }));
// how long the streamed replies took to begin, and how fast they
// wrote after (#196)
if (g.timed) {
const ms = Math.round(g.ttft_ms / g.timed);
sub.push(t("TTFT {ms}", { ms: ms < 1000 ? t("{n} ms", { n: ms }) : t("{n} s", { n: (ms / 1000).toFixed(1) }) }));
if (g.decode_ms > 0) sub.push(t("{n} tok/s", { n: Math.round(g.decode_out / (g.decode_ms / 1000)) }));
}
who.append(el("div", "sub", sub.join(" · ")));
r.append(who);
const share = el("div", "share");
+4
View File
@@ -1122,6 +1122,10 @@ const I18N = {
"{n} on": "启用 {n} 个",
"{n} s": "{n} 秒",
"{n} ms": "{n} 毫秒",
"TTFT {ms}": "首字 {ms}",
"first token in {ms}": "{ms} 后出首个 token",
"first text in {ms}": "{ms} 后出首字",
"{n} tok/s": "{n} token/秒",
"{n} min": "{n} 分钟",
"{h} h {m} min": "{h} 小时 {m} 分钟",
"{n} h": "{n} 小时",
+12 -1
View File
@@ -127,6 +127,16 @@
const hm = d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
return d.toDateString() === n.toDateString() ? hm : d.toLocaleDateString([], { weekday: "short" }) + " " + hm;
}
// how long a reply took to begin, and how fast it wrote after (#196)
const speedOf = (out, ms, ttft) => out && ttft && ms > ttft ? out / ((ms - ttft) / 1000) : 0;
function firstNote(r, tr) {
if (!tr.ttft) return "";
let s = " · " + t("first token in {ms}", { ms: took(tr.ttft) });
if (tr.firstText > tr.ttft) s += " · " + t("first text in {ms}", { ms: took(tr.firstText) });
const v = speedOf(r.out, tr.ms, tr.ttft);
if (v) s += " · " + t("{n} tok/s", { n: Math.round(v) });
return s;
}
const tokens = (n) => n >= 1e6 ? (n / 1e6).toFixed(1) + "M" : n >= 1e3 ? (n / 1e3).toFixed(1) + "k" : String(Math.round(n));
const pct = (n) => Math.round(n) + "%";
const FAIL = { rate: "rate limited", credit: "out of credit", quota: "quota used up", other: "failed", canceled: "canceled", foreign: "another account's reasoning", floor: "reply too short", verify: "needs verification" };
@@ -429,7 +439,7 @@
if (tr.effort) name += " " + t("at {level} reasoning", { level: tr.effort });
if (!tr.done) return t("{who} is answering…", { who: name });
if (tr.status < 400) {
const tk = r.tokens ? " · " + t("{n} tokens", { n: tokens(r.tokens) }) : "";
const tk = (r.tokens ? " · " + t("{n} tokens", { n: tokens(r.tokens) }) : "") + firstNote(r, tr);
return i > 0
? t("{who} answered in {ms}{tk}. {agent} got one clean reply and never saw the {n} that failed first.", { who: name, ms: took(tr.ms), tk, agent, n: i })
: t("{who} answered in {ms}{tk}.", { who: name, ms: took(tr.ms), tk });
@@ -1024,6 +1034,7 @@
const meta = [];
if (r.tries.length > 1) meta.push(t("{n} tries", { n: r.tries.length }));
if (r.done && r.ms) meta.push(took(r.ms));
if (r.done && r.ttft) meta.push(t("TTFT {ms}", { ms: took(r.ttft) }));
if (r.tokens) meta.push(t("{n} tokens", { n: tokens(r.tokens) }));
b.append(when, asked, to, el("span", "meta", meta.join(" · ")));
b.title = `${agentName(r.agent)} · ${r.model} → ${r.provider}`;
+40 -1
View File
@@ -36,7 +36,13 @@ type Record struct {
// pick for the turn, or the agent's own — as it takes it; "" for none
Effort string `json:"effort,omitempty"`
Millis int64 `json:"ms"`
Status int `json:"status"`
// TTFT: ms from the request to its reply's first content — text,
// reasoning or a tool call — and FirstText to its first text, counted
// as Millis is, so Millis-TTFT is how long the reply took to write;
// none for a reply that wasn't streamed (#196)
TTFT int64 `json:"ttft_ms,omitempty"`
FirstText int64 `json:"first_text_ms,omitempty"`
Status int `json:"status"`
// Session is the conversation the call was part of, as its agent names
// it (X-Magpie-Session, or the session header Claude Code, Codex or
// OpenCode sends): several sessions on one model told apart
@@ -162,11 +168,36 @@ type Totals struct {
Reasoning int `json:"reasoning"`
Cost float64 `json:"cost"` // USD at list prices, for the priced calls
Unpriced int `json:"unpriced"` // calls with tokens but no known price
// Timed: the answered calls whose first token was timed (streamed),
// TTFT the sum of their ttft_ms; DecodeMs the time from it to the end
// of those that wrote any, over which DecodeOut tokens came: their
// mean wait, and how fast they wrote (#196)
Timed int `json:"timed,omitempty"`
TTFT int64 `json:"ttft_ms,omitempty"`
DecodeMs int64 `json:"decode_ms,omitempty"`
DecodeOut int `json:"decode_out,omitempty"`
}
// Tokens is what went in and out, excluding cache traffic.
func (t Totals) Tokens() int { return t.Input + t.Output }
// MeanTTFT is the mean ms to the first token of the timed calls, 0 for none.
func (t Totals) MeanTTFT() int64 {
if t.Timed == 0 {
return 0
}
return t.TTFT / int64(t.Timed)
}
// Speed is how fast the timed calls wrote, in tokens a second after their
// first: 0 for none.
func (t Totals) Speed() float64 {
if t.DecodeMs <= 0 {
return 0
}
return float64(t.DecodeOut) / (float64(t.DecodeMs) / 1000)
}
func (t *Totals) add(r Record, price *catalog.Price) {
t.Calls++
if r.Status >= 400 {
@@ -177,6 +208,14 @@ func (t *Totals) add(r Record, price *catalog.Price) {
t.CacheRead += r.CacheRead
t.CacheWrite += r.CacheWrite
t.Reasoning += r.Reasoning
if r.TTFT > 0 && r.Status < 400 {
t.Timed++
t.TTFT += r.TTFT
if r.Output > 0 && r.Millis > r.TTFT {
t.DecodeMs += r.Millis - r.TTFT
t.DecodeOut += r.Output
}
}
if r.Input+r.Output == 0 {
return
}
+31
View File
@@ -1,7 +1,9 @@
package usage
import (
"encoding/json"
"path/filepath"
"strings"
"testing"
"time"
@@ -101,3 +103,32 @@ func TestSummarizeSessions(t *testing.T) {
t.Fatalf("%+v", ss)
}
}
// The timed calls (#196) sum to a mean time to the first token and how
// fast the replies were written after it; old records, whole replies and
// failures aren't in them, and a record without them reads as before.
func TestSummarizeTimesFirstTokens(t *testing.T) {
now := time.Date(2026, 9, 23, 15, 30, 0, 0, time.UTC)
at := now.Add(-time.Hour)
recs := []Record{
{Time: at, Provider: "p", Model: "m", Output: 100, Millis: 3000, TTFT: 1000, FirstText: 2000, Status: 200},
{Time: at, Provider: "p", Model: "m", Output: 50, Millis: 1500, TTFT: 500, Status: 200},
{Time: at, Provider: "p", Model: "m", Output: 999, Millis: 800, Status: 200}, // not streamed
{Time: at, Provider: "p", Model: "m", Millis: 100, TTFT: 90, Status: 502}, // failed
}
s := summarize(Today, now, recs)
m := s.Models[0]
if m.Timed != 2 || m.MeanTTFT() != 750 || m.DecodeMs != 3000 || m.DecodeOut != 150 || m.Speed() != 50 {
t.Fatalf("model: %+v", m.Totals)
}
if s.Timed != 2 || s.Series[14].Timed != 2 {
t.Fatalf("totals: %+v", s.Totals)
}
if (Totals{}).Speed() != 0 || (Totals{}).MeanTTFT() != 0 {
t.Fatal("nothing timed")
}
b, _ := json.Marshal(Record{Time: at, Provider: "p", Model: "m", Millis: 5})
if strings.Contains(string(b), "ttft") || strings.Contains(string(b), "first_text") {
t.Fatalf("untimed record: %s", b)
}
}
+21 -1
View File
@@ -58,7 +58,7 @@ func usageCmd(args []string) error {
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))
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 {
@@ -86,6 +86,26 @@ func usageCmd(args []string) error {
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