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
+329
View File
@@ -0,0 +1,329 @@
package admin
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"gorm.io/gorm"
"openteam/server/internal/channel"
"openteam/server/internal/pkg/httpx"
"openteam/server/internal/store"
)
type channelHandler struct {
db *gorm.DB
ch *channel.Service
log *zap.Logger
}
type channelInput struct {
Name string `json:"name" binding:"required"`
Provider string `json:"provider" binding:"omitempty,oneof=openai anthropic compatible"`
BaseURL string `json:"baseUrl" binding:"required"`
APIKey string `json:"apiKey"`
Weight int `json:"weight"`
Priority int `json:"priority"`
TimeoutMs int `json:"timeoutMs"`
MaxConcurrency int `json:"maxConcurrency"`
Enabled *bool `json:"enabled"`
Formats []string `json:"formats"`
}
// ListChannels handles GET /api/v1/admin/channels.
func (h *channelHandler) List(c *gin.Context) {
var channels []store.Channel
if err := h.db.Order("id ASC").Find(&channels).Error; err != nil {
h.log.Warn("list channels failed", zap.Error(err))
httpx.Fail(c, http.StatusInternalServerError, "list channels failed")
return
}
out := make([]gin.H, 0, len(channels))
for i := range channels {
out = append(out, channelDTO(&channels[i]))
}
httpx.OK(c, out)
}
// Create handles POST /api/v1/admin/channels.
func (h *channelHandler) Create(c *gin.Context) {
var in channelInput
if !httpx.Bind(c, &in) {
return
}
provider := in.Provider
if len(in.Formats) > 0 {
if err := validateFormats(in.Formats); err != nil {
httpx.Fail(c, http.StatusBadRequest, err.Error())
return
}
provider = deriveProvider(in.Formats)
}
if provider == "" {
provider = "openai"
}
enc, err := h.ch.EncryptKey(in.APIKey)
if err != nil {
httpx.Fail(c, http.StatusInternalServerError, "encrypt key failed")
return
}
ch := &store.Channel{
Name: in.Name, Provider: provider, BaseURL: in.BaseURL, Formats: in.Formats,
APIKeyEnc: enc, Weight: defaultIf(in.Weight, 1), Priority: in.Priority,
TimeoutMs: defaultIf(in.TimeoutMs, 300000), MaxConcurrency: defaultIf(in.MaxConcurrency, 100),
Enabled: true,
}
if in.Enabled != nil {
ch.Enabled = *in.Enabled
}
if err := h.db.Create(ch).Error; err != nil {
h.log.Warn("create channel failed", zap.Error(err))
httpx.Fail(c, http.StatusInternalServerError, "create channel failed")
return
}
httpx.Created(c, channelDTO(ch))
}
// Update handles PUT /api/v1/admin/channels/:id.
func (h *channelHandler) Update(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
httpx.Fail(c, http.StatusBadRequest, "invalid channel id")
return
}
var ch store.Channel
if err := h.db.First(&ch, id).Error; err != nil {
httpx.Fail(c, http.StatusNotFound, "channel not found")
return
}
var in channelInput
if !httpx.Bind(c, &in) {
return
}
updates := map[string]any{
"name": in.Name, "base_url": in.BaseURL,
"weight": defaultIf(in.Weight, ch.Weight), "priority": in.Priority,
"timeout_ms": defaultIf(in.TimeoutMs, ch.TimeoutMs),
"max_concurrency": defaultIf(in.MaxConcurrency, ch.MaxConcurrency),
}
if len(in.Formats) > 0 {
if err := validateFormats(in.Formats); err != nil {
httpx.Fail(c, http.StatusBadRequest, err.Error())
return
}
updates["provider"] = deriveProvider(in.Formats)
// GORM map updates skip the json serializer, so encode explicitly;
// a raw []string would be emitted as a SQL row-value list.
formatsJSON, err := json.Marshal(in.Formats)
if err != nil {
httpx.Fail(c, http.StatusInternalServerError, "serialize formats failed")
return
}
updates["formats"] = string(formatsJSON)
} else if in.Provider != "" {
updates["provider"] = in.Provider
}
if in.Enabled != nil {
updates["enabled"] = *in.Enabled
}
if in.APIKey != "" {
enc, err := h.ch.EncryptKey(in.APIKey)
if err != nil {
httpx.Fail(c, http.StatusInternalServerError, "encrypt key failed")
return
}
updates["api_key_enc"] = enc
}
if err := h.db.Model(&ch).Updates(updates).Error; err != nil {
h.log.Warn("update channel failed", zap.Error(err))
httpx.Fail(c, http.StatusInternalServerError, "update channel failed")
return
}
h.db.First(&ch, id)
httpx.OK(c, channelDTO(&ch))
}
// Delete handles DELETE /api/v1/admin/channels/:id.
func (h *channelHandler) Delete(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
httpx.Fail(c, http.StatusBadRequest, "invalid channel id")
return
}
if err := h.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("channel_id = ?", id).Delete(&store.ChannelModelBinding{}).Error; err != nil {
return err
}
return tx.Delete(&store.Channel{}, id).Error
}); err != nil {
h.log.Warn("delete channel failed", zap.Error(err))
httpx.Fail(c, http.StatusInternalServerError, "delete channel failed")
return
}
httpx.OK(c, gin.H{"ok": true})
}
// Test handles POST /api/v1/admin/channels/:id/test.
func (h *channelHandler) Test(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
httpx.Fail(c, http.StatusBadRequest, "invalid channel id")
return
}
var ch store.Channel
if err := h.db.First(&ch, id).Error; err != nil {
httpx.Fail(c, http.StatusNotFound, "channel not found")
return
}
ok, latency, model, err := h.ch.Test(&ch)
if err != nil || !ok {
msg := "test failed"
if err != nil {
msg = err.Error()
}
httpx.Fail(c, http.StatusBadGateway, msg)
return
}
httpx.OK(c, gin.H{"ok": true, "latencyMs": latency, "model": model})
}
// ImportModels handles POST /api/v1/admin/channels/:id/import-models.
func (h *channelHandler) ImportModels(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
httpx.Fail(c, http.StatusBadRequest, "invalid channel id")
return
}
var ch store.Channel
if err := h.db.First(&ch, id).Error; err != nil {
httpx.Fail(c, http.StatusNotFound, "channel not found")
return
}
names, err := h.ch.ImportModels(&ch)
if err != nil {
httpx.Fail(c, http.StatusBadGateway, "import failed: "+err.Error())
return
}
httpx.OK(c, gin.H{"imported": names, "count": len(names)})
}
// ListBindings handles GET /api/v1/admin/channels/:id/bindings.
func (h *channelHandler) ListBindings(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
httpx.Fail(c, http.StatusBadRequest, "invalid channel id")
return
}
var bindings []store.ChannelModelBinding
if err := h.db.Preload("Model").Where("channel_id = ?", id).Find(&bindings).Error; err != nil {
h.log.Warn("list bindings failed", zap.Error(err))
httpx.Fail(c, http.StatusInternalServerError, "list bindings failed")
return
}
out := make([]gin.H, 0, len(bindings))
for _, b := range bindings {
out = append(out, gin.H{
"id": b.ID, "modelId": b.ModelID, "modelName": b.Model.Name,
"upstreamModel": b.UpstreamModel, "weight": b.Weight,
})
}
httpx.OK(c, out)
}
// SaveBindings handles PUT /api/v1/admin/channels/:id/bindings.
func (h *channelHandler) SaveBindings(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
httpx.Fail(c, http.StatusBadRequest, "invalid channel id")
return
}
var in struct {
Items []struct {
ModelID int64 `json:"modelId"`
UpstreamModel string `json:"upstreamModel"`
Weight int `json:"weight"`
} `json:"items"`
}
if !httpx.Bind(c, &in) {
return
}
if err := h.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("channel_id = ?", id).Delete(&store.ChannelModelBinding{}).Error; err != nil {
return err
}
for _, item := range in.Items {
if item.ModelID == 0 {
continue
}
b := store.ChannelModelBinding{
ChannelID: id, ModelID: item.ModelID,
UpstreamModel: item.UpstreamModel, Weight: defaultIf(item.Weight, 1),
}
if err := tx.Create(&b).Error; err != nil {
return err
}
}
return nil
}); err != nil {
h.log.Warn("save bindings failed", zap.Error(err))
httpx.Fail(c, http.StatusInternalServerError, "save bindings failed")
return
}
httpx.OK(c, gin.H{"ok": true})
}
func channelDTO(ch *store.Channel) gin.H {
return gin.H{
"id": ch.ID, "name": ch.Name, "provider": ch.Provider, "baseUrl": ch.BaseURL,
"formats": ch.FormatsResolved(),
"weight": ch.Weight, "priority": ch.Priority, "timeoutMs": ch.TimeoutMs,
"maxConcurrency": ch.MaxConcurrency, "healthStatus": ch.HealthStatus,
"healthFailures": ch.HealthFailures, "enabled": ch.Enabled,
"createdAt": ch.CreatedAt, "updatedAt": ch.UpdatedAt,
}
}
var validFormats = map[string]bool{
store.FormatOpenAIChat: true,
store.FormatOpenAIResponses: true,
store.FormatAnthropic: true,
}
func validateFormats(fs []string) error {
for _, f := range fs {
if !validFormats[f] {
return fmt.Errorf("invalid api format: %s", f)
}
}
return nil
}
// deriveProvider computes the conversion-target provider from the declared
// formats. Anthropic-only channels convert to Claude; anything else targets the
// OpenAI chat-completions family.
func deriveProvider(formats []string) string {
hasAnthropic, hasOpenAI := false, false
for _, f := range formats {
switch f {
case store.FormatAnthropic:
hasAnthropic = true
case store.FormatOpenAIChat, store.FormatOpenAIResponses:
hasOpenAI = true
}
}
if hasAnthropic && !hasOpenAI {
return "anthropic"
}
return "openai"
}
func defaultIf(v, def int) int {
if v == 0 {
return def
}
return v
}
+57
View File
@@ -0,0 +1,57 @@
package admin
import (
"encoding/json"
"net/http"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"gorm.io/gorm"
"openteam/server/internal/pkg/httpx"
"openteam/server/internal/store"
)
type configHandler struct {
db *gorm.DB
log *zap.Logger
}
// Get handles GET /api/v1/admin/config — returns the full config map.
func (h *configHandler) Get(c *gin.Context) {
var confs []store.SystemConfig
if err := h.db.Find(&confs).Error; err != nil {
h.log.Warn("load config failed", zap.Error(err))
httpx.Fail(c, http.StatusInternalServerError, "load config failed")
return
}
out := map[string]any{}
for _, conf := range confs {
var v any
if err := json.Unmarshal(conf.Value, &v); err == nil {
out[conf.Key] = v
}
}
httpx.OK(c, out)
}
// Put handles PUT /api/v1/admin/config — upserts key/value pairs.
func (h *configHandler) Put(c *gin.Context) {
var in map[string]any
if !httpx.Bind(c, &in) {
return
}
for k, v := range in {
raw, err := json.Marshal(v)
if err != nil {
continue
}
conf := store.SystemConfig{Key: k, Value: raw}
if err := h.db.Save(&conf).Error; err != nil {
h.log.Warn("save config failed", zap.Error(err), zap.String("key", k))
httpx.Fail(c, http.StatusInternalServerError, "save config failed")
return
}
}
httpx.OK(c, gin.H{"ok": true})
}
+57
View File
@@ -0,0 +1,57 @@
package admin
import (
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"gorm.io/gorm"
"openteam/server/internal/billing"
"openteam/server/internal/channel"
"openteam/server/internal/user"
)
// Register wires the admin routes onto a gin group.
func Register(g *gin.RouterGroup, db *gorm.DB, ch *channel.Service, bill *billing.Service, log *zap.Logger) {
// Handler instances.
chHandler := &channelHandler{db: db, ch: ch, log: log}
modelHandler := &modelHandler{db: db, log: log}
userHandler := &userHandler{db: db, bill: bill, log: log}
usageHandler := &usageHandler{db: db, bill: bill, log: log}
configHandler := &configHandler{db: db, log: log}
g = g.Group("", user.RequireAdmin())
// Channels.
g.GET("/channels", chHandler.List)
g.POST("/channels", chHandler.Create)
g.PUT("/channels/:id", chHandler.Update)
g.DELETE("/channels/:id", chHandler.Delete)
g.POST("/channels/:id/test", chHandler.Test)
g.POST("/channels/:id/import-models", chHandler.ImportModels)
g.GET("/channels/:id/bindings", chHandler.ListBindings)
g.PUT("/channels/:id/bindings", chHandler.SaveBindings)
// Models.
g.GET("/models", modelHandler.List)
g.POST("/models", modelHandler.Create)
g.PUT("/models/:id", modelHandler.Update)
g.PUT("/models/:id/price", modelHandler.UpdatePrice)
// Users.
g.GET("/users", userHandler.List)
g.PATCH("/users/:id", userHandler.Update)
g.POST("/users/:id/balance", userHandler.AdjustBalance)
// Recharges (reserved; approve/reject wire the paused state machine).
g.GET("/recharges", usageHandler.Recharges)
g.POST("/recharges/:id/approve", usageHandler.ApproveRecharge)
g.POST("/recharges/:id/reject", usageHandler.RejectRecharge)
// Usage & stats.
g.GET("/usage", usageHandler.Usage)
g.GET("/stats/overview", usageHandler.Overview)
// System config.
g.GET("/config", configHandler.Get)
g.PUT("/config", configHandler.Put)
}
+171
View File
@@ -0,0 +1,171 @@
package admin
import (
"net/http"
"strconv"
"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"
)
type modelHandler struct {
db *gorm.DB
log *zap.Logger
}
type modelInput struct {
Name string `json:"name" binding:"required"`
DisplayName string `json:"displayName"`
InputPrice string `json:"inputPrice"`
OutputPrice string `json:"outputPrice"`
CacheReadPrice string `json:"cacheReadPrice"`
Enabled *bool `json:"enabled"`
Sort *int `json:"sort"`
}
// List handles GET /api/v1/admin/models.
func (h *modelHandler) List(c *gin.Context) {
var models []store.Model
if err := h.db.Order("sort ASC, id ASC").Find(&models).Error; err != nil {
h.log.Warn("list models failed", zap.Error(err))
httpx.Fail(c, http.StatusInternalServerError, "list models failed")
return
}
out := make([]gin.H, 0, len(models))
for i := range models {
out = append(out, modelDTO(&models[i]))
}
httpx.OK(c, out)
}
// Create handles POST /api/v1/admin/models.
func (h *modelHandler) Create(c *gin.Context) {
var in modelInput
if !httpx.Bind(c, &in) {
return
}
m := &store.Model{
Name: in.Name,
DisplayName: in.DisplayName,
InputPrice: parsePrice(in.InputPrice),
OutputPrice: parsePrice(in.OutputPrice),
CacheReadPrice: parsePrice(in.CacheReadPrice),
Enabled: true,
}
if in.Enabled != nil {
m.Enabled = *in.Enabled
}
if in.Sort != nil {
m.Sort = *in.Sort
}
if err := h.db.Create(m).Error; err != nil {
h.log.Warn("create model failed", zap.Error(err))
httpx.Fail(c, http.StatusInternalServerError, "create model failed")
return
}
httpx.Created(c, modelDTO(m))
}
// Update handles PUT /api/v1/admin/models/:id.
func (h *modelHandler) Update(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
httpx.Fail(c, http.StatusBadRequest, "invalid model id")
return
}
var m store.Model
if err := h.db.First(&m, id).Error; err != nil {
httpx.Fail(c, http.StatusNotFound, "model not found")
return
}
var in modelInput
if !httpx.Bind(c, &in) {
return
}
updates := map[string]any{
"name": in.Name,
}
if in.DisplayName != "" {
updates["display_name"] = in.DisplayName
}
if in.InputPrice != "" {
updates["input_price"] = parsePrice(in.InputPrice)
}
if in.OutputPrice != "" {
updates["output_price"] = parsePrice(in.OutputPrice)
}
if in.CacheReadPrice != "" {
updates["cache_read_price"] = parsePrice(in.CacheReadPrice)
}
if in.Enabled != nil {
updates["enabled"] = *in.Enabled
}
if in.Sort != nil {
updates["sort"] = *in.Sort
}
if err := h.db.Model(&m).Updates(updates).Error; err != nil {
h.log.Warn("update model failed", zap.Error(err))
httpx.Fail(c, http.StatusInternalServerError, "update model failed")
return
}
h.db.First(&m, id)
httpx.OK(c, modelDTO(&m))
}
// UpdatePrice handles PUT /api/v1/admin/models/:id/price.
func (h *modelHandler) UpdatePrice(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
httpx.Fail(c, http.StatusBadRequest, "invalid model id")
return
}
var in struct {
InputPrice string `json:"inputPrice"`
OutputPrice string `json:"outputPrice"`
CacheReadPrice string `json:"cacheReadPrice"`
}
if !httpx.Bind(c, &in) {
return
}
updates := map[string]any{}
if in.InputPrice != "" {
updates["input_price"] = parsePrice(in.InputPrice)
}
if in.OutputPrice != "" {
updates["output_price"] = parsePrice(in.OutputPrice)
}
if in.CacheReadPrice != "" {
updates["cache_read_price"] = parsePrice(in.CacheReadPrice)
}
if len(updates) == 0 {
httpx.Fail(c, http.StatusBadRequest, "no price fields provided")
return
}
res := h.db.Model(&store.Model{}).Where("id = ?", id).Updates(updates)
if res.RowsAffected == 0 {
httpx.Fail(c, http.StatusNotFound, "model not found")
return
}
httpx.OK(c, gin.H{"ok": true})
}
func parsePrice(s string) decimal.Decimal {
d, err := decimal.NewFromString(s)
if err != nil {
return decimal.Zero
}
return d.Round(8)
}
func modelDTO(m *store.Model) gin.H {
return gin.H{
"id": m.ID, "name": m.Name, "displayName": m.DisplayName,
"inputPrice": m.InputPrice.String(), "outputPrice": m.OutputPrice.String(),
"cacheReadPrice": m.CacheReadPrice.String(), "enabled": m.Enabled, "sort": m.Sort,
}
}
+222
View File
@@ -0,0 +1,222 @@
package admin
import (
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
"github.com/shopspring/decimal"
"go.uber.org/zap"
"gorm.io/gorm"
"openteam/server/internal/billing"
"openteam/server/internal/pkg/httpx"
"openteam/server/internal/store"
"openteam/server/internal/user"
)
type usageHandler struct {
db *gorm.DB
bill *billing.Service
log *zap.Logger
}
// Overview handles GET /api/v1/admin/stats/overview.
func (h *usageHandler) Overview(c *gin.Context) {
now := time.Now()
today := now.Format("2006-01-02")
month := now.Format("2006-01-02")
var totals struct {
Requests int
Cost decimal.Decimal
}
h.db.Model(&store.UsageDaily{}).
Select("COALESCE(SUM(requests),0) as requests, COALESCE(SUM(cost),0) as cost").
Scan(&totals)
var todayAgg struct {
Requests int
Cost decimal.Decimal
InputTokens int64
OutputTokens int64
}
h.db.Model(&store.UsageDaily{}).Where("date = ?", today).
Select("COALESCE(SUM(requests),0) as requests, COALESCE(SUM(cost),0) as cost, COALESCE(SUM(input_tokens),0) as input_tokens, COALESCE(SUM(output_tokens),0) as output_tokens").
Scan(&todayAgg)
var monthAgg struct {
Requests int
Cost decimal.Decimal
}
h.db.Model(&store.UsageDaily{}).Where("date >= ? AND date <= ?", month[:7]+"-01", today).
Select("COALESCE(SUM(requests),0) as requests, COALESCE(SUM(cost),0) as cost").
Scan(&monthAgg)
var userCount, channelCount, modelCount int64
h.db.Model(&store.User{}).Count(&userCount)
h.db.Model(&store.Channel{}).Count(&channelCount)
h.db.Model(&store.Model{}).Count(&modelCount)
httpx.OK(c, gin.H{
"total": gin.H{
"requests": totals.Requests,
"cost": totals.Cost.String(),
"users": userCount,
"channels": channelCount,
"models": modelCount,
},
"today": gin.H{
"requests": todayAgg.Requests,
"cost": todayAgg.Cost.String(),
"inputTokens": todayAgg.InputTokens,
"outputTokens": todayAgg.OutputTokens,
},
"month": gin.H{
"requests": monthAgg.Requests,
"cost": monthAgg.Cost.String(),
},
})
}
// Usage handles GET /api/v1/admin/usage?from&to&userId&model&group=day|model|user.
func (h *usageHandler) Usage(c *gin.Context) {
from := c.Query("from")
to := c.Query("to")
group := c.DefaultQuery("group", "day")
q := h.db.Model(&store.UsageDaily{})
if from != "" {
q = q.Where("date >= ?", from)
}
if to != "" {
q = q.Where("date <= ?", to)
}
if uid := c.Query("userId"); uid != "" {
q = q.Where("user_id = ?", uid)
}
if m := c.Query("model"); m != "" {
q = q.Where("model_id = ?", m)
}
var rows []struct {
Key string `gorm:"column:g"`
Requests int
InputTokens int64
OutputTokens int64
Cost decimal.Decimal
}
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")
case "user":
q = q.Joins("JOIN users ON users.id = usage_dailies.user_id").
Select("users.username 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("users.username")
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("admin usage query failed", zap.Error(err))
httpx.Fail(c, http.StatusInternalServerError, "usage query 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)
}
// Recharges handles GET /api/v1/admin/recharges.
func (h *usageHandler) Recharges(c *gin.Context) {
status := c.Query("status")
q := h.db.Preload("User").Model(&store.RechargeOrder{})
if status != "" {
q = q.Where("status = ?", status)
}
var orders []store.RechargeOrder
if err := q.Order("id DESC").Limit(100).Find(&orders).Error; err != nil {
h.log.Warn("list recharges failed", zap.Error(err))
httpx.Fail(c, http.StatusInternalServerError, "list recharges failed")
return
}
out := make([]gin.H, 0, len(orders))
for i := range orders {
o := &orders[i]
out = append(out, gin.H{
"id": o.ID, "userId": o.UserID, "username": o.User.Username,
"amount": o.Amount.String(), "status": o.Status, "method": o.Method,
"remark": o.Remark, "createdAt": o.CreatedAt,
})
}
httpx.OK(c, out)
}
// ApproveRecharge handles POST /api/v1/admin/recharges/:id/approve.
func (h *usageHandler) ApproveRecharge(c *gin.Context) {
h.decideRecharge(c, "approve")
}
// RejectRecharge handles POST /api/v1/admin/recharges/:id/reject.
func (h *usageHandler) RejectRecharge(c *gin.Context) {
h.decideRecharge(c, "reject")
}
func (h *usageHandler) decideRecharge(c *gin.Context, action string) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
httpx.Fail(c, http.StatusBadRequest, "invalid order id")
return
}
admin := user.Current(c)
var order store.RechargeOrder
if err := h.db.First(&order, id).Error; err != nil {
httpx.Fail(c, http.StatusNotFound, "order not found")
return
}
if order.Status != "pending" {
httpx.Fail(c, http.StatusBadRequest, "order already processed")
return
}
var in struct {
Remark string `json:"remark"`
}
_ = c.ShouldBindJSON(&in)
now := time.Now()
if action == "approve" {
err = h.db.Transaction(func(tx *gorm.DB) error {
if _, cerr := h.bill.Credit(order.UserID, order.Amount, "recharge", orderIDStr(order.ID)); cerr != nil {
return cerr
}
return tx.Model(&order).Updates(map[string]any{
"status": "credited", "reviewed_by": admin.ID, "reviewed_at": now, "remark": in.Remark,
}).Error
})
} else {
err = h.db.Model(&order).Updates(map[string]any{
"status": "rejected", "reviewed_by": admin.ID, "reviewed_at": now, "remark": in.Remark,
}).Error
}
if err != nil {
h.log.Warn("recharge decision failed", zap.Error(err))
httpx.Fail(c, http.StatusInternalServerError, "recharge decision failed")
return
}
httpx.OK(c, gin.H{"ok": true, "status": order.Status})
}
func orderIDStr(id int64) string {
return strconv.FormatInt(id, 10)
}
+140
View File
@@ -0,0 +1,140 @@
package admin
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/shopspring/decimal"
"go.uber.org/zap"
"gorm.io/gorm"
"openteam/server/internal/billing"
"openteam/server/internal/pkg/httpx"
"openteam/server/internal/store"
)
type userHandler struct {
db *gorm.DB
bill *billing.Service
log *zap.Logger
}
// List handles GET /api/v1/admin/users?page&search.
func (h *userHandler) List(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
if page < 1 {
page = 1
}
pageSize := 20
search := c.Query("search")
q := h.db.Model(&store.User{})
if search != "" {
like := "%" + search + "%"
q = q.Where("username LIKE ? OR email LIKE ?", like, like)
}
var total int64
q.Count(&total)
var users []store.User
if err := q.Order("id DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&users).Error; err != nil {
h.log.Warn("list users failed", zap.Error(err))
httpx.Fail(c, http.StatusInternalServerError, "list users failed")
return
}
out := make([]gin.H, 0, len(users))
for i := range users {
out = append(out, userDTO(&users[i]))
}
httpx.OK(c, gin.H{"total": total, "page": page, "items": out})
}
// Update handles PATCH /api/v1/admin/users/:id.
func (h *userHandler) Update(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
httpx.Fail(c, http.StatusBadRequest, "invalid user id")
return
}
var u store.User
if err := h.db.First(&u, id).Error; err != nil {
httpx.Fail(c, http.StatusNotFound, "user not found")
return
}
var in struct {
Role *string `json:"role"`
Status *string `json:"status"`
}
if !httpx.Bind(c, &in) {
return
}
updates := map[string]any{}
if in.Role != nil {
if *in.Role != "admin" && *in.Role != "user" {
httpx.Fail(c, http.StatusBadRequest, "invalid role")
return
}
updates["role"] = *in.Role
}
if in.Status != nil {
if *in.Status != "active" && *in.Status != "disabled" {
httpx.Fail(c, http.StatusBadRequest, "invalid status")
return
}
updates["status"] = *in.Status
}
if len(updates) > 0 {
if err := h.db.Model(&u).Updates(updates).Error; err != nil {
h.log.Warn("update user failed", zap.Error(err))
httpx.Fail(c, http.StatusInternalServerError, "update user failed")
return
}
}
h.db.First(&u, id)
httpx.OK(c, userDTO(&u))
}
// AdjustBalance handles POST /api/v1/admin/users/:id/balance.
func (h *userHandler) AdjustBalance(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
httpx.Fail(c, http.StatusBadRequest, "invalid user id")
return
}
var in struct {
Amount string `json:"amount" binding:"required"`
Remark string `json:"remark"`
}
if !httpx.Bind(c, &in) {
return
}
amount, err := decimal.NewFromString(in.Amount)
if err != nil {
httpx.Fail(c, http.StatusBadRequest, "invalid amount")
return
}
if amount.IsZero() {
httpx.Fail(c, http.StatusBadRequest, "amount must be non-zero")
return
}
after, err := h.bill.AdminAdjust(id, amount.Round(8), in.Remark)
if err != nil {
if err == billing.ErrInsufficientBalance {
httpx.Fail(c, http.StatusBadRequest, "amount would make balance negative")
return
}
h.log.Warn("adjust balance failed", zap.Error(err))
httpx.Fail(c, http.StatusInternalServerError, "adjust balance failed")
return
}
httpx.OK(c, gin.H{"balanceAfter": after.String()})
}
func userDTO(u *store.User) gin.H {
return gin.H{
"id": u.ID, "username": u.Username, "email": u.Email,
"role": u.Role, "balance": u.Balance.String(), "status": u.Status,
"lastLoginAt": u.LastLoginAt, "createdAt": u.CreatedAt,
}
}
+99
View File
@@ -0,0 +1,99 @@
package apikey
import (
"errors"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"openteam/server/internal/pkg/httpx"
"openteam/server/internal/user"
)
type Handler struct {
svc *Service
log *zap.Logger
}
func NewHandler(svc *Service, log *zap.Logger) *Handler {
return &Handler{svc: svc, log: log}
}
// List handles GET /api/v1/keys.
func (h *Handler) List(c *gin.Context) {
u := user.Current(c)
keys, err := h.svc.List(u.ID)
if err != nil {
h.log.Warn("list keys failed", zap.Error(err))
httpx.Fail(c, http.StatusInternalServerError, "list keys failed")
return
}
out := make([]map[string]any, 0, len(keys))
for i := range keys {
out = append(out, Public(&keys[i]))
}
httpx.OK(c, out)
}
// Create handles POST /api/v1/keys.
func (h *Handler) Create(c *gin.Context) {
u := user.Current(c)
var in CreateInput
if !httpx.Bind(c, &in) {
return
}
rec, plain, err := h.svc.Create(u.ID, in)
if err != nil {
h.log.Warn("create key failed", zap.Error(err))
httpx.Fail(c, http.StatusInternalServerError, "create key failed")
return
}
data := Public(rec)
data["key"] = plain // shown only once
httpx.Created(c, data)
}
// Update handles PATCH /api/v1/keys/:id.
func (h *Handler) Update(c *gin.Context) {
u := user.Current(c)
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
httpx.Fail(c, http.StatusBadRequest, "invalid key id")
return
}
var in UpdateInput
if !httpx.Bind(c, &in) {
return
}
rec, err := h.svc.Update(u.ID, id, in)
if err != nil {
if errors.Is(err, ErrKeyNotFound) {
httpx.Fail(c, http.StatusNotFound, "api key not found")
return
}
httpx.Fail(c, http.StatusInternalServerError, "update key failed")
return
}
httpx.OK(c, Public(rec))
}
// Delete handles DELETE /api/v1/keys/:id.
func (h *Handler) Delete(c *gin.Context) {
u := user.Current(c)
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
httpx.Fail(c, http.StatusBadRequest, "invalid key id")
return
}
if err := h.svc.Delete(u.ID, id); err != nil {
if errors.Is(err, ErrKeyNotFound) {
httpx.Fail(c, http.StatusNotFound, "api key not found")
return
}
httpx.Fail(c, http.StatusInternalServerError, "revoke key failed")
return
}
httpx.OK(c, gin.H{"ok": true})
}
+160
View File
@@ -0,0 +1,160 @@
package apikey
import (
"encoding/json"
"errors"
"time"
"go.uber.org/zap"
"gorm.io/gorm"
"openteam/server/internal/pkg/crypto"
"openteam/server/internal/pkg/rand"
"openteam/server/internal/store"
)
var ErrKeyNotFound = errors.New("api key not found")
type Service struct {
db *gorm.DB
log *zap.Logger
}
func NewService(db *gorm.DB, log *zap.Logger) *Service {
return &Service{db: db, log: log}
}
type CreateInput struct {
Name string `json:"name" binding:"required,max=128"`
QuotaTokensPerDay *int64 `json:"quotaTokensPerDay"`
QuotaRequestsPerDay *int `json:"quotaRequestsPerDay"`
AllowedModels []string `json:"allowedModels"`
ExpiresInDays *int `json:"expiresInDays"`
}
type UpdateInput struct {
Name *string `json:"name"`
QuotaTokensPerDay *int64 `json:"quotaTokensPerDay"`
QuotaRequestsPerDay *int `json:"quotaRequestsPerDay"`
AllowedModels []string `json:"allowedModels"`
ExpiresAt *time.Time `json:"expiresAt"`
Status *string `json:"status"`
}
// Create generates a key and returns it once along with the stored record.
func (s *Service) Create(userID int64, in CreateInput) (*store.ApiKey, string, error) {
plain, err := rand.Base62(48)
if err != nil {
return nil, "", err
}
full := "sk-" + plain
rec := &store.ApiKey{
UserID: userID,
Name: in.Name,
KeyHash: crypto.HashSHA256(full),
KeyPrefix: "sk-" + plain[:8],
QuotaTokensPerDay: in.QuotaTokensPerDay,
QuotaRequestsPerDay: in.QuotaRequestsPerDay,
AllowedModels: in.AllowedModels,
Status: "active",
}
if in.ExpiresInDays != nil && *in.ExpiresInDays > 0 {
t := time.Now().AddDate(0, 0, *in.ExpiresInDays)
rec.ExpiresAt = &t
}
if err := s.db.Create(rec).Error; err != nil {
return nil, "", err
}
return rec, full, nil
}
// List returns the user's keys (never the hash).
func (s *Service) List(userID int64) ([]store.ApiKey, error) {
var keys []store.ApiKey
err := s.db.Where("user_id = ?", userID).Order("id DESC").Find(&keys).Error
return keys, err
}
// Update patches a key owned by the user.
func (s *Service) Update(userID, keyID int64, in UpdateInput) (*store.ApiKey, error) {
var k store.ApiKey
if err := s.db.First(&k, "id = ? AND user_id = ?", keyID, userID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrKeyNotFound
}
return nil, err
}
updates := map[string]any{}
if in.Name != nil {
updates["name"] = *in.Name
}
if in.QuotaTokensPerDay != nil {
updates["quota_tokens_per_day"] = *in.QuotaTokensPerDay
}
if in.QuotaRequestsPerDay != nil {
updates["quota_requests_per_day"] = *in.QuotaRequestsPerDay
}
if in.AllowedModels != nil {
// GORM map updates skip the json serializer, so encode explicitly.
modelsJSON, err := json.Marshal(in.AllowedModels)
if err != nil {
return nil, err
}
updates["allowed_models"] = string(modelsJSON)
}
if in.ExpiresAt != nil {
updates["expires_at"] = in.ExpiresAt
}
if in.Status != nil {
updates["status"] = *in.Status
}
if len(updates) > 0 {
if err := s.db.Model(&k).Updates(updates).Error; err != nil {
return nil, err
}
}
return s.Get(userID, keyID)
}
// Delete revokes a key (soft revoke by status).
func (s *Service) Delete(userID, keyID int64) error {
res := s.db.Model(&store.ApiKey{}).
Where("id = ? AND user_id = ?", keyID, userID).
Update("status", "revoked")
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return ErrKeyNotFound
}
return nil
}
// Get loads one key owned by the user.
func (s *Service) Get(userID, keyID int64) (*store.ApiKey, error) {
var k store.ApiKey
if err := s.db.First(&k, "id = ? AND user_id = ?", keyID, userID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrKeyNotFound
}
return nil, err
}
return &k, nil
}
// Public returns a DTO without the hash.
func Public(k *store.ApiKey) map[string]any {
return map[string]any{
"id": k.ID,
"userId": k.UserID,
"name": k.Name,
"keyPrefix": k.KeyPrefix,
"quotaTokensPerDay": k.QuotaTokensPerDay,
"quotaRequestsPerDay": k.QuotaRequestsPerDay,
"allowedModels": k.AllowedModels,
"expiresAt": k.ExpiresAt,
"status": k.Status,
"lastUsedAt": k.LastUsedAt,
"createdAt": k.CreatedAt,
}
}
+117
View File
@@ -0,0 +1,117 @@
package billing
import (
"errors"
"time"
"github.com/shopspring/decimal"
"go.uber.org/zap"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"openteam/server/internal/store"
)
var (
ErrInsufficientBalance = errors.New("insufficient balance")
ErrUserNotFound = errors.New("user not found")
)
// PriceSnapshot mirrors a model's prices at billing time.
type PriceSnapshot struct {
InputPrice decimal.Decimal
OutputPrice decimal.Decimal
CacheReadPrice decimal.Decimal
}
// CostFromPrices computes cost for token counts using per-1M-token prices.
func CostFromPrices(in, out, cacheRead int64, prices PriceSnapshot) decimal.Decimal {
perM := decimal.NewFromInt(1_000_000)
cost := prices.InputPrice.Mul(decimal.NewFromInt(in)).Div(perM).
Add(prices.OutputPrice.Mul(decimal.NewFromInt(out)).Div(perM)).
Add(prices.CacheReadPrice.Mul(decimal.NewFromInt(cacheRead)).Div(perM))
return cost.Round(8)
}
// EstimateCost approximates cost from token estimates.
func (s *Service) EstimateCost(modelID int64, in, out, cacheRead int64) (decimal.Decimal, error) {
var m store.Model
if err := s.db.First(&m, modelID).Error; err != nil {
return decimal.Zero, err
}
return CostFromPrices(in, out, cacheRead, PriceSnapshot{
InputPrice: m.InputPrice, OutputPrice: m.OutputPrice, CacheReadPrice: m.CacheReadPrice,
}), nil
}
type Service struct {
db *gorm.DB
log *zap.Logger
}
func NewService(db *gorm.DB, log *zap.Logger) *Service {
return &Service{db: db, log: log}
}
// CheckBalance returns whether the user can afford the estimated cost.
func (s *Service) CheckBalance(userID int64, estimated decimal.Decimal) error {
var u store.User
if err := s.db.First(&u, userID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrUserNotFound
}
return err
}
if u.Balance.LessThan(estimated) {
return ErrInsufficientBalance
}
return nil
}
// Deduct atomically debits the user balance and appends a ledger entry.
func (s *Service) Deduct(userID int64, change decimal.Decimal, logType, refID string) (decimal.Decimal, error) {
return s.mutateBalance(userID, change.Neg(), logType, refID)
}
// Credit adds to the user balance (recharge / refund / admin adjust).
func (s *Service) Credit(userID int64, change decimal.Decimal, logType, refID string) (decimal.Decimal, error) {
return s.mutateBalance(userID, change, logType, refID)
}
// mutateBalance performs the balance update in a transaction so ledger and
// balance always agree.
func (s *Service) mutateBalance(userID int64, delta decimal.Decimal, logType, refID string) (decimal.Decimal, error) {
var after decimal.Decimal
err := s.db.Transaction(func(tx *gorm.DB) error {
var u store.User
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&u, userID).Error; err != nil {
return err
}
newBal := u.Balance.Add(delta)
if newBal.LessThan(decimal.Zero) {
return ErrInsufficientBalance
}
if err := tx.Model(&u).Update("balance", newBal).Error; err != nil {
return err
}
after = newBal
return tx.Create(&store.BalanceLog{
UserID: userID,
Change: delta,
BalanceAfter: newBal,
Type: logType,
RefID: refID,
CreatedAt: time.Now(),
}).Error
})
if err != nil {
return decimal.Zero, err
}
return after, nil
}
// AdminAdjust changes a user's balance with an optional reason.
func (s *Service) AdminAdjust(userID int64, amount decimal.Decimal, reason string) (decimal.Decimal, error) {
return s.mutateBalance(userID, amount, "admin_adjust", reason)
}
+53
View File
@@ -0,0 +1,53 @@
package channel
import (
"time"
"go.uber.org/zap"
"openteam/server/internal/store"
)
// StartHealthCheck runs the periodic health-check loop in a goroutine.
// Only channels that are enabled and have a bound test model are checked.
func (s *Service) StartHealthCheck() {
go func() {
ticker := time.NewTicker(s.cfg.HealthCheck.Interval)
defer ticker.Stop()
s.runHealthCheck()
for range ticker.C {
s.runHealthCheck()
}
}()
}
func (s *Service) runHealthCheck() {
var channels []store.Channel
if err := s.db.Where("enabled = ?", true).Find(&channels).Error; err != nil {
s.log.Warn("health check query failed", zap.Error(err))
return
}
for i := range channels {
ch := &channels[i]
// Skip channels already in cooldown until the cooldown elapses.
if ch.HealthStatus == "cooldown" {
continue
}
ok, _, _, err := s.Test(ch)
if err != nil || !ok {
s.MarkFailure(ch.ID)
continue
}
s.MarkHealthy(ch.ID, "healthy")
}
}
// RecoverCooldown moves channels back from cooldown to degraded after the
// cooldown window, giving them another chance to pass the health check.
// Called periodically; keeps cooldown bounded.
func (s *Service) RecoverCooldown() {
cutoff := time.Now().Add(-s.cfg.HealthCheck.Cooldown)
s.db.Model(&store.Channel{}).
Where("health_status = ? AND updated_at < ?", "cooldown", cutoff).
Updates(map[string]any{"health_status": "degraded", "health_failures": 0})
}
+220
View File
@@ -0,0 +1,220 @@
package channel
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
"go.uber.org/zap"
"gorm.io/gorm"
"openteam/server/internal/pkg/crypto"
"openteam/server/internal/store"
)
// EncryptKey encrypts an upstream API key for storage.
func (s *Service) EncryptKey(plain string) (string, error) {
return crypto.Encrypt(plain, s.master)
}
// ImportModels fetches the channel's model list (GET /v1/models) and creates
// global Model records plus bindings. Returns the imported model names.
func (s *Service) ImportModels(ch *store.Channel) ([]string, error) {
key, err := s.DecryptKey(ch.APIKeyEnc)
if err != nil {
return nil, err
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
base := strings.TrimSuffix(ch.BaseURL, "/")
url := base + "/v1/models"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Accept", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
raw, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
if err != nil {
return nil, err
}
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("channel returned %d: %s", resp.StatusCode, truncate(string(raw), 200))
}
var payload struct {
Data []struct {
ID string `json:"id"`
} `json:"data"`
}
if err := json.Unmarshal(raw, &payload); err != nil {
return nil, err
}
if len(payload.Data) == 0 {
return nil, errors.New("no models returned")
}
imported := []string{}
err = s.db.Transaction(func(tx *gorm.DB) error {
for _, m := range payload.Data {
if m.ID == "" {
continue
}
var model store.Model
err := tx.Where("name = ?", m.ID).First(&model).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
model = store.Model{Name: m.ID, DisplayName: m.ID}
if err := tx.Create(&model).Error; err != nil {
return err
}
} else if err != nil {
return err
}
var binding store.ChannelModelBinding
err = tx.Where("channel_id = ? AND model_id = ?", ch.ID, model.ID).First(&binding).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
binding = store.ChannelModelBinding{
ChannelID: ch.ID, ModelID: model.ID, UpstreamModel: m.ID, Weight: 1,
}
if err := tx.Create(&binding).Error; err != nil {
return err
}
}
imported = append(imported, m.ID)
}
return nil
})
return imported, err
}
// Test sends a cheap test request to the channel and reports success + latency.
func (s *Service) Test(ch *store.Channel) (bool, int, string, error) {
key, err := s.DecryptKey(ch.APIKeyEnc)
if err != nil {
return false, 0, "", err
}
model := s.cfg.HealthCheck.TestModel
if model == "" {
// Pick the first bound model, if any.
var b store.ChannelModelBinding
if err := s.db.Where("channel_id = ?", ch.ID).First(&b).Error; err == nil {
model = b.UpstreamModel
}
}
if model == "" {
return false, 0, "", errors.New("no test model configured; set HEALTHCHECK_TEST_MODEL or bind a model")
}
timeout := time.Duration(s.cfg.HealthCheck.TimeoutMs) * time.Millisecond
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
// Test the first endpoint the channel actually serves, so channels that
// omit chat completions (e.g. Anthropic-only) still get a valid probe.
testFmt := pickTestFormat(ch)
var body []byte
switch testFmt {
case store.FormatAnthropic:
body, _ = json.Marshal(map[string]any{
"model": model, "max_tokens": 8,
"messages": []any{map[string]any{"role": "user", "content": "ping"}},
})
case store.FormatOpenAIResponses:
body, _ = json.Marshal(map[string]any{"model": model, "input": "ping"})
default:
body, _ = json.Marshal(map[string]any{
"model": model,
"messages": []any{map[string]any{"role": "user", "content": "ping"}},
"max_tokens": 8,
})
}
url := strings.TrimSuffix(ch.BaseURL, "/") + healthCheckPath(testFmt)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(string(body)))
if err != nil {
return false, 0, "", err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+key)
if testFmt == store.FormatAnthropic {
req.Header.Set("anthropic-version", "2023-06-01")
}
start := time.Now()
resp, err := http.DefaultClient.Do(req)
latency := int(time.Since(start).Milliseconds())
if err != nil {
return false, latency, "", err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return false, latency, "", fmt.Errorf("test request failed: %d %s", resp.StatusCode, truncate(string(raw), 160))
}
return true, latency, model, nil
}
// MarkHealthy updates health status and resets the failure counter.
func (s *Service) MarkHealthy(chID int64, status string) {
s.db.Model(&store.Channel{}).Where("id = ?", chID).
Updates(map[string]any{"health_status": status, "health_failures": 0})
}
// MarkFailure increments the failure counter and sets cooldown when maxed.
func (s *Service) MarkFailure(chID int64) {
var ch store.Channel
if err := s.db.First(&ch, chID).Error; err != nil {
return
}
failures := ch.HealthFailures + 1
status := "degraded"
if failures >= s.cfg.HealthCheck.MaxFailures {
status = "cooldown"
}
s.db.Model(&ch).Updates(map[string]any{"health_failures": failures, "health_status": status})
s.log.Info("channel health failure", zap.Int64("channel_id", chID),
zap.Int("failures", failures), zap.String("status", status))
}
// pickTestFormat chooses a supported format to probe, preferring the cheapest
// endpoint. Falls back to chat completions so legacy channels keep working.
func pickTestFormat(ch *store.Channel) string {
for _, want := range []string{store.FormatOpenAIChat, store.FormatAnthropic, store.FormatOpenAIResponses} {
for _, f := range ch.FormatsResolved() {
if f == want {
return want
}
}
}
return store.FormatOpenAIChat
}
func healthCheckPath(f string) string {
switch f {
case store.FormatAnthropic:
return "/v1/messages"
case store.FormatOpenAIResponses:
return "/v1/responses"
default:
return "/v1/chat/completions"
}
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "..."
}
+156
View File
@@ -0,0 +1,156 @@
package channel
import (
"errors"
"math/rand"
"sort"
"sync"
"go.uber.org/zap"
"gorm.io/gorm"
"openteam/server/internal/config"
"openteam/server/internal/pkg/crypto"
"openteam/server/internal/store"
)
var ErrNoChannel = errors.New("no available channel for model")
// Service manages channels, model bindings and load-balanced selection.
type Service struct {
db *gorm.DB
cfg *config.Config
log *zap.Logger
mu sync.RWMutex
sem map[int64]chan struct{} // per-channel concurrency limiter
master string
}
func NewService(db *gorm.DB, cfg *config.Config, log *zap.Logger) *Service {
return &Service{
db: db, cfg: cfg, log: log,
sem: map[int64]chan struct{}{},
master: cfg.MasterKey,
}
}
// DecryptKey decrypts a channel's stored upstream API key.
func (s *Service) DecryptKey(enc string) (string, error) {
return crypto.Decrypt(enc, s.master)
}
// Acquire takes a concurrency slot for a channel (blocks if saturated).
func (s *Service) Acquire(channelID int64) (func(), error) {
s.mu.Lock()
lim, ok := s.sem[channelID]
if !ok {
var rec store.Channel
if err := s.db.First(&rec, channelID).Error; err != nil {
s.mu.Unlock()
return nil, err
}
limit := rec.MaxConcurrency
if limit <= 0 {
limit = 100
}
lim = make(chan struct{}, limit)
s.sem[channelID] = lim
}
s.mu.Unlock()
lim <- struct{}{}
return func() { <-lim }, nil
}
// ResolveModel returns the model registry record.
func (s *Service) ResolveModel(name string) (*store.Model, error) {
var m store.Model
if err := s.db.Where("name = ? AND enabled = ?", name, true).First(&m).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("model not found: " + name)
}
return nil, err
}
return &m, nil
}
// SelectChannel picks a healthy channel bound to the given model.
// Channels with higher priority (lower number) and higher weight win;
// cooldown channels are skipped, degraded channels are deprioritized.
// Channels in exclude (already tried in this request) are skipped.
func (s *Service) SelectChannel(modelID int64, exclude map[int64]bool) (*store.Channel, *store.ChannelModelBinding, error) {
var bindings []store.ChannelModelBinding
if err := s.db.Preload("Channel").Where("model_id = ?", modelID).Find(&bindings).Error; err != nil {
return nil, nil, err
}
var candidates []struct {
ch *store.Channel
b *store.ChannelModelBinding
score float64
}
for i := range bindings {
ch := &bindings[i].Channel
if !ch.Enabled {
continue
}
if ch.HealthStatus == "cooldown" {
continue
}
if exclude[ch.ID] {
continue
}
score := float64(bindings[i].Weight)
if ch.HealthStatus == "degraded" {
score *= 0.1
}
candidates = append(candidates, struct {
ch *store.Channel
b *store.ChannelModelBinding
score float64
}{ch: ch, b: &bindings[i], score: score})
}
if len(candidates) == 0 {
return nil, nil, ErrNoChannel
}
// Sort by priority asc, then weight desc.
sort.SliceStable(candidates, func(i, j int) bool {
if candidates[i].ch.Priority != candidates[j].ch.Priority {
return candidates[i].ch.Priority < candidates[j].ch.Priority
}
return candidates[i].score > candidates[j].score
})
// Weighted random pick among the top priority group.
total := 0.0
for _, c := range candidates {
total += c.score
}
if total <= 0 {
return candidates[0].ch, candidates[0].b, nil
}
pick := rand.Float64() * total
for _, c := range candidates {
pick -= c.score
if pick <= 0 {
return c.ch, c.b, nil
}
}
return candidates[0].ch, candidates[0].b, nil
}
// ListModelsForChannel returns the model names a channel serves.
func (s *Service) ListModelsForChannel(channelID int64) ([]string, error) {
var names []string
err := s.db.Table("channel_model_bindings").
Joins("JOIN models ON models.id = channel_model_bindings.model_id").
Where("channel_model_bindings.channel_id = ? AND models.enabled = ?", channelID, true).
Order("models.sort ASC, models.id ASC").
Pluck("models.name", &names).Error
return names, err
}
// ListEnabledModels returns all enabled models for GET /v1/models.
func (s *Service) ListEnabledModels() ([]store.Model, error) {
var models []store.Model
err := s.db.Where("enabled = ?", true).Order("sort ASC, id ASC").Find(&models).Error
return models, err
}
+143
View File
@@ -0,0 +1,143 @@
package config
import (
"fmt"
"os"
"strings"
"time"
)
// Config is the runtime configuration, loaded from environment variables.
type Config struct {
Env string
Debug bool
HTTPPort string
PublicBase string // external base URL, used for cookies
AllowOrigins []string
DB struct {
Driver string // "sqlite" (dev default) or "postgres"
DSN string
}
Redis struct {
Addr string
Password string
Enabled bool
}
Auth struct {
AccessTokenTTL time.Duration
RefreshTokenTTL time.Duration
JWTSecret string
RefreshCookieName string
RefreshCookieSecure bool // false for local http dev; set true behind TLS
RefreshCookieSameSite string
}
// MasterKey encrypts channel API keys at rest (AES-GCM).
MasterKey string
Proxy struct {
DefaultMaxTokens int
DefaultTimeoutMs int
BillingExactBalance bool // reject when estimated cost > balance
MaxRetries int // additional channel attempts on transport/5xx failures
}
Registration struct {
Mode string // "open" | "invite"
}
HealthCheck struct {
Interval time.Duration
MaxFailures int
Cooldown time.Duration
TimeoutMs int
TestModel string
}
RateLimit struct {
RequestsPerMin int // per user global limiter
Burst int
}
MetricsEnabled bool
}
func Load() *Config {
c := &Config{}
c.Env = get("APP_ENV", "development")
c.Debug = strings.EqualFold(get("DEBUG", "false"), "true")
c.HTTPPort = get("HTTP_PORT", "8080")
c.PublicBase = get("PUBLIC_BASE", "http://localhost:8080")
if o := get("CORS_ORIGINS", "*"); o != "*" {
c.AllowOrigins = strings.Split(o, ",")
} else {
c.AllowOrigins = []string{"*"}
}
c.DB.Driver = get("DB_DRIVER", "sqlite")
if c.DB.Driver == "postgres" {
c.DB.DSN = get("DATABASE_URL", "host=localhost user=postgres password=postgres dbname=openteam port=5432 sslmode=disable")
} else {
path := get("SQLITE_PATH", "data/openteam.db")
c.DB.DSN = path
}
c.Redis.Addr = get("REDIS_ADDR", "localhost:6379")
c.Redis.Password = get("REDIS_PASSWORD", "")
c.Redis.Enabled = strings.EqualFold(get("REDIS_ENABLED", "false"), "true")
c.Auth.AccessTokenTTL = duration(get("ACCESS_TOKEN_TTL", "2h"), 2*time.Hour)
c.Auth.RefreshTokenTTL = duration(get("REFRESH_TOKEN_TTL", "168h"), 7*24*time.Hour)
c.Auth.JWTSecret = get("JWT_SECRET", "dev-only-secret-change-me")
c.Auth.RefreshCookieName = get("REFRESH_COOKIE_NAME", "ot_refresh")
c.Auth.RefreshCookieSecure = strings.EqualFold(get("REFRESH_COOKIE_SECURE", "false"), "true")
c.Auth.RefreshCookieSameSite = get("REFRESH_COOKIE_SAMESITE", "lax")
c.MasterKey = get("MASTER_KEY", "dev-only-master-key-change-me")
c.Proxy.DefaultMaxTokens = intVal(get("DEFAULT_MAX_TOKENS", "4096"), 4096)
c.Proxy.DefaultTimeoutMs = intVal(get("PROXY_TIMEOUT_MS", "300000"), 300000)
c.Proxy.BillingExactBalance = strings.EqualFold(get("BILLING_EXACT_BALANCE", "false"), "true")
c.Proxy.MaxRetries = intVal(get("PROXY_MAX_RETRIES", "1"), 1)
c.Registration.Mode = get("REGISTRATION_MODE", "open")
c.HealthCheck.Interval = duration(get("HEALTHCHECK_INTERVAL", "60s"), time.Minute)
c.HealthCheck.MaxFailures = intVal(get("HEALTHCHECK_MAX_FAILURES", "3"), 3)
c.HealthCheck.Cooldown = duration(get("HEALTHCHECK_COOLDOWN", "300s"), 5*time.Minute)
c.HealthCheck.TimeoutMs = intVal(get("HEALTHCHECK_TIMEOUT_MS", "15000"), 15000)
c.HealthCheck.TestModel = get("HEALTHCHECK_TEST_MODEL", "")
c.RateLimit.RequestsPerMin = intVal(get("RATE_LIMIT_PER_MIN", "60"), 60)
c.RateLimit.Burst = intVal(get("RATE_LIMIT_BURST", "120"), 120)
c.MetricsEnabled = strings.EqualFold(get("METRICS_ENABLED", "false"), "true")
return c
}
func get(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
func intVal(s string, def int) int {
n := 0
if _, err := fmt.Sscanf(s, "%d", &n); err != nil || n <= 0 {
return def
}
return n
}
func duration(s string, def time.Duration) time.Duration {
d, err := time.ParseDuration(s)
if err != nil || d <= 0 {
return def
}
return d
}
+64
View File
@@ -0,0 +1,64 @@
package crypto
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"errors"
"fmt"
"io"
)
// Encrypt seals a secret with AES-GCM. The master key can be any string;
// it is hashed to a fixed-size AES key.
func Encrypt(plaintext, masterKey string) (string, error) {
key := sha256.Sum256([]byte(masterKey))
block, err := aes.NewCipher(key[:])
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return "", err
}
sealed := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
return base64.StdEncoding.EncodeToString(sealed), nil
}
// Decrypt opens a ciphertext produced by Encrypt.
func Decrypt(ciphertext, masterKey string) (string, error) {
key := sha256.Sum256([]byte(masterKey))
data, err := base64.StdEncoding.DecodeString(ciphertext)
if err != nil {
return "", err
}
block, err := aes.NewCipher(key[:])
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
if len(data) < gcm.NonceSize() {
return "", errors.New("ciphertext too short")
}
nonce, sealed := data[:gcm.NonceSize()], data[gcm.NonceSize():]
plain, err := gcm.Open(nil, nonce, sealed, nil)
if err != nil {
return "", fmt.Errorf("decrypt failed (bad master key?): %w", err)
}
return string(plain), nil
}
// HashSHA256 returns the hex SHA-256 of a string (used for API key lookup).
func HashSHA256(s string) string {
sum := sha256.Sum256([]byte(s))
return fmt.Sprintf("%x", sum)
}
+42
View File
@@ -0,0 +1,42 @@
package httpx
import (
"net/http"
"github.com/gin-gonic/gin"
)
// APIError is the standard error body for the management API.
type APIError struct {
Code int `json:"code"`
Message string `json:"message"`
}
// OK writes a JSON success response.
func OK(c *gin.Context, data any) {
c.JSON(http.StatusOK, gin.H{"code": 0, "data": data})
}
// Created writes a 201 response.
func Created(c *gin.Context, data any) {
c.JSON(http.StatusCreated, gin.H{"code": 0, "data": data})
}
// Fail writes an error response with the given status.
func Fail(c *gin.Context, status int, message string) {
c.AbortWithStatusJSON(status, gin.H{"code": status, "message": message})
}
// FailWithCode writes an error with a custom business code.
func FailWithCode(c *gin.Context, status, code int, message string) {
c.AbortWithStatusJSON(status, gin.H{"code": code, "message": message})
}
// Bind parses the JSON body and aborts with 400 on failure.
func Bind(c *gin.Context, dst any) bool {
if err := c.ShouldBindJSON(dst); err != nil {
Fail(c, http.StatusBadRequest, "invalid request body: "+err.Error())
return false
}
return true
}
+66
View File
@@ -0,0 +1,66 @@
package jwt
import (
"errors"
"strconv"
"time"
"github.com/golang-jwt/jwt/v5"
)
// Claims is the payload of a signed token.
type Claims struct {
UserID int64 `json:"uid"`
Username string `json:"username"`
Role string `json:"role"`
Type string `json:"typ"` // access | refresh
jwt.RegisteredClaims
}
func sign(secret string, c Claims) (string, error) {
t := jwt.NewWithClaims(jwt.SigningMethodHS256, c)
return t.SignedString([]byte(secret))
}
// SignAccess issues a short-lived access token.
func SignAccess(secret string, userID int64, username, role string, ttl time.Duration) (string, error) {
return sign(secret, Claims{
UserID: userID, Username: username, Role: role, Type: "access",
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(ttl)),
IssuedAt: jwt.NewNumericDate(time.Now()),
Subject: fmtID(userID),
},
})
}
// SignRefresh issues a long-lived refresh token.
func SignRefresh(secret string, userID int64, ttl time.Duration) (string, error) {
return sign(secret, Claims{
UserID: userID, Type: "refresh",
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(ttl)),
IssuedAt: jwt.NewNumericDate(time.Now()),
Subject: fmtID(userID),
},
})
}
// Parse validates a token and returns its claims.
func Parse(secret, token string) (*Claims, error) {
var c Claims
parsed, err := jwt.ParseWithClaims(token, &c, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, errors.New("unexpected signing method")
}
return []byte(secret), nil
})
if err != nil || !parsed.Valid {
return nil, errors.New("invalid token")
}
return &c, nil
}
func fmtID(id int64) string {
return strconv.FormatInt(id, 10)
}
+62
View File
@@ -0,0 +1,62 @@
package password
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"errors"
"fmt"
"strings"
"golang.org/x/crypto/argon2"
)
const (
argonTime = 3
argonMemory = 64 * 1024
argonThreads = 2
argonKeyLen = 32
argonSaltLen = 16
)
// Hash hashes a plaintext password with argon2id.
func Hash(plain string) (string, error) {
salt := make([]byte, argonSaltLen)
if _, err := rand.Read(salt); err != nil {
return "", err
}
key := argon2.IDKey([]byte(plain), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
enc := base64.RawStdEncoding
return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
argon2.Version, argonMemory, argonTime, argonThreads,
enc.EncodeToString(salt), enc.EncodeToString(key)), nil
}
// Verify checks a plaintext password against an argon2id hash string.
func Verify(plain, encoded string) (bool, error) {
parts := strings.Split(encoded, "$")
if len(parts) != 6 || parts[1] != "argon2id" {
return false, errors.New("malformed password hash")
}
var version int
var memory uint32
var time_ uint32
var threads uint8
if _, err := fmt.Sscanf(parts[2], "v=%d", &version); err != nil {
return false, err
}
if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &time_, &threads); err != nil {
return false, err
}
enc := base64.RawStdEncoding
salt, err := enc.DecodeString(parts[4])
if err != nil {
return false, err
}
want, err := enc.DecodeString(parts[5])
if err != nil {
return false, err
}
got := argon2.IDKey([]byte(plain), salt, time_, memory, threads, uint32(len(want)))
return subtle.ConstantTimeCompare(got, want) == 1, nil
}
+21
View File
@@ -0,0 +1,21 @@
package rand
import (
crand "crypto/rand"
"math/big"
)
const base62 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
// Base62 returns a cryptographically random base62 string of length n.
func Base62(n int) (string, error) {
out := make([]byte, n)
for i := range out {
idx, err := crand.Int(crand.Reader, big.NewInt(int64(len(base62))))
if err != nil {
return "", err
}
out[i] = base62[idx.Int64()]
}
return string(out), nil
}
@@ -0,0 +1,73 @@
package ratelimit
import (
"sync"
"time"
)
// Limiter is a token-bucket rate limiter keyed by string.
type Limiter struct {
mu sync.Mutex
rate float64 // tokens per second
burst float64
tokens map[string]*bucket
}
type bucket struct {
tokens float64
lastFill time.Time
}
// New creates a limiter refilling `rate` tokens/sec with `burst` capacity.
func New(rate float64, burst int) *Limiter {
return &Limiter{
rate: rate,
burst: float64(burst),
tokens: map[string]*bucket{},
}
}
// Allow checks whether `key` may take one token now.
func (l *Limiter) Allow(key string) bool {
return l.Take(key, 1)
}
// Take checks whether `key` may take n tokens now.
func (l *Limiter) Take(key string, n float64) bool {
l.mu.Lock()
defer l.mu.Unlock()
now := time.Now()
b, ok := l.tokens[key]
if !ok {
b = &bucket{tokens: l.burst, lastFill: now}
l.tokens[key] = b
}
// Refill based on elapsed time.
elapsed := now.Sub(b.lastFill).Seconds()
b.tokens = minF(l.burst, b.tokens+elapsed*l.rate)
b.lastFill = now
if b.tokens >= n {
b.tokens -= n
return true
}
return false
}
// Sweep removes idle buckets to bound memory. Call periodically.
func (l *Limiter) Sweep(olderThan time.Duration) {
l.mu.Lock()
defer l.mu.Unlock()
cutoff := time.Now().Add(-olderThan)
for k, b := range l.tokens {
if b.lastFill.Before(cutoff) {
delete(l.tokens, k)
}
}
}
func minF(a, b float64) float64 {
if a < b {
return a
}
return b
}
+50
View File
@@ -0,0 +1,50 @@
package claude
import "encoding/json"
// Request is an Anthropic Messages request.
type Request struct {
Model string `json:"model"`
MaxTokens int `json:"max_tokens"`
Stream bool `json:"stream"`
System json.RawMessage `json:"system"`
Messages json.RawMessage `json:"messages"`
Temperature *float64 `json:"temperature"`
TopP *float64 `json:"top_p"`
TopK *int `json:"top_k"`
StopSequences json.RawMessage `json:"stop_sequences"`
Tools json.RawMessage `json:"tools"`
ToolChoice json.RawMessage `json:"tool_choice"`
Metadata json.RawMessage `json:"metadata"`
}
// Usage is the Claude usage block.
type Usage struct {
InputTokens int64 `json:"input_tokens"`
OutputTokens int64 `json:"output_tokens"`
CacheReadInputTokens int64 `json:"cache_read_input_tokens,omitempty"`
CacheCreationInputTokens int64 `json:"cache_creation_input_tokens,omitempty"`
}
// MessageResponse is the non-stream response.
type MessageResponse struct {
ID string `json:"id"`
Type string `json:"type"`
Role string `json:"role"`
Model string `json:"model"`
Content json.RawMessage `json:"content"`
StopReason string `json:"stop_reason"`
StopSequence string `json:"stop_sequence"`
Usage *Usage `json:"usage"`
}
// StreamEvent is one event in the Claude streaming event sequence.
type StreamEvent struct {
Type string `json:"type"`
Message json.RawMessage `json:"message,omitempty"`
Index *int `json:"index,omitempty"`
Delta json.RawMessage `json:"delta,omitempty"`
Usage json.RawMessage `json:"usage,omitempty"`
ContentBlock json.RawMessage `json:"content_block,omitempty"`
Error json.RawMessage `json:"error,omitempty"`
}
+196
View File
@@ -0,0 +1,196 @@
package convert
import (
"encoding/json"
"strings"
"openteam/server/internal/proxy/claude"
"openteam/server/internal/proxy/openai"
"openteam/server/internal/proxy/responses"
)
// CanonicalRequest is the gateway-internal standard form (OpenAI chat shape).
// Every protocol is converted into this before being emitted to a channel.
type CanonicalRequest struct {
Model string
System string
Messages []openai.ChatMessage
Stream bool
Temperature *float64
TopP *float64
MaxTokens *int
Stop []string
Tools []openai.Tool
ToolChoice json.RawMessage
ResponseFormat json.RawMessage
// Extra passthrough-only fields for OpenAI channels.
RawOpenAIExtras map[string]json.RawMessage
}
// contentString returns a plain-text representation of a message content,
// handling both string and structured (Claude-style blocks) content.
func contentString(raw json.RawMessage) string {
if len(raw) == 0 {
return ""
}
var s string
if err := json.Unmarshal(raw, &s); err == nil {
return s
}
var blocks []struct {
Type string `json:"type"`
Text string `json:"text"`
}
if err := json.Unmarshal(raw, &blocks); err == nil {
out := ""
for _, b := range blocks {
if b.Text != "" {
out += b.Text
}
}
return out
}
return ""
}
// requestFromOpenAIChat parses an OpenAI chat body into the canonical form.
func requestFromOpenAIChat(body []byte) (*CanonicalRequest, error) {
var r openai.ChatRequest
if err := json.Unmarshal(body, &r); err != nil {
return nil, err
}
var msgs []openai.ChatMessage
if err := json.Unmarshal(r.Messages, &msgs); err != nil {
return nil, err
}
req := &CanonicalRequest{
Model: r.Model,
Messages: msgs,
Stream: r.Stream,
Temperature: r.Temperature,
TopP: r.TopP,
Stop: parseStop(r.Stop),
ToolChoice: r.ToolChoice,
}
if r.MaxTokens != nil {
req.MaxTokens = r.MaxTokens
} else if r.MaxCompl != nil {
req.MaxTokens = r.MaxCompl
}
if len(r.Tools) > 0 {
_ = json.Unmarshal(r.Tools, &req.Tools)
}
req.ResponseFormat = r.ResponseFmt
req.System = extractSystem(msgs)
req.RawOpenAIExtras = rawExtras(body, "model", "messages", "stream", "temperature", "top_p", "max_tokens", "max_completion_tokens", "stop", "tools", "tool_choice", "response_format", "user")
return req, nil
}
func requestFromClaude(body []byte) (*CanonicalRequest, error) {
var r claude.Request
if err := json.Unmarshal(body, &r); err != nil {
return nil, err
}
var msgs []openai.ChatMessage
// Claude messages: content can be a string or blocks; tool_use/tool_result
// blocks map to assistant.tool_calls and role=tool messages.
if err := claudeMessagesToChat(r.Messages, &msgs); err != nil {
return nil, err
}
req := &CanonicalRequest{
Model: r.Model,
Messages: msgs,
Stream: r.Stream,
Temperature: r.Temperature,
TopP: r.TopP,
Stop: parseStop(r.StopSequences),
}
if r.MaxTokens > 0 {
mt := r.MaxTokens
req.MaxTokens = &mt
}
if len(r.Tools) > 0 {
_ = json.Unmarshal(r.Tools, &req.Tools)
}
req.ToolChoice = r.ToolChoice
// Claude system can be a string or array of blocks.
req.System = contentString(r.System)
return req, nil
}
func requestFromResponses(body []byte) (*CanonicalRequest, error) {
var r responses.Request
if err := json.Unmarshal(body, &r); err != nil {
return nil, err
}
req := &CanonicalRequest{
Model: r.Model,
Stream: r.Stream,
}
// instructions → system.
req.System = contentString(r.Instructions)
// Parse input items into chat messages.
msgs, err := responsesInputToChat(r.Input)
if err != nil {
return nil, err
}
req.Messages = msgs
if r.MaxOutputTokens != nil {
req.MaxTokens = r.MaxOutputTokens
}
if len(r.Tools) > 0 {
_ = json.Unmarshal(r.Tools, &req.Tools)
}
// output_format / text.format → response_format.
if len(r.OutputFormat) > 0 {
req.ResponseFormat = r.OutputFormat
} else if len(r.Text) > 0 {
var t struct {
Format json.RawMessage `json:"format"`
}
if json.Unmarshal(r.Text, &t) == nil && len(t.Format) > 0 {
req.ResponseFormat = t.Format
}
}
return req, nil
}
func extractSystem(msgs []openai.ChatMessage) string {
var parts []string
for _, m := range msgs {
if m.Role == "system" {
if s := contentString(m.Content); s != "" {
parts = append(parts, s)
}
}
}
return strings.Join(parts, "\n")
}
func parseStop(raw json.RawMessage) []string {
if len(raw) == 0 {
return nil
}
var one string
if err := json.Unmarshal(raw, &one); err == nil {
return []string{one}
}
var many []string
if err := json.Unmarshal(raw, &many); err == nil {
return many
}
return nil
}
// rawExtras captures fields not otherwise modeled so they can be re-emitted
// on OpenAI passthrough-style conversions.
func rawExtras(body []byte, skip ...string) map[string]json.RawMessage {
var obj map[string]json.RawMessage
if json.Unmarshal(body, &obj) != nil {
return nil
}
for _, k := range skip {
delete(obj, k)
}
return obj
}
@@ -0,0 +1,269 @@
package convert
import (
"encoding/json"
"openteam/server/internal/proxy/openai"
)
// claudeMessagesToChat converts Claude messages JSON into OpenAI chat messages.
func claudeMessagesToChat(raw json.RawMessage, out *[]openai.ChatMessage) error {
var msgs []struct {
Role string `json:"role"`
Content json.RawMessage `json:"content"`
}
if err := json.Unmarshal(raw, &msgs); err != nil {
return err
}
for _, m := range msgs {
var text string
if err := json.Unmarshal(m.Content, &text); err == nil {
content, _ := json.Marshal(text)
*out = append(*out, openai.ChatMessage{Role: m.Role, Content: content})
continue
}
var blocks []map[string]json.RawMessage
if err := json.Unmarshal(m.Content, &blocks); err != nil {
return err
}
// Split blocks into: plain content parts, tool_use (→ tool_calls),
// and tool_result (→ separate role=tool messages).
var parts []json.RawMessage
var toolCalls []json.RawMessage
for _, b := range blocks {
var typ string
_ = json.Unmarshal(b["type"], &typ)
switch typ {
case "tool_use":
tc := map[string]any{
"id": rawString(b["id"]),
"type": "function",
"function": map[string]any{
"name": rawString(b["name"]),
"arguments": string(b["input"]),
},
}
encoded, _ := json.Marshal(tc)
toolCalls = append(toolCalls, encoded)
case "tool_result":
msg := openai.ChatMessage{
Role: "tool",
ToolCallID: rawString(b["tool_use_id"]),
}
// content may be a string or array of text blocks.
if b["content"] != nil {
var s string
if json.Unmarshal(b["content"], &s) == nil {
content, _ := json.Marshal(s)
msg.Content = content
} else {
var texts []struct {
Type string `json:"type"`
Text string `json:"text"`
}
if json.Unmarshal(b["content"], &texts) == nil {
var buf string
for _, t := range texts {
buf += t.Text
}
content, _ := json.Marshal(buf)
msg.Content = content
}
}
}
encoded, _ := json.Marshal(msg)
*out = append(*out, msg)
_ = encoded
default:
// text / image blocks → OpenAI content part.
part, err := claudeBlockToOpenAIContent(b)
if err != nil {
return err
}
if part != nil {
parts = append(parts, part)
}
}
}
if len(toolCalls) > 0 {
msg := openai.ChatMessage{Role: "assistant"}
if len(parts) > 0 {
content, _ := json.Marshal(partsToText(parts))
msg.Content = content
}
tcArr, _ := json.Marshal(toolCalls)
msg.ToolCalls = tcArr
*out = append(*out, msg)
} else if len(parts) > 0 {
if len(parts) == 1 {
// Collapse a single text part back to a plain string.
var s string
if json.Unmarshal(parts[0], &s) == nil {
*out = append(*out, openai.ChatMessage{Role: m.Role, Content: parts[0]})
} else {
arr, _ := json.Marshal(parts)
*out = append(*out, openai.ChatMessage{Role: m.Role, Content: arr})
}
} else {
arr, _ := json.Marshal(parts)
*out = append(*out, openai.ChatMessage{Role: m.Role, Content: arr})
}
}
}
return nil
}
func claudeBlockToOpenAIContent(b map[string]json.RawMessage) (json.RawMessage, error) {
var typ string
_ = json.Unmarshal(b["type"], &typ)
switch typ {
case "text":
part := map[string]any{"type": "text", "text": rawString(b["text"])}
return json.Marshal(part)
case "image":
// b["source"] may not be present; be defensive.
if b["source"] != nil {
var src struct {
Type string `json:"type"`
URL string `json:"url"`
Data string `json:"data"`
MediaType string `json:"media_type"`
}
_ = json.Unmarshal(b["source"], &src)
if src.URL != "" {
return json.Marshal(map[string]any{"type": "image_url", "image_url": map[string]any{"url": src.URL}})
}
if src.Data != "" {
return json.Marshal(map[string]any{"type": "image_url", "image_url": map[string]any{"url": "data:" + src.MediaType + ";base64," + src.Data}})
}
}
return nil, nil
default:
return nil, nil
}
}
func partsToText(parts []json.RawMessage) string {
var out string
for _, p := range parts {
var t struct {
Type string `json:"type"`
Text string `json:"text"`
}
if json.Unmarshal(p, &t) == nil {
out += t.Text
}
}
return out
}
func rawString(raw json.RawMessage) string {
if len(raw) == 0 {
return ""
}
var s string
if err := json.Unmarshal(raw, &s); err != nil {
return string(raw)
}
return s
}
// chatToClaudeMessages converts chat messages (with system already removed)
// into Claude Messages body.
func chatToClaudeMessages(msgs []openai.ChatMessage, system string) (map[string]any, error) {
claudeMsgs := []map[string]any{}
for _, m := range msgs {
if m.Role == "system" {
continue
}
// Tool calls on assistant messages → tool_use blocks.
if m.Role == "assistant" && len(m.ToolCalls) > 0 {
content := []map[string]any{}
// Preserve any text content.
if text := contentString(m.Content); text != "" {
content = append(content, map[string]any{"type": "text", "text": text})
}
var calls []struct {
ID string `json:"id"`
Function struct {
Name string `json:"name"`
Arguments json.RawMessage `json:"arguments"`
} `json:"function"`
}
_ = json.Unmarshal(m.ToolCalls, &calls)
for _, c := range calls {
var input map[string]any
if err := json.Unmarshal(c.Function.Arguments, &input); err != nil {
input = map[string]any{"raw": string(c.Function.Arguments)}
}
content = append(content, map[string]any{
"type": "tool_use",
"id": c.ID,
"name": c.Function.Name,
"input": input,
})
}
claudeMsgs = append(claudeMsgs, map[string]any{"role": "assistant", "content": content})
continue
}
// Tool role messages → tool_result blocks.
if m.Role == "tool" {
content := []map[string]any{{
"type": "tool_result",
"tool_use_id": m.ToolCallID,
"content": contentString(m.Content),
}}
claudeMsgs = append(claudeMsgs, map[string]any{"role": "user", "content": content})
continue
}
claudeMsgs = append(claudeMsgs, map[string]any{"role": m.Role, "content": contentString(m.Content)})
}
if len(claudeMsgs) == 0 {
claudeMsgs = append(claudeMsgs, map[string]any{"role": "user", "content": "Hi"})
}
body := map[string]any{"messages": claudeMsgs}
if system != "" {
body["system"] = system
}
return body, nil
}
// toolsToClaude converts OpenAI function tools to Claude tools.
func toolsToClaude(tools []openai.Tool) []map[string]any {
out := []map[string]any{}
for _, t := range tools {
if t.Type != "" && t.Type != "function" {
continue
}
out = append(out, map[string]any{
"name": t.Function.Name,
"description": t.Function.Description,
"input_schema": json.RawMessage(t.Function.Parameters),
})
}
return out
}
// claudeToolsToOpenAI converts Claude tools to OpenAI function tools.
func claudeToolsToOpenAI(tools json.RawMessage) []openai.Tool {
var list []struct {
Name string `json:"name"`
Description string `json:"description"`
InputSchema json.RawMessage `json:"input_schema"`
}
if err := json.Unmarshal(tools, &list); err != nil {
return nil
}
out := []openai.Tool{}
for _, t := range list {
out = append(out, openai.Tool{
Type: "function",
Function: openai.FunctionTool{
Name: t.Name,
Description: t.Description,
Parameters: t.InputSchema,
},
})
}
return out
}
+357
View File
@@ -0,0 +1,357 @@
package convert
import (
"encoding/json"
"openteam/server/internal/proxy/claude"
"openteam/server/internal/proxy/openai"
)
// ClientProtocol mirrors the gateway's Protocol but kept here to avoid an
// import cycle with the proxy package.
type ClientProtocol string
const (
ClientOpenAIChat ClientProtocol = "openai-chat"
ClientOpenAIResponses ClientProtocol = "openai-responses"
ClientAnthropic ClientProtocol = "anthropic"
)
// Request converts a request body from the client protocol to the channel
// provider's native format.
func Request(client ClientProtocol, provider string, body []byte, upstreamModel string) ([]byte, error) {
var canon *CanonicalRequest
var err error
switch client {
case ClientOpenAIChat:
canon, err = requestFromOpenAIChat(body)
case ClientOpenAIResponses:
canon, err = requestFromResponses(body)
case ClientAnthropic:
canon, err = requestFromClaude(body)
default:
return nil, jsonError("unsupported client protocol")
}
if err != nil {
return nil, err
}
canon.Model = upstreamModel
if canon.Model == "" {
canon.Model = ""
}
switch provider {
case "anthropic":
return canonicalToClaude(canon)
case "openai", "compatible":
return canonicalToOpenAIChat(canon)
default:
return nil, jsonError("unsupported provider")
}
}
// Response converts a non-stream upstream response into the client protocol.
func Response(client ClientProtocol, provider string, body []byte) ([]byte, error) {
if client == ClientOpenAIChat && provider == "openai" {
return body, nil // passthrough
}
if client == ClientOpenAIResponses && provider == "openai" {
return body, nil
}
if client == ClientAnthropic && provider == "anthropic" {
return body, nil
}
if provider == "anthropic" {
var mr claude.MessageResponse
if err := json.Unmarshal(body, &mr); err != nil {
return nil, err
}
switch client {
case ClientOpenAIChat:
return claudeResponseToChat(&mr)
case ClientOpenAIResponses:
return claudeResponseToResponses(&mr)
}
}
if provider == "openai" || provider == "compatible" {
var cc openai.ChatCompletion
if err := json.Unmarshal(body, &cc); err != nil {
return nil, err
}
switch client {
case ClientAnthropic:
return chatResponseToClaude(&cc)
}
}
return nil, jsonError("no conversion path")
}
// canonicalToOpenAIChat re-emits the canonical request as OpenAI chat JSON.
func canonicalToOpenAIChat(c *CanonicalRequest) ([]byte, error) {
obj := map[string]any{
"model": c.Model,
"messages": c.Messages,
}
if c.Stream {
obj["stream"] = true
}
if c.Temperature != nil {
obj["temperature"] = *c.Temperature
}
if c.TopP != nil {
obj["top_p"] = *c.TopP
}
if c.MaxTokens != nil {
obj["max_tokens"] = *c.MaxTokens
}
if len(c.Stop) > 0 {
obj["stop"] = c.Stop
}
if len(c.Tools) > 0 {
obj["tools"] = c.Tools
}
if len(c.ToolChoice) > 0 {
obj["tool_choice"] = json.RawMessage(c.ToolChoice)
}
if len(c.ResponseFormat) > 0 {
obj["response_format"] = json.RawMessage(c.ResponseFormat)
}
for k, v := range c.RawOpenAIExtras {
obj[k] = json.RawMessage(v)
}
return json.Marshal(obj)
}
// canonicalToClaude emits the canonical request as an Anthropic Messages body.
func canonicalToClaude(c *CanonicalRequest) ([]byte, error) {
body, err := chatToClaudeMessages(c.Messages, c.System)
if err != nil {
return nil, err
}
maxTokens := 4096
if c.MaxTokens != nil {
maxTokens = *c.MaxTokens
}
body["model"] = c.Model
body["max_tokens"] = maxTokens
if c.Stream {
body["stream"] = true
}
if c.Temperature != nil {
// Claude clamps temperature to [0,1].
t := *c.Temperature
if t > 1 {
t = 1
}
if t < 0 {
t = 0
}
body["temperature"] = t
}
if c.TopP != nil {
body["top_p"] = *c.TopP
}
if len(c.Stop) > 0 {
body["stop_sequences"] = c.Stop
}
if tools := toolsToClaude(c.Tools); len(tools) > 0 {
body["tools"] = tools
}
if len(c.ToolChoice) > 0 {
var tc json.RawMessage
if err := json.Unmarshal(c.ToolChoice, &tc); err == nil {
body["tool_choice"] = tc
}
}
return json.Marshal(body)
}
// claudeResponseToChat converts a Claude non-stream response to OpenAI chat.
func claudeResponseToChat(mr *claude.MessageResponse) ([]byte, error) {
var blocks []struct {
Type string `json:"type"`
Text string `json:"text"`
ID string `json:"id"`
Name string `json:"name"`
Input json.RawMessage `json:"input"`
}
_ = json.Unmarshal(mr.Content, &blocks)
content := ""
var toolCalls []map[string]any
for _, b := range blocks {
switch b.Type {
case "text":
if content == "" {
content = b.Text
} else {
content += b.Text
}
case "tool_use":
input, _ := json.Marshal(b.Input)
toolCalls = append(toolCalls, map[string]any{
"id": b.ID,
"type": "function",
"function": map[string]any{
"name": b.Name,
"arguments": string(input),
},
})
}
}
msg := map[string]any{"role": "assistant", "content": content}
if len(toolCalls) > 0 {
msg["tool_calls"] = toolCalls
}
finish := mapClaudeStopReason(mr.StopReason)
choices := []any{map[string]any{"index": 0, "message": msg, "finish_reason": finish}}
resp := map[string]any{
"id": "chatcmpl-" + mr.ID,
"object": "chat.completion",
"created": json.Number("0"),
"model": mr.Model,
"choices": choices,
}
if mr.Usage != nil {
resp["usage"] = map[string]any{
"prompt_tokens": mr.Usage.InputTokens,
"completion_tokens": mr.Usage.OutputTokens,
"total_tokens": mr.Usage.InputTokens + mr.Usage.OutputTokens,
}
}
return json.Marshal(resp)
}
// chatResponseToClaude converts an OpenAI non-stream response to Claude.
func chatResponseToClaude(cc *openai.ChatCompletion) ([]byte, error) {
content := []map[string]any{}
if len(cc.Choices) > 0 {
ch := cc.Choices[0]
if text := contentString(ch.Message.Content); text != "" {
content = append(content, map[string]any{"type": "text", "text": text})
}
if len(ch.Message.ToolCalls) > 0 {
var calls []struct {
ID string `json:"id"`
Function struct {
Name string `json:"name"`
Arguments json.RawMessage `json:"arguments"`
} `json:"function"`
}
_ = json.Unmarshal(ch.Message.ToolCalls, &calls)
for _, call := range calls {
var input map[string]any
_ = json.Unmarshal(call.Function.Arguments, &input)
content = append(content, map[string]any{
"type": "tool_use",
"id": call.ID,
"name": call.Function.Name,
"input": input,
})
}
}
}
resp := map[string]any{
"id": mrID(cc.ID),
"type": "message",
"role": "assistant",
"model": cc.Model,
"content": content,
"stop_reason": mapChatStopReason(cc),
"usage": map[string]any{
"input_tokens": usageInt64(cc, true),
"output_tokens": usageInt64(cc, false),
},
}
return json.Marshal(resp)
}
// claudeResponseToResponses converts a Claude non-stream response to a
// Responses API response.
func claudeResponseToResponses(mr *claude.MessageResponse) ([]byte, error) {
chatCC := &openai.ChatCompletion{
ID: mr.ID,
Model: mr.Model,
Choices: []openai.ChatChoice{{FinishReason: mapClaudeStopReason(mr.StopReason)}},
}
// Reuse the chat conversion then re-shape into responses items.
chatBody, err := claudeResponseToChat(mr)
if err == nil {
var cc openai.ChatCompletion
if json.Unmarshal(chatBody, &cc) == nil {
chatCC = &cc
}
}
items, status := chatToResponsesOutput(chatCC)
resp := map[string]any{
"id": "resp_" + mr.ID,
"object": "response",
"created": json.Number("0"),
"model": mr.Model,
"status": status,
"output": items,
}
if mr.Usage != nil {
resp["usage"] = map[string]any{
"input_tokens": mr.Usage.InputTokens,
"output_tokens": mr.Usage.OutputTokens,
"total_tokens": mr.Usage.InputTokens + mr.Usage.OutputTokens,
"input_tokens_details": map[string]any{
"cached_tokens": mr.Usage.CacheReadInputTokens,
},
}
}
return json.Marshal(resp)
}
func mapClaudeStopReason(reason string) string {
switch reason {
case "end_turn":
return "stop"
case "max_tokens":
return "length"
case "stop_sequence":
return "stop"
case "tool_use":
return "tool_calls"
case "refusal":
return "content_filter"
default:
return "stop"
}
}
func mapChatStopReason(cc *openai.ChatCompletion) string {
if len(cc.Choices) == 0 {
return "end_turn"
}
switch cc.Choices[0].FinishReason {
case "stop":
return "end_turn"
case "length":
return "max_tokens"
case "tool_calls":
return "tool_use"
case "content_filter":
return "refusal"
default:
return "end_turn"
}
}
func usageInt64(cc *openai.ChatCompletion, input bool) int64 {
if cc.Usage == nil {
return 0
}
if input {
return cc.Usage.PromptTokens
}
return cc.Usage.CompletionTokens
}
func mrID(id string) string {
if id == "" {
return "msg_unknown"
}
return id
}
+161
View File
@@ -0,0 +1,161 @@
package convert
import (
"encoding/json"
"errors"
"openteam/server/internal/proxy/openai"
)
// responsesInputToChat converts Responses API input items into chat messages.
func responsesInputToChat(raw json.RawMessage) ([]openai.ChatMessage, error) {
var msgs []openai.ChatMessage
// `input` may be a plain string.
var s string
if json.Unmarshal(raw, &s) == nil {
content, _ := json.Marshal(s)
msgs = append(msgs, openai.ChatMessage{Role: "user", Content: content})
return msgs, nil
}
// Or an array of content parts (text/image).
var parts []map[string]json.RawMessage
if json.Unmarshal(raw, &parts) == nil {
chat, err := contentPartsToChat(parts)
if err == nil {
return chat, nil
}
}
var items []map[string]json.RawMessage
if err := json.Unmarshal(raw, &items); err != nil {
return nil, err
}
for _, item := range items {
var typ string
_ = json.Unmarshal(item["type"], &typ)
switch typ {
case "message":
var role string
_ = json.Unmarshal(item["role"], &role)
content, err := contentPartsToText(item["content"])
if err != nil {
continue
}
msgs = append(msgs, openai.ChatMessage{Role: role, Content: content})
case "function_call":
tc := map[string]any{
"id": rawString(item["call_id"]),
"type": "function",
"function": map[string]any{
"name": rawString(item["name"]),
"arguments": rawString(item["arguments"]),
},
}
tcArr, _ := json.Marshal([]any{tc})
msgs = append(msgs, openai.ChatMessage{Role: "assistant", ToolCalls: tcArr})
case "function_call_output":
content, _ := json.Marshal(rawString(item["output"]))
msgs = append(msgs, openai.ChatMessage{Role: "tool", ToolCallID: rawString(item["call_id"]), Content: content})
case "reasoning", "computer_call", "web_search_call":
// Not representable in chat; drop.
}
}
if len(msgs) == 0 {
content, _ := json.Marshal("")
msgs = append(msgs, openai.ChatMessage{Role: "user", Content: content})
}
return msgs, nil
}
// contentPartsToText flattens an array of content parts into a string.
func contentPartsToText(raw json.RawMessage) (json.RawMessage, error) {
var parts []map[string]json.RawMessage
if err := json.Unmarshal(raw, &parts); err != nil {
var s string
if err := json.Unmarshal(raw, &s); err == nil {
c, _ := json.Marshal(s)
return c, nil
}
return nil, err
}
var buf string
for _, p := range parts {
var typ string
_ = json.Unmarshal(p["type"], &typ)
switch typ {
case "input_text", "output_text", "text":
buf += rawString(p["text"])
}
}
c, _ := json.Marshal(buf)
return c, nil
}
func contentPartsToChat(parts []map[string]json.RawMessage) ([]openai.ChatMessage, error) {
out := []map[string]any{}
for _, p := range parts {
var typ string
_ = json.Unmarshal(p["type"], &typ)
switch typ {
case "input_text", "output_text", "text":
out = append(out, map[string]any{"type": "text", "text": rawString(p["text"])})
case "input_image":
out = append(out, map[string]any{
"type": "image_url",
"image_url": map[string]any{
"url": "data:" + rawString(p["media_type"]) + ";base64," + rawString(p["image_url"]),
},
})
}
}
if len(out) == 0 {
return nil, jsonError("empty content")
}
arr, _ := json.Marshal(out)
msg := openai.ChatMessage{Role: "user", Content: arr}
return []openai.ChatMessage{msg}, nil
}
// chatToResponsesOutput builds Responses output items from a chat completion.
func chatToResponsesOutput(cc *openai.ChatCompletion) ([]map[string]any, string) {
var items []map[string]any
var status = "completed"
if len(cc.Choices) == 0 {
return items, status
}
ch := cc.Choices[0]
if ch.FinishReason == "length" {
status = "incomplete"
}
content := []map[string]any{}
if text := contentString(ch.Message.Content); text != "" {
content = append(content, map[string]any{"type": "output_text", "text": text})
}
items = append(items, map[string]any{"type": "message", "role": "assistant", "content": content})
if len(ch.Message.ToolCalls) > 0 {
var calls []struct {
ID string `json:"id"`
Function struct {
Name string `json:"name"`
Arguments json.RawMessage `json:"arguments"`
} `json:"function"`
}
if json.Unmarshal(ch.Message.ToolCalls, &calls) == nil {
for _, call := range calls {
items = append(items, map[string]any{
"type": "function_call",
"call_id": call.ID,
"name": call.Function.Name,
"arguments": rawString(call.Function.Arguments),
})
}
}
}
return items, status
}
func jsonError(msg string) error {
return errors.New(msg)
}
+416
View File
@@ -0,0 +1,416 @@
package convert
import (
"encoding/json"
"openteam/server/internal/proxy/claude"
"openteam/server/internal/proxy/openai"
"openteam/server/internal/proxy/stream"
)
// Usage is the streaming token usage snapshot.
type Usage struct {
Input int64
Output int64
CacheRead int64
CacheCreation int64
}
// Translator converts SSE events from an upstream stream into client frames.
type Translator interface {
// Feed handles one upstream SSE event, returning client frames to write.
Feed(ev stream.SSEEvent) ([]stream.SSEEvent, error)
// Finish is called at end-of-stream, returning final frames.
Finish() ([]stream.SSEEvent, error)
// Usage returns the latest known usage.
Usage() *Usage
}
// claudeToChatTranslator converts a Claude stream to OpenAI chat chunks.
type claudeToChatTranslator struct {
model string
usage *Usage
started bool
finishSent bool
toolCallIndex int
toolCallID string
toolCallName string
}
func (t *claudeToChatTranslator) Feed(ev stream.SSEEvent) ([]stream.SSEEvent, error) {
if ev.Done {
return nil, nil
}
var e claude.StreamEvent
if err := json.Unmarshal([]byte(ev.Data), &e); err != nil {
return nil, nil
}
var out []stream.SSEEvent
switch e.Type {
case "message_start":
if e.Message != nil {
var msg struct {
Model string `json:"model"`
}
_ = json.Unmarshal(e.Message, &msg)
t.model = msg.Model
}
chunk, _ := json.Marshal(openai.ChatChunk{
ID: "chatcmpl-stream", Object: "chat.completion.chunk", Model: t.model,
Choices: []openai.ChatChunkChoice{{Index: 0, Delta: openai.ChatDelta{Role: "assistant"}}},
})
out = append(out, stream.SSEEvent{Data: string(chunk)})
t.started = true
case "content_block_start":
var cb struct {
Index int `json:"index"`
Block json.RawMessage `json:"content_block"`
}
_ = json.Unmarshal([]byte(ev.Data), &cb)
var block struct {
Type string `json:"type"`
ID string `json:"id"`
Name string `json:"name"`
}
_ = json.Unmarshal(cb.Block, &block)
if block.Type == "tool_use" {
t.toolCallIndex = cb.Index
t.toolCallID = block.ID
t.toolCallName = block.Name
tc, _ := json.Marshal([]map[string]any{{
"index": cb.Index, "id": block.ID, "type": "function",
"function": map[string]any{"name": block.Name, "arguments": ""},
}})
chunk, _ := json.Marshal(openai.ChatChunk{
ID: "chatcmpl-stream", Object: "chat.completion.chunk", Model: t.model,
Choices: []openai.ChatChunkChoice{{Index: 0, Delta: openai.ChatDelta{ToolCalls: tc}}},
})
out = append(out, stream.SSEEvent{Data: string(chunk)})
}
case "content_block_delta":
var d struct {
Index int `json:"index"`
Delta json.RawMessage `json:"delta"`
}
_ = json.Unmarshal([]byte(ev.Data), &d)
var delta struct {
Type string `json:"type"`
Text string `json:"text"`
PartialJSON string `json:"partial_json"`
}
_ = json.Unmarshal(d.Delta, &delta)
if delta.Type == "text_delta" && delta.Text != "" {
chunk, _ := json.Marshal(openai.ChatChunk{
ID: "chatcmpl-stream", Object: "chat.completion.chunk", Model: t.model,
Choices: []openai.ChatChunkChoice{{Index: 0, Delta: openai.ChatDelta{Content: delta.Text}}},
})
out = append(out, stream.SSEEvent{Data: string(chunk)})
} else if delta.Type == "input_json_delta" && delta.PartialJSON != "" {
tc, _ := json.Marshal([]map[string]any{{
"index": d.Index, "function": map[string]any{"arguments": delta.PartialJSON},
}})
chunk, _ := json.Marshal(openai.ChatChunk{
ID: "chatcmpl-stream", Object: "chat.completion.chunk", Model: t.model,
Choices: []openai.ChatChunkChoice{{Index: 0, Delta: openai.ChatDelta{ToolCalls: tc}}},
})
out = append(out, stream.SSEEvent{Data: string(chunk)})
}
case "message_delta":
var d struct {
Delta json.RawMessage `json:"delta"`
Usage json.RawMessage `json:"usage"`
}
_ = json.Unmarshal([]byte(ev.Data), &d)
if len(d.Usage) > 0 {
var u claude.Usage
if json.Unmarshal(d.Usage, &u) == nil {
t.usage = &Usage{
Input: u.InputTokens, Output: u.OutputTokens,
CacheRead: u.CacheReadInputTokens, CacheCreation: u.CacheCreationInputTokens,
}
}
}
if len(d.Delta) > 0 && !t.finishSent {
var delta struct {
StopReason string `json:"stop_reason"`
}
_ = json.Unmarshal(d.Delta, &delta)
if delta.StopReason != "" {
reason := mapClaudeStopReason(delta.StopReason)
chunk, _ := json.Marshal(openai.ChatChunk{
ID: "chatcmpl-stream", Object: "chat.completion.chunk", Model: t.model,
Choices: []openai.ChatChunkChoice{{Index: 0, Delta: openai.ChatDelta{}, FinishReason: &reason}},
})
out = append(out, stream.SSEEvent{Data: string(chunk)})
t.finishSent = true
}
}
}
return out, nil
}
func (t *claudeToChatTranslator) Finish() ([]stream.SSEEvent, error) {
if !t.finishSent {
reason := "stop"
chunk, _ := json.Marshal(openai.ChatChunk{
ID: "chatcmpl-stream", Object: "chat.completion.chunk", Model: t.model,
Choices: []openai.ChatChunkChoice{{Index: 0, Delta: openai.ChatDelta{}, FinishReason: &reason}},
})
t.finishSent = true
return []stream.SSEEvent{{Data: string(chunk)}, {Data: "[DONE]"}}, nil
}
return []stream.SSEEvent{{Data: "[DONE]"}}, nil
}
func (t *claudeToChatTranslator) Usage() *Usage { return t.usage }
// chatToClaudeTranslator converts an OpenAI chat stream to Claude events.
type chatToClaudeTranslator struct {
usage *Usage
started bool
openBlock bool
blockType string
toolIndex int
finishSent bool
}
func (t *chatToClaudeTranslator) Feed(ev stream.SSEEvent) ([]stream.SSEEvent, error) {
if ev.Done {
return nil, nil
}
var chunk openai.ChatChunk
if err := json.Unmarshal([]byte(ev.Data), &chunk); err != nil {
return nil, nil
}
if chunk.Usage != nil {
t.usage = &Usage{Input: chunk.Usage.PromptTokens, Output: chunk.Usage.CompletionTokens}
}
var out []stream.SSEEvent
if len(chunk.Choices) == 0 {
return out, nil
}
ch := chunk.Choices[0]
if !t.started {
msg, _ := json.Marshal(map[string]any{
"id": "msg_stream", "type": "message", "role": "assistant",
"model": chunk.Model, "content": []any{},
})
start, _ := json.Marshal(map[string]any{"type": "message_start", "message": json.RawMessage(msg)})
out = append(out, stream.SSEEvent{Data: string(start)})
t.started = true
}
if ch.Delta.Content != "" {
if !t.openBlock || t.blockType != "text" {
start, _ := json.Marshal(map[string]any{
"type": "content_block_start", "index": 0,
"content_block": map[string]any{"type": "text", "text": ""},
})
out = append(out, stream.SSEEvent{Data: string(start)})
t.openBlock = true
t.blockType = "text"
t.toolIndex = 0
}
delta, _ := json.Marshal(map[string]any{
"type": "content_block_delta", "index": 0,
"delta": map[string]any{"type": "text_delta", "text": ch.Delta.Content},
})
out = append(out, stream.SSEEvent{Data: string(delta)})
}
if len(ch.Delta.ToolCalls) > 0 {
var calls []struct {
Index *int `json:"index"`
ID string `json:"id"`
Function struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
} `json:"function"`
}
_ = json.Unmarshal(ch.Delta.ToolCalls, &calls)
for _, call := range calls {
idx := 0
if call.Index != nil {
idx = *call.Index
}
if !t.openBlock || t.blockType != "tool_use" || idx != t.toolIndex {
start, _ := json.Marshal(map[string]any{
"type": "content_block_start", "index": idx,
"content_block": map[string]any{
"type": "tool_use", "id": call.ID, "name": call.Function.Name, "input": map[string]any{},
},
})
out = append(out, stream.SSEEvent{Data: string(start)})
t.openBlock = true
t.blockType = "tool_use"
t.toolIndex = idx
}
if call.Function.Arguments != "" {
delta, _ := json.Marshal(map[string]any{
"type": "content_block_delta", "index": idx,
"delta": map[string]any{"type": "input_json_delta", "partial_json": call.Function.Arguments},
})
out = append(out, stream.SSEEvent{Data: string(delta)})
}
}
}
if ch.FinishReason != nil && !t.finishSent {
reason := mapChatStopReasonToClaude(*ch.FinishReason)
md, _ := json.Marshal(map[string]any{
"type": "message_delta",
"delta": map[string]any{"stop_reason": reason, "stop_sequence": nil},
})
out = append(out, stream.SSEEvent{Data: string(md)})
t.finishSent = true
}
return out, nil
}
func (t *chatToClaudeTranslator) Finish() ([]stream.SSEEvent, error) {
if !t.finishSent {
md, _ := json.Marshal(map[string]any{
"type": "message_delta",
"delta": map[string]any{"stop_reason": "end_turn", "stop_sequence": nil},
})
t.finishSent = true
return []stream.SSEEvent{{Data: string(md)}, {Data: "{\"type\":\"message_stop\"}"}}, nil
}
return []stream.SSEEvent{{Data: "{\"type\":\"message_stop\"}"}}, nil
}
func (t *chatToClaudeTranslator) Usage() *Usage { return t.usage }
// claudeToResponsesTranslator converts a Claude stream to Responses events.
type claudeToResponsesTranslator struct {
usage *Usage
model string
completed bool
}
func (t *claudeToResponsesTranslator) Feed(ev stream.SSEEvent) ([]stream.SSEEvent, error) {
if ev.Done {
return nil, nil
}
var e claude.StreamEvent
if err := json.Unmarshal([]byte(ev.Data), &e); err != nil {
return nil, nil
}
var out []stream.SSEEvent
switch e.Type {
case "message_start":
if e.Message != nil {
var msg struct {
Model string `json:"model"`
}
_ = json.Unmarshal(e.Message, &msg)
t.model = msg.Model
}
created, _ := json.Marshal(map[string]any{
"type": "response.created",
"response": map[string]any{"id": "resp_stream", "object": "response", "status": "in_progress", "model": t.model, "output": []any{}},
})
out = append(out, stream.SSEEvent{Data: string(created)})
case "content_block_delta":
var d struct {
Index int `json:"index"`
Delta json.RawMessage `json:"delta"`
}
_ = json.Unmarshal([]byte(ev.Data), &d)
var delta struct {
Type string `json:"type"`
Text string `json:"text"`
}
_ = json.Unmarshal(d.Delta, &delta)
if delta.Type == "text_delta" && delta.Text != "" {
item, _ := json.Marshal(map[string]any{
"type": "response.output_text.delta", "item_id": "msg_stream", "output_index": 0,
"delta": delta.Text,
})
out = append(out, stream.SSEEvent{Data: string(item)})
}
case "message_delta":
var d struct {
Usage json.RawMessage `json:"usage"`
}
_ = json.Unmarshal([]byte(ev.Data), &d)
if len(d.Usage) > 0 {
var u claude.Usage
if json.Unmarshal(d.Usage, &u) == nil {
t.usage = &Usage{
Input: u.InputTokens, Output: u.OutputTokens,
CacheRead: u.CacheReadInputTokens, CacheCreation: u.CacheCreationInputTokens,
}
}
}
if !t.completed {
item, _ := json.Marshal(map[string]any{
"type": "response.completed",
"response": map[string]any{
"id": "resp_stream", "object": "response", "status": "completed", "model": t.model,
"output": []map[string]any{{"type": "message", "role": "assistant", "content": []any{}}},
},
})
out = append(out, stream.SSEEvent{Data: string(item)})
t.completed = true
}
}
return out, nil
}
func (t *claudeToResponsesTranslator) Finish() ([]stream.SSEEvent, error) {
if !t.completed {
item, _ := json.Marshal(map[string]any{
"type": "response.completed",
"response": map[string]any{
"id": "resp_stream", "object": "response", "status": "completed", "model": t.model,
"output": []map[string]any{{"type": "message", "role": "assistant", "content": []any{}}},
},
})
t.completed = true
return []stream.SSEEvent{{Data: string(item)}, {Data: "[DONE]"}}, nil
}
return []stream.SSEEvent{{Data: "[DONE]"}}, nil
}
func (t *claudeToResponsesTranslator) Usage() *Usage { return t.usage }
func mapChatStopReasonToClaude(reason string) string {
switch reason {
case "stop":
return "end_turn"
case "length":
return "max_tokens"
case "tool_calls":
return "tool_use"
case "content_filter":
return "refusal"
default:
return "end_turn"
}
}
// NewTranslator returns the stream translator for a client protocol +
// upstream provider pair, or nil for passthrough (no translation needed).
func NewTranslator(client ClientProtocol, provider string) Translator {
switch client {
case ClientOpenAIChat:
if provider == "anthropic" {
return &claudeToChatTranslator{}
}
case ClientOpenAIResponses:
if provider == "anthropic" {
return &claudeToResponsesTranslator{}
}
case ClientAnthropic:
if provider == "openai" || provider == "compatible" {
return &chatToClaudeTranslator{}
}
}
return nil
}
+17
View File
@@ -0,0 +1,17 @@
package proxy
import (
"openteam/server/internal/proxy/convert"
)
// convertRequest translates a request body from the client protocol to the
// channel provider's native format.
func convertRequest(route Route, provider string, body []byte, upstreamModel string) ([]byte, error) {
return convert.Request(clientProto(route), provider, body, upstreamModel)
}
// convertResponse translates a non-stream upstream response back to the
// client protocol.
func convertResponse(route Route, provider string, body []byte) ([]byte, error) {
return convert.Response(clientProto(route), provider, body)
}
+414
View File
@@ -0,0 +1,414 @@
package proxy
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"go.uber.org/zap"
"gorm.io/gorm"
"openteam/server/internal/apikey"
"openteam/server/internal/billing"
"openteam/server/internal/channel"
"openteam/server/internal/config"
"openteam/server/internal/pkg/ratelimit"
"openteam/server/internal/usage"
"openteam/server/internal/store"
)
// Protocol identifies the client-facing API protocol.
type Protocol string
const (
ProtocolOpenAIChat Protocol = "openai-chat"
ProtocolOpenAIResponses Protocol = "openai-responses"
ProtocolAnthropic Protocol = "anthropic"
)
// Route describes one proxied endpoint.
type Route struct {
Protocol Protocol
UpstreamPath string // suffix after base URL, e.g. /v1/chat/completions
NativeProvider string // provider type that matches this protocol ("openai" | "anthropic")
}
var Routes = []Route{
{Protocol: ProtocolOpenAIChat, UpstreamPath: "/v1/chat/completions", NativeProvider: "openai"},
{Protocol: ProtocolOpenAIResponses, UpstreamPath: "/v1/responses", NativeProvider: "openai"},
{Protocol: ProtocolAnthropic, UpstreamPath: "/v1/messages", NativeProvider: "anthropic"},
}
const maxBodyBytes = 16 << 20 // 16 MiB
type Gateway struct {
db *gorm.DB
cfg *config.Config
log *zap.Logger
channel *channel.Service
billing *billing.Service
usage *usage.Service
apiKeys *apikey.Service
limiter *ratelimit.Limiter
client *http.Client
}
func NewGateway(db *gorm.DB, cfg *config.Config, log *zap.Logger,
ch *channel.Service, bill *billing.Service, use *usage.Service, ak *apikey.Service) *Gateway {
return &Gateway{
db: db, cfg: cfg, log: log,
channel: ch, billing: bill, usage: use, apiKeys: ak,
limiter: ratelimit.New(float64(cfg.RateLimit.RequestsPerMin)/60.0, cfg.RateLimit.Burst),
client: &http.Client{
// Transport-level timeout; stream reads rely on the context so a
// connected-but-silent upstream is still bounded.
Timeout: time.Duration(cfg.Proxy.DefaultTimeoutMs) * time.Millisecond,
},
}
}
// authContext carries the authenticated key + user through a proxy request.
type authContext struct {
key *store.ApiKey
user *store.User
}
// Handle builds a gin handler for a route.
func (g *Gateway) Handle(route Route) gin.HandlerFunc {
return func(c *gin.Context) {
g.proxy(c, route)
}
}
func (g *Gateway) proxy(c *gin.Context, route Route) {
start := time.Now()
reqID := uuid.NewString()
c.Header("X-Request-Id", reqID)
auth, err := g.authenticate(c)
if err != nil {
g.writeProxyError(c, route.Protocol, http.StatusUnauthorized, "invalid API key")
return
}
body, err := io.ReadAll(io.LimitReader(c.Request.Body, maxBodyBytes))
if err != nil {
g.writeProxyError(c, route.Protocol, http.StatusBadRequest, "failed to read request body")
return
}
if len(body) == 0 {
g.writeProxyError(c, route.Protocol, http.StatusBadRequest, "empty request body")
return
}
var meta struct {
Model string `json:"model"`
Stream bool `json:"stream"`
}
if err := json.Unmarshal(body, &meta); err != nil {
g.writeProxyError(c, route.Protocol, http.StatusBadRequest, "invalid JSON body: "+err.Error())
return
}
if meta.Model == "" {
g.writeProxyError(c, route.Protocol, http.StatusBadRequest, "missing model field")
return
}
// Per-key model whitelist.
if len(auth.key.AllowedModels) > 0 && !contains(auth.key.AllowedModels, meta.Model) {
g.writeProxyError(c, route.Protocol, http.StatusForbidden,
"model not allowed for this API key: "+meta.Model)
return
}
// Rate limit (user + key).
if !g.limiter.Allow(fmt.Sprintf("u:%d", auth.user.ID)) {
g.writeProxyError(c, route.Protocol, http.StatusTooManyRequests, "rate limit exceeded")
return
}
if !g.limiter.Allow(fmt.Sprintf("k:%d", auth.key.ID)) {
g.writeProxyError(c, route.Protocol, http.StatusTooManyRequests, "key rate limit exceeded")
return
}
// Resolve model.
model, err := g.channel.ResolveModel(meta.Model)
if err != nil {
g.writeProxyError(c, route.Protocol, http.StatusNotFound, err.Error())
return
}
// Channel attempts with failover: on transport/5xx failures the request is
// retried against another channel bound to the same model.
exclude := map[int64]bool{}
attempts := 1 + g.cfg.Proxy.MaxRetries
if attempts < 1 {
attempts = 1
}
var lastCh *store.Channel
formatBlocked := false
attempted := false
for attempt := 0; attempt < attempts; attempt++ {
ch, binding, err := g.channel.SelectChannel(model.ID, exclude)
if err != nil {
// Every bound channel was skipped for format reasons: say so
// clearly instead of reporting a generic upstream failure.
if formatBlocked && !attempted {
g.writeProxyError(c, route.Protocol, http.StatusBadRequest,
"no channel supports the "+string(route.Protocol)+" API format")
return
}
if attempt == 0 {
g.writeProxyError(c, route.Protocol, http.StatusServiceUnavailable, "no available channel for model")
} else {
g.recordError(reqID, auth, model, lastCh, start, "upstream_error")
g.writeProxyError(c, route.Protocol, http.StatusBadGateway, "all upstream channels failed")
}
return
}
exclude[ch.ID] = true
lastCh = ch
// Skip channels that cannot serve this protocol at all (no native
// support and no conversion path).
if !channelServesFormat(ch, route) {
formatBlocked = true
g.log.Debug("channel cannot serve protocol",
zap.String("protocol", string(route.Protocol)), zap.Int64("channel_id", ch.ID))
continue
}
// Optional balance pre-check with an estimate.
if g.cfg.Proxy.BillingExactBalance {
estIn := int64(len(body) / 4)
estOut := int64(512)
if meta.Stream {
estOut = int64(g.cfg.Proxy.DefaultMaxTokens)
}
est, cerr := g.billing.EstimateCost(model.ID, estIn, estOut, 0)
if cerr == nil {
if berr := g.billing.CheckBalance(auth.user.ID, est); berr != nil {
g.recordError(reqID, auth, model, ch, start, "insufficient_balance")
g.writeProxyError(c, route.Protocol, http.StatusPaymentRequired, "insufficient balance")
return
}
}
}
upstreamKey, err := g.channel.DecryptKey(ch.APIKeyEnc)
if err != nil {
g.log.Error("decrypt channel key", zap.Error(err), zap.Int64("channel_id", ch.ID))
g.writeProxyError(c, route.Protocol, http.StatusBadGateway, "channel key unavailable")
return
}
release, err := g.channel.Acquire(ch.ID)
if err != nil {
g.writeProxyError(c, route.Protocol, http.StatusServiceUnavailable, "channel unavailable")
return
}
attempted = true
retryable := g.forward(c, route, meta.Model, ch, binding.UpstreamModel, upstreamKey, body, start, reqID, auth, model)
release()
if !retryable {
return
}
g.log.Warn("upstream failed, retrying on another channel",
zap.Int64("model_id", model.ID), zap.Int64("channel_id", ch.ID), zap.Int("attempt", attempt+1))
}
if formatBlocked && !attempted {
g.writeProxyError(c, route.Protocol, http.StatusBadRequest,
"no channel supports the "+string(route.Protocol)+" API format")
return
}
g.recordError(reqID, auth, model, lastCh, start, "upstream_error")
g.writeProxyError(c, route.Protocol, http.StatusBadGateway, "all upstream channels failed")
}
// forward decides passthrough vs conversion and calls the upstream.
// It returns true when the failure is retryable on another channel.
func (g *Gateway) forward(c *gin.Context, route Route, clientModel string,
ch *store.Channel, upstreamModel, upstreamKey string, body []byte,
start time.Time, reqID string, auth *authContext, model *store.Model) bool {
upstreamBody, converted := g.prepareUpstreamBody(route, ch, body, upstreamModel)
if converted {
c.Header("x-converted", "true")
}
// Build upstream request bound to the client context so disconnects cancel it.
ctx := c.Request.Context()
upstreamURL := strings.TrimSuffix(ch.BaseURL, "/") + upstreamPath(route, ch)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, upstreamURL, bytes.NewReader(upstreamBody))
if err != nil {
g.writeProxyError(c, route.Protocol, http.StatusBadGateway, "failed to build upstream request")
return false
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+upstreamKey)
req.Header.Set("Accept", "application/json")
req.Header.Set("X-Request-Id", reqID)
// Explicitly drop hop-by-hop / auth-ish headers we don't want forwarded.
copyProxyHeaders(c, req)
resp, err := g.client.Do(req)
if err != nil {
// Client disconnect vs upstream failure.
if errors.Is(ctx.Err(), context.Canceled) {
g.recordCanceled(reqID, auth, model, ch, start)
return false
}
g.log.Warn("upstream request failed", zap.Error(err), zap.Int64("channel_id", ch.ID))
return true
}
defer resp.Body.Close()
if resp.StatusCode >= 500 {
errBody, _ := io.ReadAll(resp.Body)
g.log.Warn("upstream returned 5xx", zap.Int("status", resp.StatusCode),
zap.Int64("channel_id", ch.ID), zap.String("body", truncateText(string(errBody), 512)))
return true
}
if resp.StatusCode >= 400 {
errBody, _ := io.ReadAll(resp.Body)
g.recordError(reqID, auth, model, ch, start, "upstream_"+strconv.Itoa(resp.StatusCode))
g.writeUpstreamError(c, route.Protocol, resp.StatusCode, errBody)
return false
}
streaming := bodyStreamFlag(body, route)
if streaming {
g.streamResponse(c, route, resp, start, reqID, auth, model, ch)
} else {
g.plainResponse(c, route, resp, start, reqID, auth, model, ch)
}
return false
}
// prepareUpstreamBody rewrites the model name, or converts the body when the
// channel does not serve the client protocol natively. Returns the payload and
// whether any conversion happened.
func (g *Gateway) prepareUpstreamBody(route Route, ch *store.Channel, body []byte, upstreamModel string) ([]byte, bool) {
if ch.SupportsFormat(string(route.Protocol)) {
if upstreamModel == "" || sameModel(body, upstreamModel) {
return body, false
}
rewritten, err := setModelField(body, upstreamModel)
if err != nil {
return body, false
}
return rewritten, true
}
converted, err := convertRequest(route, ch.Provider, body, upstreamModel)
if err != nil {
g.log.Warn("request conversion failed, falling back to passthrough",
zap.Error(err), zap.String("route", string(route.Protocol)), zap.String("provider", ch.Provider))
return body, false
}
return converted, true
}
// streamResponse forwards an SSE stream to the client while extracting usage.
func (g *Gateway) streamResponse(c *gin.Context, route Route, resp *http.Response,
start time.Time, reqID string, auth *authContext, model *store.Model, ch *store.Channel) {
c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive")
if upstreamProvider(route, ch) == "anthropic" {
g.streamAnthropic(c, route, resp, start, reqID, auth, model, ch)
return
}
g.streamOpenAI(c, route, resp, start, reqID, auth, model, ch)
}
// plainResponse buffers a non-stream upstream response and returns it.
func (g *Gateway) plainResponse(c *gin.Context, route Route, resp *http.Response,
start time.Time, reqID string, auth *authContext, model *store.Model, ch *store.Channel) {
raw, err := io.ReadAll(resp.Body)
if err != nil {
g.log.Warn("read upstream body", zap.Error(err))
g.writeProxyError(c, route.Protocol, http.StatusBadGateway, "failed to read upstream response")
return
}
out := raw
usageInfo := parseUsageForProtocol(route.Protocol, raw)
if !ch.SupportsFormat(string(route.Protocol)) {
converted, cerr := convertResponse(route, ch.Provider, raw)
if cerr == nil {
out = converted
usageInfo = parseUsageForProtocol(route.Protocol, out)
c.Header("x-converted", "true")
} else {
g.log.Warn("response conversion failed, forwarding raw",
zap.Error(cerr), zap.String("protocol", string(route.Protocol)))
}
}
c.Data(http.StatusOK, "application/json", out)
g.afterComplete(start, reqID, auth, model, ch, usageInfo, "success", "")
}
// afterComplete performs billing and usage accounting for a finished request.
func (g *Gateway) afterComplete(start time.Time, reqID string, auth *authContext,
model *store.Model, ch *store.Channel, usageInfo *tokenUsage, status, errCode string) {
latency := int(time.Since(start).Milliseconds())
if usageInfo == nil {
usageInfo = &tokenUsage{}
}
cost := billing.CostFromPrices(usageInfo.input, usageInfo.output, usageInfo.cacheRead,
billing.PriceSnapshot{
InputPrice: model.InputPrice, OutputPrice: model.OutputPrice, CacheReadPrice: model.CacheReadPrice,
})
go func() {
// Asynchronous: deduct balance first, then record usage.
if cost.IsPositive() {
if _, err := g.billing.Deduct(auth.user.ID, cost, "usage", reqID); err != nil {
g.log.Warn("deduct balance failed", zap.Error(err),
zap.Int64("user_id", auth.user.ID), zap.String("request_id", reqID))
}
}
g.usage.Record(usage.Record{
RequestID: reqID,
UserID: auth.user.ID,
KeyID: auth.key.ID,
ChannelID: ch.ID,
ModelID: model.ID,
ModelName: model.Name,
InputTokens: usageInfo.input,
OutputTokens: usageInfo.output,
CacheReadTokens: usageInfo.cacheRead,
CacheCreationTokens: usageInfo.cacheCreation,
InputPrice: model.InputPrice,
OutputPrice: model.OutputPrice,
CacheReadPrice: model.CacheReadPrice,
Cost: cost,
LatencyMs: latency,
Status: status,
ErrorCode: errCode,
})
g.db.Model(&store.ApiKey{}).Where("id = ?", auth.key.ID).
Update("last_used_at", time.Now())
}()
}
func (g *Gateway) recordError(reqID string, auth *authContext, model *store.Model, ch *store.Channel, start time.Time, errCode string) {
g.afterComplete(start, reqID, auth, model, ch, &tokenUsage{}, "error", errCode)
}
func (g *Gateway) recordCanceled(reqID string, auth *authContext, model *store.Model, ch *store.Channel, start time.Time) {
g.afterComplete(start, reqID, auth, model, ch, &tokenUsage{}, "canceled", "client_disconnect")
}
+248
View File
@@ -0,0 +1,248 @@
package proxy
import (
"encoding/json"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"openteam/server/internal/pkg/crypto"
"openteam/server/internal/store"
)
// tokenUsage is the normalized usage extracted from any protocol.
type tokenUsage struct {
input int64
output int64
cacheRead int64
cacheCreation int64
}
// respStreamEvent is the streaming shape of the OpenAI Responses API used for
// usage sniffing on passthrough responses streams.
type respStreamEvent struct {
Type string `json:"type"`
Response *struct {
Usage *struct {
InputTokens int64 `json:"input_tokens"`
OutputTokens int64 `json:"output_tokens"`
} `json:"usage"`
} `json:"response"`
}
// authenticate resolves the Bearer API key to a key + user.
func (g *Gateway) authenticate(c *gin.Context) (*authContext, error) {
auth := c.GetHeader("Authorization")
token := ""
if strings.HasPrefix(auth, "Bearer ") {
token = strings.TrimPrefix(auth, "Bearer ")
} else if strings.HasPrefix(auth, "sk-") {
// Some clients send the raw key without the Bearer scheme.
token = auth
} else {
return nil, errUnauthorized
}
var key store.ApiKey
if err := g.db.Where("key_hash = ?", crypto.HashSHA256(token)).First(&key).Error; err != nil {
return nil, errUnauthorized
}
if key.Status != "active" {
return nil, errUnauthorized
}
if key.ExpiresAt != nil && key.ExpiresAt.Before(now()) {
return nil, errUnauthorized
}
var user store.User
if err := g.db.First(&user, key.UserID).Error; err != nil {
return nil, errUnauthorized
}
if user.Status != "active" {
return nil, errUnauthorized
}
return &authContext{key: &key, user: &user}, nil
}
var errUnauthorized = &unauthorizedError{}
type unauthorizedError struct{}
func (*unauthorizedError) Error() string { return "invalid API key" }
// writeProxyError writes a gateway-generated error in the client's protocol.
func (g *Gateway) writeProxyError(c *gin.Context, proto Protocol, status int, message string) {
c.Header("Content-Type", "application/json")
switch proto {
case ProtocolAnthropic:
c.AbortWithStatusJSON(status, map[string]any{
"type": "error",
"error": map[string]any{"type": statusType(status), "message": message},
})
default:
c.AbortWithStatusJSON(status, map[string]any{
"error": map[string]any{"message": message, "type": "gateway_error", "code": "gateway_error"},
})
}
}
// writeUpstreamError maps an upstream error body to the client protocol.
func (g *Gateway) writeUpstreamError(c *gin.Context, proto Protocol, status int, body []byte) {
switch proto {
case ProtocolAnthropic:
// Extract the upstream Claude error if present.
var up struct {
Error struct {
Type string `json:"type"`
Message string `json:"message"`
} `json:"error"`
}
if json.Unmarshal(body, &up) == nil && up.Error.Message != "" {
c.AbortWithStatusJSON(status, map[string]any{
"type": "error",
"error": map[string]any{"type": up.Error.Type, "message": up.Error.Message},
})
return
}
c.AbortWithStatusJSON(status, map[string]any{
"type": "error",
"error": map[string]any{"type": statusType(status), "message": upstreamMessage(status, body)},
})
default:
var up struct {
Error struct {
Message string `json:"message"`
Type string `json:"type"`
Code string `json:"code"`
Param string `json:"param"`
} `json:"error"`
}
if json.Unmarshal(body, &up) == nil && up.Error.Message != "" {
c.AbortWithStatusJSON(status, map[string]any{
"error": map[string]any{
"message": up.Error.Message, "type": up.Error.Type, "code": up.Error.Code, "param": up.Error.Param,
},
})
return
}
c.AbortWithStatusJSON(status, map[string]any{
"error": map[string]any{
"message": upstreamMessage(status, body), "type": statusType(status), "code": statusType(status),
},
})
}
}
func statusType(status int) string {
switch {
case status == 429:
return "rate_limit_error"
case status >= 500:
return "api_error"
case status >= 400:
return "invalid_request_error"
default:
return "api_error"
}
}
func upstreamMessage(status int, body []byte) string {
msg := strings.TrimSpace(string(body))
if msg == "" {
msg = http.StatusText(status)
}
return truncateText(msg, 512)
}
func truncateText(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "..."
}
// copyProxyHeaders forwards selected request headers upstream.
func copyProxyHeaders(c *gin.Context, req *http.Request) {
for _, h := range []string{"OpenAI-Organization", "OpenAI-Beta", "anthropic-version", "anthropic-beta", "X-Stainless-Lang", "X-Stainless-Package-Version"} {
if v := c.GetHeader(h); v != "" {
req.Header.Set(h, v)
}
}
}
// bodyStreamFlag determines streaming intent from the raw body + route.
func bodyStreamFlag(body []byte, route Route) bool {
var m struct {
Stream bool `json:"stream"`
}
_ = json.Unmarshal(body, &m)
return m.Stream
}
// setModelField rewrites the "model" key in a JSON object.
func setModelField(body []byte, model string) ([]byte, error) {
var obj map[string]json.RawMessage
if err := json.Unmarshal(body, &obj); err != nil {
return nil, err
}
m, _ := json.Marshal(model)
obj["model"] = m
return json.Marshal(obj)
}
// sameModel reports whether the body's model already equals upstreamModel.
func sameModel(body []byte, upstreamModel string) bool {
var m struct {
Model string `json:"model"`
}
_ = json.Unmarshal(body, &m)
return m.Model == upstreamModel
}
func contains(list []string, s string) bool {
for _, v := range list {
if v == s {
return true
}
}
return false
}
// upstreamPath returns the endpoint to POST to. When the channel serves the
// client protocol natively the route's own path is used; otherwise the request
// is converted and must hit the channel's conversion-target path.
func upstreamPath(route Route, ch *store.Channel) string {
if ch.SupportsFormat(string(route.Protocol)) {
return route.UpstreamPath
}
if ch.Provider == "anthropic" {
return "/v1/messages"
}
return "/v1/chat/completions"
}
// upstreamProvider returns the format family ("openai" | "anthropic") the
// channel will actually speak for this request: the client's own family on
// passthrough, otherwise the channel's conversion target.
func upstreamProvider(route Route, ch *store.Channel) string {
if ch.SupportsFormat(string(route.Protocol)) {
return route.NativeProvider
}
return ch.Provider
}
// channelServesFormat reports whether the channel can handle the route's
// protocol: natively, or via a conversion path that exists. Every protocol can
// convert to either Claude or chat completions except one case: Responses-API
// requests have no conversion into an openai-family upstream (the
// responses->chat response side is unimplemented), so an openai-family channel
// that does not declare responses support cannot serve them at all.
func channelServesFormat(ch *store.Channel, route Route) bool {
if ch.SupportsFormat(string(route.Protocol)) {
return true
}
return !(route.Protocol == ProtocolOpenAIResponses && ch.Provider != "anthropic")
}
func now() time.Time { return time.Now() }
+34
View File
@@ -0,0 +1,34 @@
package proxy
import (
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// ModelsHandler serves GET /v1/models (OpenAI-style list).
type ModelsHandler struct {
db *gorm.DB
gw *Gateway
}
func NewModelsHandler(db *gorm.DB, gw *Gateway) *ModelsHandler {
return &ModelsHandler{db: db, gw: gw}
}
func (h *ModelsHandler) List(c *gin.Context) {
models, err := h.gw.channel.ListEnabledModels()
if err != nil {
c.JSON(502, gin.H{"error": gin.H{"message": "list models failed", "type": "api_error"}})
return
}
data := make([]gin.H, 0, len(models))
for _, m := range models {
data = append(data, gin.H{
"id": m.Name,
"object": "model",
"created": m.CreatedAt.Unix(),
"owned_by": "openteam",
})
}
c.JSON(200, gin.H{"object": "list", "data": data})
}
+87
View File
@@ -0,0 +1,87 @@
package openai
import "encoding/json"
// ChatRequest is a chat completions request. Only the fields the gateway
// needs are typed; the rest is preserved via Raw for passthrough.
type ChatRequest struct {
Model string `json:"model"`
Messages json.RawMessage `json:"messages"`
Stream bool `json:"stream"`
Temperature *float64 `json:"temperature"`
TopP *float64 `json:"top_p"`
MaxTokens *int `json:"max_tokens"`
MaxCompl *int `json:"max_completion_tokens"`
Stop json.RawMessage `json:"stop"`
Tools json.RawMessage `json:"tools"`
ToolChoice json.RawMessage `json:"tool_choice"`
ResponseFmt json.RawMessage `json:"response_format"`
StreamOpts json.RawMessage `json:"stream_options"`
User string `json:"user"`
}
// ChatMessage is one message in the canonical/chat shape.
type ChatMessage struct {
Role string `json:"role"`
Content json.RawMessage `json:"content,omitempty"`
Name string `json:"name,omitempty"`
ToolCalls json.RawMessage `json:"tool_calls,omitempty"`
ToolCallID string `json:"tool_call_id,omitempty"`
}
// Tool is a function tool definition.
type Tool struct {
Type string `json:"type"`
Function FunctionTool `json:"function"`
}
type FunctionTool struct {
Name string `json:"name"`
Description string `json:"description"`
Parameters json.RawMessage `json:"parameters"`
}
// ChatCompletion is the non-stream response.
type ChatCompletion struct {
ID string `json:"id"`
Object string `json:"object"`
Created int64 `json:"created"`
Model string `json:"model"`
Choices []ChatChoice `json:"choices"`
Usage *Usage `json:"usage,omitempty"`
}
type ChatChoice struct {
Index int `json:"index"`
Message ChatMessage `json:"message"`
FinishReason string `json:"finish_reason"`
}
// Usage is the token usage block.
type Usage struct {
PromptTokens int64 `json:"prompt_tokens"`
CompletionTokens int64 `json:"completion_tokens"`
TotalTokens int64 `json:"total_tokens"`
}
// ChatChunk is one streaming chunk.
type ChatChunk struct {
ID string `json:"id"`
Object string `json:"object"`
Created int64 `json:"created"`
Model string `json:"model"`
Choices []ChatChunkChoice `json:"choices"`
Usage *Usage `json:"usage,omitempty"`
}
type ChatChunkChoice struct {
Index int `json:"index"`
Delta ChatDelta `json:"delta"`
FinishReason *string `json:"finish_reason"`
}
type ChatDelta struct {
Role string `json:"role,omitempty"`
Content string `json:"content,omitempty"`
ToolCalls json.RawMessage `json:"tool_calls,omitempty"`
}
+42
View File
@@ -0,0 +1,42 @@
package responses
import "encoding/json"
// Request is an OpenAI Responses API request. The gateway only needs the
// model + stream fields for routing; the full body passes through raw.
type Request struct {
Model string `json:"model"`
Stream bool `json:"stream"`
Instructions json.RawMessage `json:"instructions"`
Input json.RawMessage `json:"input"`
MaxOutputTokens *int `json:"max_output_tokens"`
PreviousResponseID string `json:"previous_response_id"`
Tools json.RawMessage `json:"tools"`
Reasoning json.RawMessage `json:"reasoning"`
Text json.RawMessage `json:"text"`
OutputFormat json.RawMessage `json:"output_format"`
}
// Response is the non-stream Responses response.
type Response struct {
ID string `json:"id"`
Object string `json:"object"`
Created int64 `json:"created"`
Model string `json:"model"`
Status string `json:"status"`
Output json.RawMessage `json:"output"`
Usage *Usage `json:"usage,omitempty"`
}
type Usage struct {
InputTokens int64 `json:"input_tokens"`
OutputTokens int64 `json:"output_tokens"`
TotalTokens int64 `json:"total_tokens"`
InputTokensDetails UsageDetails `json:"input_tokens_details,omitempty"`
OutputTokensDetails UsageDetails `json:"output_tokens_details,omitempty"`
}
type UsageDetails struct {
CachedTokens int64 `json:"cached_tokens,omitempty"`
ReasoningTokens int64 `json:"reasoning_tokens,omitempty"`
}
+201
View File
@@ -0,0 +1,201 @@
package proxy
import (
"bufio"
"bytes"
"encoding/json"
"io"
"net/http"
"time"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"openteam/server/internal/proxy/claude"
"openteam/server/internal/proxy/convert"
"openteam/server/internal/proxy/openai"
"openteam/server/internal/proxy/stream"
"openteam/server/internal/store"
)
// streamOpenAI forwards an SSE stream to an OpenAI-protocol client.
func (g *Gateway) streamOpenAI(c *gin.Context, route Route, resp *http.Response,
start time.Time, reqID string, auth *authContext, model *store.Model, ch *store.Channel) {
w := c.Writer
tr := convert.NewTranslator(clientProto(route), upstreamProvider(route, ch))
done := make(chan struct{})
if tr == nil {
// Passthrough: copy raw frames, sniffing usage from OpenAI chunks
// and Responses-API stream events.
var usage *tokenUsage
onEvent := func(ev stream.SSEEvent) {
if ev.Done || ev.Data == "" {
return
}
var chunk openai.ChatChunk
if json.Unmarshal([]byte(ev.Data), &chunk) == nil && chunk.Usage != nil {
usage = &tokenUsage{
input: chunk.Usage.PromptTokens,
output: chunk.Usage.CompletionTokens,
}
return
}
var resp respStreamEvent
if json.Unmarshal([]byte(ev.Data), &resp) == nil && resp.Response != nil && resp.Response.Usage != nil {
usage = &tokenUsage{
input: resp.Response.Usage.InputTokens,
output: resp.Response.Usage.OutputTokens,
}
}
}
go func() {
defer close(done)
copyRawSSE(w, resp.Body, onEvent)
}()
<-done
g.afterComplete(start, reqID, auth, model, ch, usage, "success", "")
return
}
// Converted: read upstream events, emit translated frames.
var finalUsage *tokenUsage
go func() {
defer close(done)
err := stream.ReadSSE(resp.Body, func(ev stream.SSEEvent) error {
frames, ferr := tr.Feed(ev)
if ferr != nil {
return ferr
}
for _, f := range frames {
if err := stream.Write(w, f); err != nil {
return err
}
}
return nil
})
if err != nil {
g.log.Debug("upstream stream read ended", zap.Error(err))
}
fin, _ := tr.Finish()
for _, f := range fin {
_ = stream.Write(w, f)
}
if u := tr.Usage(); u != nil {
finalUsage = &tokenUsage{input: u.Input, output: u.Output, cacheRead: u.CacheRead, cacheCreation: u.CacheCreation}
}
}()
<-done
g.afterComplete(start, reqID, auth, model, ch, finalUsage, "success", "")
}
// streamAnthropic forwards an SSE stream to a Claude-protocol client.
func (g *Gateway) streamAnthropic(c *gin.Context, route Route, resp *http.Response,
start time.Time, reqID string, auth *authContext, model *store.Model, ch *store.Channel) {
w := c.Writer
tr := convert.NewTranslator(clientProto(route), upstreamProvider(route, ch))
done := make(chan struct{})
if tr == nil {
// Passthrough: copy raw frames, sniffing usage from message_delta.
var usage *tokenUsage
onEvent := func(ev stream.SSEEvent) {
if ev.Done || ev.Data == "" {
return
}
var e claude.StreamEvent
if json.Unmarshal([]byte(ev.Data), &e) != nil || e.Type != "message_delta" {
return
}
var u claude.Usage
if json.Unmarshal(e.Usage, &u) == nil {
usage = &tokenUsage{
input: u.InputTokens, output: u.OutputTokens,
cacheRead: u.CacheReadInputTokens, cacheCreation: u.CacheCreationInputTokens,
}
}
}
go func() {
defer close(done)
copyRawSSE(w, resp.Body, onEvent)
}()
<-done
g.afterComplete(start, reqID, auth, model, ch, usage, "success", "")
return
}
// Converted: read upstream events, emit translated frames.
var finalUsage *tokenUsage
go func() {
defer close(done)
err := stream.ReadSSE(resp.Body, func(ev stream.SSEEvent) error {
frames, ferr := tr.Feed(ev)
if ferr != nil {
return ferr
}
for _, f := range frames {
if err := stream.Write(w, f); err != nil {
return err
}
}
return nil
})
if err != nil {
g.log.Debug("upstream stream read ended", zap.Error(err))
}
fin, _ := tr.Finish()
for _, f := range fin {
_ = stream.Write(w, f)
}
if u := tr.Usage(); u != nil {
finalUsage = &tokenUsage{input: u.Input, output: u.Output, cacheRead: u.CacheRead, cacheCreation: u.CacheCreation}
}
}()
<-done
g.afterComplete(start, reqID, auth, model, ch, finalUsage, "success", "")
}
// copyRawSSE copies an upstream SSE stream verbatim, invoking onEvent for
// each data frame (used for passthrough + usage sniffing).
func copyRawSSE(w http.ResponseWriter, r io.Reader, onEvent func(stream.SSEEvent)) {
br := bufio.NewReader(r)
for {
line, err := br.ReadBytes('\n')
if len(line) > 0 {
if _, werr := w.Write(line); werr != nil {
return
}
trimmed := bytes.TrimSpace(line)
if bytes.HasPrefix(trimmed, []byte("data:")) {
data := bytes.TrimSpace(trimmed[len("data:"):])
ev := stream.SSEEvent{
Data: string(data),
Done: bytes.Equal(data, []byte("[DONE]")),
}
if ev.Data != "" {
onEvent(ev)
}
}
}
if err != nil {
break
}
}
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
}
// clientProto maps a proxy Protocol to the convert package's protocol type.
func clientProto(route Route) convert.ClientProtocol {
switch route.Protocol {
case ProtocolOpenAIResponses:
return convert.ClientOpenAIResponses
case ProtocolAnthropic:
return convert.ClientAnthropic
default:
return convert.ClientOpenAIChat
}
}
+79
View File
@@ -0,0 +1,79 @@
package stream
import (
"bufio"
"bytes"
"io"
"net/http"
"strings"
)
// SSEEvent is a single SSE data frame.
type SSEEvent struct {
Data string // the JSON payload of the `data:` line
Done bool // true when the payload is [DONE]
}
// ReadSSE reads SSE frames from r, calling fn for each `data:` line.
// It is used both for reading upstream streams and, via a pipe, for writing
// converted streams to the client.
func ReadSSE(r io.Reader, fn func(SSEEvent) error) error {
br := bufio.NewReader(r)
for {
line, err := br.ReadString('\n')
if err != nil {
if err == io.EOF {
return nil
}
return err
}
line = trimCRLF(line)
if !bytes.HasPrefix([]byte(line), []byte("data:")) {
continue
}
data := line[len("data:"):]
data = strings.TrimPrefix(data, " ")
if len(data) == 0 {
continue
}
ev := SSEEvent{Data: data, Done: bytes.Equal([]byte(data), []byte("[DONE]"))}
if err := fn(ev); err != nil {
return err
}
}
}
// Write writes an SSE event to w and flushes it.
func Write(w http.ResponseWriter, ev SSEEvent) error {
if ev.Done {
if _, err := io.WriteString(w, "data: [DONE]\n\n"); err != nil {
return err
}
} else {
if _, err := io.WriteString(w, "data: "+ev.Data+"\n\n"); err != nil {
return err
}
}
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
return nil
}
// WriteRaw writes a raw SSE frame string (with trailing newlines) and flushes.
func WriteRaw(w http.ResponseWriter, frame []byte) error {
if _, err := w.Write(frame); err != nil {
return err
}
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
return nil
}
func trimCRLF(s string) string {
for len(s) > 0 && (s[len(s)-1] == '\n' || s[len(s)-1] == '\r') {
s = s[:len(s)-1]
}
return s
}
+48
View File
@@ -0,0 +1,48 @@
package proxy
import (
"encoding/json"
"openteam/server/internal/proxy/claude"
"openteam/server/internal/proxy/openai"
"openteam/server/internal/proxy/responses"
)
// parseUsageForProtocol extracts token usage from a non-stream response body.
func parseUsageForProtocol(proto Protocol, body []byte) *tokenUsage {
switch proto {
case ProtocolAnthropic:
var mr claude.MessageResponse
if err := json.Unmarshal(body, &mr); err != nil || mr.Usage == nil {
return nil
}
return &tokenUsage{
input: mr.Usage.InputTokens,
output: mr.Usage.OutputTokens,
cacheRead: mr.Usage.CacheReadInputTokens,
cacheCreation: mr.Usage.CacheCreationInputTokens,
}
case ProtocolOpenAIResponses:
var r responses.Response
if err := json.Unmarshal(body, &r); err != nil || r.Usage == nil {
return nil
}
return &tokenUsage{
input: r.Usage.InputTokens,
output: r.Usage.OutputTokens,
cacheRead: r.Usage.InputTokensDetails.CachedTokens,
}
default:
var cc openai.ChatCompletion
if err := json.Unmarshal(body, &cc); err != nil || cc.Usage == nil {
return nil
}
return &tokenUsage{input: cc.Usage.PromptTokens, output: cc.Usage.CompletionTokens}
}
}
// estimateTokensFromText is a coarse fallback used when the upstream omits usage.
func estimateTokensFromText(s string) int64 {
// ~4 chars per token, per OpenAI's common heuristic.
return int64(len(s)/4 + 1)
}
+84
View File
@@ -0,0 +1,84 @@
package recharge
import (
"net/http"
"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"
)
// Handler exposes the reserved recharge order endpoints.
type Handler struct {
db *gorm.DB
log *zap.Logger
}
func NewHandler(db *gorm.DB, log *zap.Logger) *Handler {
return &Handler{db: db, log: log}
}
type CreateInput struct {
Amount decimal.Decimal `json:"amount" binding:"required"`
Remark string `json:"remark"`
}
// Create handles POST /api/v1/recharges — creates a pending manual order.
func (h *Handler) Create(c *gin.Context) {
u := user.Current(c)
var in CreateInput
if !httpx.Bind(c, &in) {
return
}
if in.Amount.LessThanOrEqual(decimal.Zero) {
httpx.Fail(c, http.StatusBadRequest, "amount must be positive")
return
}
order := &store.RechargeOrder{
UserID: u.ID,
Amount: in.Amount,
Status: "pending",
Method: "manual",
Remark: in.Remark,
}
if err := h.db.Create(order).Error; err != nil {
h.log.Warn("create recharge order failed", zap.Error(err))
httpx.Fail(c, http.StatusInternalServerError, "create order failed")
return
}
httpx.Created(c, orderDTO(order))
}
// List handles GET /api/v1/recharges.
func (h *Handler) List(c *gin.Context) {
u := user.Current(c)
var orders []store.RechargeOrder
if err := h.db.Where("user_id = ?", u.ID).Order("id DESC").Find(&orders).Error; err != nil {
h.log.Warn("list recharge orders failed", zap.Error(err))
httpx.Fail(c, http.StatusInternalServerError, "list orders failed")
return
}
out := make([]gin.H, 0, len(orders))
for i := range orders {
out = append(out, orderDTO(&orders[i]))
}
httpx.OK(c, out)
}
func orderDTO(o *store.RechargeOrder) gin.H {
return gin.H{
"id": o.ID,
"amount": o.Amount.String(),
"status": o.Status,
"method": o.Method,
"remark": o.Remark,
"reviewedBy": o.ReviewedBy,
"reviewedAt": o.ReviewedAt,
"createdAt": o.CreatedAt,
}
}
+196
View File
@@ -0,0 +1,196 @@
package store
import (
"time"
"github.com/shopspring/decimal"
"gorm.io/gorm"
)
// User is a registered account.
type User struct {
ID int64 `gorm:"primaryKey"`
Username string `gorm:"uniqueIndex;size:64"`
Email string `gorm:"uniqueIndex;size:255"`
PasswordHash string `gorm:"size:255"`
Role string `gorm:"size:16;default:user"` // admin | user
Balance decimal.Decimal `gorm:"type:numeric(20,8);default:0"`
Status string `gorm:"size:16;default:active"` // active | disabled
InviteCode string `gorm:"size:64"`
LastLoginAt *time.Time
CreatedAt time.Time
UpdatedAt time.Time
}
// ApiKey is an API key issued to a user.
type ApiKey struct {
ID int64 `gorm:"primaryKey"`
UserID int64 `gorm:"index"`
Name string `gorm:"size:128"`
KeyHash string `gorm:"uniqueIndex;size:128"`
KeyPrefix string `gorm:"size:16"`
QuotaTokensPerDay *int64
QuotaRequestsPerDay *int
AllowedModels []string `gorm:"serializer:json"`
ExpiresAt *time.Time
Status string `gorm:"size:16;default:active"` // active | revoked
LastUsedAt *time.Time
CreatedAt time.Time
UpdatedAt time.Time
}
// Channel is an upstream provider.
type Channel struct {
ID int64 `gorm:"primaryKey"`
Name string `gorm:"size:128"`
Provider string `gorm:"size:32"` // openai | anthropic | compatible
BaseURL string `gorm:"size:512"`
APIKeyEnc string `gorm:"size:2048"` // AES-GCM ciphertext
Weight int `gorm:"default:1"`
Priority int `gorm:"default:0"` // lower = preferred
TimeoutMs int `gorm:"default:300000"`
MaxConcurrency int `gorm:"default:100"`
HealthStatus string `gorm:"size:16;default:healthy"` // healthy | degraded | cooldown
HealthFailures int
Enabled bool `gorm:"default:true"`
Formats []string `gorm:"serializer:json"` // client API formats served natively; empty = derive from Provider
CreatedAt time.Time
UpdatedAt time.Time
}
// Client API format keys a channel can serve natively. They match the proxy
// route protocol values so SupportsFormat can compare them directly.
const (
FormatOpenAIChat = "openai-chat"
FormatOpenAIResponses = "openai-responses"
FormatAnthropic = "anthropic"
)
// FormatsResolved returns the API formats the channel serves directly. When
// Formats is empty it falls back to the provider's native formats so legacy
// rows behave exactly as before.
func (c *Channel) FormatsResolved() []string {
if len(c.Formats) > 0 {
out := make([]string, len(c.Formats))
copy(out, c.Formats)
return out
}
if c.Provider == "anthropic" {
return []string{FormatAnthropic}
}
return []string{FormatOpenAIChat, FormatOpenAIResponses}
}
// SupportsFormat reports whether the channel serves a client protocol
// natively (passthrough) without conversion.
func (c *Channel) SupportsFormat(f string) bool {
for _, x := range c.FormatsResolved() {
if x == f {
return true
}
}
return false
}
// Model is the global model registry with pricing.
type Model struct {
ID int64 `gorm:"primaryKey"`
Name string `gorm:"uniqueIndex;size:128"`
DisplayName string `gorm:"size:255"`
InputPrice decimal.Decimal `gorm:"type:numeric(20,8);default:0"` // per 1M tokens
OutputPrice decimal.Decimal `gorm:"type:numeric(20,8);default:0"`
CacheReadPrice decimal.Decimal `gorm:"type:numeric(20,8);default:0"`
Enabled bool `gorm:"default:true"`
Sort int `gorm:"default:0"`
CreatedAt time.Time
UpdatedAt time.Time
}
// ChannelModelBinding binds a global model to a channel with an upstream name.
type ChannelModelBinding struct {
ID int64 `gorm:"primaryKey"`
ChannelID int64 `gorm:"index;uniqueIndex:idx_channel_model"`
ModelID int64 `gorm:"index;uniqueIndex:idx_channel_model"`
UpstreamModel string `gorm:"size:255"`
Weight int `gorm:"default:1"`
Channel Channel `gorm:"foreignKey:ChannelID"`
Model Model `gorm:"foreignKey:ModelID"`
}
// UsageLog is one proxied request's billing record.
type UsageLog struct {
ID int64 `gorm:"primaryKey"`
RequestID string `gorm:"size:128"`
UserID int64 `gorm:"index:idx_user_created,priority:1"`
KeyID int64
ChannelID int64
ModelID int64
ModelName string `gorm:"size:128"`
InputTokens int64
OutputTokens int64
CacheReadTokens int64
CacheCreationTokens int64
InputPrice decimal.Decimal `gorm:"type:numeric(20,8)"`
OutputPrice decimal.Decimal `gorm:"type:numeric(20,8)"`
CacheReadPrice decimal.Decimal `gorm:"type:numeric(20,8)"`
Cost decimal.Decimal `gorm:"type:numeric(20,8)"`
LatencyMs int
Status string `gorm:"size:16"` // success | error | canceled
ErrorCode string `gorm:"size:64"`
CreatedAt time.Time `gorm:"index:idx_user_created,priority:2"`
}
// UsageDaily is the pre-aggregated per-user per-model daily rollup.
type UsageDaily struct {
ID int64 `gorm:"primaryKey"`
UserID int64 `gorm:"index:idx_user_date,priority:1"`
ModelID int64
Date string `gorm:"size:10;index:idx_user_date,priority:2"` // YYYY-MM-DD
Requests int
InputTokens int64
OutputTokens int64
CacheReadTokens int64
CacheCreationTokens int64
Cost decimal.Decimal `gorm:"type:numeric(20,8)"`
}
// RechargeOrder is reserved for the (paused) recharge feature.
type RechargeOrder struct {
ID int64 `gorm:"primaryKey"`
UserID int64 `gorm:"index"`
Amount decimal.Decimal `gorm:"type:numeric(20,8)"`
Status string `gorm:"size:16;default:pending"` // pending | credited | rejected
Method string `gorm:"size:16;default:manual"` // manual | online
TransactionID string `gorm:"size:128"`
ReviewedBy *int64
ReviewedAt *time.Time
Remark string `gorm:"size:512"`
CreatedAt time.Time
UpdatedAt time.Time
User User `gorm:"foreignKey:UserID"`
}
// BalanceLog is a user balance ledger entry.
type BalanceLog struct {
ID int64 `gorm:"primaryKey"`
UserID int64 `gorm:"index:idx_balance_user_created,priority:1"`
Change decimal.Decimal `gorm:"type:numeric(20,8)"`
BalanceAfter decimal.Decimal `gorm:"type:numeric(20,8)"`
Type string `gorm:"size:32"` // recharge | usage | refund | admin_adjust
RefID string `gorm:"size:128"`
CreatedAt time.Time `gorm:"index:idx_balance_user_created,priority:2"`
}
// SystemConfig is a key/value store for runtime settings.
type SystemConfig struct {
Key string `gorm:"primaryKey;size:128"`
Value []byte `gorm:"serializer:json"`
}
// Migrate creates/upgrades the schema.
func Migrate(db *gorm.DB) error {
return db.AutoMigrate(
&User{}, &ApiKey{}, &Channel{}, &Model{}, &ChannelModelBinding{},
&UsageLog{}, &UsageDaily{}, &RechargeOrder{}, &BalanceLog{}, &SystemConfig{},
)
}
+44
View File
@@ -0,0 +1,44 @@
package store
import (
"fmt"
"os"
"path/filepath"
"github.com/glebarez/sqlite"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func Open(driver, dsn string, debug bool) (*gorm.DB, error) {
cfg := &gorm.Config{}
if debug {
cfg.Logger = logger.Default.LogMode(logger.Info)
} else {
cfg.Logger = logger.Default.LogMode(logger.Warn)
}
var dialector gorm.Dialector
switch driver {
case "postgres":
dialector = postgres.Open(dsn)
default:
if err := os.MkdirAll(filepath.Dir(dsn), 0o755); err != nil {
return nil, fmt.Errorf("create data dir: %w", err)
}
dialector = sqlite.Open(dsn)
}
db, err := gorm.Open(dialector, cfg)
if err != nil {
return nil, err
}
sqlDB, err := db.DB()
if err != nil {
return nil, err
}
sqlDB.SetMaxOpenConns(20)
sqlDB.SetMaxIdleConns(5)
return db, nil
}
+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
}
+107
View File
@@ -0,0 +1,107 @@
package user
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"openteam/server/internal/config"
"openteam/server/internal/pkg/httpx"
)
type Handler struct {
svc *Service
cfg *config.Config
log *zap.Logger
}
func NewHandler(svc *Service, cfg *config.Config, log *zap.Logger) *Handler {
return &Handler{svc: svc, cfg: cfg, log: log}
}
// Register handles POST /api/v1/auth/register.
func (h *Handler) Register(c *gin.Context) {
var in RegisterInput
if !httpx.Bind(c, &in) {
return
}
u, pair, err := h.svc.Register(in)
if err != nil {
if errors.Is(err, ErrUserExists) {
httpx.Fail(c, http.StatusConflict, err.Error())
return
}
h.log.Warn("register failed", zap.Error(err))
httpx.Fail(c, http.StatusBadRequest, err.Error())
return
}
h.setRefreshCookie(c, pair.RefreshToken)
httpx.Created(c, gin.H{"user": GetPublic(u), "token": pair})
}
// Login handles POST /api/v1/auth/login.
func (h *Handler) Login(c *gin.Context) {
var in LoginInput
if !httpx.Bind(c, &in) {
return
}
u, pair, err := h.svc.Login(in)
if err != nil {
if errors.Is(err, ErrBadCredentials) || errors.Is(err, ErrUserDisabled) {
httpx.Fail(c, http.StatusUnauthorized, err.Error())
return
}
h.log.Warn("login failed", zap.Error(err))
httpx.Fail(c, http.StatusInternalServerError, "login failed")
return
}
h.setRefreshCookie(c, pair.RefreshToken)
httpx.OK(c, gin.H{"user": GetPublic(u), "token": pair})
}
// Refresh handles POST /api/v1/auth/refresh.
func (h *Handler) Refresh(c *gin.Context) {
token, err := c.Cookie(h.cfg.Auth.RefreshCookieName)
if err != nil || token == "" {
httpx.Fail(c, http.StatusUnauthorized, "missing refresh token")
return
}
pair, err := h.svc.Refresh(token)
if err != nil {
httpx.Fail(c, http.StatusUnauthorized, "invalid refresh token")
return
}
h.setRefreshCookie(c, pair.RefreshToken)
httpx.OK(c, gin.H{"token": pair})
}
// Logout handles POST /api/v1/auth/logout.
func (h *Handler) Logout(c *gin.Context) {
c.SetCookie(h.cfg.Auth.RefreshCookieName, "", -1, "/", "", h.cfg.Auth.RefreshCookieSecure, true)
httpx.OK(c, gin.H{"ok": true})
}
// Me handles GET /api/v1/auth/me.
func (h *Handler) Me(c *gin.Context) {
u := Current(c)
httpx.OK(c, GetPublic(u))
}
// Profile handles GET /api/v1/user/profile.
func (h *Handler) Profile(c *gin.Context) {
u := Current(c)
httpx.OK(c, GetPublic(u))
}
// Balance handles GET /api/v1/user/balance.
func (h *Handler) Balance(c *gin.Context) {
u := Current(c)
httpx.OK(c, gin.H{"balance": u.Balance.String()})
}
func (h *Handler) setRefreshCookie(c *gin.Context, token string) {
c.SetCookie(h.cfg.Auth.RefreshCookieName, token,
int(h.cfg.Auth.RefreshTokenTTL.Seconds()), "/", "", h.cfg.Auth.RefreshCookieSecure, true)
}
+74
View File
@@ -0,0 +1,74 @@
package user
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"openteam/server/internal/pkg/httpx"
"openteam/server/internal/pkg/jwt"
"openteam/server/internal/store"
)
const (
ctxUserKey = "current_user"
ctxClaimsKey = "current_claims"
)
// Current returns the authenticated user (set by Middleware).
func Current(c *gin.Context) *store.User {
if v, ok := c.Get(ctxUserKey); ok {
if u, ok := v.(*store.User); ok {
return u
}
}
return nil
}
// Claims returns the JWT claims of the current request.
func Claims(c *gin.Context) *jwt.Claims {
if v, ok := c.Get(ctxClaimsKey); ok {
if cl, ok := v.(*jwt.Claims); ok {
return cl
}
}
return nil
}
// Middleware authenticates the management API via an access token.
func (s *Service) Middleware(secret string) gin.HandlerFunc {
return func(c *gin.Context) {
auth := c.GetHeader("Authorization")
if !strings.HasPrefix(auth, "Bearer ") {
httpx.Fail(c, http.StatusUnauthorized, "missing bearer token")
return
}
token := strings.TrimPrefix(auth, "Bearer ")
claims, err := jwt.Parse(secret, token)
if err != nil || claims.Type != "access" {
httpx.Fail(c, http.StatusUnauthorized, "invalid or expired token")
return
}
var u store.User
if err := s.db.First(&u, claims.UserID).Error; err != nil || u.Status != "active" {
httpx.Fail(c, http.StatusUnauthorized, "user not found or disabled")
return
}
c.Set(ctxUserKey, &u)
c.Set(ctxClaimsKey, claims)
c.Next()
}
}
// RequireAdmin guards admin-only routes.
func RequireAdmin() gin.HandlerFunc {
return func(c *gin.Context) {
u := Current(c)
if u == nil || u.Role != "admin" {
httpx.Fail(c, http.StatusForbidden, "admin only")
return
}
c.Next()
}
}
+192
View File
@@ -0,0 +1,192 @@
package user
import (
"encoding/json"
"errors"
"time"
"github.com/shopspring/decimal"
"go.uber.org/zap"
"gorm.io/gorm"
"openteam/server/internal/config"
"openteam/server/internal/pkg/jwt"
"openteam/server/internal/pkg/password"
"openteam/server/internal/store"
)
var (
ErrUserExists = errors.New("username or email already exists")
ErrBadCredentials = errors.New("invalid username/email or password")
ErrUserDisabled = errors.New("account disabled")
ErrInvalidRefresh = errors.New("invalid refresh token")
)
type Service struct {
db *gorm.DB
cfg *config.Config
log *zap.Logger
}
func NewService(db *gorm.DB, cfg *config.Config, log *zap.Logger) *Service {
return &Service{db: db, cfg: cfg, log: log}
}
type RegisterInput struct {
Username string `json:"username" binding:"required,min=3,max=64"`
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required,min=8,max=128"`
InviteCode string `json:"inviteCode"`
}
type TokenPair struct {
AccessToken string `json:"accessToken"`
TokenType string `json:"tokenType"`
ExpiresIn int64 `json:"expiresIn"`
RefreshToken string `json:"refreshToken"`
}
// Register creates a user. Returns a token pair plus the created user.
func (s *Service) Register(in RegisterInput) (*store.User, *TokenPair, error) {
if s.cfg.Registration.Mode == "invite" {
// Invite mode: validate the invite code before allowing signup.
ok, err := s.validateInviteCode(in.InviteCode)
if err != nil {
return nil, nil, err
}
if !ok {
return nil, nil, errors.New("invalid invite code")
}
}
hash, err := password.Hash(in.Password)
if err != nil {
return nil, nil, err
}
u := &store.User{
Username: in.Username,
Email: in.Email,
PasswordHash: hash,
Role: "user",
Balance: decimalZero(),
Status: "active",
InviteCode: in.InviteCode,
}
if err := s.db.Create(u).Error; err != nil {
if errors.Is(err, gorm.ErrDuplicatedKey) {
return nil, nil, ErrUserExists
}
return nil, nil, err
}
pair, err := s.issuePair(u)
if err != nil {
return nil, nil, err
}
return u, pair, nil
}
type LoginInput struct {
Account string `json:"account" binding:"required"` // username or email
Password string `json:"password" binding:"required"`
}
// Login authenticates by username or email and returns a token pair.
func (s *Service) Login(in LoginInput) (*store.User, *TokenPair, error) {
var u store.User
err := s.db.Where("username = ? OR email = ?", in.Account, in.Account).First(&u).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil, ErrBadCredentials
}
if err != nil {
return nil, nil, err
}
ok, err := password.Verify(in.Password, u.PasswordHash)
if err != nil || !ok {
return nil, nil, ErrBadCredentials
}
if u.Status != "active" {
return nil, nil, ErrUserDisabled
}
now := time.Now()
u.LastLoginAt = &now
s.db.Model(&u).Update("last_login_at", now)
pair, err := s.issuePair(&u)
if err != nil {
return nil, nil, err
}
return &u, pair, nil
}
// Refresh issues a new access token from a valid refresh token.
func (s *Service) Refresh(refreshToken string) (*TokenPair, error) {
claims, err := jwt.Parse(s.cfg.Auth.JWTSecret, refreshToken)
if err != nil || claims.Type != "refresh" {
return nil, ErrInvalidRefresh
}
var u store.User
if err := s.db.First(&u, claims.UserID).Error; err != nil {
return nil, ErrInvalidRefresh
}
if u.Status != "active" {
return nil, ErrUserDisabled
}
return s.issuePair(&u)
}
// GetByID loads a user.
func (s *Service) GetByID(id int64) (*store.User, error) {
var u store.User
if err := s.db.First(&u, id).Error; err != nil {
return nil, err
}
return &u, nil
}
// GetPublic returns a user DTO without secrets.
func GetPublic(u *store.User) map[string]any {
return map[string]any{
"id": u.ID,
"username": u.Username,
"email": u.Email,
"role": u.Role,
"balance": u.Balance.String(),
"status": u.Status,
"createdAt": u.CreatedAt,
"lastLoginAt": u.LastLoginAt,
}
}
func (s *Service) validateInviteCode(code string) (bool, error) {
var conf store.SystemConfig
if err := s.db.First(&conf, "key = ?", "invite_codes").Error; err != nil {
return false, nil
}
var codes map[string]bool
if len(conf.Value) > 0 {
if err := json.Unmarshal(conf.Value, &codes); err != nil {
return false, err
}
}
return codes[code], nil
}
func (s *Service) issuePair(u *store.User) (*TokenPair, error) {
access, err := jwt.SignAccess(s.cfg.Auth.JWTSecret, u.ID, u.Username, u.Role, s.cfg.Auth.AccessTokenTTL)
if err != nil {
return nil, err
}
refresh, err := jwt.SignRefresh(s.cfg.Auth.JWTSecret, u.ID, s.cfg.Auth.RefreshTokenTTL)
if err != nil {
return nil, err
}
return &TokenPair{
AccessToken: access,
TokenType: "Bearer",
ExpiresIn: int64(s.cfg.Auth.AccessTokenTTL.Seconds()),
RefreshToken: refresh,
}, nil
}
func decimalZero() decimal.Decimal {
return decimal.NewFromInt(0)
}