Changeset 0.29.0 (#195)
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
@@ -8,9 +9,9 @@ import (
|
||||
"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"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
)
|
||||
|
||||
// ── Team Provider Handlers ──────────────────
|
||||
@@ -18,19 +19,13 @@ import (
|
||||
// ListTeamProviders returns API configs scoped to a team.
|
||||
func (h *TeamHandler) ListTeamProviders(c *gin.Context) {
|
||||
teamID := getTeamID(c)
|
||||
ctx := c.Request.Context()
|
||||
|
||||
rows, err := database.DB.Query(database.Q(`
|
||||
SELECT id, name, provider, endpoint, api_key_enc,
|
||||
model_default, config, is_active, is_private, created_at, updated_at
|
||||
FROM provider_configs
|
||||
WHERE scope = 'team' AND owner_id = $1
|
||||
ORDER BY name ASC
|
||||
`), teamID)
|
||||
configs, err := h.stores.Providers.ListAllForTeam(ctx, 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"`
|
||||
@@ -46,23 +41,34 @@ func (h *TeamHandler) ListTeamProviders(c *gin.Context) {
|
||||
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
|
||||
result := make([]teamProvider, 0, len(configs))
|
||||
for _, cfg := range configs {
|
||||
var md *string
|
||||
if cfg.ModelDefault != "" {
|
||||
md = &cfg.ModelDefault
|
||||
}
|
||||
p.HasKey = len(apiKeyEnc) > 0
|
||||
p.Config = parseJSONBConfig(configRaw)
|
||||
configs = append(configs, p)
|
||||
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": configs,
|
||||
"allow_team_providers": isTeamProvidersAllowed(teamID),
|
||||
"data": result,
|
||||
"allow_team_providers": isTeamProvidersAllowed(h.stores, teamID),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -70,7 +76,7 @@ func (h *TeamHandler) ListTeamProviders(c *gin.Context) {
|
||||
func (h *TeamHandler) CreateTeamProvider(c *gin.Context) {
|
||||
teamID := getTeamID(c)
|
||||
|
||||
if !isTeamProvidersAllowed(teamID) {
|
||||
if !isTeamProvidersAllowed(h.stores, teamID) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "team providers are not enabled for this team"})
|
||||
return
|
||||
}
|
||||
@@ -98,18 +104,6 @@ func (h *TeamHandler) CreateTeamProvider(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
configJSON := "{}"
|
||||
if req.Config != nil {
|
||||
b, _ := json.Marshal(req.Config)
|
||||
configJSON = string(b)
|
||||
}
|
||||
|
||||
headersJSON := "{}"
|
||||
if req.Headers != nil {
|
||||
b, _ := json.Marshal(req.Headers)
|
||||
headersJSON = string(b)
|
||||
}
|
||||
|
||||
// Encrypt the API key for team scope
|
||||
var apiKeyEnc, keyNonce []byte
|
||||
if req.APIKey != "" {
|
||||
@@ -125,27 +119,43 @@ func (h *TeamHandler) CreateTeamProvider(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
id, err := database.InsertReturningID(`
|
||||
INSERT INTO provider_configs (scope, owner_id, name, provider, endpoint,
|
||||
api_key_enc, key_nonce, key_scope, model_default, config, is_private, headers)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, 'team', $8, $9::jsonb, $10, $11::jsonb)
|
||||
RETURNING id
|
||||
`, "team", teamID, req.Name, req.Provider, req.Endpoint,
|
||||
apiKeyEnc, keyNonce, req.ModelDefault, configJSON, req.IsPrivate, headersJSON,
|
||||
)
|
||||
if err != nil {
|
||||
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": id})
|
||||
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"`
|
||||
@@ -163,29 +173,23 @@ func (h *TeamHandler) UpdateTeamProvider(c *gin.Context) {
|
||||
}
|
||||
|
||||
// Verify provider belongs to this team
|
||||
var count int
|
||||
database.DB.QueryRow(database.Q(`SELECT COUNT(*) FROM provider_configs WHERE id = $1 AND scope = 'team' AND owner_id = $2`), providerID, teamID).Scan(&count)
|
||||
if count == 0 {
|
||||
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 dynamic update using ? placeholders (works on both dialects)
|
||||
setClauses := []string{"updated_at = " + database.Q("NOW()")}
|
||||
args := []interface{}{}
|
||||
// Build patch
|
||||
patch := models.ProviderConfigPatch{}
|
||||
fieldCount := 0
|
||||
|
||||
addSet := func(col string, val interface{}) {
|
||||
setClauses = append(setClauses, col+" = ?")
|
||||
args = append(args, val)
|
||||
if req.Name != nil {
|
||||
patch.Name = req.Name
|
||||
fieldCount++
|
||||
}
|
||||
|
||||
if req.Name != nil {
|
||||
addSet("name", *req.Name)
|
||||
}
|
||||
if req.Endpoint != nil {
|
||||
addSet("endpoint", *req.Endpoint)
|
||||
patch.Endpoint = req.Endpoint
|
||||
fieldCount++
|
||||
}
|
||||
if req.APIKey != nil && *req.APIKey != "" {
|
||||
if h.vault != nil {
|
||||
@@ -194,28 +198,36 @@ func (h *TeamHandler) UpdateTeamProvider(c *gin.Context) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encrypt API key"})
|
||||
return
|
||||
}
|
||||
addSet("api_key_enc", enc)
|
||||
addSet("key_nonce", nonce)
|
||||
patch.APIKeyEnc = enc
|
||||
patch.KeyNonce = nonce
|
||||
} else {
|
||||
addSet("api_key_enc", []byte(*req.APIKey))
|
||||
patch.APIKeyEnc = []byte(*req.APIKey)
|
||||
}
|
||||
fieldCount++
|
||||
}
|
||||
if req.ModelDefault != nil {
|
||||
addSet("model_default", *req.ModelDefault)
|
||||
patch.ModelDefault = req.ModelDefault
|
||||
fieldCount++
|
||||
}
|
||||
if req.IsActive != nil {
|
||||
addSet("is_active", *req.IsActive)
|
||||
patch.IsActive = req.IsActive
|
||||
fieldCount++
|
||||
}
|
||||
if req.IsPrivate != nil {
|
||||
addSet("is_private", *req.IsPrivate)
|
||||
patch.IsPrivate = req.IsPrivate
|
||||
fieldCount++
|
||||
}
|
||||
if req.Config != nil {
|
||||
b, _ := json.Marshal(req.Config)
|
||||
addSet("config", string(b))
|
||||
patch.Config = models.JSONMap(req.Config)
|
||||
fieldCount++
|
||||
}
|
||||
if req.Headers != nil {
|
||||
b, _ := json.Marshal(req.Headers)
|
||||
addSet("headers", string(b))
|
||||
headersMap := models.JSONMap{}
|
||||
for k, v := range req.Headers {
|
||||
headersMap[k] = v
|
||||
}
|
||||
patch.Headers = headersMap
|
||||
fieldCount++
|
||||
}
|
||||
|
||||
if fieldCount == 0 {
|
||||
@@ -223,24 +235,7 @@ func (h *TeamHandler) UpdateTeamProvider(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
args = append(args, providerID, teamID)
|
||||
|
||||
// Build final query with ? placeholders, then convert to $N for Postgres
|
||||
query := "UPDATE provider_configs SET "
|
||||
for i, s := range setClauses {
|
||||
if i > 0 {
|
||||
query += ", "
|
||||
}
|
||||
query += s
|
||||
}
|
||||
query += " WHERE id = ? AND scope = 'team' AND owner_id = ?"
|
||||
|
||||
if database.IsPostgres() {
|
||||
query = convertPlaceholders(query)
|
||||
}
|
||||
|
||||
_, err := database.DB.Exec(query, args...)
|
||||
if err != nil {
|
||||
if err := h.stores.Providers.Update(ctx, providerID, patch); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update provider"})
|
||||
return
|
||||
}
|
||||
@@ -253,16 +248,12 @@ func (h *TeamHandler) DeleteTeamProvider(c *gin.Context) {
|
||||
teamID := getTeamID(c)
|
||||
providerID := c.Param("id")
|
||||
|
||||
result, err := database.DB.Exec(database.Q(`
|
||||
DELETE FROM provider_configs WHERE id = $1 AND scope = 'team' AND owner_id = $2
|
||||
`), providerID, teamID)
|
||||
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
|
||||
}
|
||||
|
||||
rows, _ := result.RowsAffected()
|
||||
if rows == 0 {
|
||||
if n == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "provider not found in this team"})
|
||||
return
|
||||
}
|
||||
@@ -274,46 +265,46 @@ func (h *TeamHandler) DeleteTeamProvider(c *gin.Context) {
|
||||
func (h *TeamHandler) ListTeamProviderModels(c *gin.Context) {
|
||||
teamID := getTeamID(c)
|
||||
providerID := c.Param("id")
|
||||
ctx := c.Request.Context()
|
||||
|
||||
var name, providerType, endpoint string
|
||||
var apiKeyEnc, keyNonce []byte
|
||||
var keyScope string
|
||||
var headersJSON []byte
|
||||
err := database.DB.QueryRow(database.Q(`
|
||||
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 {
|
||||
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(providerType)
|
||||
provider, err := providers.Get(cfg.Provider)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported provider"})
|
||||
return
|
||||
}
|
||||
|
||||
key := ""
|
||||
if len(apiKeyEnc) > 0 {
|
||||
if cfg.HasKey() {
|
||||
if h.vault != nil {
|
||||
var err error
|
||||
key, err = h.vault.Decrypt(apiKeyEnc, keyNonce, keyScope, "")
|
||||
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(apiKeyEnc)
|
||||
key = string(cfg.APIKeyEnc)
|
||||
}
|
||||
}
|
||||
|
||||
var customHeaders map[string]string
|
||||
_ = json.Unmarshal(headersJSON, &customHeaders)
|
||||
if cfg.Headers != nil {
|
||||
b, _ := json.Marshal(cfg.Headers)
|
||||
_ = json.Unmarshal(b, &customHeaders)
|
||||
}
|
||||
|
||||
modelList, err := provider.ListModels(c.Request.Context(), providers.ProviderConfig{
|
||||
Endpoint: endpoint,
|
||||
modelList, err := provider.ListModels(ctx, providers.ProviderConfig{
|
||||
Endpoint: cfg.Endpoint,
|
||||
APIKey: key,
|
||||
CustomHeaders: customHeaders,
|
||||
})
|
||||
@@ -335,7 +326,7 @@ func (h *TeamHandler) ListTeamProviderModels(c *gin.Context) {
|
||||
out = append(out, modelInfo{ID: m.ID, Type: m.Type, Capabilities: caps})
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"models": out, "provider": name})
|
||||
c.JSON(http.StatusOK, gin.H{"models": out, "provider": cfg.Name})
|
||||
}
|
||||
|
||||
// parseJSONBConfig parses a JSONB text string into a map.
|
||||
@@ -351,32 +342,30 @@ func parseJSONBConfig(raw string) map[string]interface{} {
|
||||
}
|
||||
|
||||
// isTeamProvidersAllowed checks if team providers are enabled.
|
||||
func isTeamProvidersAllowed(teamID string) bool {
|
||||
if database.DB == nil {
|
||||
func isTeamProvidersAllowed(stores store.Stores, teamID string) bool {
|
||||
if stores.GlobalConfig == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
var globalVal string
|
||||
err := database.DB.QueryRow(`
|
||||
SELECT value FROM global_settings WHERE key = 'allow_team_providers'
|
||||
`).Scan(&globalVal)
|
||||
ctx := context.Background()
|
||||
|
||||
// Check global setting
|
||||
globalVal, err := stores.GlobalConfig.GetString(ctx, "allow_team_providers")
|
||||
if err == nil && globalVal == "false" {
|
||||
return false
|
||||
}
|
||||
|
||||
var settingsJSON []byte
|
||||
err = database.DB.QueryRow(database.Q(`SELECT settings FROM teams WHERE id = $1`), teamID).Scan(&settingsJSON)
|
||||
// Check team-level setting
|
||||
team, err := stores.Teams.GetByID(ctx, teamID)
|
||||
if err != nil {
|
||||
return true
|
||||
return true // fail open
|
||||
}
|
||||
|
||||
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
|
||||
if team.Settings != nil {
|
||||
if v, ok := team.Settings["allow_team_providers"]; ok {
|
||||
if bVal, ok := v.(bool); ok {
|
||||
return bVal
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user