Files
magpie/internal/usage/request_snapshot.go
T
yetone 6f64956f27 routing: a group can let a member go that hasn't begun answering after a set time and ask the next, and a request's detail splits its first token into magpie's time and the vendor's (Chen on X: answers through magpie felt slow, with no way to tell whether magpie or the vendor was the wait) — a group's editor has a Slow to start row (Wait, 30 s, 1 min, 2 min; Wait, the default, keeps today's behaviour). With a time set, a streamed request to a member that has sent no text, reasoning or tool call by then is cancelled before any of its bytes reach the agent (the held start is dropped; keepalives go on meanwhile) and the next member is asked; the try is marked slow ("slow to start", narrated in the route), and the member doesn't rest, since nothing is wrong with it. The last member left is always waited for. The card says "Next after 1 min without a first token". Each request now records when it went to the vendor (httptrace WroteRequest, sent_ms), and its detail shows In magpie and Vendor's first token beside First token; after a failover In magpie includes the time spent on the members let go. With the owner's real DeepSeek key in a sandbox, a group [a local member that sends its role chunk then stalls 60 s, deepseek-flash] with 3 s: the answer came from DeepSeek in 4.4 s, none of the stalled member's stream reached the client, its try read slow / "no first token in 3s" with no rest, and the ledger had ttft 3622, sent 3003. TestGroupFirstTokenFailsOver (failover, the last one waited for, one in time kept; passes -race) and group-first-token.test.cjs and usage-ledger-detail.test.cjs (WebKit, en and zh) fail on the old code. group-from-model-add's narrow-window case fails the same on origin/main; Chromium doesn't launch in this sandbox. go vet, builds (darwin, GOOS=linux nogui, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass (TestProvidersAnswerWhileListsComeIn flaked once, passes alone).
2026-10-04 19:33:55 +08:00

142 lines
3.3 KiB
Go

package usage
// Cache snapshots encode the fixed request fields without reflection. The
// encoding is private to this process: no on-disk format or migration. Time
// locations are retained separately so a round trip preserves displayed times.
import (
"bytes"
"compress/flate"
"encoding/binary"
"io"
"math"
"sync"
"time"
"unsafe"
)
var rowCompressors = sync.Pool{New: func() any {
w, _ := flate.NewWriter(io.Discard, flate.BestSpeed)
return w
}}
func (c *rowChunk) pack() *rowChunk {
if c == nil || len(c.Rows) < 64 || len(c.Archive) != 0 {
return c
}
data := make([]byte, 0, len(c.Rows)*64)
put := func(v uint64) { data = binary.AppendUvarint(data, v) }
put(uint64(len(c.Rows)))
put(uint64(len(c.Strings)))
for _, s := range c.Strings {
put(uint64(len(s)))
data = append(data, s...)
}
var locations []*time.Location
locs := map[*time.Location]uint64{}
for _, p := range c.Rows {
loc := p.Time.Location()
id, ok := locs[loc]
if !ok {
id = uint64(len(locations))
locs[loc] = id
locations = append(locations, loc)
}
put(uint64(p.Time.Unix()))
put(uint64(p.Time.Nanosecond()))
put(id)
for _, n := range p.Text {
put(uint64(n))
}
for _, n := range p.Tokens {
put(uint64(n))
}
for _, n := range []int64{p.Millis, p.TTFT, p.FirstText, p.Order, p.RouteID, p.Sent} {
put(uint64(n))
}
put(math.Float64bits(p.Cost))
put(uint64(p.Status))
put(uint64(p.Flags))
}
var b bytes.Buffer
w := rowCompressors.Get().(*flate.Writer)
w.Reset(&b)
_, err := w.Write(data)
closeErr := w.Close()
w.Reset(io.Discard)
rowCompressors.Put(w)
if err != nil || closeErr != nil || int64(b.Len()) >= c.Bytes {
return c
}
next := *c
next.Rows, next.Strings, next.dict = nil, nil, nil
next.Archive, next.Locations = b.Bytes(), locations
next.Bytes = int64(len(next.Archive) + len(locations)*8)
return &next
}
func (c *rowChunk) unpack() *rowChunk {
if c == nil || len(c.Archive) == 0 {
return c
}
r := flate.NewReader(bytes.NewReader(c.Archive))
data, err := io.ReadAll(r)
r.Close()
if err != nil {
return nil
}
valid := true
take := func() uint64 {
n, k := binary.Uvarint(data)
if k <= 0 {
valid = false
return 0
}
data = data[k:]
return n
}
rows, strs := take(), take()
if rows > uint64(len(data)) || strs > uint64(len(data)) {
return nil
}
next := *c
next.Archive, next.Locations = nil, nil
next.Rows = make([]packedRow, int(rows))
next.Strings = make([]string, int(strs))
next.Bytes = int64(len(next.Rows)) * int64(unsafe.Sizeof(packedRow{}))
for i := range next.Strings {
n := take()
if n > uint64(len(data)) {
return nil
}
next.Strings[i] = string(data[:n])
data = data[n:]
next.Bytes += int64(n) + 16
}
for i := range next.Rows {
p := &next.Rows[i]
sec, nano, loc := int64(take()), int64(take()), take()
if loc >= uint64(len(c.Locations)) {
return nil
}
p.Time = time.Unix(sec, nano).In(c.Locations[loc])
for j := range p.Text {
n := take()
if n >= strs {
return nil
}
p.Text[j] = uint32(n)
}
for j := range p.Tokens {
p.Tokens[j] = int64(take())
}
p.Millis, p.TTFT, p.FirstText, p.Order = int64(take()), int64(take()), int64(take()), int64(take())
p.RouteID = int64(take())
p.Sent = int64(take())
p.Cost, p.Status, p.Flags = math.Float64frombits(take()), int32(take()), uint8(take())
}
if !valid || len(data) != 0 {
return nil
}
return &next
}