Skip to content
163 changes: 163 additions & 0 deletions internal/metrics/savings.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
package metrics

import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"log/slog"
"math"
"os"
"path/filepath"
"sync"
"time"
)

// TokenMetadata holds per-call token savings estimation.
// Attached as _meta in tool responses when metrics are enabled.
type TokenMetadata struct {
TokensSaved int `json:"tokens_saved"`
BaselineTokens int `json:"baseline_tokens"`
ResponseTokens int `json:"response_tokens"`
CostAvoided float64 `json:"cost_avoided"`
CompressionRatio float64 `json:"compression_ratio"`
}

// EstimateTokens approximates token count from byte length using
// the standard English heuristic of 1 token ≈ 4 characters.
func EstimateTokens(s string) int {
return len(s) / 4
}

// CalculateSavings computes token savings for a single tool call.
// baselineBytes: byte count of all files the user would have read manually.
// responseBytes: byte count of the actual tool response.
// pricePerToken: USD cost per output token (e.g. 0.000015 for Claude Sonnet).
func CalculateSavings(baselineBytes, responseBytes int, pricePerToken float64) TokenMetadata {
baseline := baselineBytes / 4
response := responseBytes / 4
saved := baseline - response
if saved < 0 {
saved = 0
}
ratio := 0.0
if baseline > 0 {
ratio = math.Round(float64(response)/float64(baseline)*1000) / 1000
}
return TokenMetadata{
TokensSaved: saved,
BaselineTokens: baseline,
ResponseTokens: response,
CostAvoided: math.Round(float64(saved)*pricePerToken*1e6) / 1e6,
CompressionRatio: ratio,
}
}

// savingsRecord is the on-disk format for cumulative savings.
type savingsRecord struct {
InstallID string `json:"install_id"`
TotalTokensSaved int64 `json:"total_tokens_saved"`
TotalCostAvoided float64 `json:"total_cost_avoided"`
LastUpdated string `json:"last_updated"`
}

// Tracker accumulates token savings across calls and persists totals.
type Tracker struct {
mu sync.Mutex
path string
InstallID string
TotalTokensSaved int64
TotalCostAvoided float64
}

// NewTracker loads or creates savings.json at path. Generates a random
// InstallID on first run. Never returns an error (fail-open).
func NewTracker(path string) *Tracker {
t := &Tracker{path: path}

data, err := os.ReadFile(path)
if err != nil {
if !os.IsNotExist(err) {
slog.Warn("metrics: failed to read savings file", "path", path, "err", err)
}
t.InstallID = newInstallID()
return t
}

var rec savingsRecord
if err := json.Unmarshal(data, &rec); err != nil {
slog.Warn("metrics: malformed savings file, starting fresh", "path", path, "err", err)
t.InstallID = newInstallID()
return t
}

t.InstallID = rec.InstallID
t.TotalTokensSaved = rec.TotalTokensSaved
t.TotalCostAvoided = rec.TotalCostAvoided
return t
}

// Record atomically increments TotalTokensSaved and TotalCostAvoided
// by the values in meta, then persists to file.
func (t *Tracker) Record(meta TokenMetadata) {
t.mu.Lock()
defer t.mu.Unlock()

t.TotalTokensSaved += int64(meta.TokensSaved)
t.TotalCostAvoided += meta.CostAvoided

rec := savingsRecord{
InstallID: t.InstallID,
TotalTokensSaved: t.TotalTokensSaved,
TotalCostAvoided: t.TotalCostAvoided,
LastUpdated: time.Now().UTC().Format(time.RFC3339),
}

data, err := json.MarshalIndent(rec, "", " ")
if err != nil {
slog.Warn("metrics: failed to marshal savings", "err", err)
return
}

dir := filepath.Dir(t.path)
tmp, err := os.CreateTemp(dir, "savings-*.json.tmp")
if err != nil {
slog.Warn("metrics: failed to create temp file", "dir", dir, "err", err)
return
}
tmpName := tmp.Name()

if _, err := tmp.Write(data); err != nil {
tmp.Close()
os.Remove(tmpName)
slog.Warn("metrics: failed to write temp file", "err", err)
return
}
if err := tmp.Close(); err != nil {
os.Remove(tmpName)
slog.Warn("metrics: failed to close temp file", "err", err)
return
}

if err := os.Rename(tmpName, t.path); err != nil {
os.Remove(tmpName)
slog.Warn("metrics: failed to rename temp file", "src", tmpName, "dst", t.path, "err", err)
return
}

slog.Debug("metrics: savings persisted", "path", t.path, "total_tokens_saved", t.TotalTokensSaved)
}

// Snapshot returns current cumulative totals under lock.
func (t *Tracker) Snapshot() (totalTokensSaved int64, totalCostAvoided float64) {
t.mu.Lock()
defer t.mu.Unlock()
return t.TotalTokensSaved, t.TotalCostAvoided
}

func newInstallID() string {
b := make([]byte, 8)
if _, err := rand.Read(b); err != nil {
return "unknown"
}
return hex.EncodeToString(b)
}
166 changes: 166 additions & 0 deletions internal/metrics/savings_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
package metrics

import (
"encoding/json"
"os"
"path/filepath"
"testing"
)

func TestEstimateTokens(t *testing.T) {
if got := EstimateTokens("hello"); got != 1 {
t.Fatalf("EstimateTokens(\"hello\") = %d, want 1", got)
}
if got := EstimateTokens(""); got != 0 {
t.Fatalf("EstimateTokens(\"\") = %d, want 0", got)
}
}

func TestCalculateSavings(t *testing.T) {
tests := []struct {
name string
baselineBytes int
responseBytes int
pricePerToken float64
wantTokensSaved int
wantCompressionRatio float64
}{
{
name: "NormalCase",
baselineBytes: 4000,
responseBytes: 400,
pricePerToken: 0.000015,
wantTokensSaved: 900,
wantCompressionRatio: 0.1,
},
{
name: "NoSavings",
baselineBytes: 100,
responseBytes: 200,
pricePerToken: 0.000015,
wantTokensSaved: 0,
wantCompressionRatio: 2.0,
},
{
name: "ZeroBaseline",
baselineBytes: 0,
responseBytes: 400,
pricePerToken: 0.000015,
wantTokensSaved: 0,
wantCompressionRatio: 0.0,
},
{
name: "ZeroBoth",
baselineBytes: 0,
responseBytes: 0,
pricePerToken: 0.000015,
wantTokensSaved: 0,
wantCompressionRatio: 0.0,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := CalculateSavings(tt.baselineBytes, tt.responseBytes, tt.pricePerToken)
if got.TokensSaved != tt.wantTokensSaved {
t.Errorf("TokensSaved = %d, want %d", got.TokensSaved, tt.wantTokensSaved)
}
if got.CompressionRatio != tt.wantCompressionRatio {
t.Errorf("CompressionRatio = %v, want %v", got.CompressionRatio, tt.wantCompressionRatio)
}
})
}
}

func TestCalculateSavings_NormalCaseFields(t *testing.T) {
got := CalculateSavings(4000, 400, 0.000015)
if got.BaselineTokens != 1000 {
t.Errorf("BaselineTokens = %d, want 1000", got.BaselineTokens)
}
if got.ResponseTokens != 100 {
t.Errorf("ResponseTokens = %d, want 100", got.ResponseTokens)
}
if got.CostAvoided != 0.0135 {
t.Errorf("CostAvoided = %v, want 0.0135", got.CostAvoided)
}
}

func TestTracker_RecordAndPersist(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "savings.json")

tr := NewTracker(path)
meta := TokenMetadata{TokensSaved: 500, CostAvoided: 0.0075}
tr.Record(meta)
tr.Record(meta)

totalTokens, totalCost := tr.Snapshot()
if totalTokens != 1000 {
t.Errorf("TotalTokensSaved = %d, want 1000", totalTokens)
}
if totalCost != 0.015 {
t.Errorf("TotalCostAvoided = %v, want 0.015", totalCost)
}

data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("failed to read savings file: %v", err)
}
var rec savingsRecord
if err := json.Unmarshal(data, &rec); err != nil {
t.Fatalf("savings file is not valid JSON: %v", err)
}
if rec.TotalTokensSaved != 1000 {
t.Errorf("file TotalTokensSaved = %d, want 1000", rec.TotalTokensSaved)
}
if rec.InstallID == "" {
t.Error("file InstallID should not be empty")
}
if rec.LastUpdated == "" {
t.Error("file LastUpdated should not be empty")
}
}

func TestTracker_LoadExisting(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "savings.json")

existing := savingsRecord{
InstallID: "abc123",
TotalTokensSaved: 5000,
TotalCostAvoided: 0.075,
LastUpdated: "2026-01-01T00:00:00Z",
}
data, _ := json.Marshal(existing)
if err := os.WriteFile(path, data, 0o600); err != nil {
t.Fatalf("failed to write test file: %v", err)
}

tr := NewTracker(path)
if tr.InstallID != "abc123" {
t.Errorf("InstallID = %q, want %q", tr.InstallID, "abc123")
}

tr.Record(TokenMetadata{TokensSaved: 200})
totalTokens, _ := tr.Snapshot()
if totalTokens != 5200 {
t.Errorf("TotalTokensSaved = %d, want 5200", totalTokens)
}
}

func TestTracker_MissingDir(t *testing.T) {
path := filepath.Join(t.TempDir(), "nonexistent-subdir", "savings.json")

tr := NewTracker(path)
totalTokens, totalCost := tr.Snapshot()
if totalTokens != 0 || totalCost != 0 {
t.Errorf("expected zero snapshot for missing dir tracker, got (%d, %v)", totalTokens, totalCost)
}

// Record should not panic even if dir doesn't exist
tr.Record(TokenMetadata{TokensSaved: 100})
totalTokens, _ = tr.Snapshot()
if totalTokens != 100 {
t.Errorf("TotalTokensSaved after Record = %d, want 100", totalTokens)
}
}
27 changes: 27 additions & 0 deletions internal/store/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,19 @@ func (c *ConfigStore) GetInt(key string, defaultVal int) int {
return n
}

// GetFloat64 returns a float64 config value.
func (c *ConfigStore) GetFloat64(key string, defaultVal float64) float64 {
raw := c.Get(key, "")
if raw == "" {
return defaultVal
}
f, err := strconv.ParseFloat(raw, 64)
if err != nil {
return defaultVal
}
return f
}

// Set stores a key-value pair (upsert).
func (c *ConfigStore) Set(key, value string) error {
_, err := c.db.ExecContext(context.Background(),
Expand Down Expand Up @@ -143,4 +156,18 @@ const (
// Accepts human-readable sizes: "2G", "512M", "4096M".
// Default: empty (no limit). Applied on server startup.
ConfigMemLimit = "mem_limit"

// ConfigMetricsEnabled controls whether token savings estimation is computed
// and included in tool responses. Default: true.
// Set to false with: codebase-memory-mcp config set metrics_enabled false
ConfigMetricsEnabled = "metrics_enabled"

// ConfigPricingModel selects the token pricing model for cost estimation.
// Supported values: "claude-sonnet" (default), "claude-opus", "gpt-4o", "custom".
// When "custom", ConfigCustomPricePerToken is used.
ConfigPricingModel = "pricing_model"

// ConfigCustomPricePerToken is the USD cost per output token used when
// ConfigPricingModel is "custom". Example: 0.000015 for $15/M tokens.
ConfigCustomPricePerToken = "custom_price_per_token"
)
Loading