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:
@@ -0,0 +1,380 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"opencatd-open/internal/channel"
|
||||
"opencatd-open/internal/dao"
|
||||
"opencatd-open/internal/proxy/convert"
|
||||
"opencatd-open/internal/store"
|
||||
"opencatd-open/pkg/config"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Gateway struct {
|
||||
ctx context.Context
|
||||
cfg *config.Config
|
||||
db *gorm.DB
|
||||
wg *sync.WaitGroup
|
||||
httpClient *http.Client
|
||||
|
||||
userDAO *dao.UserDAO
|
||||
apiKeyDAO *dao.ApiKeyDAO
|
||||
usageDAO *dao.UsageDAO
|
||||
dailyDAO *dao.DailyUsageDAO
|
||||
channelSvc *channel.Service
|
||||
}
|
||||
|
||||
func NewGateway(ctx context.Context, cfg *config.Config, db *gorm.DB, wg *sync.WaitGroup, userDAO *dao.UserDAO, apiKeyDAO *dao.ApiKeyDAO, usageDAO *dao.UsageDAO, dailyDAO *dao.DailyUsageDAO) *Gateway {
|
||||
client := &http.Client{Timeout: 120 * time.Second}
|
||||
if os.Getenv("LOCAL_PROXY") != "" {
|
||||
proxyUrl, err := url.Parse(os.Getenv("LOCAL_PROXY"))
|
||||
if err == nil {
|
||||
tr := &http.Transport{
|
||||
Proxy: http.ProxyURL(proxyUrl),
|
||||
}
|
||||
client.Transport = tr
|
||||
}
|
||||
}
|
||||
|
||||
return &Gateway{
|
||||
ctx: ctx,
|
||||
cfg: cfg,
|
||||
db: db,
|
||||
wg: wg,
|
||||
httpClient: client,
|
||||
userDAO: userDAO,
|
||||
apiKeyDAO: apiKeyDAO,
|
||||
usageDAO: usageDAO,
|
||||
dailyDAO: dailyDAO,
|
||||
channelSvc: nil,
|
||||
}
|
||||
}
|
||||
|
||||
func (g *Gateway) SetChannelService(svc *channel.Service) {
|
||||
g.channelSvc = svc
|
||||
}
|
||||
|
||||
// Request represents a parsed incoming request
|
||||
type Request struct {
|
||||
Model string
|
||||
Stream bool
|
||||
Protocol string // "chat", "messages", "responses"
|
||||
Body []byte
|
||||
APIKey *store.APIKey
|
||||
UserID uint64
|
||||
}
|
||||
|
||||
// ParseRequest parses the incoming request and extracts key fields
|
||||
func (g *Gateway) ParseRequest(c *gin.Context, protocol string) (*Request, error) {
|
||||
body, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read body: %w", err)
|
||||
}
|
||||
|
||||
apiKey, _ := c.Get("api_key")
|
||||
userID, _ := c.Get("user_id")
|
||||
|
||||
req := &Request{
|
||||
Protocol: protocol,
|
||||
Body: body,
|
||||
UserID: userID.(uint64),
|
||||
}
|
||||
|
||||
if ak, ok := apiKey.(*store.APIKey); ok {
|
||||
req.APIKey = ak
|
||||
}
|
||||
|
||||
// Parse model and stream based on protocol
|
||||
switch protocol {
|
||||
case "chat":
|
||||
var parsed convert.ChatCompletionRequest
|
||||
if err := json.Unmarshal(body, &parsed); err != nil {
|
||||
return nil, fmt.Errorf("invalid chat request: %w", err)
|
||||
}
|
||||
req.Model = parsed.Model
|
||||
req.Stream = parsed.Stream
|
||||
case "messages":
|
||||
var parsed convert.MessagesRequest
|
||||
if err := json.Unmarshal(body, &parsed); err != nil {
|
||||
return nil, fmt.Errorf("invalid messages request: %w", err)
|
||||
}
|
||||
req.Model = parsed.Model
|
||||
req.Stream = parsed.Stream
|
||||
case "responses":
|
||||
var parsed convert.ResponsesRequest
|
||||
if err := json.Unmarshal(body, &parsed); err != nil {
|
||||
return nil, fmt.Errorf("invalid responses request: %w", err)
|
||||
}
|
||||
req.Model = parsed.Model
|
||||
req.Stream = parsed.Stream
|
||||
}
|
||||
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// Dispatch routes the request to the appropriate upstream
|
||||
func (g *Gateway) Dispatch(c *gin.Context, req *Request) {
|
||||
if g.channelSvc == nil {
|
||||
g.writeError(c, http.StatusBadGateway, "channel service not available")
|
||||
return
|
||||
}
|
||||
|
||||
ch, err := g.channelSvc.SelectChannel(g.ctx, req.Model)
|
||||
if err != nil {
|
||||
g.writeError(c, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
apiKey, err := g.channelSvc.GetAPIKey(ch)
|
||||
if err != nil {
|
||||
g.writeError(c, http.StatusBadGateway, "failed to decrypt API key")
|
||||
return
|
||||
}
|
||||
|
||||
// Determine target format and convert if needed
|
||||
targetFormat := req.Protocol
|
||||
if len(ch.FormatsEffective()) > 0 {
|
||||
// Prefer the channel's native format
|
||||
for _, f := range ch.FormatsEffective() {
|
||||
if f == req.Protocol {
|
||||
targetFormat = f
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build upstream URL
|
||||
upstreamPath := g.getUpstreamPath(req.Protocol)
|
||||
upstreamURL := ch.UpstreamURL(req.Protocol, upstreamPath)
|
||||
|
||||
// Convert request if needed
|
||||
var requestBody []byte
|
||||
if targetFormat != req.Protocol {
|
||||
requestBody, err = g.convertRequest(req.Body, req.Protocol, targetFormat)
|
||||
if err != nil {
|
||||
g.writeError(c, http.StatusBadRequest, "conversion failed: "+err.Error())
|
||||
return
|
||||
}
|
||||
} else {
|
||||
requestBody = req.Body
|
||||
}
|
||||
|
||||
// Create upstream request
|
||||
httpReq, err := http.NewRequestWithContext(g.ctx, "POST", upstreamURL, bytes.NewReader(requestBody))
|
||||
if err != nil {
|
||||
g.writeError(c, http.StatusBadGateway, "failed to create request")
|
||||
return
|
||||
}
|
||||
|
||||
// Set headers
|
||||
g.setHeaders(httpReq, ch, apiKey, targetFormat)
|
||||
|
||||
// Execute request
|
||||
start := time.Now()
|
||||
resp, err := g.httpClient.Do(httpReq)
|
||||
latency := time.Since(start)
|
||||
if err != nil {
|
||||
g.channelSvc.RecordFailure(ch.ID)
|
||||
g.writeError(c, http.StatusBadGateway, fmt.Sprintf("upstream error: %v (latency: %v)", err, latency))
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Record success
|
||||
g.channelSvc.RecordSuccess(ch.ID)
|
||||
|
||||
// Handle response
|
||||
if resp.StatusCode >= 400 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
log.Printf("Upstream error: status=%d body=%s", resp.StatusCode, string(body))
|
||||
c.Data(resp.StatusCode, "application/json", body)
|
||||
return
|
||||
}
|
||||
|
||||
// Stream or buffer response
|
||||
if req.Stream {
|
||||
g.streamResponse(c, resp, req.Protocol, ch)
|
||||
} else {
|
||||
g.bufferResponse(c, resp, req.Protocol, ch)
|
||||
}
|
||||
}
|
||||
|
||||
func (g *Gateway) getUpstreamPath(protocol string) string {
|
||||
switch protocol {
|
||||
case "chat":
|
||||
return "/chat/completions"
|
||||
case "messages":
|
||||
return "/messages"
|
||||
case "responses":
|
||||
return "/responses"
|
||||
default:
|
||||
return "/chat/completions"
|
||||
}
|
||||
}
|
||||
|
||||
func (g *Gateway) setHeaders(req *http.Request, ch *store.Channel, apiKey string, format string) {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
switch ch.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")
|
||||
}
|
||||
}
|
||||
|
||||
func (g *Gateway) convertRequest(body []byte, from, to string) ([]byte, error) {
|
||||
switch {
|
||||
case from == "chat" && to == "messages":
|
||||
var req convert.ChatCompletionRequest
|
||||
if err := json.Unmarshal(body, &req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
msgReq, err := convert.ChatToMessages(&req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(msgReq)
|
||||
|
||||
case from == "chat" && to == "responses":
|
||||
var req convert.ChatCompletionRequest
|
||||
if err := json.Unmarshal(body, &req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
respReq, err := convert.ChatToResponses(&req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(respReq)
|
||||
|
||||
case from == "messages" && to == "chat":
|
||||
var req convert.MessagesRequest
|
||||
if err := json.Unmarshal(body, &req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Messages -> Chat: we need to construct a ChatCompletionRequest
|
||||
chatReq := &convert.ChatCompletionRequest{
|
||||
Model: req.Model,
|
||||
}
|
||||
for _, m := range req.Messages {
|
||||
chatReq.Messages = append(chatReq.Messages, m)
|
||||
}
|
||||
if req.Temperature != nil {
|
||||
chatReq.Temperature = req.Temperature
|
||||
}
|
||||
if req.TopP != nil {
|
||||
chatReq.TopP = req.TopP
|
||||
}
|
||||
chatReq.Tools = req.Tools
|
||||
chatReq.Stream = req.Stream
|
||||
return json.Marshal(chatReq)
|
||||
|
||||
case from == "responses" && to == "chat":
|
||||
var req convert.ResponsesRequest
|
||||
if err := json.Unmarshal(body, &req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
chatReq := &convert.ChatCompletionRequest{
|
||||
Model: req.Model,
|
||||
}
|
||||
for _, item := range req.Input {
|
||||
chatReq.Messages = append(chatReq.Messages, convert.Message{
|
||||
Role: item.Role,
|
||||
Content: item.Content,
|
||||
})
|
||||
}
|
||||
chatReq.Tools = req.Tools
|
||||
chatReq.Stream = req.Stream
|
||||
return json.Marshal(chatReq)
|
||||
|
||||
default:
|
||||
return body, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (g *Gateway) streamResponse(c *gin.Context, resp *http.Response, protocol string, ch *store.Channel) {
|
||||
c.Header("Content-Type", "text/event-stream")
|
||||
c.Header("Cache-Control", "no-cache")
|
||||
c.Header("Connection", "keep-alive")
|
||||
c.Status(http.StatusOK)
|
||||
|
||||
writer := convert.NewSSEWriter(c.Writer)
|
||||
parser := convert.NewSSEParser(resp.Body)
|
||||
|
||||
for {
|
||||
event, err := parser.ReadEvent()
|
||||
if err != nil {
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
log.Printf("Stream parse error: %v", err)
|
||||
break
|
||||
}
|
||||
|
||||
if event.Event == "error" {
|
||||
log.Printf("Upstream stream error: %s", event.Data)
|
||||
break
|
||||
}
|
||||
|
||||
// Write raw SSE event based on protocol
|
||||
if err := writer.WriteEvent("chat CompletionChunk", event.Data); err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
writer.WriteDone()
|
||||
}
|
||||
|
||||
func (g *Gateway) bufferResponse(c *gin.Context, resp *http.Response, protocol string, ch *store.Channel) {
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
g.writeError(c, http.StatusBadGateway, "failed to read response")
|
||||
return
|
||||
}
|
||||
|
||||
c.Data(resp.StatusCode, "application/json", body)
|
||||
}
|
||||
|
||||
func (g *Gateway) writeError(c *gin.Context, status int, message string) {
|
||||
protocol := c.GetHeader("X-Protocol")
|
||||
if protocol == "" {
|
||||
protocol = "chat"
|
||||
}
|
||||
|
||||
switch {
|
||||
case strings.Contains(c.GetHeader("Accept"), "text/event-stream"):
|
||||
c.Header("Content-Type", "text/event-stream")
|
||||
c.Status(status)
|
||||
fmt.Fprintf(c.Writer, "data: {\"error\":{\"message\":\"%s\"}}\n\n", message)
|
||||
fmt.Fprintf(c.Writer, "data: [DONE]\n\n")
|
||||
case protocol == "messages":
|
||||
c.JSON(status, gin.H{
|
||||
"type": "error",
|
||||
"error": gin.H{
|
||||
"type": "api_error",
|
||||
"message": message,
|
||||
},
|
||||
})
|
||||
default:
|
||||
c.JSON(status, gin.H{
|
||||
"error": gin.H{
|
||||
"message": message,
|
||||
"type": "invalid_request_error",
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user