Files
DeepSeek-Reasonix/internal/frontend/serve/session_usage.go
T
yhh 07d2d4bdd0 Show the cache hit rate after the cold start, and the output ratio
The session's first executor request pays for the prefix every later one
reuses, so the session-wide hit rate mixes "did the cache work once warm"
with "how much a cold start could reuse". usagereport already records the
cold start, but nothing a live session reads projected it.

The live figures come from the serve ledger, which restarts on /new,
/resume and /fork; usageTally does not, so its ColdStart can belong to an
earlier conversation in the same process. The broadcaster therefore takes
the cold start from the ledger's own population: the first request the
ledger files under the executor (pricing.LedgerSource, the rule Add
already used), split by the same CacheSplit the ledger applies, and reset
with it. SessionUsage carries it as coldStart.

- /status (TUI): "warm cache" row, ledger hit rate with the cold start
  taken out of hit, miss and unknown; "out/in" row, output ÷ input.
- Desktop session overview behind the cache meter: the same two figures,
  computed in state/overview beside cacheRate.
- wire-parity: ColdStartUsage mirrored in port/session.ts.
2026-11-07 09:06:37 +00:00

102 lines
3.9 KiB
Go

package serve
import (
"sort"
"reasonix/internal/contract/pricing"
)
// SourceUsage is what one usage source (the main agent, a sub-agent, compaction)
// has spent since this session was opened in this run. Requests counts provider
// responses that reported usage with a cost quote; a request that reported none
// is in no figure here. CacheUnknown is prompt the endpoint did not split into
// cached and uncached, which is billed as uncached and left out of a hit rate.
type SourceUsage struct {
Source string `json:"source"`
Requests int `json:"requests"`
PromptTokens int `json:"promptTokens"`
CompletionTokens int `json:"completionTokens"`
CacheHit int `json:"cacheHit"`
CacheMiss int `json:"cacheMiss"`
CacheUnknown int `json:"cacheUnknown,omitempty"`
Cost pricing.CostQuote `json:"cost"`
}
// SessionUsage is the session's usage grouped by source, read from the same
// ledger the cost total is. It begins when the ledger does: a new, resumed or
// forked session starts it again, so these are totals since this session was
// opened in this run, not over the conversation's whole life.
type SessionUsage struct {
Requests int `json:"requests"`
PromptTokens int `json:"promptTokens"`
CompletionTokens int `json:"completionTokens"`
CacheHit int `json:"cacheHit"`
CacheMiss int `json:"cacheMiss"`
CacheUnknown int `json:"cacheUnknown,omitempty"`
WorkMs int64 `json:"workMs"`
Sources []SourceUsage `json:"sources"`
// ColdStart is the first executor request in these totals, which it is also
// in; subtracting it gives the rate once the prefix it paid for was warm.
ColdStart *ColdStartUsage `json:"coldStart,omitempty"`
}
// ColdStartUsage is the session's opening executor request, split the way the
// ledger split it. It pays for the prefix every later request reuses, so it is
// the one request a warm hit rate leaves out.
type ColdStartUsage struct {
PromptTokens int `json:"promptTokens"`
CacheHit int `json:"cacheHit"`
CacheMiss int `json:"cacheMiss"`
CacheUnknown int `json:"cacheUnknown,omitempty"`
}
func coldStartOf(tokens pricing.UsageTokens) *ColdStartUsage {
hit, miss, unknown := tokens.CacheSplit()
return &ColdStartUsage{PromptTokens: tokens.PromptTokens, CacheHit: hit, CacheMiss: miss, CacheUnknown: unknown}
}
// SessionUsage totals the ledger by source. WorkMs is the answered work time of
// the turns that gave a final answer; failed and cancelled turns add nothing.
func (b *Broadcaster) SessionUsage() SessionUsage {
out := SessionUsage{Sources: []SourceUsage{}}
if b == nil {
return out
}
b.mu.Lock()
defer b.mu.Unlock()
out.WorkMs = b.workMs
if b.coldStart != nil {
cold := *b.coldStart
out.ColdStart = &cold
}
grouped := b.ledger.EntriesBySource()
names := make([]string, 0, len(grouped))
for name := range grouped {
names = append(names, name)
}
sort.Strings(names)
for _, name := range names {
src := SourceUsage{Source: name}
quotes := make([]pricing.CostQuote, 0, len(grouped[name]))
for _, ent := range grouped[name] {
src.Requests += ent.RequestCount
src.PromptTokens += ent.PromptTokens
src.CompletionTokens += ent.CompletionTokens
src.CacheHit += ent.CacheHitTokens
src.CacheMiss += ent.CacheMissTokens
src.CacheUnknown += ent.CacheUnknownTokens
quotes = append(quotes, ent.Quote)
}
sort.Slice(quotes, func(i, j int) bool { return quotes[i].ModelRef < quotes[j].ModelRef })
src.Cost = pricing.AggregateQuotes(quotes, b.displayCurrency)
out.Requests += src.Requests
out.PromptTokens += src.PromptTokens
out.CompletionTokens += src.CompletionTokens
out.CacheHit += src.CacheHit
out.CacheMiss += src.CacheMiss
out.CacheUnknown += src.CacheUnknown
out.Sources = append(out.Sources, src)
}
return out
}