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/apiconfigs.go
Jeffrey Smith 11fd8c1e57 step 1: rename module chat-switchboard → switchboard-core
- go.mod module name
- All 714 import references across 289 Go files
- VERSION: 0.1.0
- CI DB names: switchboard_core_{ci,dev,test}
- Docker image: gobha/switchboard-core
- Test fixtures: JWT issuer, repo names
- .env.example, docker-compose container name
- Compiles clean (go build exit 0)
2026-03-25 19:48:04 -04:00

298 lines
9.0 KiB
Go

package handlers
import (
"errors"
"log"
"net/http"
"github.com/gin-gonic/gin"
"switchboard-core/crypto"
"switchboard-core/models"
"switchboard-core/store"
)
// ProviderConfigHandler handles user-facing provider config endpoints.
type ProviderConfigHandler struct {
stores store.Stores
vault *crypto.KeyResolver
}
func NewProviderConfigHandler(s store.Stores, vault *crypto.KeyResolver) *ProviderConfigHandler {
return &ProviderConfigHandler{stores: s, vault: vault}
}
// ListConfigs returns configs accessible to the user (global + personal + team).
func (h *ProviderConfigHandler) ListConfigs(c *gin.Context) {
userID := getUserID(c)
// User settings → Providers shows only personal (BYOK) configs.
// Global/team providers are managed by admins and surfaced via the model list.
cfgs, err := h.stores.Providers.ListForUser(c.Request.Context(), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list configs"})
return
}
// Mask API keys
type safeConfig 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,omitempty"`
Scope string `json:"scope"`
IsActive bool `json:"is_active"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
out := make([]safeConfig, len(cfgs))
for i, cfg := range cfgs {
out[i] = safeConfig{
ID: cfg.ID,
Name: cfg.Name,
Provider: cfg.Provider,
Endpoint: cfg.Endpoint,
HasKey: cfg.HasKey(),
ModelDefault: cfg.ModelDefault,
Scope: cfg.Scope,
IsActive: cfg.IsActive,
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": out})
}
// GetConfig returns a single config by ID (if user has access).
func (h *ProviderConfigHandler) GetConfig(c *gin.Context) {
userID := getUserID(c)
id := c.Param("id")
if ok, _ := h.stores.Providers.UserCanAccess(c.Request.Context(), userID, id); !ok {
c.JSON(http.StatusNotFound, gin.H{"error": "config not found"})
return
}
cfg, err := h.stores.Providers.GetByID(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "config not found"})
return
}
c.JSON(http.StatusOK, cfg)
}
// CreateConfig creates a personal provider config (BYOK).
// After creation, automatically fetches models from the provider API and enables them.
func (h *ProviderConfigHandler) CreateConfig(c *gin.Context) {
userID := getUserID(c)
// Check policy
allowed, _ := h.stores.Policies.GetBool(c.Request.Context(), "allow_user_byok")
if !allowed {
c.JSON(http.StatusForbidden, gin.H{"error": "personal API keys not allowed"})
return
}
var req struct {
Name string `json:"name" binding:"required"`
Provider string `json:"provider" binding:"required"`
Endpoint string `json:"endpoint" binding:"required"`
APIKey string `json:"api_key"`
ModelDefault string `json:"model_default,omitempty"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
cfg := &models.ProviderConfig{
Name: req.Name,
Provider: req.Provider,
Endpoint: req.Endpoint,
ModelDefault: req.ModelDefault,
Scope: models.ScopePersonal,
KeyScope: models.ScopePersonal,
OwnerID: &userID,
IsActive: true,
}
// Encrypt the API key with user's UEK (personal scope)
if req.APIKey != "" {
if h.vault != nil {
enc, nonce, err := h.vault.EncryptForScope(req.APIKey, "personal", userID)
if err != nil {
if err == crypto.ErrVaultLocked {
c.JSON(http.StatusUnauthorized, gin.H{"error": "vault is locked — please log in again"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encrypt API key"})
return
}
cfg.APIKeyEnc = enc
cfg.KeyNonce = nonce
} else {
cfg.APIKeyEnc = []byte(req.APIKey)
}
}
if err := h.stores.Providers.Create(c.Request.Context(), cfg); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create config"})
return
}
// Auto-fetch models from the provider API and enable them.
// The user added this key to USE it — don't make them hunt for a fetch button.
resp := gin.H{
"id": cfg.ID,
"name": cfg.Name,
"provider": cfg.Provider,
"endpoint": cfg.Endpoint,
"scope": cfg.Scope,
}
result, err := syncAndEnableProviderModels(c.Request.Context(), h.stores, cfg, req.APIKey)
if err != nil {
// Provider created successfully but model fetch failed.
// Return 201 (provider exists) with a warning so the frontend can show it.
resp["warning"] = "Provider created but model fetch failed: " + err.Error()
resp["models_fetched"] = 0
log.Printf("warn: BYOK auto-fetch for %s (%s) failed: %v", cfg.ID, cfg.Provider, err)
} else {
resp["models_fetched"] = result.Total
}
c.JSON(http.StatusCreated, resp)
}
// UpdateConfig updates a personal provider config.
func (h *ProviderConfigHandler) UpdateConfig(c *gin.Context) {
userID := getUserID(c)
id := c.Param("id")
existing, err := h.stores.Providers.GetByID(c.Request.Context(), id)
if err != nil || existing.Scope != models.ScopePersonal || existing.OwnerID == nil || *existing.OwnerID != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "can only update your own configs"})
return
}
// Bind standard fields
var req struct {
models.ProviderConfigPatch
APIKey *string `json:"api_key,omitempty"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
patch := req.ProviderConfigPatch
// Transfer api_key → encrypted fields
if req.APIKey != nil && *req.APIKey != "" {
if h.vault != nil {
enc, nonce, err := h.vault.EncryptForScope(*req.APIKey, "personal", userID)
if err != nil {
if err == crypto.ErrVaultLocked {
c.JSON(http.StatusUnauthorized, gin.H{"error": "vault is locked — please log in again"})
return
}
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)
}
}
if err := h.stores.Providers.Update(c.Request.Context(), id, patch); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update config"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "config updated"})
}
// DeleteConfig deletes a personal provider config.
func (h *ProviderConfigHandler) DeleteConfig(c *gin.Context) {
userID := getUserID(c)
id := c.Param("id")
existing, err := h.stores.Providers.GetByID(c.Request.Context(), id)
if err != nil || existing.Scope != models.ScopePersonal || existing.OwnerID == nil || *existing.OwnerID != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "can only delete your own configs"})
return
}
h.stores.Catalog.DeleteForProvider(c.Request.Context(), id)
if err := h.stores.Providers.Delete(c.Request.Context(), id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete config"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "config deleted"})
}
// ListModels returns models for a specific provider config.
func (h *ProviderConfigHandler) ListModels(c *gin.Context) {
id := c.Param("id")
entries, err := h.stores.Catalog.ListForProvider(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list models"})
return
}
c.JSON(http.StatusOK, gin.H{"data": entries})
}
// FetchModels fetches models from the provider API and auto-enables them.
// This is the user-facing equivalent of admin POST /models/fetch.
// Allows refreshing models for existing BYOK providers.
func (h *ProviderConfigHandler) FetchModels(c *gin.Context) {
userID := getUserID(c)
id := c.Param("id")
cfg, err := h.stores.Providers.GetByID(c.Request.Context(), id)
if err != nil || cfg.Scope != models.ScopePersonal || cfg.OwnerID == nil || *cfg.OwnerID != userID {
c.JSON(http.StatusNotFound, gin.H{"error": "provider not found"})
return
}
// Decrypt the API key for the provider API call
apiKey := ""
if len(cfg.APIKeyEnc) > 0 {
if h.vault != nil {
apiKey, err = h.vault.Decrypt(cfg.APIKeyEnc, cfg.KeyNonce, cfg.KeyScope, userID)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "vault is locked — please log in again"})
return
}
} else {
apiKey = string(cfg.APIKeyEnc)
}
}
result, err := syncAndEnableProviderModels(c.Request.Context(), h.stores, cfg, apiKey)
if err != nil {
if errors.Is(err, ErrUpstreamTimeout) {
c.JSON(http.StatusGatewayTimeout, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusBadGateway, gin.H{"error": "failed to fetch models: " + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "models synced",
"added": result.Added,
"updated": result.Updated,
"total": result.Total,
})
}