374 lines
10 KiB
Go
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
|
|
}
|