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, }) }