package handlers import ( "database/sql" "encoding/json" "log" "math" "net/http" "strconv" "github.com/gin-gonic/gin" "git.gobha.me/xcaliber/chat-switchboard/database" "git.gobha.me/xcaliber/chat-switchboard/providers" ) // ── Request / Response Types ──────────────── type createAPIConfigRequest 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"` } type updateAPIConfigRequest 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"` } type apiConfigResponse struct { ID string `json:"id"` UserID *string `json:"user_id"` Name string `json:"name"` Provider string `json:"provider"` Endpoint string `json:"endpoint"` HasKey bool `json:"has_key"` // Never expose the actual key ModelDefault *string `json:"model_default"` Config map[string]interface{} `json:"config"` IsActive bool `json:"is_active"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` } // APIConfigHandler holds dependencies. type APIConfigHandler struct{} // NewAPIConfigHandler creates a new handler. func NewAPIConfigHandler() *APIConfigHandler { return &APIConfigHandler{} } // ── List API Configs ──────────────────────── func (h *APIConfigHandler) ListConfigs(c *gin.Context) { userID := getUserID(c) page, perPage, offset := parsePagination(c) // Count: user's configs + global configs var total int err := database.DB.QueryRow( `SELECT COUNT(*) FROM api_configs WHERE user_id = $1 OR user_id IS NULL`, userID, ).Scan(&total) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to count configs"}) return } rows, err := database.DB.Query(` SELECT id, user_id, name, provider, endpoint, api_key_encrypted, model_default, config, is_active, created_at, updated_at FROM api_configs WHERE user_id = $1 OR user_id IS NULL ORDER BY user_id NULLS LAST, name ASC LIMIT $2 OFFSET $3 `, userID, perPage, offset) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list configs"}) return } defer rows.Close() configs := make([]apiConfigResponse, 0) for rows.Next() { cfg, err := scanAPIConfig(rows) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to scan config"}) return } configs = append(configs, cfg) } c.JSON(http.StatusOK, paginatedResponse{ Data: configs, Page: page, PerPage: perPage, Total: total, TotalPages: int(math.Ceil(float64(total) / float64(perPage))), }) } // ── Create API Config ─────────────────────── func (h *APIConfigHandler) CreateConfig(c *gin.Context) { userID := getUserID(c) var req createAPIConfigRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } // Validate provider exists 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) } var cfg apiConfigResponse var apiKeyEnc *string var configRaw string err := database.DB.QueryRow(` INSERT INTO api_configs (user_id, name, provider, endpoint, api_key_encrypted, model_default, config) VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb) RETURNING id, user_id, name, provider, endpoint, api_key_encrypted, model_default, config::text, is_active, created_at, updated_at `, userID, req.Name, req.Provider, req.Endpoint, req.APIKey, req.ModelDefault, configJSON, ).Scan( &cfg.ID, &cfg.UserID, &cfg.Name, &cfg.Provider, &cfg.Endpoint, &apiKeyEnc, &cfg.ModelDefault, &configRaw, &cfg.IsActive, &cfg.CreatedAt, &cfg.UpdatedAt, ) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create config"}) return } cfg.HasKey = apiKeyEnc != nil && *apiKeyEnc != "" cfg.Config = parseJSONBConfig(configRaw) c.JSON(http.StatusCreated, cfg) } // ── Get API Config ────────────────────────── func (h *APIConfigHandler) GetConfig(c *gin.Context) { userID := getUserID(c) configID := c.Param("id") row := database.DB.QueryRow(` SELECT id, user_id, name, provider, endpoint, api_key_encrypted, model_default, config::text, is_active, created_at, updated_at FROM api_configs WHERE id = $1 AND (user_id = $2 OR user_id IS NULL) `, configID, userID) var cfg apiConfigResponse var apiKeyEnc *string var configRaw string err := row.Scan( &cfg.ID, &cfg.UserID, &cfg.Name, &cfg.Provider, &cfg.Endpoint, &apiKeyEnc, &cfg.ModelDefault, &configRaw, &cfg.IsActive, &cfg.CreatedAt, &cfg.UpdatedAt, ) if err == sql.ErrNoRows { c.JSON(http.StatusNotFound, gin.H{"error": "config not found"}) return } if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get config"}) return } cfg.HasKey = apiKeyEnc != nil && *apiKeyEnc != "" cfg.Config = parseJSONBConfig(configRaw) c.JSON(http.StatusOK, cfg) } // ── Update API Config ─────────────────────── func (h *APIConfigHandler) UpdateConfig(c *gin.Context) { userID := getUserID(c) configID := c.Param("id") var req updateAPIConfigRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } // Verify ownership (only owner can update, not global) var ownerID *string err := database.DB.QueryRow(`SELECT user_id FROM api_configs WHERE id = $1`, configID).Scan(&ownerID) if err == sql.ErrNoRows { c.JSON(http.StatusNotFound, gin.H{"error": "config not found"}) return } if ownerID == nil || *ownerID != userID { c.JSON(http.StatusForbidden, gin.H{"error": "cannot modify global or other user's config"}) return } // Dynamic update setClauses := []string{} args := []interface{}{} argN := 1 addClause := func(col string, val interface{}) { setClauses = append(setClauses, col+" = $"+strconv.Itoa(argN)) args = append(args, val) argN++ } if req.Name != nil { addClause("name", *req.Name) } if req.Endpoint != nil { addClause("endpoint", *req.Endpoint) } if req.APIKey != nil { addClause("api_key_encrypted", *req.APIKey) } if req.ModelDefault != nil { addClause("model_default", *req.ModelDefault) } if req.Config != nil { b, _ := json.Marshal(req.Config) addClause("config", string(b)) } if req.IsActive != nil { addClause("is_active", *req.IsActive) } if len(setClauses) == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"}) return } query := "UPDATE api_configs SET updated_at = NOW(), " for i, clause := range setClauses { if i > 0 { query += ", " } query += clause } query += " WHERE id = $" + strconv.Itoa(argN) args = append(args, configID) _, err = database.DB.Exec(query, args...) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update config"}) return } h.GetConfig(c) } // ── Delete API Config ─────────────────────── func (h *APIConfigHandler) DeleteConfig(c *gin.Context) { userID := getUserID(c) configID := c.Param("id") // Only allow deleting own configs result, err := database.DB.Exec( `DELETE FROM api_configs WHERE id = $1 AND user_id = $2`, configID, userID, ) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete config"}) return } rows, _ := result.RowsAffected() if rows == 0 { c.JSON(http.StatusNotFound, gin.H{"error": "config not found or cannot delete global config"}) return } c.JSON(http.StatusOK, gin.H{"message": "config deleted"}) } // ── List Models from a Config ─────────────── func (h *APIConfigHandler) ListModels(c *gin.Context) { userID := getUserID(c) configID := c.Param("id") // Load config including API key var providerID, endpoint string var apiKey *string err := database.DB.QueryRow(` SELECT provider, endpoint, api_key_encrypted FROM api_configs WHERE id = $1 AND (user_id = $2 OR user_id IS NULL) AND is_active = true `, configID, userID).Scan(&providerID, &endpoint, &apiKey) if err == sql.ErrNoRows { c.JSON(http.StatusNotFound, gin.H{"error": "config not found"}) return } if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load config"}) return } provider, err := providers.Get(providerID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } key := "" if apiKey != nil { key = *apiKey } models, err := provider.ListModels(c.Request.Context(), providers.ProviderConfig{ Endpoint: endpoint, APIKey: key, }) if err != nil { c.JSON(http.StatusBadGateway, gin.H{"error": "failed to fetch models: " + err.Error()}) return } c.JSON(http.StatusOK, gin.H{ "config_id": configID, "provider": providerID, "models": models, }) } // ── List All Available Models (aggregate) ─── func (h *APIConfigHandler) ListAllModels(c *gin.Context) { userID := getUserID(c) rows, err := database.DB.Query(` SELECT id, name, provider, endpoint, api_key_encrypted FROM api_configs WHERE (user_id = $1 OR user_id IS NULL) AND is_active = true `, userID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list configs"}) return } defer rows.Close() type modelEntry struct { ID string `json:"id"` Name string `json:"name,omitempty"` OwnedBy string `json:"owned_by,omitempty"` ConfigID string `json:"config_id"` Provider string `json:"provider"` Capabilities providers.ModelCapabilities `json:"capabilities"` } allModels := make([]modelEntry, 0) for rows.Next() { var cfgID, name, providerID, endpoint string var apiKey *string if err := rows.Scan(&cfgID, &name, &providerID, &endpoint, &apiKey); err != nil { continue } provider, err := providers.Get(providerID) if err != nil { continue } key := "" if apiKey != nil { key = *apiKey } models, err := provider.ListModels(c.Request.Context(), providers.ProviderConfig{ Endpoint: endpoint, APIKey: key, }) if err != nil { continue // Skip configs that fail } for _, m := range models { // Provider caps are authoritative; fill gaps from known table caps := providers.MergeCapabilities(m.Capabilities, m.ID) caps.MaxOutputTokens = providers.ResolveMaxOutput(m.ID, caps) allModels = append(allModels, modelEntry{ ID: m.ID, Name: m.Name, OwnedBy: m.OwnedBy, ConfigID: cfgID, Provider: providerID, Capabilities: caps, }) } } c.JSON(http.StatusOK, gin.H{"models": allModels}) } // ── List Enabled Models (from model_configs) ─ func (h *APIConfigHandler) ListEnabledModels(c *gin.Context) { userID := getUserID(c) type enabledModel struct { ID string `json:"id"` ModelID string `json:"model_id"` DisplayName *string `json:"display_name"` Provider string `json:"provider"` ProviderName string `json:"provider_name"` ConfigID string `json:"config_id"` Capabilities providers.ModelCapabilities `json:"capabilities"` Pricing *providers.ModelPricing `json:"pricing,omitempty"` IsPreset bool `json:"is_preset,omitempty"` PresetID string `json:"preset_id,omitempty"` PresetScope string `json:"preset_scope,omitempty"` PresetAvatar string `json:"preset_avatar,omitempty"` } models := make([]enabledModel, 0) // ── 1. Admin model_configs (pre-synced via FetchModels) ── rows, err := database.DB.Query(` SELECT mc.id, mc.model_id, mc.display_name, ac.provider, ac.name, mc.api_config_id, mc.capabilities FROM model_configs mc JOIN api_configs ac ON mc.api_config_id = ac.id WHERE mc.is_enabled = true AND ac.is_active = true AND ac.user_id IS NULL ORDER BY ac.name, mc.model_id `) if err == nil { defer rows.Close() for rows.Next() { var m enabledModel var capsJSON []byte if err := rows.Scan(&m.ID, &m.ModelID, &m.DisplayName, &m.Provider, &m.ProviderName, &m.ConfigID, &capsJSON); err != nil { continue } // Parse DB capabilities (from provider at sync time) var dbCaps providers.ModelCapabilities _ = json.Unmarshal(capsJSON, &dbCaps) // Provider-reported caps are authoritative; fill gaps from known table/heuristics if dbCaps.HasProviderData() { m.Capabilities = providers.MergeCapabilities(dbCaps, m.ModelID) } else { // No provider data — use known table or heuristics as base knownCaps, found := providers.LookupKnownModel(m.ModelID) if !found { knownCaps = providers.InferCapabilities(m.ModelID) } m.Capabilities = knownCaps } m.Capabilities.MaxOutputTokens = providers.ResolveMaxOutput(m.ModelID, m.Capabilities) models = append(models, m) } } // ── 2. User provider models (live query) ── userRows, err := database.DB.Query(` SELECT id, name, provider, endpoint, api_key_encrypted, custom_headers FROM api_configs WHERE user_id = $1 AND is_active = true `, userID) if err == nil { defer userRows.Close() for userRows.Next() { var cfgID, name, providerID, endpoint string var apiKey *string var headersJSON []byte if err := userRows.Scan(&cfgID, &name, &providerID, &endpoint, &apiKey, &headersJSON); err != nil { continue } provider, err := providers.Get(providerID) if err != nil { continue } key := "" if apiKey != nil { key = *apiKey } var customHeaders map[string]string _ = json.Unmarshal(headersJSON, &customHeaders) provModels, err := provider.ListModels(c.Request.Context(), providers.ProviderConfig{ Endpoint: endpoint, APIKey: key, CustomHeaders: customHeaders, }) if err != nil { log.Printf("[models] user provider %q (%s) list failed: %v", name, providerID, err) continue } for _, pm := range provModels { caps := pm.Capabilities // Provider-reported caps are authoritative; fill gaps caps = providers.MergeCapabilities(caps, pm.ID) caps.MaxOutputTokens = providers.ResolveMaxOutput(pm.ID, caps) models = append(models, enabledModel{ ID: pm.ID, ModelID: pm.ID, Provider: providerID, ProviderName: name, ConfigID: cfgID, Capabilities: caps, Pricing: pm.Pricing, }) } } } // ── 3. Active presets (global + user's personal + shared) ── presetRows, err := database.DB.Query(` SELECT mp.id, mp.name, mp.description, mp.base_model_id, mp.api_config_id, mp.icon, mp.avatar, mp.scope, mp.temperature, mp.max_tokens, COALESCE(ac.provider, '') as provider, COALESCE(ac.name, '') as provider_name FROM model_presets mp LEFT JOIN api_configs ac ON mp.api_config_id = ac.id WHERE mp.is_active = true AND ( mp.scope = 'global' OR (mp.scope = 'personal' AND mp.created_by = $1) OR (mp.scope = 'personal' AND mp.is_shared = true) ) ORDER BY mp.scope ASC, mp.name ASC `, userID) if err == nil { defer presetRows.Close() for presetRows.Next() { var presetID, name, description, baseModelID, icon, avatar, scope, provID, provName string var apiConfigID *string var temp *float64 var maxTok *int if err := presetRows.Scan(&presetID, &name, &description, &baseModelID, &apiConfigID, &icon, &avatar, &scope, &temp, &maxTok, &provID, &provName); err != nil { continue } // Inherit capabilities from the base model already loaded in sections 1/2 var caps providers.ModelCapabilities capsFound := false for _, existing := range models { if existing.ModelID == baseModelID { caps = existing.Capabilities capsFound = true break } } if !capsFound { // Base model not in enabled list — try synced model_configs var capsJSON []byte err := database.DB.QueryRow(` SELECT capabilities FROM model_configs WHERE model_id = $1 ORDER BY updated_at DESC LIMIT 1 `, baseModelID).Scan(&capsJSON) if err == nil && len(capsJSON) > 0 { _ = json.Unmarshal(capsJSON, &caps) capsFound = true } } if !capsFound { var found bool caps, found = providers.LookupKnownModel(baseModelID) if !found { caps = providers.InferCapabilities(baseModelID) } } caps.MaxOutputTokens = providers.ResolveMaxOutput(baseModelID, caps) cfgID := "" if apiConfigID != nil { cfgID = *apiConfigID } // Build display name: "icon Name (base-model)" displayName := name if icon != "" { displayName = icon + " " + name } models = append(models, enabledModel{ ID: presetID, ModelID: baseModelID, DisplayName: &displayName, Provider: provID, ProviderName: provName, ConfigID: cfgID, Capabilities: caps, IsPreset: true, PresetID: presetID, PresetScope: scope, PresetAvatar: avatar, }) } } c.JSON(http.StatusOK, gin.H{"models": models}) } // ── Helpers ───────────────────────────────── type scannable interface { Scan(dest ...interface{}) error } func scanAPIConfig(row scannable) (apiConfigResponse, error) { var cfg apiConfigResponse var apiKeyEnc *string var configRaw string err := row.Scan( &cfg.ID, &cfg.UserID, &cfg.Name, &cfg.Provider, &cfg.Endpoint, &apiKeyEnc, &cfg.ModelDefault, &configRaw, &cfg.IsActive, &cfg.CreatedAt, &cfg.UpdatedAt, ) if err != nil { return cfg, err } cfg.HasKey = apiKeyEnc != nil && *apiKeyEnc != "" cfg.Config = parseJSONBConfig(configRaw) return cfg, nil } func parseJSONBConfig(raw string) map[string]interface{} { result := make(map[string]interface{}) _ = json.Unmarshal([]byte(raw), &result) return result }