Files
openteam/server/internal/admin/channels.go
T
SakurasanandClaude Sonnet 5 d0e31b198f 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>
2026-08-15 21:05:02 +08:00

330 lines
9.3 KiB
Go

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
}