Files
SakurasanandClaude Sonnet 5 d0e31b198f feat(server): API relay gateway backend M0-M4
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>
2026-08-15 21:05:02 +08:00

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})
}