feat(server): API relay gateway backend M0-M4
Gin + GORM + pure-Go SQLite. Users/auth (JWT), API key management with quotas, proxy gateway with weighted channel failover and health checks, usage/billing ledger, cross-protocol conversion (Anthropic Messages / OpenAI Chat Completions / OpenAI Responses), and channel/model admin API. Channels declare native API formats and auto-convert the rest. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
b0c7439c01
commit
d0e31b198f
@@ -0,0 +1,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})
|
||||
}
|
||||
@@ -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] + "..."
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user