Gin + GORM + pure-Go SQLite. Users/auth (JWT), API key management with quotas, proxy gateway with weighted channel failover and health checks, usage/billing ledger, cross-protocol conversion (Anthropic Messages / OpenAI Chat Completions / OpenAI Responses), and channel/model admin API. Channels declare native API formats and auto-convert the rest. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
194 lines
5.1 KiB
Go
194 lines
5.1 KiB
Go
package usage
|
|
|
|
import (
|
|
"net/http"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/shopspring/decimal"
|
|
"go.uber.org/zap"
|
|
"gorm.io/gorm"
|
|
|
|
"openteam/server/internal/pkg/httpx"
|
|
"openteam/server/internal/store"
|
|
"openteam/server/internal/user"
|
|
)
|
|
|
|
type Handler struct {
|
|
db *gorm.DB
|
|
log *zap.Logger
|
|
}
|
|
|
|
func NewHandler(db *gorm.DB, log *zap.Logger) *Handler {
|
|
return &Handler{db: db, log: log}
|
|
}
|
|
|
|
// Summary handles GET /api/v1/usage/summary.
|
|
func (h *Handler) Summary(c *gin.Context) {
|
|
u := user.Current(c)
|
|
now := time.Now()
|
|
today := now.Format("2006-01-02")
|
|
month := now.Format("2006-01")
|
|
|
|
type agg struct {
|
|
Requests int
|
|
InputTokens int64
|
|
OutputTokens int64
|
|
Cost decimal.Decimal
|
|
}
|
|
|
|
todayAgg := h.aggregate(u.ID, today, today)
|
|
monthAgg := h.aggregate(u.ID, month+"-01", now.Format("2006-01-02"))
|
|
total := h.aggregate(u.ID, "", "")
|
|
|
|
httpx.OK(c, gin.H{
|
|
"today": gin.H{
|
|
"requests": todayAgg.Requests,
|
|
"inputTokens": todayAgg.InputTokens,
|
|
"outputTokens": todayAgg.OutputTokens,
|
|
"cost": todayAgg.Cost.String(),
|
|
},
|
|
"month": gin.H{
|
|
"requests": monthAgg.Requests,
|
|
"inputTokens": monthAgg.InputTokens,
|
|
"outputTokens": monthAgg.OutputTokens,
|
|
"cost": monthAgg.Cost.String(),
|
|
},
|
|
"total": gin.H{
|
|
"requests": total.Requests,
|
|
"inputTokens": total.InputTokens,
|
|
"outputTokens": total.OutputTokens,
|
|
"cost": total.Cost.String(),
|
|
},
|
|
})
|
|
}
|
|
|
|
func (h *Handler) aggregate(userID int64, from, to string) struct {
|
|
Requests int
|
|
InputTokens int64
|
|
OutputTokens int64
|
|
Cost decimal.Decimal
|
|
} {
|
|
var out struct {
|
|
Requests int
|
|
InputTokens int64
|
|
OutputTokens int64
|
|
Cost decimal.Decimal
|
|
}
|
|
q := h.db.Model(&store.UsageDaily{}).Where("user_id = ?", userID)
|
|
if from != "" {
|
|
q = q.Where("date >= ?", from)
|
|
}
|
|
if to != "" {
|
|
q = q.Where("date <= ?", to)
|
|
}
|
|
q.Select("COALESCE(SUM(requests),0) as requests, COALESCE(SUM(input_tokens),0) as input_tokens, COALESCE(SUM(output_tokens),0) as output_tokens, COALESCE(SUM(cost),0) as cost").
|
|
Scan(&out)
|
|
return out
|
|
}
|
|
|
|
// Stats handles GET /api/v1/usage/stats?from&to&group=day|model.
|
|
func (h *Handler) Stats(c *gin.Context) {
|
|
u := user.Current(c)
|
|
from := c.Query("from")
|
|
to := c.Query("to")
|
|
group := c.DefaultQuery("group", "day")
|
|
|
|
var rows []struct {
|
|
Key string `gorm:"column:g"`
|
|
Requests int
|
|
InputTokens int64
|
|
OutputTokens int64
|
|
Cost decimal.Decimal
|
|
}
|
|
|
|
q := h.db.Model(&store.UsageDaily{}).Where("user_id = ?", u.ID)
|
|
if from != "" {
|
|
q = q.Where("date >= ?", from)
|
|
}
|
|
if to != "" {
|
|
q = q.Where("date <= ?", to)
|
|
}
|
|
|
|
switch group {
|
|
case "model":
|
|
q = q.Joins("JOIN models ON models.id = usage_dailies.model_id").
|
|
Select("models.name as g, COALESCE(SUM(requests),0) as requests, COALESCE(SUM(input_tokens),0) as input_tokens, COALESCE(SUM(output_tokens),0) as output_tokens, COALESCE(SUM(cost),0) as cost").
|
|
Group("models.name")
|
|
default:
|
|
q = q.Select("date as g, COALESCE(SUM(requests),0) as requests, COALESCE(SUM(input_tokens),0) as input_tokens, COALESCE(SUM(output_tokens),0) as output_tokens, COALESCE(SUM(cost),0) as cost").
|
|
Group("date").Order("date ASC")
|
|
}
|
|
if err := q.Scan(&rows).Error; err != nil {
|
|
h.log.Warn("usage stats failed", zap.Error(err))
|
|
httpx.Fail(c, http.StatusInternalServerError, "usage stats failed")
|
|
return
|
|
}
|
|
out := make([]gin.H, 0, len(rows))
|
|
for _, r := range rows {
|
|
out = append(out, gin.H{
|
|
"key": r.Key,
|
|
"requests": r.Requests,
|
|
"inputTokens": r.InputTokens,
|
|
"outputTokens": r.OutputTokens,
|
|
"cost": r.Cost.String(),
|
|
})
|
|
}
|
|
httpx.OK(c, out)
|
|
}
|
|
|
|
// Logs handles GET /api/v1/usage/logs?from&to&page&model&keyId.
|
|
func (h *Handler) Logs(c *gin.Context) {
|
|
u := user.Current(c)
|
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
|
pageSize := 20
|
|
if page < 1 {
|
|
page = 1
|
|
}
|
|
from := c.Query("from")
|
|
to := c.Query("to")
|
|
|
|
q := h.db.Model(&store.UsageLog{}).Where("user_id = ?", u.ID)
|
|
if from != "" {
|
|
q = q.Where("created_at >= ?", from)
|
|
}
|
|
if to != "" {
|
|
q = q.Where("created_at <= ?", to+" 23:59:59")
|
|
}
|
|
if m := c.Query("model"); m != "" {
|
|
q = q.Where("model_name = ?", m)
|
|
}
|
|
if kid := c.Query("keyId"); kid != "" {
|
|
q = q.Where("key_id = ?", kid)
|
|
}
|
|
|
|
var total int64
|
|
q.Count(&total)
|
|
|
|
var logs []store.UsageLog
|
|
if err := q.Order("id DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&logs).Error; err != nil {
|
|
h.log.Warn("usage logs failed", zap.Error(err))
|
|
httpx.Fail(c, http.StatusInternalServerError, "usage logs failed")
|
|
return
|
|
}
|
|
out := make([]gin.H, 0, len(logs))
|
|
for _, l := range logs {
|
|
out = append(out, gin.H{
|
|
"id": l.ID,
|
|
"requestId": l.RequestID,
|
|
"model": l.ModelName,
|
|
"channelId": l.ChannelID,
|
|
"inputTokens": l.InputTokens,
|
|
"outputTokens": l.OutputTokens,
|
|
"cacheReadTokens": l.CacheReadTokens,
|
|
"cost": l.Cost.String(),
|
|
"latencyMs": l.LatencyMs,
|
|
"status": l.Status,
|
|
"errorCode": l.ErrorCode,
|
|
"createdAt": l.CreatedAt,
|
|
})
|
|
}
|
|
httpx.OK(c, gin.H{"total": total, "page": page, "pageSize": pageSize, "items": out})
|
|
}
|