refactor: complete backend rewrite for multi-protocol proxy

Major rewrite of the Go backend to support:
- Three API format imports: openai, anthropic, compatible
- Three protocol conversions: Chat Completions, Responses, Messages
- Hub-and-spoke architecture with Chat as intermediate format

Deleted:
- opencat.go (old entry)
- store/, team/, pkg/team/, pkg/store/ (old data layer)
- internal/model/, internal/consts/ (old types)
- internal/service/team/, internal/controller/team/ (old handlers)
- llm/ (removed LLM client library, pure proxy mode)
- dist/, assets/ (old build artifacts)

Added:
- internal/store/ — 9 GORM models + multi-DB support
- internal/pkg/ — crypto (AES-GCM), apikey, jwt, ratelimit, resp, tokenizer
- internal/channel/ — channel selection, weighted LB, health checks
- internal/proxy/convert/ — 6 protocol conversion functions + SSE streaming
- internal/proxy/ — gateway with request dispatch and upstream selection
- internal/usage/ — async usage recorder with batch writes
- internal/api/ — management API (auth, users, keys, channels, models)
- Makefile for build/test/deploy

Fixed API to match frontend expectations:
- Login response wraps token in { data: { token } }
- GET /api/profile route added
- Profile response wraps user in { code, data }
- Role returned as number (10=admin, 1=user)
This commit is contained in:
Sakurasan
2026-08-30 11:49:31 +08:00
parent aa0d87f132
commit ef3025dd80
127 changed files with 4623 additions and 10500 deletions
+221
View File
@@ -0,0 +1,221 @@
package channel
import (
"context"
"fmt"
"log"
"math/rand"
"opencatd-open/internal/dao"
"opencatd-open/internal/store"
"opencatd-open/internal/pkg/crypto"
"sync"
"time"
)
type Service struct {
channelDAO *dao.ChannelDAO
modelDAO *dao.ModelDAO
// Health tracking
mu sync.RWMutex
healthStatus map[uint64]*channelHealth
}
type channelHealth struct {
status string
consecutive int
lastCheck time.Time
cooldown time.Time
}
func NewService(channelDAO *dao.ChannelDAO, modelDAO *dao.ModelDAO) *Service {
return &Service{
channelDAO: channelDAO,
modelDAO: modelDAO,
healthStatus: make(map[uint64]*channelHealth),
}
}
// SelectChannel selects the best channel for a given model using weighted random selection
func (s *Service) SelectChannel(ctx context.Context, modelName string) (*store.Channel, error) {
channels, err := s.channelDAO.GetEnabledChannelsByModel(modelName)
if err != nil {
return nil, fmt.Errorf("failed to get channels for model %s: %w", modelName, err)
}
if len(channels) == 0 {
return nil, fmt.Errorf("no enabled channels for model: %s", modelName)
}
// Filter out unhealthy channels
candidates := s.filterHealthy(channels)
if len(candidates) == 0 {
// If all channels are unhealthy, try the first one anyway
candidates = channels[:1]
}
// Weighted random selection
totalWeight := 0
for _, ch := range candidates {
totalWeight += ch.Weight
}
if totalWeight == 0 {
return candidates[0], nil
}
r := rand.Intn(totalWeight)
for _, ch := range candidates {
r -= ch.Weight
if r < 0 {
return ch, nil
}
}
return candidates[0], nil
}
// GetChannelByKeyID decrypts the API key for a channel
func (s *Service) GetChannelByKeyID(ctx context.Context, channelID uint64) (*store.Channel, error) {
ch, err := s.channelDAO.GetByID(channelID)
if err != nil {
return nil, err
}
return ch, nil
}
// GetAPIKey decrypts the channel's API key
func (s *Service) GetAPIKey(ch *store.Channel) (string, error) {
return crypto.Decrypt(ch.APIKeyEnc)
}
// RecordSuccess records a successful request to a channel
func (s *Service) RecordSuccess(channelID uint64) {
s.mu.Lock()
defer s.mu.Unlock()
h := s.getOrCreateHealth(channelID)
h.consecutive = 0
h.status = store.ChannelHealthHealthy
h.lastCheck = time.Now()
}
// RecordFailure records a failed request to a channel
func (s *Service) RecordFailure(channelID uint64) {
s.mu.Lock()
defer s.mu.Unlock()
h := s.getOrCreateHealth(channelID)
h.consecutive++
h.lastCheck = time.Now()
if h.consecutive >= 3 {
h.status = store.ChannelHealthDegraded
h.cooldown = time.Now().Add(5 * time.Minute)
}
if h.consecutive >= 5 {
h.status = store.ChannelHealthCooldown
h.cooldown = time.Now().Add(15 * time.Minute)
}
}
// RecordTimeout records a timeout to a channel
func (s *Service) RecordTimeout(channelID uint64) {
s.RecordFailure(channelID)
}
func (s *Service) getOrCreateHealth(channelID uint64) *channelHealth {
h, ok := s.healthStatus[channelID]
if !ok {
h = &channelHealth{
status: store.ChannelHealthHealthy,
}
s.healthStatus[channelID] = h
}
return h
}
func (s *Service) filterHealthy(channels []*store.Channel) []*store.Channel {
s.mu.RLock()
defer s.mu.RUnlock()
var healthy []*store.Channel
now := time.Now()
for _, ch := range channels {
h, ok := s.healthStatus[ch.ID]
if !ok {
healthy = append(healthy, ch)
continue
}
// Check if cooldown has expired
if now.After(h.cooldown) && h.cooldown.IsZero() == false {
h.consecutive = 0
h.status = store.ChannelHealthHealthy
healthy = append(healthy, ch)
continue
}
if h.status == store.ChannelHealthHealthy || h.status == store.ChannelHealthDegraded {
healthy = append(healthy, ch)
}
}
return healthy
}
// GetHealthStatus returns the health status of a channel
func (s *Service) GetHealthStatus(channelID uint64) string {
s.mu.RLock()
defer s.mu.RUnlock()
h, ok := s.healthStatus[channelID]
if !ok {
return store.ChannelHealthHealthy
}
return h.status
}
// ChannelCandidate represents a channel with its resolved API key
type ChannelCandidate struct {
Channel *store.Channel
APIKey string
Format string
}
// SelectCandidates returns candidates for a model, sorted by priority
func (s *Service) SelectCandidates(ctx context.Context, modelName string, preferredFormat string) ([]ChannelCandidate, error) {
channels, err := s.channelDAO.GetEnabledChannelsByModel(modelName)
if err != nil {
return nil, err
}
var candidates []ChannelCandidate
for _, ch := range channels {
// Check if channel supports the preferred format
formats := ch.FormatsEffective()
supported := false
for _, f := range formats {
if f == preferredFormat || preferredFormat == "" {
supported = true
break
}
}
if !supported {
continue
}
apiKey, err := crypto.Decrypt(ch.APIKeyEnc)
if err != nil {
log.Printf("Failed to decrypt API key for channel %s: %v", ch.Name, err)
continue
}
candidates = append(candidates, ChannelCandidate{
Channel: ch,
APIKey: apiKey,
Format: preferredFormat,
})
}
return candidates, nil
}
+125
View File
@@ -0,0 +1,125 @@
package channel
import (
"opencatd-open/internal/store"
"testing"
)
func TestChannelFormatsEffective(t *testing.T) {
tests := []struct {
name string
channel store.Channel
expected []string
}{
{
name: "anthropic default",
channel: store.Channel{
Provider: store.ChannelProviderAnthropic,
},
expected: []string{store.FormatMessages},
},
{
name: "openai default",
channel: store.Channel{
Provider: store.ChannelProviderOpenAI,
},
expected: []string{store.FormatChat, store.FormatResponses},
},
{
name: "compatible default",
channel: store.Channel{
Provider: store.ChannelProviderCompatible,
},
expected: []string{store.FormatChat},
},
{
name: "custom formats override",
channel: store.Channel{
Provider: store.ChannelProviderOpenAI,
Formats: []string{store.FormatChat},
},
expected: []string{store.FormatChat},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := tt.channel.FormatsEffective()
if len(result) != len(tt.expected) {
t.Errorf("FormatsEffective() returned %d formats, want %d", len(result), len(tt.expected))
return
}
for i, f := range result {
if f != tt.expected[i] {
t.Errorf("FormatsEffective()[%d] = %q, want %q", i, f, tt.expected[i])
}
}
})
}
}
func TestChannelUpstreamURL(t *testing.T) {
tests := []struct {
name string
channel store.Channel
proto string
path string
expected string
}{
{
name: "basic openai",
channel: store.Channel{
BaseURL: "https://api.openai.com",
},
proto: "chat",
path: "/chat/completions",
expected: "https://api.openai.com/v1/chat/completions",
},
{
name: "with trailing slash",
channel: store.Channel{
BaseURL: "https://api.openai.com/",
},
proto: "chat",
path: "/chat/completions",
expected: "https://api.openai.com/v1/chat/completions",
},
{
name: "with version segment",
channel: store.Channel{
BaseURL: "https://api.openai.com/v1",
},
proto: "chat",
path: "/chat/completions",
expected: "https://api.openai.com/v1/chat/completions",
},
{
name: "custom base URL per protocol",
channel: store.Channel{
BaseURL: "https://default.openai.com",
BaseURLs: map[string]string{"chat": "https://chat.openai.com"},
},
proto: "chat",
path: "/chat/completions",
expected: "https://chat.openai.com/v1/chat/completions",
},
{
name: "empty base",
channel: store.Channel{
BaseURL: "",
},
proto: "chat",
path: "/chat/completions",
expected: "/chat/completions",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := tt.channel.UpstreamURL(tt.proto, tt.path)
if result != tt.expected {
t.Errorf("UpstreamURL() = %q, want %q", result, tt.expected)
}
})
}
}
+108
View File
@@ -0,0 +1,108 @@
package channel
import (
"context"
"fmt"
"opencatd-open/internal/dao"
"opencatd-open/internal/store"
"opencatd-open/internal/pkg/crypto"
"net/http"
"time"
)
type HealthChecker struct {
channelDAO *dao.ChannelDAO
service *Service
client *http.Client
}
func NewHealthChecker(channelDAO *dao.ChannelDAO, service *Service) *HealthChecker {
return &HealthChecker{
channelDAO: channelDAO,
service: service,
client: &http.Client{
Timeout: 10 * time.Second,
},
}
}
// CheckChannel performs a health check on a channel
func (hc *HealthChecker) CheckChannel(ctx context.Context, channel *store.Channel) error {
apiKey, err := crypto.Decrypt(channel.APIKeyEnc)
if err != nil {
return fmt.Errorf("failed to decrypt API key: %w", err)
}
// Simple health check: try to list models
var url string
switch channel.Provider {
case store.ChannelProviderOpenAI:
url = channel.UpstreamURL("chat", "/models")
case store.ChannelProviderAnthropic:
url = "https://api.anthropic.com/v1/models"
default:
url = channel.UpstreamURL("chat", "/models")
}
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
// Set headers based on provider
switch channel.Provider {
case store.ChannelProviderOpenAI, store.ChannelProviderCompatible:
req.Header.Set("Authorization", "Bearer "+apiKey)
case store.ChannelProviderAnthropic:
req.Header.Set("x-api-key", apiKey)
req.Header.Set("anthropic-version", "2023-06-01")
}
req.Header.Set("Content-Type", "application/json")
resp, err := hc.client.Do(req)
if err != nil {
hc.service.RecordFailure(channel.ID)
return fmt.Errorf("health check failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusOK {
hc.service.RecordSuccess(channel.ID)
return nil
}
hc.service.RecordFailure(channel.ID)
return fmt.Errorf("health check returned status %d", resp.StatusCode)
}
// CheckAllChannels checks health of all enabled channels
func (hc *HealthChecker) CheckAllChannels(ctx context.Context) error {
channels, err := hc.channelDAO.ListEnabled()
if err != nil {
return err
}
for _, ch := range channels {
if err := hc.CheckChannel(ctx, ch); err != nil {
fmt.Printf("Channel %s health check failed: %v\n", ch.Name, err)
}
}
return nil
}
// StartPeriodicCheck starts periodic health checks
func (hc *HealthChecker) StartPeriodicCheck(ctx context.Context, interval time.Duration) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if err := hc.CheckAllChannels(ctx); err != nil {
fmt.Printf("Periodic health check error: %v\n", err)
}
}
}
}