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>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
b0c7439c01
commit
d0e31b198f
@@ -0,0 +1,193 @@
|
||||
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})
|
||||
}
|
||||
Reference in New Issue
Block a user