mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 04:14:56 +00:00
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.
206 lines
6.2 KiB
Go
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)
|
|
}
|
|
}
|