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