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,329 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"openteam/server/internal/channel"
|
||||
"openteam/server/internal/pkg/httpx"
|
||||
"openteam/server/internal/store"
|
||||
)
|
||||
|
||||
type channelHandler struct {
|
||||
db *gorm.DB
|
||||
ch *channel.Service
|
||||
log *zap.Logger
|
||||
}
|
||||
|
||||
type channelInput struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Provider string `json:"provider" binding:"omitempty,oneof=openai anthropic compatible"`
|
||||
BaseURL string `json:"baseUrl" binding:"required"`
|
||||
APIKey string `json:"apiKey"`
|
||||
Weight int `json:"weight"`
|
||||
Priority int `json:"priority"`
|
||||
TimeoutMs int `json:"timeoutMs"`
|
||||
MaxConcurrency int `json:"maxConcurrency"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
Formats []string `json:"formats"`
|
||||
}
|
||||
|
||||
// ListChannels handles GET /api/v1/admin/channels.
|
||||
func (h *channelHandler) List(c *gin.Context) {
|
||||
var channels []store.Channel
|
||||
if err := h.db.Order("id ASC").Find(&channels).Error; err != nil {
|
||||
h.log.Warn("list channels failed", zap.Error(err))
|
||||
httpx.Fail(c, http.StatusInternalServerError, "list channels failed")
|
||||
return
|
||||
}
|
||||
out := make([]gin.H, 0, len(channels))
|
||||
for i := range channels {
|
||||
out = append(out, channelDTO(&channels[i]))
|
||||
}
|
||||
httpx.OK(c, out)
|
||||
}
|
||||
|
||||
// Create handles POST /api/v1/admin/channels.
|
||||
func (h *channelHandler) Create(c *gin.Context) {
|
||||
var in channelInput
|
||||
if !httpx.Bind(c, &in) {
|
||||
return
|
||||
}
|
||||
provider := in.Provider
|
||||
if len(in.Formats) > 0 {
|
||||
if err := validateFormats(in.Formats); err != nil {
|
||||
httpx.Fail(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
provider = deriveProvider(in.Formats)
|
||||
}
|
||||
if provider == "" {
|
||||
provider = "openai"
|
||||
}
|
||||
enc, err := h.ch.EncryptKey(in.APIKey)
|
||||
if err != nil {
|
||||
httpx.Fail(c, http.StatusInternalServerError, "encrypt key failed")
|
||||
return
|
||||
}
|
||||
ch := &store.Channel{
|
||||
Name: in.Name, Provider: provider, BaseURL: in.BaseURL, Formats: in.Formats,
|
||||
APIKeyEnc: enc, Weight: defaultIf(in.Weight, 1), Priority: in.Priority,
|
||||
TimeoutMs: defaultIf(in.TimeoutMs, 300000), MaxConcurrency: defaultIf(in.MaxConcurrency, 100),
|
||||
Enabled: true,
|
||||
}
|
||||
if in.Enabled != nil {
|
||||
ch.Enabled = *in.Enabled
|
||||
}
|
||||
if err := h.db.Create(ch).Error; err != nil {
|
||||
h.log.Warn("create channel failed", zap.Error(err))
|
||||
httpx.Fail(c, http.StatusInternalServerError, "create channel failed")
|
||||
return
|
||||
}
|
||||
httpx.Created(c, channelDTO(ch))
|
||||
}
|
||||
|
||||
// Update handles PUT /api/v1/admin/channels/:id.
|
||||
func (h *channelHandler) Update(c *gin.Context) {
|
||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
httpx.Fail(c, http.StatusBadRequest, "invalid channel id")
|
||||
return
|
||||
}
|
||||
var ch store.Channel
|
||||
if err := h.db.First(&ch, id).Error; err != nil {
|
||||
httpx.Fail(c, http.StatusNotFound, "channel not found")
|
||||
return
|
||||
}
|
||||
var in channelInput
|
||||
if !httpx.Bind(c, &in) {
|
||||
return
|
||||
}
|
||||
updates := map[string]any{
|
||||
"name": in.Name, "base_url": in.BaseURL,
|
||||
"weight": defaultIf(in.Weight, ch.Weight), "priority": in.Priority,
|
||||
"timeout_ms": defaultIf(in.TimeoutMs, ch.TimeoutMs),
|
||||
"max_concurrency": defaultIf(in.MaxConcurrency, ch.MaxConcurrency),
|
||||
}
|
||||
if len(in.Formats) > 0 {
|
||||
if err := validateFormats(in.Formats); err != nil {
|
||||
httpx.Fail(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
updates["provider"] = deriveProvider(in.Formats)
|
||||
// GORM map updates skip the json serializer, so encode explicitly;
|
||||
// a raw []string would be emitted as a SQL row-value list.
|
||||
formatsJSON, err := json.Marshal(in.Formats)
|
||||
if err != nil {
|
||||
httpx.Fail(c, http.StatusInternalServerError, "serialize formats failed")
|
||||
return
|
||||
}
|
||||
updates["formats"] = string(formatsJSON)
|
||||
} else if in.Provider != "" {
|
||||
updates["provider"] = in.Provider
|
||||
}
|
||||
if in.Enabled != nil {
|
||||
updates["enabled"] = *in.Enabled
|
||||
}
|
||||
if in.APIKey != "" {
|
||||
enc, err := h.ch.EncryptKey(in.APIKey)
|
||||
if err != nil {
|
||||
httpx.Fail(c, http.StatusInternalServerError, "encrypt key failed")
|
||||
return
|
||||
}
|
||||
updates["api_key_enc"] = enc
|
||||
}
|
||||
if err := h.db.Model(&ch).Updates(updates).Error; err != nil {
|
||||
h.log.Warn("update channel failed", zap.Error(err))
|
||||
httpx.Fail(c, http.StatusInternalServerError, "update channel failed")
|
||||
return
|
||||
}
|
||||
h.db.First(&ch, id)
|
||||
httpx.OK(c, channelDTO(&ch))
|
||||
}
|
||||
|
||||
// Delete handles DELETE /api/v1/admin/channels/:id.
|
||||
func (h *channelHandler) Delete(c *gin.Context) {
|
||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
httpx.Fail(c, http.StatusBadRequest, "invalid channel id")
|
||||
return
|
||||
}
|
||||
if err := h.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Where("channel_id = ?", id).Delete(&store.ChannelModelBinding{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Delete(&store.Channel{}, id).Error
|
||||
}); err != nil {
|
||||
h.log.Warn("delete channel failed", zap.Error(err))
|
||||
httpx.Fail(c, http.StatusInternalServerError, "delete channel failed")
|
||||
return
|
||||
}
|
||||
httpx.OK(c, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// Test handles POST /api/v1/admin/channels/:id/test.
|
||||
func (h *channelHandler) Test(c *gin.Context) {
|
||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
httpx.Fail(c, http.StatusBadRequest, "invalid channel id")
|
||||
return
|
||||
}
|
||||
var ch store.Channel
|
||||
if err := h.db.First(&ch, id).Error; err != nil {
|
||||
httpx.Fail(c, http.StatusNotFound, "channel not found")
|
||||
return
|
||||
}
|
||||
ok, latency, model, err := h.ch.Test(&ch)
|
||||
if err != nil || !ok {
|
||||
msg := "test failed"
|
||||
if err != nil {
|
||||
msg = err.Error()
|
||||
}
|
||||
httpx.Fail(c, http.StatusBadGateway, msg)
|
||||
return
|
||||
}
|
||||
httpx.OK(c, gin.H{"ok": true, "latencyMs": latency, "model": model})
|
||||
}
|
||||
|
||||
// ImportModels handles POST /api/v1/admin/channels/:id/import-models.
|
||||
func (h *channelHandler) ImportModels(c *gin.Context) {
|
||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
httpx.Fail(c, http.StatusBadRequest, "invalid channel id")
|
||||
return
|
||||
}
|
||||
var ch store.Channel
|
||||
if err := h.db.First(&ch, id).Error; err != nil {
|
||||
httpx.Fail(c, http.StatusNotFound, "channel not found")
|
||||
return
|
||||
}
|
||||
names, err := h.ch.ImportModels(&ch)
|
||||
if err != nil {
|
||||
httpx.Fail(c, http.StatusBadGateway, "import failed: "+err.Error())
|
||||
return
|
||||
}
|
||||
httpx.OK(c, gin.H{"imported": names, "count": len(names)})
|
||||
}
|
||||
|
||||
// ListBindings handles GET /api/v1/admin/channels/:id/bindings.
|
||||
func (h *channelHandler) ListBindings(c *gin.Context) {
|
||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
httpx.Fail(c, http.StatusBadRequest, "invalid channel id")
|
||||
return
|
||||
}
|
||||
var bindings []store.ChannelModelBinding
|
||||
if err := h.db.Preload("Model").Where("channel_id = ?", id).Find(&bindings).Error; err != nil {
|
||||
h.log.Warn("list bindings failed", zap.Error(err))
|
||||
httpx.Fail(c, http.StatusInternalServerError, "list bindings failed")
|
||||
return
|
||||
}
|
||||
out := make([]gin.H, 0, len(bindings))
|
||||
for _, b := range bindings {
|
||||
out = append(out, gin.H{
|
||||
"id": b.ID, "modelId": b.ModelID, "modelName": b.Model.Name,
|
||||
"upstreamModel": b.UpstreamModel, "weight": b.Weight,
|
||||
})
|
||||
}
|
||||
httpx.OK(c, out)
|
||||
}
|
||||
|
||||
// SaveBindings handles PUT /api/v1/admin/channels/:id/bindings.
|
||||
func (h *channelHandler) SaveBindings(c *gin.Context) {
|
||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
httpx.Fail(c, http.StatusBadRequest, "invalid channel id")
|
||||
return
|
||||
}
|
||||
var in struct {
|
||||
Items []struct {
|
||||
ModelID int64 `json:"modelId"`
|
||||
UpstreamModel string `json:"upstreamModel"`
|
||||
Weight int `json:"weight"`
|
||||
} `json:"items"`
|
||||
}
|
||||
if !httpx.Bind(c, &in) {
|
||||
return
|
||||
}
|
||||
if err := h.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Where("channel_id = ?", id).Delete(&store.ChannelModelBinding{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, item := range in.Items {
|
||||
if item.ModelID == 0 {
|
||||
continue
|
||||
}
|
||||
b := store.ChannelModelBinding{
|
||||
ChannelID: id, ModelID: item.ModelID,
|
||||
UpstreamModel: item.UpstreamModel, Weight: defaultIf(item.Weight, 1),
|
||||
}
|
||||
if err := tx.Create(&b).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
h.log.Warn("save bindings failed", zap.Error(err))
|
||||
httpx.Fail(c, http.StatusInternalServerError, "save bindings failed")
|
||||
return
|
||||
}
|
||||
httpx.OK(c, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func channelDTO(ch *store.Channel) gin.H {
|
||||
return gin.H{
|
||||
"id": ch.ID, "name": ch.Name, "provider": ch.Provider, "baseUrl": ch.BaseURL,
|
||||
"formats": ch.FormatsResolved(),
|
||||
"weight": ch.Weight, "priority": ch.Priority, "timeoutMs": ch.TimeoutMs,
|
||||
"maxConcurrency": ch.MaxConcurrency, "healthStatus": ch.HealthStatus,
|
||||
"healthFailures": ch.HealthFailures, "enabled": ch.Enabled,
|
||||
"createdAt": ch.CreatedAt, "updatedAt": ch.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
var validFormats = map[string]bool{
|
||||
store.FormatOpenAIChat: true,
|
||||
store.FormatOpenAIResponses: true,
|
||||
store.FormatAnthropic: true,
|
||||
}
|
||||
|
||||
func validateFormats(fs []string) error {
|
||||
for _, f := range fs {
|
||||
if !validFormats[f] {
|
||||
return fmt.Errorf("invalid api format: %s", f)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// deriveProvider computes the conversion-target provider from the declared
|
||||
// formats. Anthropic-only channels convert to Claude; anything else targets the
|
||||
// OpenAI chat-completions family.
|
||||
func deriveProvider(formats []string) string {
|
||||
hasAnthropic, hasOpenAI := false, false
|
||||
for _, f := range formats {
|
||||
switch f {
|
||||
case store.FormatAnthropic:
|
||||
hasAnthropic = true
|
||||
case store.FormatOpenAIChat, store.FormatOpenAIResponses:
|
||||
hasOpenAI = true
|
||||
}
|
||||
}
|
||||
if hasAnthropic && !hasOpenAI {
|
||||
return "anthropic"
|
||||
}
|
||||
return "openai"
|
||||
}
|
||||
|
||||
func defaultIf(v, def int) int {
|
||||
if v == 0 {
|
||||
return def
|
||||
}
|
||||
return v
|
||||
}
|
||||
Reference in New Issue
Block a user