Files
magpie/internal/sessions/call_cache.go
T
yetone b8fdba5d64 usage: OpenCode's own calls are counted, as Claude Code's and Codex's are (#680, Sun1090: cc-switch 会统计 OpenCode 的用量,我这样不会被统计吗?) — OpenCode used on its own providers (OpenCode Zen/Go, its own keys), not through magpie's gateway, never reached the Usage page: the local calls were Claude Code's and Codex's session files only. Now each OpenCode assistant message is a call (agent opencode): its model, provider (as the session provider), input, output with the reasoning in it (reasoning kept apart too, as Codex's), cache read and write, its end time and how long it took, the session it ran under (a subagent's in its parent's), and a reply that failed with no tokens as a failed call (a stopped one is none). They are read where the Sessions page reads OpenCode: opencode.db — OpenCode 2's session_v2/session_message (a compaction on the reply before it) or the older session/message, the old ones not yet copied while OpenCode 2 migrates — else the JSON files under storage/. A reply through magpie's gateway (provider "magpie", or "dial") is left out: the gateway logged it already. Each session is a call source of its own, read again whole only when it changed (its size is the messages' count plus the sum of their time_updated, so an older message written again tells too) and kept in the call cache on disk as a file's calls are; the database listing is kept while opencode.db and its WAL are unchanged, and a read-only handle is shared between sources and closed after 10 s unused. The Sessions listing is unchanged (allFiles still takes callFiles; Calls and CallSources take callSources). ContentOf says no place for an OpenCode call. OpenCode's own cost isn't used: calls are priced at magpie's effective prices, as the others are. TestOpenCodeCallsJSON, TestOpenCodeCallsDB (cache kept unchanged and across a restart, an updated message read again, a failed and a stopped reply) and TestOpenCodeCallsV2 (compactions follow the reply before them, magpie's skipped) fail on the old code (no OpenCode calls). With a read-only copy of this computer's ~/.local/share/opencode (JSON storage, 3 OpenCode Zen kimi-k2.5-free replies): 3 calls, input 12281, output 235 (123 + 112 reasoning), cache read 24573 — the same as jq over the message files and as SQL SUM over an opencode.db built from them. go vet, builds (darwin, GOOS=linux, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass.
2026-10-03 21:27:17 +08:00

347 lines
8.3 KiB
Go

package sessions
// Request metadata uses checksummed append-only frames. A growing session saves
// only new/changed calls and its parser continuation. Corrupt shards rebuild
// from source. Sessions summaries never load these files.
import (
"bytes"
"encoding/binary"
"encoding/gob"
"hash/crc32"
"hash/fnv"
"io"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
// v3 persists Codex cumulative counters explicitly; v2 shards can contain
// inflated deltas after a reload, so rebuild their derived rows from source.
// v4: a Codex call's input no longer holds what it wrote to the cache (#589).
const callCacheVersion = "calls-v4"
const maxKeptCalls = 131072
const maxKeptFiles = 64
var callsMu sync.Mutex
var callIO [64]sync.Mutex
var callGeneration uint64
var callCache = map[string]*callFile{}
var callCounts = map[string]int{} // small allocation hints, never call records
var callOrder []string
var callRoot string
func callCacheDir() string { return filepath.Join(filepath.Dir(CachePath()), callCacheVersion) }
func callCachePath(path string) string { return filepath.Join(callCacheDir(), hashHead(path)+".gob") }
func resetCalls() {
callsMu.Lock()
defer callsMu.Unlock()
callCache, callOrder = map[string]*callFile{}, nil
callCounts = map[string]int{}
callRoot = ""
callGeneration++
resetOCCalls()
}
func pruneCalls(files []file) {
root := callCacheDir()
callsMu.Lock()
moved := callRoot != root
if moved {
callGeneration++
callCache, callOrder, callRoot = map[string]*callFile{}, nil, root
callCounts = map[string]int{}
}
on := make(map[string]bool, len(files))
shards := make(map[string]bool, len(files))
for _, f := range files {
on[f.path] = true
shards[hashHead(f.path)+".gob"] = true
}
order := callOrder[:0]
for _, path := range callOrder {
if on[path] {
order = append(order, path)
} else {
delete(callCache, path)
}
}
callOrder = order
for path := range callCounts {
if !on[path] {
delete(callCounts, path)
}
}
callsMu.Unlock()
if moved {
// an older version's shards are never read again
olds, _ := filepath.Glob(filepath.Join(filepath.Dir(root), "calls-v*"))
for _, old := range olds {
if old != root {
_ = os.RemoveAll(old)
}
}
}
// IO never holds the metadata lock. A grace period protects live writers.
entries, _ := os.ReadDir(root)
for _, e := range entries {
if e.IsDir() {
continue
}
orphan := strings.HasSuffix(e.Name(), ".gob") && !shards[e.Name()]
if strings.HasPrefix(e.Name(), "calls-") && strings.HasSuffix(e.Name(), ".tmp") {
if st, err := e.Info(); err == nil && time.Since(st.ModTime()) > time.Hour {
orphan = true
}
}
if orphan {
_ = os.Remove(filepath.Join(root, e.Name()))
}
}
}
func keepCalls(path string, st *callFile) {
callCounts[path] = len(st.Calls)
delete(callCache, path)
order := callOrder[:0]
for _, p := range callOrder {
if p != path {
order = append(order, p)
}
}
callOrder = order
if len(st.Calls) > maxKeptCalls {
return
}
n := len(st.Calls)
for _, s := range callCache {
n += len(s.Calls)
}
for len(callOrder) > 0 && (n > maxKeptCalls || len(callOrder) >= maxKeptFiles) {
p := callOrder[0]
n -= len(callCache[p].Calls)
delete(callCache, p)
callOrder = callOrder[1:]
}
callCache[path] = st
callOrder = append(callOrder, path)
}
func readCalls(f file) *callFile {
h := fnv.New32a()
_, _ = h.Write([]byte(f.path))
lock := &callIO[h.Sum32()%uint32(len(callIO))]
lock.Lock()
defer lock.Unlock()
callsMu.Lock()
old, generation := callCache[f.path], callGeneration
callsMu.Unlock()
if old == nil {
old = loadCalls(f)
}
if old != nil && old.Size == f.size && old.Mod == f.mod.UnixNano() {
callsMu.Lock()
if generation == callGeneration {
keepCalls(f.path, old)
}
callsMu.Unlock()
return old
}
if f.agent == "opencode" {
// rows, not lines: a session that changed is read again whole
st, ok := readOpenCodeCalls(f)
if !ok && old != nil {
return old // not readable now: what was read before
}
if ok {
st.Size, st.Mod = f.size, f.mod.UnixNano()
writeCalls(st, 0, false)
}
callsMu.Lock()
if ok && generation == callGeneration {
keepCalls(f.path, st)
}
callsMu.Unlock()
return st
}
st := prepareCalls(f, old)
continued := old != nil && st.Off == old.Off && st.Off > 0
start := 0
if continued {
start = len(old.Calls)
}
off, err := scanAt(f.path, st.Off, func(b []byte) bool { return callHead(st, b) }, func(b []byte, start, end int64) bool {
st.At, st.End = start, end
if f.agent == "codex" {
codexCallLine(st, b)
} else {
claudeCallLine(st, b)
}
return true
})
if err == nil {
st.Off, st.Size, st.Mod = off, f.size, f.mod.UnixNano()
st.ContentHash = prefixHash(f.path, f.size)
writeCalls(st, start, continued)
}
st.dirty = nil
callsMu.Lock()
if generation == callGeneration {
keepCalls(f.path, st)
}
callsMu.Unlock()
return st
}
// Common strings are stored once per append frame; File is implied by State.
type callPatch struct {
Index int
Call Call
Text [12]uint32
Began time.Time
}
type callFrame struct {
State callFile
Strings []string
Patches []callPatch
}
func callText(c *Call) [12]*string {
return [12]*string{&c.Agent, &c.Session, &c.Model, &c.Requested, &c.Effort, &c.Cwd, &c.Upstream, &c.AccountID, &c.UserID, &c.Error, &c.ErrorText, &c.File}
}
func loadCalls(f file) *callFile {
in, err := os.Open(callCachePath(f.path))
if err != nil {
return nil
}
defer in.Close()
var st *callFile
var calls []Call
msgs := map[string]int{}
began := map[string]time.Time{}
for {
var header [8]byte
_, err = io.ReadFull(in, header[:])
if err == io.EOF {
break
}
if err != nil {
return nil
}
size := binary.LittleEndian.Uint32(header[:4])
if size == 0 || size > 256<<20 {
return nil
}
data := make([]byte, int(size))
if _, err = io.ReadFull(in, data); err != nil || crc32.ChecksumIEEE(data) != binary.LittleEndian.Uint32(header[4:]) {
return nil
}
var frame callFrame
if gob.NewDecoder(bytes.NewReader(data)).Decode(&frame) != nil || frame.State.Path != f.path || frame.State.Agent != f.agent {
return nil
}
if st != nil && frame.State.Off < st.Off {
return nil
}
for _, p := range frame.Patches {
if p.Index < 0 || p.Index > len(calls) {
return nil
}
for i, field := range callText(&p.Call) {
if int(p.Text[i]) >= len(frame.Strings) {
return nil
}
*field = frame.Strings[p.Text[i]]
}
p.Call.File = f.path
if p.Index == len(calls) {
calls = append(calls, p.Call)
} else {
calls[p.Index] = p.Call
}
if p.Call.Msg != "" {
msgs[p.Call.Msg] = p.Index
if !p.Began.IsZero() {
began[p.Call.Msg] = p.Began
}
}
}
st = &frame.State
}
if st == nil {
return nil
}
st.Calls, st.Msgs, st.Began = calls, msgs, began
return st
}
func writeCalls(st *callFile, start int, appendOnly bool) {
frame := callFrame{State: *st, Strings: []string{""}}
frame.State.Calls = nil
frame.State.Msgs = nil
frame.State.Began = nil
frame.State.Strs = nil
dict := map[string]uint32{"": 0}
add := func(i int) {
p := callPatch{Index: i, Call: st.Calls[i], Began: st.Began[st.Calls[i].Msg]}
p.Call.File = ""
for j, field := range callText(&p.Call) {
id, ok := dict[*field]
if !ok {
id = uint32(len(frame.Strings))
dict[*field] = id
frame.Strings = append(frame.Strings, *field)
}
p.Text[j] = id
*field = ""
}
frame.Patches = append(frame.Patches, p)
}
for i := 0; i < start; i++ {
if st.dirty[i] {
add(i)
}
}
for i := start; i < len(st.Calls); i++ {
add(i)
}
var data bytes.Buffer
if gob.NewEncoder(&data).Encode(frame) != nil {
return
}
var header [8]byte
binary.LittleEndian.PutUint32(header[:4], uint32(data.Len()))
binary.LittleEndian.PutUint32(header[4:], crc32.ChecksumIEEE(data.Bytes()))
dir := callCacheDir()
if os.MkdirAll(dir, 0700) != nil {
return
}
var out *os.File
var err error
if appendOnly {
out, err = os.OpenFile(callCachePath(st.Path), os.O_WRONLY|os.O_APPEND, 0600)
} else {
out, err = os.CreateTemp(dir, "calls-*.tmp")
}
if err != nil {
return
}
if !appendOnly {
defer os.Remove(out.Name())
}
_, err = out.Write(header[:])
if err == nil {
_, err = out.Write(data.Bytes())
}
if closeErr := out.Close(); err == nil {
err = closeErr
}
if err == nil && !appendOnly {
_ = os.Rename(out.Name(), callCachePath(st.Path))
}
}