This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/server/handlers/team_providers.go
2026-03-19 18:50:27 +00:00

374 lines
10 KiB
Go

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
}