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