298 lines
9.1 KiB
Go
298 lines
9.1 KiB
Go
package handlers
|
|
|
|
import (
|
|
"errors"
|
|
"log"
|
|
"net/http"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"git.gobha.me/xcaliber/chat-switchboard/crypto"
|
|
"git.gobha.me/xcaliber/chat-switchboard/models"
|
|
"git.gobha.me/xcaliber/chat-switchboard/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{"configs": 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{"models": 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,
|
|
})
|
|
}
|