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})
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package usage
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/shopspring/decimal"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"openteam/server/internal/store"
|
||||
)
|
||||
|
||||
type Record struct {
|
||||
RequestID string
|
||||
UserID int64
|
||||
KeyID int64
|
||||
ChannelID int64
|
||||
ModelID int64
|
||||
ModelName string
|
||||
InputTokens int64
|
||||
OutputTokens int64
|
||||
CacheReadTokens int64
|
||||
CacheCreationTokens int64
|
||||
InputPrice decimal.Decimal
|
||||
OutputPrice decimal.Decimal
|
||||
CacheReadPrice decimal.Decimal
|
||||
Cost decimal.Decimal
|
||||
LatencyMs int
|
||||
Status string
|
||||
ErrorCode string
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
db *gorm.DB
|
||||
log *zap.Logger
|
||||
}
|
||||
|
||||
func NewService(db *gorm.DB, log *zap.Logger) *Service {
|
||||
return &Service{db: db, log: log}
|
||||
}
|
||||
|
||||
// Record inserts a request-level usage log and upserts the daily aggregate.
|
||||
func (s *Service) Record(r Record) error {
|
||||
date := time.Now().Format("2006-01-02")
|
||||
err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Create(&store.UsageLog{
|
||||
RequestID: r.RequestID,
|
||||
UserID: r.UserID,
|
||||
KeyID: r.KeyID,
|
||||
ChannelID: r.ChannelID,
|
||||
ModelID: r.ModelID,
|
||||
ModelName: r.ModelName,
|
||||
InputTokens: r.InputTokens,
|
||||
OutputTokens: r.OutputTokens,
|
||||
CacheReadTokens: r.CacheReadTokens,
|
||||
CacheCreationTokens: r.CacheCreationTokens,
|
||||
InputPrice: r.InputPrice,
|
||||
OutputPrice: r.OutputPrice,
|
||||
CacheReadPrice: r.CacheReadPrice,
|
||||
Cost: r.Cost,
|
||||
LatencyMs: r.LatencyMs,
|
||||
Status: r.Status,
|
||||
ErrorCode: r.ErrorCode,
|
||||
CreatedAt: time.Now(),
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var daily store.UsageDaily
|
||||
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("user_id = ? AND model_id = ? AND date = ?", r.UserID, r.ModelID, date).
|
||||
FirstOrCreate(&daily, store.UsageDaily{
|
||||
UserID: r.UserID, ModelID: r.ModelID, Date: date,
|
||||
}).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
daily.Requests++
|
||||
daily.InputTokens += r.InputTokens
|
||||
daily.OutputTokens += r.OutputTokens
|
||||
daily.CacheReadTokens += r.CacheReadTokens
|
||||
daily.CacheCreationTokens += r.CacheCreationTokens
|
||||
daily.Cost = daily.Cost.Add(r.Cost)
|
||||
return tx.Save(&daily).Error
|
||||
})
|
||||
if err != nil {
|
||||
s.log.Warn("record usage failed", zap.Error(err))
|
||||
}
|
||||
return err
|
||||
}
|
||||
Reference in New Issue
Block a user