mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-09 18:08:31 -10:00
* polish: cut per-turn overhead, stabilize cache, add /new Context economy - Compaction: preserveLast 8->6, triggerRatio 0.8->0.7 (both copies in sync). - System prompt: kill the "ELABORATE summary" directive; scale summary to the work. Comment discipline moved into the core prompt for all model families (was Anthropic-only); Minimax/DeepSeek/Qwen/etc. now get it. - Token-budget regression test ratchets the fixed per-turn overhead (base prompt + eager tool schemas) using the runtime's own estimator, so silent prompt/schema growth fails CI. Response quality - defaultResponseStyle balanced->concise. Reversible via /style. Tool discovery - <available_skills> block in the system prompt lists installed skills (name + one-line description) so the model loads the right one on the first call instead of guessing and reading the failure. Budget-capped at 640 bytes with an "...and N more" fallback; renders nothing when no skills are installed (byte-identical to prior prompt). Session lifecycle - /new starts a fresh session in place; previous session stays on disk and is named in the confirmation for /resume. Guards against mid-run reset. - /clear now notes that context persists and points to /new. Performance & caching - partitionTools no longer alpha-sorts the whole tools array every turn, which inserted a loaded deferred tool mid-array and shifted the cached prefix on every load. It now builds append-only regions [eager sorted] + [tool_search] + [loaded deferred sorted] so a mid-session load grows the tail instead of busting the eager prefix. Regression test asserts the eager block precedes any loaded tool. - /context now shows cache hit rate (flags low rate over >5 turns as a possible unstable prefix) and rolling average turn latency with time-to-first-token. Both reset on /new. Measured baselines (auto-mode, no workspace): base prompt 3162 tokens, eager tool schemas 2433 across 12 tools. Budget ceilings set ~10% above. Deliberately deferred (need a decision): inverted safety-policy rewrite (sandbox doesn't catch ~half the B-tier rules), core-tool/web_fetch deferral (activation-semantics blocker), aggressive prompt slim. * fix(tui): clear staged attachments + queued message on /new CodeRabbit: startNewSession reset conversation/usage/latency state but not the staged input (pendingImages, pendingImageLabels, pendingDocuments, queuedMessage), which is only consumed at prompt-submit. Without clearing it, the fresh session's first prompt silently inherited the previous session's images/documents/queued text. Test now stages all four and asserts they clear.
326 lines
8.7 KiB
Go
326 lines
8.7 KiB
Go
package usage
|
|
|
|
import (
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/Gitlawb/zero/internal/modelregistry"
|
|
"github.com/Gitlawb/zero/internal/zeroruntime"
|
|
)
|
|
|
|
type Normalized struct {
|
|
InputTokens int
|
|
CachedInputTokens int
|
|
CacheWriteTokens int
|
|
OutputTokens int
|
|
ReasoningTokens int
|
|
TotalTokens int
|
|
}
|
|
|
|
type RecordInput struct {
|
|
ModelID string
|
|
Usage zeroruntime.Usage
|
|
Source string
|
|
}
|
|
|
|
type Record struct {
|
|
ID string
|
|
Sequence int
|
|
ModelID string
|
|
Provider modelregistry.ProviderKind
|
|
Source string
|
|
CreatedAt string
|
|
Usage Normalized
|
|
Cost modelregistry.CostBreakdown
|
|
}
|
|
|
|
type ModelSummary struct {
|
|
ModelID string
|
|
Provider modelregistry.ProviderKind
|
|
RecordCount int
|
|
InputTokens int
|
|
CachedInputTokens int
|
|
CacheWriteTokens int
|
|
OutputTokens int
|
|
ReasoningTokens int
|
|
TotalTokens int
|
|
InputCost float64
|
|
CachedInputCost float64
|
|
CacheWriteCost float64
|
|
OutputCost float64
|
|
TotalCost float64
|
|
FormattedTotalCost string
|
|
}
|
|
|
|
type Summary struct {
|
|
RecordCount int
|
|
Currency string
|
|
InputTokens int
|
|
CachedInputTokens int
|
|
CacheWriteTokens int
|
|
OutputTokens int
|
|
ReasoningTokens int
|
|
TotalTokens int
|
|
InputCost float64
|
|
CachedInputCost float64
|
|
CacheWriteCost float64
|
|
OutputCost float64
|
|
TotalCost float64
|
|
FormattedTotalCost string
|
|
ByModel []ModelSummary
|
|
LastRecord *Record
|
|
}
|
|
|
|
type TrackerOptions struct {
|
|
Now func() time.Time
|
|
Registry *modelregistry.Registry
|
|
}
|
|
|
|
type Tracker struct {
|
|
now func() time.Time
|
|
registry modelregistry.Registry
|
|
records []Record
|
|
nextSeq int
|
|
}
|
|
|
|
func NewTracker(options TrackerOptions) *Tracker {
|
|
registry := options.Registry
|
|
if registry == nil {
|
|
defaultRegistry, err := modelregistry.DefaultRegistry()
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
registry = &defaultRegistry
|
|
}
|
|
now := options.Now
|
|
if now == nil {
|
|
now = time.Now
|
|
}
|
|
return &Tracker{now: now, registry: *registry, nextSeq: 1}
|
|
}
|
|
|
|
func (tracker *Tracker) Record(input RecordInput) (Record, error) {
|
|
model, err := tracker.registry.Require(input.ModelID)
|
|
if err != nil {
|
|
return Record{}, err
|
|
}
|
|
normalized, runtimeUsage, err := Normalize(input.Usage)
|
|
if err != nil {
|
|
return Record{}, err
|
|
}
|
|
cost, err := modelregistry.CalculateCost(model, runtimeUsage)
|
|
if err != nil {
|
|
return Record{}, err
|
|
}
|
|
sequence := tracker.nextSeq
|
|
tracker.nextSeq++
|
|
record := Record{
|
|
ID: fmt.Sprintf("zero_usage_%d", sequence),
|
|
Sequence: sequence,
|
|
ModelID: model.ID,
|
|
Provider: model.Provider,
|
|
Source: input.Source,
|
|
CreatedAt: tracker.now().UTC().Format(time.RFC3339),
|
|
Usage: normalized,
|
|
Cost: cost,
|
|
}
|
|
tracker.records = append(tracker.records, record)
|
|
return record, nil
|
|
}
|
|
|
|
func (tracker *Tracker) Records() []Record {
|
|
return append([]Record{}, tracker.records...)
|
|
}
|
|
|
|
func (tracker *Tracker) Summary() Summary {
|
|
summary := Summary{Currency: "USD"}
|
|
models := map[string]int{}
|
|
for _, record := range tracker.records {
|
|
summary.RecordCount++
|
|
addUsageToSummary(&summary, record.Usage)
|
|
addCostToSummary(&summary, record.Cost)
|
|
index, ok := models[record.ModelID]
|
|
if !ok {
|
|
summary.ByModel = append(summary.ByModel, ModelSummary{
|
|
ModelID: record.ModelID,
|
|
Provider: record.Provider,
|
|
})
|
|
index = len(summary.ByModel) - 1
|
|
models[record.ModelID] = index
|
|
}
|
|
addUsageToModel(&summary.ByModel[index], record.Usage)
|
|
addCostToModel(&summary.ByModel[index], record.Cost)
|
|
recordCopy := record
|
|
summary.LastRecord = &recordCopy
|
|
}
|
|
summary.FormattedTotalCost = formatCost(summary.TotalCost)
|
|
for index := range summary.ByModel {
|
|
summary.ByModel[index].FormattedTotalCost = formatCost(summary.ByModel[index].TotalCost)
|
|
}
|
|
return summary
|
|
}
|
|
|
|
func (tracker *Tracker) Reset() {
|
|
tracker.records = nil
|
|
tracker.nextSeq = 1
|
|
}
|
|
|
|
func Normalize(usage zeroruntime.Usage) (Normalized, zeroruntime.Usage, error) {
|
|
inputTokens, err := nonNegative(firstNonZero(usage.InputTokens, usage.PromptTokens), "inputTokens")
|
|
if err != nil {
|
|
return Normalized{}, zeroruntime.Usage{}, err
|
|
}
|
|
outputTokens, err := nonNegative(firstNonZero(usage.OutputTokens, usage.CompletionTokens), "outputTokens")
|
|
if err != nil {
|
|
return Normalized{}, zeroruntime.Usage{}, err
|
|
}
|
|
cachedInputTokens, err := nonNegative(usage.CachedInputTokens, "cachedInputTokens")
|
|
if err != nil {
|
|
return Normalized{}, zeroruntime.Usage{}, err
|
|
}
|
|
if cachedInputTokens > inputTokens {
|
|
cachedInputTokens = inputTokens
|
|
}
|
|
cacheWriteTokens, err := nonNegative(usage.CacheWriteTokens, "cacheWriteTokens")
|
|
if err != nil {
|
|
return Normalized{}, zeroruntime.Usage{}, err
|
|
}
|
|
if cacheWriteTokens > inputTokens-cachedInputTokens {
|
|
cacheWriteTokens = inputTokens - cachedInputTokens
|
|
}
|
|
reasoningTokens, err := nonNegative(usage.ReasoningTokens, "reasoningTokens")
|
|
if err != nil {
|
|
return Normalized{}, zeroruntime.Usage{}, err
|
|
}
|
|
normalized := Normalized{
|
|
InputTokens: inputTokens,
|
|
CachedInputTokens: cachedInputTokens,
|
|
CacheWriteTokens: cacheWriteTokens,
|
|
OutputTokens: outputTokens,
|
|
ReasoningTokens: reasoningTokens,
|
|
TotalTokens: inputTokens + outputTokens,
|
|
}
|
|
return normalized, zeroruntime.Usage{
|
|
InputTokens: inputTokens,
|
|
PromptTokens: inputTokens,
|
|
CachedInputTokens: cachedInputTokens,
|
|
CacheWriteTokens: cacheWriteTokens,
|
|
OutputTokens: outputTokens,
|
|
CompletionTokens: outputTokens,
|
|
ReasoningTokens: reasoningTokens,
|
|
}, nil
|
|
}
|
|
|
|
func FormatSummary(summary Summary) string {
|
|
requestLabel := "requests"
|
|
if summary.RecordCount == 1 {
|
|
requestLabel = "request"
|
|
}
|
|
return fmt.Sprintf("%s %s, %s tokens, %s", comma(summary.RecordCount), requestLabel, comma(summary.TotalTokens), summary.FormattedTotalCost)
|
|
}
|
|
|
|
// CacheHitRate is the fraction of input tokens served from the provider's prompt
|
|
// cache. CachedInputTokens is clamped to InputTokens in Normalize, so the result is
|
|
// always in [0,1]; it is 0 when no input has been recorded.
|
|
func (summary Summary) CacheHitRate() float64 {
|
|
if summary.InputTokens <= 0 {
|
|
return 0
|
|
}
|
|
return float64(summary.CachedInputTokens) / float64(summary.InputTokens)
|
|
}
|
|
|
|
// FormatCacheEfficiency renders the prompt-cache hit rate for display, e.g.
|
|
// "63% (8,200 cached / 13,100 input)", so a user can see whether cache reads are
|
|
// actually saving work. Returns "n/a" until some input has been recorded.
|
|
func FormatCacheEfficiency(summary Summary) string {
|
|
if summary.InputTokens <= 0 {
|
|
return "n/a"
|
|
}
|
|
return fmt.Sprintf("%.0f%% (%s cached / %s input)",
|
|
summary.CacheHitRate()*100,
|
|
comma(summary.CachedInputTokens),
|
|
comma(summary.InputTokens))
|
|
}
|
|
|
|
func addUsageToSummary(summary *Summary, usage Normalized) {
|
|
summary.InputTokens += usage.InputTokens
|
|
summary.CachedInputTokens += usage.CachedInputTokens
|
|
summary.CacheWriteTokens += usage.CacheWriteTokens
|
|
summary.OutputTokens += usage.OutputTokens
|
|
summary.ReasoningTokens += usage.ReasoningTokens
|
|
summary.TotalTokens += usage.TotalTokens
|
|
}
|
|
|
|
func addUsageToModel(summary *ModelSummary, usage Normalized) {
|
|
summary.RecordCount++
|
|
summary.InputTokens += usage.InputTokens
|
|
summary.CachedInputTokens += usage.CachedInputTokens
|
|
summary.CacheWriteTokens += usage.CacheWriteTokens
|
|
summary.OutputTokens += usage.OutputTokens
|
|
summary.ReasoningTokens += usage.ReasoningTokens
|
|
summary.TotalTokens += usage.TotalTokens
|
|
}
|
|
|
|
func addCostToSummary(summary *Summary, cost modelregistry.CostBreakdown) {
|
|
summary.InputCost += cost.InputCost
|
|
summary.CachedInputCost += cost.CachedInputCost
|
|
summary.CacheWriteCost += cost.CacheWriteCost
|
|
summary.OutputCost += cost.OutputCost
|
|
summary.TotalCost += cost.TotalCost
|
|
}
|
|
|
|
func addCostToModel(summary *ModelSummary, cost modelregistry.CostBreakdown) {
|
|
summary.InputCost += cost.InputCost
|
|
summary.CachedInputCost += cost.CachedInputCost
|
|
summary.CacheWriteCost += cost.CacheWriteCost
|
|
summary.OutputCost += cost.OutputCost
|
|
summary.TotalCost += cost.TotalCost
|
|
}
|
|
|
|
func formatCost(value float64) string {
|
|
formatted, err := modelregistry.FormatCostUSD(value)
|
|
if err != nil {
|
|
return "$0.0000"
|
|
}
|
|
return formatted
|
|
}
|
|
|
|
func nonNegative(value int, label string) (int, error) {
|
|
if value < 0 {
|
|
return 0, fmt.Errorf("expected %s to be non-negative", label)
|
|
}
|
|
return value, nil
|
|
}
|
|
|
|
func firstNonZero(values ...int) int {
|
|
for _, value := range values {
|
|
if value != 0 {
|
|
return value
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func comma(value int) string {
|
|
sign := ""
|
|
if value < 0 {
|
|
sign = "-"
|
|
value = -value
|
|
}
|
|
digits := fmt.Sprintf("%d", value)
|
|
if len(digits) <= 3 {
|
|
return sign + digits
|
|
}
|
|
var out []byte
|
|
prefix := len(digits) % 3
|
|
if prefix == 0 {
|
|
prefix = 3
|
|
}
|
|
out = append(out, digits[:prefix]...)
|
|
for index := prefix; index < len(digits); index += 3 {
|
|
out = append(out, ',')
|
|
out = append(out, digits[index:index+3]...)
|
|
}
|
|
return sign + string(out)
|
|
}
|