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>
330 lines
9.3 KiB
Go
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
|
|
}
|