Files
Duc Nguyen 747b58d24c fix(usage): repair cost analytics and display precision (#1330)
fix(usage): repair cost analytics and display precision

- Automatic OpenRouter pricing sync with cost backfill for traces/snapshots/events
- Live usage data merging for current-hour dashboard accuracy
- 2-decimal API cost formatting across usage/overview pages
- Comprehensive test coverage (PG, SQLite, HTTP, UI)

Merged by github-maintain automation.
2026-07-03 06:24:21 +07:00

206 lines
6.2 KiB
Go

package http
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/google/uuid"
_ "modernc.org/sqlite"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
type emptyUsageSnapshotStore struct{}
func (emptyUsageSnapshotStore) UpsertSnapshots(context.Context, []store.UsageSnapshot) error {
return nil
}
func (emptyUsageSnapshotStore) GetTimeSeries(context.Context, store.SnapshotQuery) ([]store.SnapshotTimeSeries, error) {
return nil, nil
}
func (emptyUsageSnapshotStore) GetBreakdown(context.Context, store.SnapshotQuery) ([]store.SnapshotBreakdown, error) {
return nil, nil
}
func (emptyUsageSnapshotStore) GetLatestBucket(context.Context) (*time.Time, error) {
return nil, nil
}
func TestUsageSummaryIncludesLiveCurrentHourCost(t *testing.T) {
db, tenantID := usageLiveTestDB(t)
insertUsageLiveTrace(t, db, tenantID, time.Now().UTC())
h := NewUsageHandler(emptyUsageSnapshotStore{}, nil, db)
req := httptest.NewRequest(http.MethodGet, "/v1/usage/summary?period=24h", nil)
req = req.WithContext(store.WithTenantID(req.Context(), tenantID))
rec := httptest.NewRecorder()
h.handleSummary(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
}
var body struct {
Current usageSummary `json:"current"`
}
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
t.Fatalf("decode response: %v", err)
}
if body.Current.Cost != 0.25 {
t.Fatalf("current cost = %v, want 0.25", body.Current.Cost)
}
if body.Current.InputTokens != 1100 || body.Current.OutputTokens != 250 {
t.Fatalf("tokens = %d/%d, want 1100/250", body.Current.InputTokens, body.Current.OutputTokens)
}
if body.Current.Requests != 1 || body.Current.LLMCalls != 1 || body.Current.ToolCalls != 2 {
t.Fatalf("counts = requests %d llm %d tools %d, want 1/1/2", body.Current.Requests, body.Current.LLMCalls, body.Current.ToolCalls)
}
}
func TestUsageSummaryHandlesEmptyLiveCurrentHour(t *testing.T) {
db, tenantID := usageLiveTestDB(t)
h := NewUsageHandler(emptyUsageSnapshotStore{}, nil, db)
req := httptest.NewRequest(http.MethodGet, "/v1/usage/summary?period=24h", nil)
req = req.WithContext(store.WithTenantID(req.Context(), tenantID))
rec := httptest.NewRecorder()
h.handleSummary(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
}
var body struct {
Current usageSummary `json:"current"`
}
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
t.Fatalf("decode response: %v", err)
}
if body.Current.Cost != 0 || body.Current.Requests != 0 || body.Current.LLMCalls != 0 {
t.Fatalf("current = %+v, want empty live usage", body.Current)
}
}
func TestUsageBreakdownIncludesLiveCurrentHourProviderModelCost(t *testing.T) {
db, tenantID := usageLiveTestDB(t)
now := time.Now().UTC()
insertUsageLiveTrace(t, db, tenantID, now)
h := NewUsageHandler(emptyUsageSnapshotStore{}, nil, db)
target := fmt.Sprintf(
"/v1/usage/breakdown?from=%s&to=%s&group_by=provider_model",
now.Add(-time.Hour).Format(time.RFC3339),
now.Add(time.Hour).Format(time.RFC3339),
)
req := httptest.NewRequest(http.MethodGet, target, nil)
req = req.WithContext(store.WithTenantID(req.Context(), tenantID))
rec := httptest.NewRecorder()
h.handleBreakdown(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
}
var body struct {
Rows []store.SnapshotBreakdown `json:"rows"`
}
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
t.Fatalf("decode response: %v", err)
}
if len(body.Rows) != 1 {
t.Fatalf("rows = %+v, want one provider/model row", body.Rows)
}
row := body.Rows[0]
if row.Key != "bailian/qwen3.7-plus" {
t.Fatalf("key = %q, want bailian/qwen3.7-plus", row.Key)
}
if row.TotalCost != 0.25 || row.InputTokens != 1100 || row.OutputTokens != 250 || row.LLMCallCount != 1 {
t.Fatalf("row = %+v, want live cost/tokens/llm count", row)
}
}
func usageLiveTestDB(t *testing.T) (*sql.DB, uuid.UUID) {
t.Helper()
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { db.Close() })
schema := []string{
`CREATE TABLE traces (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
agent_id TEXT,
user_id TEXT,
start_time TEXT NOT NULL,
duration_ms INTEGER,
status TEXT,
channel TEXT,
parent_trace_id TEXT,
tool_call_count INTEGER
)`,
`CREATE TABLE spans (
id TEXT PRIMARY KEY,
trace_id TEXT NOT NULL,
tenant_id TEXT NOT NULL,
span_type TEXT NOT NULL,
provider TEXT,
model TEXT,
input_tokens INTEGER,
output_tokens INTEGER,
total_cost REAL,
metadata TEXT,
start_time TEXT
)`,
}
for _, stmt := range schema {
if _, err := db.Exec(stmt); err != nil {
t.Fatalf("create schema: %v", err)
}
}
return db, uuid.Must(uuid.NewV7())
}
func insertUsageLiveTrace(t *testing.T, db *sql.DB, tenantID uuid.UUID, start time.Time) {
t.Helper()
traceID := uuid.Must(uuid.NewV7())
spanID := uuid.Must(uuid.NewV7())
if _, err := db.Exec(
`INSERT INTO traces (
id, tenant_id, agent_id, user_id, start_time, duration_ms, status, channel, tool_call_count
) VALUES (?, ?, ?, 'user-a', ?, 1200, 'completed', 'web', 2)`,
traceID, tenantID, uuid.Must(uuid.NewV7()), start,
); err != nil {
t.Fatalf("insert trace: %v", err)
}
if _, err := db.Exec(
`INSERT INTO spans (
id, trace_id, tenant_id, span_type, provider, model, input_tokens, output_tokens, total_cost, metadata, start_time
) VALUES (?, ?, ?, 'llm_call', 'bailian', 'qwen3.7-plus', 1000, 200, 0.25, ?, ?)`,
spanID, traceID, tenantID,
`{"cache_read_tokens":7,"cache_creation_tokens":11,"thinking_tokens":13}`,
start,
); err != nil {
t.Fatalf("insert span: %v", err)
}
if _, err := db.Exec(
`INSERT INTO spans (
id, trace_id, tenant_id, span_type, provider, model, input_tokens, output_tokens, total_cost, metadata, start_time
) VALUES (?, ?, ?, 'tool_call', 'bailian', 'qwen3.7-plus', 100, 50, 0, ?, ?)`,
uuid.Must(uuid.NewV7()), traceID, tenantID,
`{"cache_read_tokens":0,"cache_creation_tokens":0,"thinking_tokens":0}`,
start,
); err != nil {
t.Fatalf("insert tool span: %v", err)
}
}