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:
Sakurasan
2026-08-15 21:05:02 +08:00
co-authored by Claude Sonnet 5
parent b0c7439c01
commit d0e31b198f
45 changed files with 6222 additions and 0 deletions
+193
View File
@@ -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})
}
+91
View File
@@ -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
}