package handlers import ( "context" "encoding/json" "log" "net/http" "github.com/gin-gonic/gin" capspkg "chat-switchboard/capabilities" "chat-switchboard/auth" "chat-switchboard/health" "chat-switchboard/models" "chat-switchboard/store" ) // ModelHandler provides the unified models endpoint. type ModelHandler struct { stores store.Stores healthStore HealthStatusQuerier // provider health for status enrichment (v0.22.3, nil = disabled) } func NewModelHandler(s store.Stores) *ModelHandler { return &ModelHandler{stores: s} } // SetHealthStore attaches the health store for model status enrichment (v0.22.3). func (h *ModelHandler) SetHealthStore(hs HealthStatusQuerier) { h.healthStore = hs } // ListEnabledModels returns all models the user can access (catalog + personas), // with user preferences (hidden, sort order) applied. func (h *ModelHandler) ListEnabledModels(c *gin.Context) { userID := getUserID(c) userModels, err := capspkg.ModelsForUser(c.Request.Context(), h.stores, userID) if err != nil { log.Printf("error: ModelsForUser(%s): %v", userID, err) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to resolve models"}) return } // Enrich with provider health status (v0.22.3) if h.healthStore != nil { healthMap := h.buildHealthMap(c.Request.Context()) for i := range userModels { if s, ok := healthMap[userModels[i].ProviderConfigID]; ok { userModels[i].ProviderStatus = s } } } // Include admin default model so frontend can use it in resolution chain defaultModel, _ := h.stores.Policies.Get(c.Request.Context(), "default_model") // Model allowlist filtering (v0.24.2) — non-admin users with group-level // model restrictions only see permitted models. Persona entries whose // underlying model is disallowed are also filtered. role, _ := c.Get("role") if role != "admin" { allowlist, err := auth.ResolveModelAllowlist(c.Request.Context(), h.stores, userID) if err == nil && allowlist != nil { filtered := make([]models.UserModel, 0, len(userModels)) for _, m := range userModels { if allowlist[m.ModelID] { filtered = append(filtered, m) } } userModels = filtered } } c.JSON(http.StatusOK, gin.H{"data": userModels, "default_model": defaultModel}) } // buildHealthMap returns a map of configID → ProviderStatus from current health windows. func (h *ModelHandler) buildHealthMap(ctx context.Context) map[string]models.ProviderStatus { m := make(map[string]models.ProviderStatus) windows, err := h.healthStore.ListAllCurrent(ctx) if err != nil { return m } for _, w := range windows { m[w.ProviderConfigID] = health.DeriveStatus(w.ErrorRate(), w.RequestCount) } return m } // ResolveModelCaps is the canonical capability resolver for any model. // v0.29.0: accepts CatalogStore instead of using database.DB directly. func ResolveModelCaps(catalog store.CatalogStore, modelID, configID string) models.ModelCapabilities { // 1. Exact match: model_id + provider_config_id if configID != "" { caps, ok := capsFromCatalog(catalog, modelID, configID) if ok { caps.MaxOutputTokens = capspkg.ResolveMaxOutput(modelID, caps) return caps } } // 2. Any provider: same model_id, any config caps, ok := capsFromCatalog(catalog, modelID, "") if ok { caps.MaxOutputTokens = capspkg.ResolveMaxOutput(modelID, caps) return caps } // 3. Heuristic inference caps = capspkg.InferCapabilities(modelID) caps.MaxOutputTokens = capspkg.ResolveMaxOutput(modelID, caps) return caps } // capsFromCatalog looks up capabilities from the model_catalog table. func capsFromCatalog(catalog store.CatalogStore, modelID, configID string) (models.ModelCapabilities, bool) { if catalog == nil { return models.ModelCapabilities{}, false } var capsJSON []byte var err error if configID != "" { capsJSON, err = catalog.GetCapabilities(context.Background(), modelID, configID) } else { capsJSON, err = catalog.GetCapabilitiesAny(context.Background(), modelID) } if err != nil || len(capsJSON) == 0 { return models.ModelCapabilities{}, false } var caps models.ModelCapabilities if json.Unmarshal(capsJSON, &caps) != nil || !caps.HasProviderData() { return models.ModelCapabilities{}, false } // Merge with known data to fill gaps resolved := capspkg.ResolveIntrinsic(modelID, &caps, nil) return resolved, true }