mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-04 04:13:03 +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.
107 lines
3.0 KiB
Go
107 lines
3.0 KiB
Go
package methods
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"reflect"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/gateway"
|
|
"github.com/nextlevelbuilder/goclaw/internal/permissions"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
|
|
)
|
|
|
|
type usageSessionStore struct {
|
|
store.SessionStore
|
|
sessions []store.SessionInfoRich
|
|
opts []store.SessionListOpts
|
|
}
|
|
|
|
func (s *usageSessionStore) ListPagedRich(_ context.Context, opts store.SessionListOpts) store.SessionListRichResult {
|
|
s.opts = append(s.opts, opts)
|
|
return store.SessionListRichResult{Sessions: s.sessions, Total: len(s.sessions)}
|
|
}
|
|
|
|
type usageTracingStore struct {
|
|
store.TracingStore
|
|
costs map[string]float64
|
|
keys []string
|
|
}
|
|
|
|
func (s *usageTracingStore) GetSessionCosts(_ context.Context, sessionKeys []string) (map[string]float64, error) {
|
|
s.keys = append(s.keys[:0], sessionKeys...)
|
|
return s.costs, nil
|
|
}
|
|
|
|
func TestUsageGetIncludesTraceCost(t *testing.T) {
|
|
now := time.Date(2026, 7, 2, 12, 0, 0, 0, time.UTC)
|
|
sessions := &usageSessionStore{
|
|
sessions: []store.SessionInfoRich{
|
|
{
|
|
SessionInfo: store.SessionInfo{Key: "agent:cppai-pm:direct:user-a", Updated: now},
|
|
Model: "qwen3.7-plus",
|
|
Provider: "bailian",
|
|
InputTokens: 1_200,
|
|
OutputTokens: 300,
|
|
},
|
|
{
|
|
SessionInfo: store.SessionInfo{Key: "agent:itsddvn:direct:user-b", Updated: now.Add(-time.Minute)},
|
|
Model: "gpt-5.5",
|
|
Provider: "openai",
|
|
InputTokens: 500,
|
|
OutputTokens: 100,
|
|
},
|
|
},
|
|
}
|
|
tracing := &usageTracingStore{costs: map[string]float64{
|
|
"agent:cppai-pm:direct:user-a": 0.0123,
|
|
"agent:itsddvn:direct:user-b": 0.0045,
|
|
}}
|
|
methods := NewUsageMethods(sessions, tracing)
|
|
tenantID := uuid.Must(uuid.NewV7())
|
|
client, responses := gateway.NewCapturingTestClient(permissions.RoleViewer, tenantID, "caller", 1)
|
|
ctx := store.WithTenantID(context.Background(), tenantID)
|
|
|
|
methods.handleGet(ctx, client, sessionReqFrame(t, protocol.MethodUsageGet, map[string]any{"limit": 1}))
|
|
|
|
resp := readUsageResponse(t, responses)
|
|
if resp.Error != nil {
|
|
t.Fatalf("unexpected error: %+v", resp.Error)
|
|
}
|
|
payload, ok := resp.Payload.(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("payload type = %T", resp.Payload)
|
|
}
|
|
records, ok := payload["records"].([]any)
|
|
if !ok {
|
|
t.Fatalf("records type = %T", payload["records"])
|
|
}
|
|
if len(records) != 1 {
|
|
t.Fatalf("len(records) = %d, want 1", len(records))
|
|
}
|
|
record, ok := records[0].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("record type = %T", records[0])
|
|
}
|
|
if got, want := record["cost"], 0.0123; got != want {
|
|
t.Fatalf("cost = %#v, want %#v", got, want)
|
|
}
|
|
if want := []string{"agent:cppai-pm:direct:user-a"}; !reflect.DeepEqual(tracing.keys, want) {
|
|
t.Fatalf("cost keys = %#v, want %#v", tracing.keys, want)
|
|
}
|
|
}
|
|
|
|
func readUsageResponse(t *testing.T, ch <-chan []byte) protocol.ResponseFrame {
|
|
t.Helper()
|
|
raw := <-ch
|
|
var resp protocol.ResponseFrame
|
|
if err := json.Unmarshal(raw, &resp); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
return resp
|
|
}
|