Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
193 changes: 193 additions & 0 deletions cmd/capi/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -930,6 +930,7 @@ func (s *Server) registerRoutes(router *gin.Engine) {
account := api.Group("/account")
account.Use(s.accountMiddleware())
account.GET("/me", s.accountMe)
account.GET("/usage", s.accountUsage)
account.GET("/check-in", s.checkInStatus)
account.POST("/check-in", s.claimCheckIn)
account.POST("/api-keys", s.createOwnAPIKey)
Expand Down Expand Up @@ -2137,6 +2138,198 @@ func (s *Server) accountMe(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"user": user, "account": account, "apiKeys": keys, "session": session})
}

// usageAccumulator sums the log fields the account page reports on. Successes
// is tracked only to derive a rate and never serialised.
type usageAccumulator struct {
Requests int
Cost float64
InputTokens int
OutputTokens int
Successes int
}

func (u *usageAccumulator) add(log RequestLog) {
u.Requests++
u.Cost += log.Cost
u.InputTokens += log.InputTokens
u.OutputTokens += log.OutputTokens
if log.Status == "success" {
u.Successes++
}
}

func (u usageAccumulator) successRate() int {
if u.Requests == 0 {
return 0
}
return int(math.Round(float64(u.Successes) / float64(u.Requests) * 100))
}

type usageStats struct {
Requests int `json:"requests"`
Cost float64 `json:"cost"`
InputTokens int `json:"inputTokens"`
OutputTokens int `json:"outputTokens"`
SuccessRate int `json:"successRate"`
}

func (u usageAccumulator) stats() usageStats {
return usageStats{
Requests: u.Requests,
Cost: round4(u.Cost),
InputTokens: u.InputTokens,
OutputTokens: u.OutputTokens,
SuccessRate: u.successRate(),
}
}

// accountUsage aggregates the caller's own request logs so the account page can
// show where the balance went. It is scoped to the session user: an admin sees
// their own usage here, not the whole installation's (that is /api/overview).
func (s *Server) accountUsage(c *gin.Context) {
session, ok := s.sessionFromRequest(c)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"error": gin.H{"message": "Login required"}})
return
}
// Mirrors /api/overview: the client sends its JS getTimezoneOffset so daily
// buckets follow the reader's calendar rather than UTC.
timezoneOffset, err := strconv.Atoi(c.DefaultQuery("timezoneOffset", "0"))
if err != nil || timezoneOffset < -840 || timezoneOffset > 840 {
timezoneOffset = 0
}
location := time.FixedZone("account", -timezoneOffset*60)
rangeDays := queryInt(c, "days", 14, 1, 90)

now := time.Now().In(location)
dayKeys := make([]string, 0, rangeDays)
daily := map[string]*usageAccumulator{}
for offset := rangeDays - 1; offset >= 0; offset-- {
key := now.AddDate(0, 0, -offset).Format("2006-01-02")
dayKeys = append(dayKeys, key)
// Pre-seed so a day with no traffic renders as zero instead of
// vanishing from the series.
daily[key] = &usageAccumulator{}
}
rangeStart := dayKeys[0]
todayKey := now.Format("2006-01-02")
yesterdayKey := now.AddDate(0, 0, -1).Format("2006-01-02")
monthPrefix := now.Format("2006-01")

s.mu.Lock()
defer s.mu.Unlock()

var today, yesterday, month, total usageAccumulator
models := map[string]*usageAccumulator{}
for _, log := range s.state.Logs {
if log.UserID == nil || *log.UserID != session.UserID {
continue
}
total.add(log)

createdAt, err := time.Parse(time.RFC3339Nano, log.CreatedAt)
if err != nil {
// Still counted in the totals above; it just cannot be placed on a day.
continue
}
day := createdAt.In(location).Format("2006-01-02")

if entry, ok := daily[day]; ok {
entry.add(log)
}
switch day {
case todayKey:
today.add(log)
case yesterdayKey:
yesterday.add(log)
}
if strings.HasPrefix(day, monthPrefix) {
month.add(log)
}
// The range filter scopes the model breakdown, matching the daily series.
if day >= rangeStart {
modelID := "未知模型"
if log.Model != nil && strings.TrimSpace(*log.Model) != "" {
modelID = strings.TrimSpace(*log.Model)
}
if models[modelID] == nil {
models[modelID] = &usageAccumulator{}
}
models[modelID].add(log)
}
}

type usagePoint struct {
Day string `json:"day"`
Requests int `json:"requests"`
Cost float64 `json:"cost"`
InputTokens int `json:"inputTokens"`
OutputTokens int `json:"outputTokens"`
}
dailyPoints := make([]usagePoint, 0, len(dayKeys))
for _, key := range dayKeys {
entry := daily[key]
dailyPoints = append(dailyPoints, usagePoint{
Day: key,
Requests: entry.Requests,
Cost: round4(entry.Cost),
InputTokens: entry.InputTokens,
OutputTokens: entry.OutputTokens,
})
}

type modelPoint struct {
Model string `json:"model"`
Requests int `json:"requests"`
Cost float64 `json:"cost"`
InputTokens int `json:"inputTokens"`
OutputTokens int `json:"outputTokens"`
}
ranked := make([]modelPoint, 0, len(models))
for modelID, entry := range models {
ranked = append(ranked, modelPoint{
Model: modelID,
Requests: entry.Requests,
Cost: round4(entry.Cost),
InputTokens: entry.InputTokens,
OutputTokens: entry.OutputTokens,
})
}
sort.Slice(ranked, func(i, j int) bool {
if ranked[i].Cost != ranked[j].Cost {
return ranked[i].Cost > ranked[j].Cost
}
if ranked[i].Requests != ranked[j].Requests {
return ranked[i].Requests > ranked[j].Requests
}
return ranked[i].Model < ranked[j].Model
})
// Keep the list short enough to read at a glance; the tail folds into one
// row rather than being dropped.
const topModelCount = 6
if len(ranked) > topModelCount+1 {
other := modelPoint{Model: "其他"}
for _, entry := range ranked[topModelCount:] {
other.Requests += entry.Requests
other.Cost += entry.Cost
other.InputTokens += entry.InputTokens
other.OutputTokens += entry.OutputTokens
}
other.Cost = round4(other.Cost)
ranked = append(ranked[:topModelCount], other)
}

c.JSON(http.StatusOK, gin.H{"usage": gin.H{
"rangeDays": rangeDays,
"today": today.stats(),
"yesterday": yesterday.stats(),
"month": month.stats(),
"total": total.stats(),
"daily": dailyPoints,
"models": ranked,
}})
}

func (s *Server) updateAccountProfile(c *gin.Context) {
session, ok := s.sessionFromRequest(c)
if !ok {
Expand Down
111 changes: 111 additions & 0 deletions cmd/capi/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5199,3 +5199,114 @@ func TestPublicCatalogFiltersBySessionGroup(t *testing.T) {
t.Fatalf("catalog dropped a reachable model: %s", signedIn.Body.String())
}
}

func TestAccountUsageAggregatesOwnLogsOnly(t *testing.T) {
withEnv(t, map[string]string{"PERSISTENCE": "memory"})
server, router := testServerRouter(t)

const userID = "usr_usage"
const otherID = "usr_other"

server.mu.Lock()
server.state.Users = []User{
{ID: userID, Name: "Usage User", Role: "user", Status: "active"},
{ID: otherID, Name: "Other User", Role: "user", Status: "active"},
}
account := Account{ID: "acct_usage", UserID: userID, Username: "usage", Role: "user", Status: "active"}
server.state.Accounts = []Account{account}

utc := time.Now().UTC()
stamp := func(offsetDays int) string {
return utc.AddDate(0, 0, offsetDays).Format(time.RFC3339Nano)
}
const modelA, modelB = "gpt-5.5", "deepseek-v4"
server.state.Logs = []RequestLog{
{ID: "log_today_1", UserID: stringPtr(userID), Model: stringPtr(modelA), Status: "success", Cost: 1.5, InputTokens: 100, OutputTokens: 50, CreatedAt: stamp(0)},
{ID: "log_today_2", UserID: stringPtr(userID), Model: stringPtr(modelA), Status: "failed", Cost: 0.5, InputTokens: 10, OutputTokens: 0, CreatedAt: stamp(0)},
{ID: "log_yesterday", UserID: stringPtr(userID), Model: stringPtr(modelB), Status: "success", Cost: 2.0, InputTokens: 200, OutputTokens: 20, CreatedAt: stamp(-1)},
{ID: "log_in_range", UserID: stringPtr(userID), Model: stringPtr(modelB), Status: "success", Cost: 4.0, InputTokens: 400, OutputTokens: 40, CreatedAt: stamp(-3)},
{ID: "log_out_of_range", UserID: stringPtr(userID), Model: stringPtr(modelB), Status: "success", Cost: 8.0, InputTokens: 800, OutputTokens: 80, CreatedAt: stamp(-20)},
{ID: "log_other_user", UserID: stringPtr(otherID), Model: stringPtr(modelA), Status: "success", Cost: 99.0, InputTokens: 1, OutputTokens: 1, CreatedAt: stamp(0)},
}
server.mu.Unlock()

// The endpoint is session-scoped.
anonymous := perform(router, http.MethodGet, "/api/account/usage", "", nil)
if anonymous.Code != http.StatusUnauthorized {
t.Fatalf("anonymous usage status = %d body = %s", anonymous.Code, anonymous.Body.String())
}

session := server.createAccountSession(account, "Usage User", "password")
headers := map[string]string{"Cookie": "capi_session=" + session.ID}
response := perform(router, http.MethodGet, "/api/account/usage?days=7&timezoneOffset=0", "", headers)
if response.Code != http.StatusOK {
t.Fatalf("usage status = %d body = %s", response.Code, response.Body.String())
}

var payload struct {
Usage struct {
RangeDays int `json:"rangeDays"`
Today usageStats `json:"today"`
Yesterday usageStats `json:"yesterday"`
Total usageStats `json:"total"`
Daily []struct {
Day string `json:"day"`
Requests int `json:"requests"`
Cost float64 `json:"cost"`
} `json:"daily"`
Models []struct {
Model string `json:"model"`
Cost float64 `json:"cost"`
} `json:"models"`
} `json:"usage"`
}
if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode usage: %v", err)
}

// Only this user's logs count: 1.5 + 0.5 + 2.0 + 4.0 + 8.0. Never the other user's 99.
if payload.Usage.Total.Requests != 5 || payload.Usage.Total.Cost != 16 {
t.Fatalf("total = %#v, want 5 requests and cost 16", payload.Usage.Total)
}
if payload.Usage.Today.Requests != 2 || payload.Usage.Today.Cost != 2 {
t.Fatalf("today = %#v, want 2 requests and cost 2", payload.Usage.Today)
}
if payload.Usage.Today.SuccessRate != 50 {
t.Fatalf("today success rate = %d, want 50", payload.Usage.Today.SuccessRate)
}
if payload.Usage.Yesterday.Cost != 2 {
t.Fatalf("yesterday = %#v, want cost 2", payload.Usage.Yesterday)
}

if payload.Usage.RangeDays != 7 || len(payload.Usage.Daily) != 7 {
t.Fatalf("daily series has %d points for %d days", len(payload.Usage.Daily), payload.Usage.RangeDays)
}
zeroDays := 0
for _, point := range payload.Usage.Daily {
if point.Day == "" {
t.Fatal("a daily point is missing its day key")
}
if point.Requests == 0 {
zeroDays++
}
}
// Empty days must render as zero rather than vanishing from the series.
if zeroDays < 4 {
t.Fatalf("expected empty days to be present as zero, got %d: %#v", zeroDays, payload.Usage.Daily)
}

// The model breakdown is scoped to the same range, so the 20-day-old log
// must not appear here even though it counts toward the totals.
var modelCost float64
seen := map[string]bool{}
for _, model := range payload.Usage.Models {
modelCost += model.Cost
seen[model.Model] = true
}
if !seen[modelA] || !seen[modelB] {
t.Fatalf("model breakdown missing a model: %#v", payload.Usage.Models)
}
if modelCost != 8 {
t.Fatalf("model breakdown cost = %v, want 8 (range-scoped)", modelCost)
}
}

Large diffs are not rendered by default.

43 changes: 0 additions & 43 deletions dist/assets/index-BW0kmUgI.js

This file was deleted.

43 changes: 43 additions & 0 deletions dist/assets/index-Cv9ZCWeR.js

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions dist/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>CAPI</title>
<script type="module" crossorigin src="/assets/index-BW0kmUgI.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-ssd4aOT1.css">
<script type="module" crossorigin src="/assets/index-Cv9ZCWeR.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-699x73P2.css">
</head>
<body>
<div id="root"></div>
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"dev": "concurrently \"npm:dev:web\" \"npm:dev:api\"",
"dev:web": "vite --host 0.0.0.0",
"dev:api": "go run ./cmd/capi",
"clean:dist": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
"clean:dist": "node -e \"const fs=require('node:fs');fs.rmSync('dist',{recursive:true,force:true,maxRetries:10,retryDelay:200});if(fs.existsSync('dist')){console.error('clean:dist failed: dist/ still exists - a running process (e.g. the API server) is holding files. Stop it and rebuild, or the old bundles stay behind and get served.');process.exit(1)}\"",
"build": "npm run clean:dist && tsc -b && vite build",
"preview": "vite preview --host 0.0.0.0",
"start": "go run ./cmd/capi"
Expand Down
Loading
Loading