package handlers import ( "context" "encoding/json" "log" "net/http" "github.com/gin-gonic/gin" capspkg "chat-switchboard/capabilities" "chat-switchboard/models" "chat-switchboard/providers" "chat-switchboard/store" ) // ── Team Provider Handlers ────────────────── // ListTeamProviders returns API configs scoped to a team. func (h *TeamHandler) ListTeamProviders(c *gin.Context) { teamID := getTeamID(c) ctx := c.Request.Context() configs, err := h.stores.Providers.ListAllForTeam(ctx, teamID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list team providers"}) return } type teamProvider struct { ID string `json:"id"` Name string `json:"name"` Provider string `json:"provider"` Endpoint string `json:"endpoint"` HasKey bool `json:"has_key"` ModelDefault *string `json:"model_default"` Config map[string]interface{} `json:"config"` IsActive bool `json:"is_active"` IsPrivate bool `json:"is_private"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` } result := make([]teamProvider, 0, len(configs)) for _, cfg := range configs { var md *string if cfg.ModelDefault != "" { md = &cfg.ModelDefault } cfgMap := map[string]interface{}{} if cfg.Config != nil { cfgMap = cfg.Config } result = append(result, teamProvider{ ID: cfg.ID, Name: cfg.Name, Provider: cfg.Provider, Endpoint: cfg.Endpoint, HasKey: cfg.HasKey(), ModelDefault: md, Config: cfgMap, IsActive: cfg.IsActive, IsPrivate: cfg.IsPrivate, CreatedAt: cfg.CreatedAt.Format("2006-01-02T15:04:05Z"), UpdatedAt: cfg.UpdatedAt.Format("2006-01-02T15:04:05Z"), }) } c.JSON(http.StatusOK, gin.H{ "data": result, "allow_team_providers": isTeamProvidersAllowed(h.stores, teamID), }) } // CreateTeamProvider creates an API config scoped to a team. func (h *TeamHandler) CreateTeamProvider(c *gin.Context) { teamID := getTeamID(c) if !isTeamProvidersAllowed(h.stores, teamID) { c.JSON(http.StatusForbidden, gin.H{"error": "team providers are not enabled for this team"}) return } var req struct { Name string `json:"name" binding:"required,max=100"` Provider string `json:"provider" binding:"required"` Endpoint string `json:"endpoint" binding:"required"` APIKey string `json:"api_key"` ModelDefault string `json:"model_default,omitempty"` Config map[string]interface{} `json:"config,omitempty"` Headers map[string]string `json:"headers,omitempty"` IsPrivate bool `json:"is_private,omitempty"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if _, err := providers.Get(req.Provider); err != nil { c.JSON(http.StatusBadRequest, gin.H{ "error": "unsupported provider: " + req.Provider, "supported_providers": providers.List(), }) return } // Encrypt the API key for team scope var apiKeyEnc, keyNonce []byte if req.APIKey != "" { if h.vault != nil { var err error apiKeyEnc, keyNonce, err = h.vault.EncryptForScope(req.APIKey, "team", "") if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encrypt API key"}) return } } else { apiKeyEnc = []byte(req.APIKey) } } headersMap := models.JSONMap{} if req.Headers != nil { for k, v := range req.Headers { headersMap[k] = v } } cfg := &models.ProviderConfig{ Scope: models.ScopeTeam, OwnerID: &teamID, Name: req.Name, Provider: req.Provider, Endpoint: req.Endpoint, APIKeyEnc: apiKeyEnc, KeyNonce: keyNonce, KeyScope: "team", ModelDefault: req.ModelDefault, Config: models.JSONMap(req.Config), Headers: headersMap, IsActive: true, IsPrivate: req.IsPrivate, } if err := h.stores.Providers.Create(c.Request.Context(), cfg); err != nil { log.Printf("[WARN] Failed to create team provider: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create provider"}) return } c.JSON(http.StatusCreated, gin.H{"id": cfg.ID}) } // UpdateTeamProvider updates a team-scoped API config. func (h *TeamHandler) UpdateTeamProvider(c *gin.Context) { teamID := getTeamID(c) providerID := c.Param("id") ctx := c.Request.Context() var req struct { Name *string `json:"name,omitempty"` Endpoint *string `json:"endpoint,omitempty"` APIKey *string `json:"api_key,omitempty"` ModelDefault *string `json:"model_default,omitempty"` Config map[string]interface{} `json:"config,omitempty"` Headers map[string]string `json:"headers,omitempty"` IsActive *bool `json:"is_active,omitempty"` IsPrivate *bool `json:"is_private,omitempty"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } // Verify provider belongs to this team existing, err := h.stores.Providers.GetByID(ctx, providerID) if err != nil || existing.Scope != models.ScopeTeam || (existing.OwnerID != nil && *existing.OwnerID != teamID) { c.JSON(http.StatusNotFound, gin.H{"error": "provider not found in this team"}) return } // Build patch patch := models.ProviderConfigPatch{} fieldCount := 0 if req.Name != nil { patch.Name = req.Name fieldCount++ } if req.Endpoint != nil { patch.Endpoint = req.Endpoint fieldCount++ } if req.APIKey != nil && *req.APIKey != "" { if h.vault != nil { enc, nonce, err := h.vault.EncryptForScope(*req.APIKey, "team", "") if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encrypt API key"}) return } patch.APIKeyEnc = enc patch.KeyNonce = nonce } else { patch.APIKeyEnc = []byte(*req.APIKey) } fieldCount++ } if req.ModelDefault != nil { patch.ModelDefault = req.ModelDefault fieldCount++ } if req.IsActive != nil { patch.IsActive = req.IsActive fieldCount++ } if req.IsPrivate != nil { patch.IsPrivate = req.IsPrivate fieldCount++ } if req.Config != nil { patch.Config = models.JSONMap(req.Config) fieldCount++ } if req.Headers != nil { headersMap := models.JSONMap{} for k, v := range req.Headers { headersMap[k] = v } patch.Headers = headersMap fieldCount++ } if fieldCount == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"}) return } if err := h.stores.Providers.Update(ctx, providerID, patch); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update provider"}) return } c.JSON(http.StatusOK, gin.H{"id": providerID, "updated": true}) } // DeleteTeamProvider removes a team-scoped API config. func (h *TeamHandler) DeleteTeamProvider(c *gin.Context) { teamID := getTeamID(c) providerID := c.Param("id") n, err := h.stores.Providers.DeleteByIDAndTeam(c.Request.Context(), providerID, teamID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete provider"}) return } if n == 0 { c.JSON(http.StatusNotFound, gin.H{"error": "provider not found in this team"}) return } c.JSON(http.StatusOK, gin.H{"deleted": true}) } // ListTeamProviderModels lists models available from a team provider (live query). func (h *TeamHandler) ListTeamProviderModels(c *gin.Context) { teamID := getTeamID(c) providerID := c.Param("id") ctx := c.Request.Context() cfg, err := h.stores.Providers.GetByID(ctx, providerID) if err != nil || cfg.Scope != models.ScopeTeam || !cfg.IsActive { c.JSON(http.StatusNotFound, gin.H{"error": "provider not found"}) return } if cfg.OwnerID == nil || *cfg.OwnerID != teamID { c.JSON(http.StatusNotFound, gin.H{"error": "provider not found"}) return } provider, err := providers.Get(cfg.Provider) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported provider"}) return } key := "" if cfg.HasKey() { if h.vault != nil { var err error key, err = h.vault.Decrypt(cfg.APIKeyEnc, cfg.KeyNonce, cfg.KeyScope, "") if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to decrypt API key"}) return } } else { key = string(cfg.APIKeyEnc) } } var customHeaders map[string]string if cfg.Headers != nil { b, _ := json.Marshal(cfg.Headers) _ = json.Unmarshal(b, &customHeaders) } modelList, err := provider.ListModels(ctx, providers.ProviderConfig{ Endpoint: cfg.Endpoint, APIKey: key, CustomHeaders: customHeaders, }) if err != nil { c.JSON(http.StatusBadGateway, gin.H{"error": "failed to fetch models: " + err.Error()}) return } type modelInfo struct { ID string `json:"id"` Type string `json:"type"` Capabilities models.ModelCapabilities `json:"capabilities"` } out := make([]modelInfo, 0, len(modelList)) for _, m := range modelList { caps := capspkg.ResolveIntrinsic(m.ID, &m.Capabilities, nil) caps.MaxOutputTokens = capspkg.ResolveMaxOutput(m.ID, caps) out = append(out, modelInfo{ID: m.ID, Type: m.Type, Capabilities: caps}) } c.JSON(http.StatusOK, gin.H{"models": out, "provider": cfg.Name}) } // parseJSONBConfig parses a JSONB text string into a map. func parseJSONBConfig(raw string) map[string]interface{} { if raw == "" || raw == "{}" || raw == "null" { return map[string]interface{}{} } var m map[string]interface{} if err := json.Unmarshal([]byte(raw), &m); err != nil { return map[string]interface{}{} } return m } // isTeamProvidersAllowed checks if team providers are enabled. func isTeamProvidersAllowed(stores store.Stores, teamID string) bool { if stores.GlobalConfig == nil { return false } ctx := context.Background() // Check global setting globalVal, err := stores.GlobalConfig.GetString(ctx, "allow_team_providers") if err == nil && globalVal == "false" { return false } // Check team-level setting team, err := stores.Teams.GetByID(ctx, teamID) if err != nil { return true // fail open } if team.Settings != nil { if v, ok := team.Settings["allow_team_providers"]; ok { if bVal, ok := v.(bool); ok { return bVal } } } return true }