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 }