- go.mod module name
- All 714 import references across 289 Go files
- VERSION: 0.1.0
- CI DB names: switchboard_core_{ci,dev,test}
- Docker image: gobha/switchboard-core
- Test fixtures: JWT issuer, repo names
- .env.example, docker-compose container name
- Compiles clean (go build exit 0)
289 lines
9.2 KiB
Go
289 lines
9.2 KiB
Go
package capabilities
|
|
|
|
import (
|
|
"context"
|
|
"log"
|
|
|
|
"switchboard-core/models"
|
|
"switchboard-core/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 → personas | Hidden
|
|
// Team | Team members | Team admin → personas | 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
|
|
}
|
|
|
|
countGlobal := len(globalModels)
|
|
for _, entry := range globalModels {
|
|
caps := ResolveIntrinsic(entry.ModelID, &entry.Capabilities, nil)
|
|
prov := providerMap[entry.ProviderConfigID]
|
|
|
|
result = append(result, models.UserModel{
|
|
ID: entry.ModelID,
|
|
DisplayName: displayName(entry.DisplayName, entry.ModelID),
|
|
ModelID: entry.ModelID,
|
|
ModelType: entry.ModelType,
|
|
Source: "catalog",
|
|
ProviderConfigID: entry.ProviderConfigID,
|
|
ConfigID: entry.ProviderConfigID,
|
|
ProviderName: prov.Name,
|
|
ProviderType: prov.Provider,
|
|
Capabilities: caps,
|
|
Pricing: entry.Pricing,
|
|
Scope: models.ScopeGlobal,
|
|
Hidden: hiddenMap[models.CompositeModelKey(entry.ProviderConfigID, entry.ModelID)],
|
|
})
|
|
}
|
|
|
|
// ── 2. Team enabled catalog models → team members ────
|
|
countTeam := 0
|
|
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, nil)
|
|
result = append(result, models.UserModel{
|
|
ID: entry.ModelID,
|
|
DisplayName: displayName(entry.DisplayName, entry.ModelID),
|
|
ModelID: entry.ModelID,
|
|
ModelType: entry.ModelType,
|
|
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[models.CompositeModelKey(entry.ProviderConfigID, entry.ModelID)],
|
|
})
|
|
countTeam++
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── 3. Personal BYOK enabled models → owner ────
|
|
countPersonal := 0
|
|
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, nil)
|
|
result = append(result, models.UserModel{
|
|
ID: entry.ModelID,
|
|
DisplayName: displayName(entry.DisplayName, entry.ModelID),
|
|
ModelID: entry.ModelID,
|
|
ModelType: entry.ModelType,
|
|
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[models.CompositeModelKey(entry.ProviderConfigID, entry.ModelID)],
|
|
})
|
|
countPersonal++
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── 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 personas 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, nil)
|
|
|
|
// 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,
|
|
IsPersona: true,
|
|
PersonaID: p.ID,
|
|
PersonaHandle: p.Handle,
|
|
PersonaScope: p.Scope,
|
|
PersonaAvatar: p.Avatar,
|
|
PersonaTeamName: teamName(p, stores, ctx),
|
|
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: false, // ICD §11.2: persona visibility via grants, not model prefs
|
|
})
|
|
}
|
|
}
|
|
|
|
countPersonas := len(result) - countGlobal - countTeam - countPersonal
|
|
log.Printf("📋 ModelsForUser(%s): %d global, %d team, %d personal, %d personas → %d total",
|
|
userID, countGlobal, countTeam, countPersonal, countPersonas, len(result))
|
|
|
|
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 personas)
|
|
if catalogCaps == nil {
|
|
if entry, err := stores.Catalog.GetByModelIDAny(ctx, persona.BaseModelID); err == nil {
|
|
catalogCaps = &entry.Capabilities
|
|
}
|
|
}
|
|
caps := ResolveIntrinsic(persona.BaseModelID, catalogCaps, nil)
|
|
|
|
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
|
|
}
|