368 lines
11 KiB
Go
368 lines
11 KiB
Go
package handlers
|
|
|
|
import (
|
|
"encoding/json"
|
|
"log"
|
|
"net/http"
|
|
"strconv"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
capspkg "git.gobha.me/xcaliber/chat-switchboard/capabilities"
|
|
"git.gobha.me/xcaliber/chat-switchboard/database"
|
|
"git.gobha.me/xcaliber/chat-switchboard/models"
|
|
"git.gobha.me/xcaliber/chat-switchboard/providers"
|
|
)
|
|
|
|
// ── Team Provider Handlers ──────────────────
|
|
|
|
// ListTeamProviders returns API configs scoped to a team.
|
|
func (h *TeamHandler) ListTeamProviders(c *gin.Context) {
|
|
teamID := getTeamID(c)
|
|
|
|
rows, err := database.DB.Query(`
|
|
SELECT id, name, provider, endpoint, api_key_enc,
|
|
model_default, config::text, is_active, is_private, created_at, updated_at
|
|
FROM provider_configs
|
|
WHERE scope = 'team' AND owner_id = $1
|
|
ORDER BY name ASC
|
|
`, teamID)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list team providers"})
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
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"`
|
|
}
|
|
|
|
configs := make([]teamProvider, 0)
|
|
for rows.Next() {
|
|
var p teamProvider
|
|
var apiKeyEnc []byte
|
|
var configRaw string
|
|
if err := rows.Scan(&p.ID, &p.Name, &p.Provider, &p.Endpoint, &apiKeyEnc,
|
|
&p.ModelDefault, &configRaw, &p.IsActive, &p.IsPrivate, &p.CreatedAt, &p.UpdatedAt); err != nil {
|
|
continue
|
|
}
|
|
p.HasKey = len(apiKeyEnc) > 0
|
|
p.Config = parseJSONBConfig(configRaw)
|
|
configs = append(configs, p)
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"providers": configs,
|
|
"allow_team_providers": isTeamProvidersAllowed(teamID),
|
|
})
|
|
}
|
|
|
|
// CreateTeamProvider creates an API config scoped to a team.
|
|
func (h *TeamHandler) CreateTeamProvider(c *gin.Context) {
|
|
teamID := getTeamID(c)
|
|
|
|
if !isTeamProvidersAllowed(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"`
|
|
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
|
|
}
|
|
|
|
configJSON := "{}"
|
|
if req.Config != nil {
|
|
b, _ := json.Marshal(req.Config)
|
|
configJSON = string(b)
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
var id string
|
|
err := database.DB.QueryRow(`
|
|
INSERT INTO provider_configs (scope, owner_id, name, provider, endpoint,
|
|
api_key_enc, key_nonce, key_scope, model_default, config, is_private)
|
|
VALUES ('team', $1, $2, $3, $4, $5, $6, 'team', $7, $8::jsonb, $9)
|
|
RETURNING id
|
|
`, teamID, req.Name, req.Provider, req.Endpoint,
|
|
apiKeyEnc, keyNonce, req.ModelDefault, configJSON, req.IsPrivate,
|
|
).Scan(&id)
|
|
if 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": id})
|
|
}
|
|
|
|
// UpdateTeamProvider updates a team-scoped API config.
|
|
func (h *TeamHandler) UpdateTeamProvider(c *gin.Context) {
|
|
teamID := getTeamID(c)
|
|
providerID := c.Param("id")
|
|
|
|
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"`
|
|
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
|
|
var count int
|
|
database.DB.QueryRow(`SELECT COUNT(*) FROM provider_configs WHERE id = $1 AND scope = 'team' AND owner_id = $2`, providerID, teamID).Scan(&count)
|
|
if count == 0 {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "provider not found in this team"})
|
|
return
|
|
}
|
|
|
|
// Build dynamic update
|
|
query := "UPDATE provider_configs SET updated_at = NOW()"
|
|
args := []interface{}{}
|
|
argN := 1
|
|
|
|
if req.Name != nil {
|
|
query += ", name = $" + strconv.Itoa(argN)
|
|
args = append(args, *req.Name)
|
|
argN++
|
|
}
|
|
if req.Endpoint != nil {
|
|
query += ", endpoint = $" + strconv.Itoa(argN)
|
|
args = append(args, *req.Endpoint)
|
|
argN++
|
|
}
|
|
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
|
|
}
|
|
query += ", api_key_enc = $" + strconv.Itoa(argN)
|
|
args = append(args, enc)
|
|
argN++
|
|
query += ", key_nonce = $" + strconv.Itoa(argN)
|
|
args = append(args, nonce)
|
|
argN++
|
|
} else {
|
|
query += ", api_key_enc = $" + strconv.Itoa(argN)
|
|
args = append(args, []byte(*req.APIKey))
|
|
argN++
|
|
}
|
|
}
|
|
if req.ModelDefault != nil {
|
|
query += ", model_default = $" + strconv.Itoa(argN)
|
|
args = append(args, *req.ModelDefault)
|
|
argN++
|
|
}
|
|
if req.IsActive != nil {
|
|
query += ", is_active = $" + strconv.Itoa(argN)
|
|
args = append(args, *req.IsActive)
|
|
argN++
|
|
}
|
|
if req.IsPrivate != nil {
|
|
query += ", is_private = $" + strconv.Itoa(argN)
|
|
args = append(args, *req.IsPrivate)
|
|
argN++
|
|
}
|
|
if req.Config != nil {
|
|
b, _ := json.Marshal(req.Config)
|
|
query += ", config = $" + strconv.Itoa(argN) + "::jsonb"
|
|
args = append(args, string(b))
|
|
argN++
|
|
}
|
|
|
|
query += " WHERE id = $" + strconv.Itoa(argN) + " AND scope = 'team' AND owner_id = $" + strconv.Itoa(argN+1)
|
|
args = append(args, providerID, teamID)
|
|
|
|
_, err := database.DB.Exec(query, args...)
|
|
if 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")
|
|
|
|
result, err := database.DB.Exec(`
|
|
DELETE FROM provider_configs WHERE id = $1 AND scope = 'team' AND owner_id = $2
|
|
`, providerID, teamID)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete provider"})
|
|
return
|
|
}
|
|
|
|
rows, _ := result.RowsAffected()
|
|
if rows == 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")
|
|
|
|
var name, providerType, endpoint string
|
|
var apiKeyEnc, keyNonce []byte
|
|
var keyScope string
|
|
var headersJSON []byte
|
|
err := database.DB.QueryRow(`
|
|
SELECT name, provider, endpoint, api_key_enc, key_nonce, key_scope, headers
|
|
FROM provider_configs
|
|
WHERE id = $1 AND scope = 'team' AND owner_id = $2 AND is_active = true
|
|
`, providerID, teamID).Scan(&name, &providerType, &endpoint, &apiKeyEnc, &keyNonce, &keyScope, &headersJSON)
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "provider not found"})
|
|
return
|
|
}
|
|
|
|
provider, err := providers.Get(providerType)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported provider"})
|
|
return
|
|
}
|
|
|
|
key := ""
|
|
if len(apiKeyEnc) > 0 {
|
|
if h.vault != nil {
|
|
var err error
|
|
key, err = h.vault.Decrypt(apiKeyEnc, keyNonce, keyScope, "")
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to decrypt API key"})
|
|
return
|
|
}
|
|
} else {
|
|
key = string(apiKeyEnc)
|
|
}
|
|
}
|
|
|
|
var customHeaders map[string]string
|
|
_ = json.Unmarshal(headersJSON, &customHeaders)
|
|
|
|
modelList, err := provider.ListModels(c.Request.Context(), providers.ProviderConfig{
|
|
Endpoint: 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"`
|
|
Capabilities models.ModelCapabilities `json:"capabilities"`
|
|
}
|
|
|
|
out := make([]modelInfo, 0, len(modelList))
|
|
for _, m := range modelList {
|
|
caps := capspkg.ResolveIntrinsic(m.ID, &m.Capabilities)
|
|
caps.MaxOutputTokens = capspkg.ResolveMaxOutput(m.ID, caps)
|
|
out = append(out, modelInfo{ID: m.ID, Capabilities: caps})
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"models": out, "provider": 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(teamID string) bool {
|
|
if database.DB == nil {
|
|
return false
|
|
}
|
|
|
|
var globalVal string
|
|
err := database.DB.QueryRow(`
|
|
SELECT value FROM global_settings WHERE key = 'allow_team_providers'
|
|
`).Scan(&globalVal)
|
|
if err == nil && globalVal == "false" {
|
|
return false
|
|
}
|
|
|
|
var settingsJSON []byte
|
|
err = database.DB.QueryRow(`SELECT settings FROM teams WHERE id = $1`, teamID).Scan(&settingsJSON)
|
|
if err != nil {
|
|
return true
|
|
}
|
|
|
|
var settings map[string]interface{}
|
|
if err := json.Unmarshal(settingsJSON, &settings); err != nil {
|
|
return true
|
|
}
|
|
if v, ok := settings["allow_team_providers"]; ok {
|
|
if b, ok := v.(bool); ok {
|
|
return b
|
|
}
|
|
}
|
|
|
|
return true
|
|
}
|