package capabilities import ( "context" "log" "git.gobha.me/xcaliber/chat-switchboard/models" "git.gobha.me/xcaliber/chat-switchboard/store" ) // ModelsForUser returns all models and Personas visible to a user. // // Visibility is controlled by the three-state visibility field on each // catalog entry (enabled / team / disabled), applied per provider scope: // // Tier | enabled | team | disabled // ----------+----------------+----------------------+--------- // Global | All users | Team admin → presets | Hidden // Team | Team members | Team admin → presets | Hidden // Personal | Owner direct | N/A | Hidden // // Sources aggregated: // 1. Global catalog: enabled models → all authenticated users // 2. Team catalog: enabled models → team members // 3. Personal BYOK: enabled models → owner (if allow_user_byok) // 4. Personas: global + team-scoped + personal + shared // 5. User hidden preferences applied last func ModelsForUser(ctx context.Context, stores store.Stores, userID string) ([]models.UserModel, error) { result := make([]models.UserModel, 0) // never nil — serializes as [] not null // Load policies once policies, err := stores.Policies.GetAll(ctx) if err != nil { return nil, err } allowBYOK := policies["allow_user_byok"] == "true" // Load user's hidden preferences hiddenMap, err := stores.UserSettings.GetHiddenModelIDs(ctx, userID) if err != nil { log.Printf("warn: failed to load user model settings: %v", err) hiddenMap = make(map[string]bool) } // Get user's team IDs teamIDs, err := stores.Teams.GetUserTeamIDs(ctx, userID) if err != nil { log.Printf("warn: failed to load user teams: %v", err) } // ── 1. Global enabled catalog models → all users ──── globalModels, err := stores.Catalog.ListVisible(ctx) if err != nil { return nil, err } // Build provider name lookup for global providers globalProviders, _ := stores.Providers.ListGlobal(ctx) providerMap := make(map[string]models.ProviderConfig) for _, p := range globalProviders { providerMap[p.ID] = p } for _, entry := range globalModels { caps := ResolveIntrinsic(entry.ModelID, &entry.Capabilities) prov := providerMap[entry.ProviderConfigID] result = append(result, models.UserModel{ ID: entry.ModelID, DisplayName: displayName(entry.DisplayName, entry.ModelID), ModelID: entry.ModelID, Source: "catalog", ProviderConfigID: entry.ProviderConfigID, ConfigID: entry.ProviderConfigID, ProviderName: prov.Name, ProviderType: prov.Provider, Capabilities: caps, Pricing: entry.Pricing, Scope: models.ScopeGlobal, Hidden: hiddenMap[entry.ModelID], }) } // ── 2. Team enabled catalog models → team members ──── for _, teamID := range teamIDs { teamProviders, err := stores.Providers.ListForTeam(ctx, teamID) if err != nil { log.Printf("warn: failed to load team %s providers: %v", teamID, err) continue } for _, prov := range teamProviders { if !prov.IsActive { continue } entries, err := stores.Catalog.ListEnabledForProvider(ctx, prov.ID) if err != nil { continue } for _, entry := range entries { caps := ResolveIntrinsic(entry.ModelID, &entry.Capabilities) result = append(result, models.UserModel{ ID: entry.ModelID, DisplayName: displayName(entry.DisplayName, entry.ModelID), ModelID: entry.ModelID, Source: "catalog", ProviderConfigID: prov.ID, ConfigID: prov.ID, ProviderName: prov.Name, ProviderType: prov.Provider, Capabilities: caps, Pricing: entry.Pricing, Scope: models.ScopeTeam, OwnerID: prov.OwnerID, Hidden: hiddenMap[entry.ModelID], }) } } } // ── 3. Personal BYOK enabled models → owner ──── if allowBYOK { personalProviders, err := stores.Providers.ListForUser(ctx, userID) if err != nil { log.Printf("warn: failed to load personal providers: %v", err) } else { for _, prov := range personalProviders { if !prov.IsActive { continue } entries, err := stores.Catalog.ListEnabledForProvider(ctx, prov.ID) if err != nil { continue } for _, entry := range entries { caps := ResolveIntrinsic(entry.ModelID, &entry.Capabilities) result = append(result, models.UserModel{ ID: entry.ModelID, DisplayName: displayName(entry.DisplayName, entry.ModelID), ModelID: entry.ModelID, Source: "catalog", ProviderConfigID: prov.ID, ConfigID: prov.ID, ProviderName: prov.Name, ProviderType: prov.Provider, Capabilities: caps, Pricing: entry.Pricing, Scope: models.ScopePersonal, OwnerID: &userID, Hidden: hiddenMap[entry.ModelID], }) } } } } // ── 4. Personas (always resolved) ──────────────── personas, err := stores.Personas.ListForUser(ctx, userID) if err != nil { log.Printf("warn: failed to load personas: %v", err) } else { for _, p := range personas { // Resolve base model capabilities var catalogCaps *models.ModelCapabilities if p.ProviderConfigID != nil { if entry, err := stores.Catalog.GetByModelID(ctx, *p.ProviderConfigID, p.BaseModelID); err == nil { catalogCaps = &entry.Capabilities } } // Fallback: look up any provider's catalog entry for this model // (covers auto-resolve presets where provider_config_id is NULL) if catalogCaps == nil { if entry, err := stores.Catalog.GetByModelIDAny(ctx, p.BaseModelID); err == nil { catalogCaps = &entry.Capabilities } } caps := ResolveIntrinsic(p.BaseModelID, catalogCaps) // Load tool grants toolGrants, _ := stores.Personas.GetToolGrants(ctx, p.ID) if len(toolGrants) > 0 { caps.ToolCalling = true } // Look up provider info var provName, provType string if p.ProviderConfigID != nil { if prov, err := stores.Providers.GetByID(ctx, *p.ProviderConfigID); err == nil { provName = prov.Name provType = prov.Provider } } result = append(result, models.UserModel{ ID: p.ID, DisplayName: p.Name, ModelID: p.BaseModelID, Source: "persona", ProviderConfigID: deref(p.ProviderConfigID), ConfigID: deref(p.ProviderConfigID), ProviderName: provName, ProviderType: provType, Capabilities: caps, IsPreset: true, PresetID: p.ID, PresetScope: p.Scope, PresetAvatar: p.Avatar, PresetTeamName: teamName(p, stores, ctx), PersonaID: p.ID, Description: p.Description, Icon: p.Icon, Avatar: p.Avatar, SystemPrompt: p.SystemPrompt, Temperature: p.Temperature, MaxTokens: p.MaxTokens, ToolGrants: toolGrants, Scope: p.Scope, OwnerID: p.OwnerID, Hidden: hiddenMap[p.ID], }) } } return result, nil } // ResolveForPersona returns effective capabilities for a specific Persona. // Used at completion time to determine what tools/features are available. func ResolveForPersona(ctx context.Context, stores store.Stores, persona *models.Persona) (models.ModelCapabilities, []string, error) { var catalogCaps *models.ModelCapabilities if persona.ProviderConfigID != nil { if entry, err := stores.Catalog.GetByModelID(ctx, *persona.ProviderConfigID, persona.BaseModelID); err == nil { catalogCaps = &entry.Capabilities } } // Fallback: any provider's catalog entry (auto-resolve presets) if catalogCaps == nil { if entry, err := stores.Catalog.GetByModelIDAny(ctx, persona.BaseModelID); err == nil { catalogCaps = &entry.Capabilities } } caps := ResolveIntrinsic(persona.BaseModelID, catalogCaps) toolGrants, err := stores.Personas.GetToolGrants(ctx, persona.ID) if err != nil { return caps, nil, err } return caps, toolGrants, nil } func displayName(name, modelID string) string { if name != "" { return name } return modelID } func deref(s *string) string { if s == nil { return "" } return *s } // teamName resolves the team_name for a persona's owner (if team-scoped). func teamName(p models.Persona, stores store.Stores, ctx context.Context) string { if p.Scope != models.ScopeTeam || p.OwnerID == nil { return "" } team, err := stores.Teams.GetByID(ctx, *p.OwnerID) if err != nil { return "" } return team.Name }