From 15be26c51677ac0d46deeefcbb06a3c9fbbfc480 Mon Sep 17 00:00:00 2001 From: xcaliber Date: Sun, 22 Feb 2026 16:52:19 +0000 Subject: [PATCH] Changeset 0.8.6 (#49) --- ROADMAP.md | 28 +- VERSION | 2 +- scripts/db-validate.sh | 1 + .../migrations/021_team_providers.sql | 16 + server/handlers/admin.go | 12 +- server/handlers/apiconfigs.go | 91 ++---- server/handlers/capabilities.go | 137 ++++++++ server/handlers/completion.go | 40 +-- server/handlers/messages.go | 2 +- server/handlers/presets.go | 4 +- server/handlers/team_providers.go | 304 ++++++++++++++++++ server/handlers/teams.go | 91 +++++- server/main.go | 7 + src/index.html | 35 ++ src/js/api.js | 7 + src/js/app.js | 145 ++++++++- src/js/ui.js | 100 ++++-- 17 files changed, 870 insertions(+), 152 deletions(-) create mode 100644 server/database/migrations/021_team_providers.sql create mode 100644 server/handlers/capabilities.go create mode 100644 server/handlers/team_providers.go diff --git a/ROADMAP.md b/ROADMAP.md index 1161714..5385f5e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -218,24 +218,18 @@ Required for enterprise and compliance. Cheap to build, expensive to retrofit. - [x] User personal provider models: simple enable/disable toggle (same visibility toggle — hide from selector) -**Team Providers (0.8.6+)** -- [ ] `team_id` column on `api_configs` (nullable, symmetric with `user_id`) -- [ ] Team admins manage team-scoped provider configs +**Team Providers (0.8.6)** +- [x] `team_id` column on `api_configs` (nullable FK, indexed) +- [x] Team admins manage team-scoped provider configs (add/remove API keys, toggle active, same UI as personal providers) -- [ ] `allow_team_providers` flag in `teams.settings` JSONB - — admin controls which teams can add their own API keys -- [ ] Team provider models use same 3-state visibility: enabled/disabled/team - — enabled: team members see in model selector (direct use) - — team: team admin can use for presets only (not direct member access) - — disabled: hidden from everyone - Same cycle button, same bulk actions, scoped to team admin panel -- [ ] `ListEnabledModels` adds section: team provider models where - `visibility = 'enabled'` AND user is member of owning team -- [ ] Team preset builder shows team provider models where - `visibility IN ('enabled', 'team')` AND user is admin of owning team -- [ ] Three-tier provider hierarchy with uniform visibility semantics: - global (sys-admin) → team (team-admin) → personal (user) -- [ ] Model provenance badges: global / team:TeamName / personal +- [x] `allow_team_providers` check: global_settings + team.settings JSONB +- [x] Team provider models available in team preset builder (ListAvailableModels) + — grouped by source: Global Models / Team Provider Models (optgroup) +- [x] Team provider models NOT exposed directly in model selector + — team members access team models ONLY through curated presets + — keeps model selector clean, avoids visibility confusion +- [x] Three-tier provider hierarchy: global → team (presets only) → personal +- [x] ListConfigs excludes team-scoped providers (team_id IS NULL filter) **Usage / Cost Tracking** - [ ] Capture from provider responses: prompt_tokens, completion_tokens, diff --git a/VERSION b/VERSION index 7ada0d3..7fc2521 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.8.5 +0.8.6 diff --git a/scripts/db-validate.sh b/scripts/db-validate.sh index a522ac2..5a2317d 100644 --- a/scripts/db-validate.sh +++ b/scripts/db-validate.sh @@ -138,6 +138,7 @@ echo "" echo "Provider capabilities:" check_column "api_configs" "custom_headers" check_column "api_configs" "is_global" +check_column "api_configs" "team_id" check_table "user_model_preferences" check_column "user_model_preferences" "user_id" check_column "user_model_preferences" "model_config_id" diff --git a/server/database/migrations/021_team_providers.sql b/server/database/migrations/021_team_providers.sql new file mode 100644 index 0000000..aa8eebd --- /dev/null +++ b/server/database/migrations/021_team_providers.sql @@ -0,0 +1,16 @@ +-- ========================================== +-- Migration 021: Team Providers +-- ========================================== +-- Adds team_id to api_configs, enabling teams to have their own +-- provider configs managed by team admins. +-- +-- Provider hierarchy: global (user_id IS NULL, is_global=true) +-- → team (team_id IS NOT NULL) +-- → personal (user_id IS NOT NULL) +-- ========================================== + +-- Add team_id FK to api_configs +ALTER TABLE api_configs ADD COLUMN IF NOT EXISTS team_id UUID REFERENCES teams(id) ON DELETE CASCADE; +CREATE INDEX IF NOT EXISTS idx_api_configs_team ON api_configs(team_id) WHERE team_id IS NOT NULL; + +COMMENT ON COLUMN api_configs.team_id IS 'Team-scoped provider — managed by team admins, visible to team members'; diff --git a/server/handlers/admin.go b/server/handlers/admin.go index c06da35..0bd8f9e 100644 --- a/server/handlers/admin.go +++ b/server/handlers/admin.go @@ -523,7 +523,7 @@ func (h *AdminHandler) ListGlobalConfigs(c *gin.Context) { (api_key_encrypted IS NOT NULL AND api_key_encrypted != '') as has_key, created_at FROM api_configs - WHERE user_id IS NULL + WHERE user_id IS NULL AND team_id IS NULL ORDER BY created_at ASC `) if err != nil { @@ -605,7 +605,7 @@ func (h *AdminHandler) UpdateGlobalConfig(c *gin.Context) { sets = append(sets, "updated_at = NOW()") args = append(args, configID) - query := "UPDATE api_configs SET " + strings.Join(sets, ", ") + " WHERE id = $" + strconv.Itoa(argN) + " AND user_id IS NULL" + query := "UPDATE api_configs SET " + strings.Join(sets, ", ") + " WHERE id = $" + strconv.Itoa(argN) + " AND user_id IS NULL AND team_id IS NULL" result, err := database.DB.Exec(query, args...) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update config"}) @@ -624,7 +624,7 @@ func (h *AdminHandler) DeleteGlobalConfig(c *gin.Context) { configID := c.Param("id") result, err := database.DB.Exec( - `DELETE FROM api_configs WHERE id = $1 AND user_id IS NULL`, + `DELETE FROM api_configs WHERE id = $1 AND user_id IS NULL AND team_id IS NULL`, configID, ) if err != nil { @@ -666,7 +666,7 @@ func (h *AdminHandler) ListModelConfigs(c *gin.Context) { mc.visibility, mc.capabilities, mc.created_at, mc.updated_at FROM model_configs mc JOIN api_configs ac ON mc.api_config_id = ac.id - WHERE ac.user_id IS NULL + WHERE ac.user_id IS NULL AND ac.team_id IS NULL ORDER BY ac.name, mc.model_id `) if err != nil { @@ -697,7 +697,7 @@ func (h *AdminHandler) FetchModels(c *gin.Context) { rows, err := database.DB.Query(` SELECT id, provider, endpoint, api_key_encrypted, custom_headers FROM api_configs - WHERE user_id IS NULL AND is_active = true + WHERE user_id IS NULL AND team_id IS NULL AND is_active = true `) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list configs"}) @@ -857,7 +857,7 @@ func (h *AdminHandler) BulkUpdateModels(c *gin.Context) { result, err := database.DB.Exec( `UPDATE model_configs SET visibility = $1, updated_at = NOW() - WHERE api_config_id IN (SELECT id FROM api_configs WHERE user_id IS NULL)`, + WHERE api_config_id IN (SELECT id FROM api_configs WHERE user_id IS NULL AND team_id IS NULL)`, req.Visibility, ) if err != nil { diff --git a/server/handlers/apiconfigs.go b/server/handlers/apiconfigs.go index 60d351a..e0a020e 100644 --- a/server/handlers/apiconfigs.go +++ b/server/handlers/apiconfigs.go @@ -64,10 +64,10 @@ func (h *APIConfigHandler) ListConfigs(c *gin.Context) { userID := getUserID(c) page, perPage, offset := parsePagination(c) - // Count: user's configs + global configs + // Count: user's configs + global configs (exclude team-scoped) var total int err := database.DB.QueryRow( - `SELECT COUNT(*) FROM api_configs WHERE user_id = $1 OR user_id IS NULL`, + `SELECT COUNT(*) FROM api_configs WHERE (user_id = $1 OR user_id IS NULL) AND team_id IS NULL`, userID, ).Scan(&total) if err != nil { @@ -79,7 +79,7 @@ func (h *APIConfigHandler) ListConfigs(c *gin.Context) { 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 + WHERE (user_id = $1 OR user_id IS NULL) AND team_id IS NULL ORDER BY user_id NULLS LAST, name ASC LIMIT $2 OFFSET $3 `, userID, perPage, offset) @@ -169,7 +169,7 @@ func (h *APIConfigHandler) GetConfig(c *gin.Context) { 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) + WHERE id = $1 AND (user_id = $2 OR user_id IS NULL) AND team_id IS NULL `, configID, userID) var cfg apiConfigResponse @@ -310,7 +310,7 @@ func (h *APIConfigHandler) ListModels(c *gin.Context) { 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 + WHERE id = $1 AND (user_id = $2 OR user_id IS NULL) AND team_id IS NULL AND is_active = true `, configID, userID).Scan(&providerID, &endpoint, &apiKey) if err == sql.ErrNoRows { @@ -357,7 +357,7 @@ func (h *APIConfigHandler) ListAllModels(c *gin.Context) { 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 + WHERE (user_id = $1 OR user_id IS NULL) AND is_active = true AND team_id IS NULL `, userID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list configs"}) @@ -422,26 +422,29 @@ func (h *APIConfigHandler) ListAllModels(c *gin.Context) { // ── List Enabled Models (from model_configs) ─ +// enabledModel is the unified model entry returned by ListEnabledModels. +// Used across apiconfigs, capabilities, and preset resolution. +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"` + Source string `json:"source,omitempty"` + TeamName string `json:"team_name,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"` + PresetTeamName string `json:"preset_team_name,omitempty"` +} + 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"` - Source string `json:"source,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"` - PresetTeamName string `json:"preset_team_name,omitempty"` - } - models := make([]enabledModel, 0) // ── 1. Admin model_configs (pre-synced via FetchModels) ── @@ -449,7 +452,7 @@ func (h *APIConfigHandler) ListEnabledModels(c *gin.Context) { 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.visibility = 'enabled' AND ac.is_active = true AND ac.user_id IS NULL + WHERE mc.visibility = 'enabled' AND ac.is_active = true AND ac.is_global = true ORDER BY ac.name, mc.model_id `) if err == nil { @@ -484,10 +487,13 @@ func (h *APIConfigHandler) ListEnabledModels(c *gin.Context) { } // ── 2. User provider models (live query) ── + // NOTE: Team provider models are NOT listed here. They are only + // available to team admins for building presets (via ListAvailableModels). + // Team members access team models through curated presets only. 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 + WHERE user_id = $1 AND is_active = true AND team_id IS NULL `, userID) if err == nil { defer userRows.Close() @@ -574,41 +580,12 @@ func (h *APIConfigHandler) ListEnabledModels(c *gin.Context) { 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) - + // Inherit capabilities from base model via shared resolver cfgID := "" if apiConfigID != nil { cfgID = *apiConfigID } + caps := ResolveModelCapsFromLoaded(c, baseModelID, cfgID, models) // Build display name: "icon Name (base-model)" displayName := name @@ -665,3 +642,5 @@ func parseJSONBConfig(raw string) map[string]interface{} { _ = json.Unmarshal([]byte(raw), &result) return result } + + diff --git a/server/handlers/capabilities.go b/server/handlers/capabilities.go new file mode 100644 index 0000000..9f02277 --- /dev/null +++ b/server/handlers/capabilities.go @@ -0,0 +1,137 @@ +package handlers + +import ( + "encoding/json" + "log" + + "github.com/gin-gonic/gin" + + "git.gobha.me/xcaliber/chat-switchboard/database" + "git.gobha.me/xcaliber/chat-switchboard/providers" +) + +// ResolveModelCaps is the canonical capability resolver for any model. +// It walks a priority chain and returns the best capabilities available: +// +// 1. model_configs DB — exact match (model_id + api_config_id) +// 2. model_configs DB — any provider (same model, different config) +// 3. Known model table (static, curated) +// 4. Heuristic inference (name-based fallback) +// +// configID is optional — pass "" to skip the exact-match step. +func ResolveModelCaps(c *gin.Context, modelID, configID string) providers.ModelCapabilities { + // ── 1. Exact match: model_id + api_config_id ── + if configID != "" { + caps, ok := capsFromModelConfigs(modelID, configID) + if ok { + caps.MaxOutputTokens = providers.ResolveMaxOutput(modelID, caps) + return caps + } + } + + // ── 2. Any provider: same model_id, any config ── + caps, ok := capsFromModelConfigs(modelID, "") + if ok { + caps.MaxOutputTokens = providers.ResolveMaxOutput(modelID, caps) + return caps + } + + // ── 3. Known model table (static, curated) ── + caps, found := providers.LookupKnownModel(modelID) + if found { + caps.MaxOutputTokens = providers.ResolveMaxOutput(modelID, caps) + return caps + } + + // ── 4. Heuristic inference ── + caps = providers.InferCapabilities(modelID) + caps.MaxOutputTokens = providers.ResolveMaxOutput(modelID, caps) + return caps +} + +// capsFromModelConfigs looks up capabilities from the model_configs table. +// If configID is non-empty, it matches exactly; otherwise it finds any entry +// for the model_id (capabilities for the same model are provider-agnostic). +func capsFromModelConfigs(modelID, configID string) (providers.ModelCapabilities, bool) { + var capsJSON []byte + var err error + if configID != "" { + err = database.DB.QueryRow(` + SELECT capabilities FROM model_configs + WHERE model_id = $1 AND api_config_id = $2 + `, modelID, configID).Scan(&capsJSON) + } else { + err = database.DB.QueryRow(` + SELECT capabilities FROM model_configs + WHERE model_id = $1 ORDER BY updated_at DESC LIMIT 1 + `, modelID).Scan(&capsJSON) + } + if err != nil || len(capsJSON) == 0 { + return providers.ModelCapabilities{}, false + } + var caps providers.ModelCapabilities + if json.Unmarshal(capsJSON, &caps) != nil || !caps.HasProviderData() { + return providers.ModelCapabilities{}, false + } + return providers.MergeCapabilities(caps, modelID), true +} + +// ResolveModelCapsFromLoaded checks an existing slice of models first (avoids +// redundant DB/network calls when we already have models in memory). +func ResolveModelCapsFromLoaded(c *gin.Context, modelID, configID string, loaded []enabledModel) providers.ModelCapabilities { + // Check already-loaded models first + for _, m := range loaded { + if m.ModelID == modelID { + return m.Capabilities + } + } + // Fall through to canonical resolver + return ResolveModelCaps(c, modelID, configID) +} + +// liveQueryModelCaps queries a provider API to get capabilities for a specific model. +// Used for team provider presets whose base model isn't in model_configs. +func liveQueryModelCaps(c *gin.Context, configID, modelID string) (providers.ModelCapabilities, bool) { + var providerID, endpoint string + var apiKey *string + var headersJSON []byte + err := database.DB.QueryRow(` + SELECT provider, endpoint, api_key_encrypted, custom_headers + FROM api_configs WHERE id = $1 AND is_active = true + `, configID).Scan(&providerID, &endpoint, &apiKey, &headersJSON) + if err != nil { + return providers.ModelCapabilities{}, false + } + + provider, err := providers.Get(providerID) + if err != nil { + return providers.ModelCapabilities{}, false + } + + key := "" + if apiKey != nil { + key = *apiKey + } + + var customHeaders map[string]string + _ = json.Unmarshal(headersJSON, &customHeaders) + + modelList, err := provider.ListModels(c.Request.Context(), providers.ProviderConfig{ + Endpoint: endpoint, + APIKey: key, + CustomHeaders: customHeaders, + }) + if err != nil { + log.Printf("[caps] live query for %s via config %s failed: %v", modelID, configID, err) + return providers.ModelCapabilities{}, false + } + + for _, m := range modelList { + if m.ID == modelID { + caps := providers.MergeCapabilities(m.Capabilities, modelID) + return caps, true + } + } + + return providers.ModelCapabilities{}, false +} diff --git a/server/handlers/completion.go b/server/handlers/completion.go index fc73d9c..7e04aa6 100644 --- a/server/handlers/completion.go +++ b/server/handlers/completion.go @@ -146,7 +146,7 @@ func (h *CompletionHandler) Complete(c *gin.Context) { } // Resolve capabilities for this model — auto-set defaults - caps := h.getModelCapabilities(model, configID) + caps := h.getModelCapabilities(c, model, configID) if req.MaxTokens > 0 { provReq.MaxTokens = req.MaxTokens @@ -479,32 +479,8 @@ func escapeJSON(s string) string { // getModelCapabilities looks up capabilities from model_configs DB, // then overlays with known model defaults and heuristic detection. -func (h *CompletionHandler) getModelCapabilities(model, apiConfigID string) providers.ModelCapabilities { - // Start with known table or heuristic - // Start with known model table or heuristics - caps, found := providers.LookupKnownModel(model) - if !found { - caps = providers.InferCapabilities(model) - } - - // Overlay with DB-stored capabilities (from provider sync or admin edit — authoritative) - var capsJSON []byte - err := database.DB.QueryRow(` - SELECT capabilities FROM model_configs - WHERE model_id = $1 AND api_config_id = $2 - `, model, apiConfigID).Scan(&capsJSON) - - if err == nil && capsJSON != nil { - var dbCaps providers.ModelCapabilities - if err := json.Unmarshal(capsJSON, &dbCaps); err == nil { - if dbCaps.HasProviderData() { - // DB has real provider data — use as authoritative, fill gaps - caps = providers.MergeCapabilities(dbCaps, model) - } - } - } - - return caps +func (h *CompletionHandler) getModelCapabilities(c *gin.Context, model, apiConfigID string) providers.ModelCapabilities { + return ResolveModelCaps(c, model, apiConfigID) } // ── Config Resolution ─────────────────────── @@ -529,11 +505,11 @@ func (h *CompletionHandler) resolveConfig(userID string, channelID string, req c } } - // 3. User's first active config (personal first, then global) + // 3. User's first active config (personal first, then global — excludes team providers) if configID == "" { err := database.DB.QueryRow(` SELECT id FROM api_configs - WHERE (user_id = $1 OR is_global = true OR user_id IS NULL) AND is_active = true + WHERE (user_id = $1 OR is_global = true) AND is_active = true AND team_id IS NULL ORDER BY user_id NULLS LAST, created_at ASC LIMIT 1 `, userID).Scan(&configID) @@ -542,14 +518,16 @@ func (h *CompletionHandler) resolveConfig(userID string, channelID string, req c } } - // Load the config including custom headers and provider settings + // Load the config — allow personal, global, OR team configs the user belongs to var providerID, endpoint string var apiKey, modelDefault *string var customHeadersJSON, providerSettingsJSON []byte err := database.DB.QueryRow(` SELECT provider, endpoint, api_key_encrypted, model_default, custom_headers, provider_settings FROM api_configs - WHERE id = $1 AND (user_id = $2 OR is_global = true OR user_id IS NULL) AND is_active = true + WHERE id = $1 AND is_active = true + AND (user_id = $2 OR is_global = true + OR team_id IN (SELECT team_id FROM team_members WHERE user_id = $2)) `, configID, userID).Scan(&providerID, &endpoint, &apiKey, &modelDefault, &customHeadersJSON, &providerSettingsJSON) if err == sql.ErrNoRows { diff --git a/server/handlers/messages.go b/server/handlers/messages.go index f5b47b5..46d2cc5 100644 --- a/server/handlers/messages.go +++ b/server/handlers/messages.go @@ -432,7 +432,7 @@ func (h *MessageHandler) Regenerate(c *gin.Context) { Messages: llmMessages, } - caps := comp.getModelCapabilities(model, configID) + caps := comp.getModelCapabilities(c, model, configID) if maxTokens > 0 { provReq.MaxTokens = maxTokens } else { diff --git a/server/handlers/presets.go b/server/handlers/presets.go index 76c5610..b408827 100644 --- a/server/handlers/presets.go +++ b/server/handlers/presets.go @@ -128,7 +128,7 @@ func (h *PresetHandler) CreateUserPreset(c *gin.Context) { var count int err := database.DB.QueryRow(` SELECT COUNT(*) FROM api_configs - WHERE id = $1 AND (user_id = $2 OR is_global = true OR user_id IS NULL) AND is_active = true + WHERE id = $1 AND (user_id = $2 OR is_global = true) AND is_active = true AND team_id IS NULL `, *req.APIConfigID, userID).Scan(&count) if err != nil || count == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid or inaccessible API config"}) @@ -342,7 +342,7 @@ func (h *PresetHandler) CreateAdminPreset(c *gin.Context) { var count int err := database.DB.QueryRow(` SELECT COUNT(*) FROM api_configs - WHERE id = $1 AND (is_global = true OR user_id IS NULL) AND is_active = true + WHERE id = $1 AND is_global = true AND is_active = true AND team_id IS NULL `, *req.APIConfigID).Scan(&count) if err != nil || count == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "global presets must use a global API config"}) diff --git a/server/handlers/team_providers.go b/server/handlers/team_providers.go new file mode 100644 index 0000000..91616fb --- /dev/null +++ b/server/handlers/team_providers.go @@ -0,0 +1,304 @@ +package handlers + +import ( + "encoding/json" + "log" + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + + "git.gobha.me/xcaliber/chat-switchboard/database" + "git.gobha.me/xcaliber/chat-switchboard/providers" +) + +// ── Team Provider Handlers ────────────────── + +// ListTeamProviders returns API configs scoped to a team. +// GET /api/v1/teams/:teamId/providers +func (h *TeamHandler) ListTeamProviders(c *gin.Context) { + teamID := getTeamID(c) + + rows, err := database.DB.Query(` + SELECT id, name, provider, endpoint, api_key_encrypted, + model_default, config::text, is_active, is_private, created_at, updated_at + FROM api_configs + WHERE team_id = $1 + ORDER BY name ASC + `, teamID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list team providers"}) + return + } + defer rows.Close() + + type teamProvider 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"` + Config map[string]interface{} `json:"config"` + IsActive bool `json:"is_active"` + IsPrivate bool `json:"is_private"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + } + + configs := make([]teamProvider, 0) + for rows.Next() { + var p teamProvider + var apiKeyEnc *string + var configRaw string + if err := rows.Scan(&p.ID, &p.Name, &p.Provider, &p.Endpoint, &apiKeyEnc, + &p.ModelDefault, &configRaw, &p.IsActive, &p.IsPrivate, &p.CreatedAt, &p.UpdatedAt); err != nil { + continue + } + p.HasKey = apiKeyEnc != nil && *apiKeyEnc != "" + p.Config = parseJSONBConfig(configRaw) + configs = append(configs, p) + } + + c.JSON(http.StatusOK, gin.H{ + "providers": configs, + "allow_team_providers": isTeamProvidersAllowed(teamID), + }) +} + +// CreateTeamProvider creates an API config scoped to a team. +// POST /api/v1/teams/:teamId/providers +func (h *TeamHandler) CreateTeamProvider(c *gin.Context) { + teamID := getTeamID(c) + + // Check allow_team_providers setting + if !isTeamProvidersAllowed(teamID) { + c.JSON(http.StatusForbidden, gin.H{"error": "team providers are not enabled for this team"}) + return + } + + var req createAPIConfigRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + 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 id string + err := database.DB.QueryRow(` + INSERT INTO api_configs (team_id, name, provider, endpoint, api_key_encrypted, model_default, config, is_private) + VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8) + RETURNING id + `, teamID, req.Name, req.Provider, req.Endpoint, req.APIKey, req.ModelDefault, configJSON, req.IsPrivate, + ).Scan(&id) + if err != nil { + log.Printf("[WARN] Failed to create team provider: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create provider"}) + return + } + + c.JSON(http.StatusCreated, gin.H{"id": id}) +} + +// UpdateTeamProvider updates a team-scoped API config. +// PUT /api/v1/teams/:teamId/providers/:id +func (h *TeamHandler) UpdateTeamProvider(c *gin.Context) { + teamID := getTeamID(c) + providerID := c.Param("id") + + var req updateAPIConfigRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Verify provider belongs to this team + var count int + database.DB.QueryRow(`SELECT COUNT(*) FROM api_configs WHERE id = $1 AND team_id = $2`, providerID, teamID).Scan(&count) + if count == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "provider not found in this team"}) + return + } + + // Build dynamic update + query := "UPDATE api_configs SET updated_at = NOW()" + args := []interface{}{} + argN := 1 + + if req.Name != nil { + query += ", name = $" + strconv.Itoa(argN) + args = append(args, *req.Name) + argN++ + } + if req.Endpoint != nil { + query += ", endpoint = $" + strconv.Itoa(argN) + args = append(args, *req.Endpoint) + argN++ + } + if req.APIKey != nil { + query += ", api_key_encrypted = $" + strconv.Itoa(argN) + args = append(args, *req.APIKey) + argN++ + } + if req.ModelDefault != nil { + query += ", model_default = $" + strconv.Itoa(argN) + args = append(args, *req.ModelDefault) + argN++ + } + if req.IsActive != nil { + query += ", is_active = $" + strconv.Itoa(argN) + args = append(args, *req.IsActive) + argN++ + } + if req.IsPrivate != nil { + query += ", is_private = $" + strconv.Itoa(argN) + args = append(args, *req.IsPrivate) + argN++ + } + if req.Config != nil { + b, _ := json.Marshal(req.Config) + query += ", config = $" + strconv.Itoa(argN) + "::jsonb" + args = append(args, string(b)) + argN++ + } + + query += " WHERE id = $" + strconv.Itoa(argN) + " AND team_id = $" + strconv.Itoa(argN+1) + args = append(args, providerID, teamID) + + _, err := database.DB.Exec(query, args...) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update provider"}) + return + } + + c.JSON(http.StatusOK, gin.H{"id": providerID, "updated": true}) +} + +// DeleteTeamProvider removes a team-scoped API config. +// DELETE /api/v1/teams/:teamId/providers/:id +func (h *TeamHandler) DeleteTeamProvider(c *gin.Context) { + teamID := getTeamID(c) + providerID := c.Param("id") + + result, err := database.DB.Exec(` + DELETE FROM api_configs WHERE id = $1 AND team_id = $2 + `, providerID, teamID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete provider"}) + return + } + + rows, _ := result.RowsAffected() + if rows == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "provider not found in this team"}) + return + } + + c.JSON(http.StatusOK, gin.H{"deleted": true}) +} + +// ListTeamProviderModels lists models available from a team provider (live query). +// GET /api/v1/teams/:teamId/providers/:id/models +func (h *TeamHandler) ListTeamProviderModels(c *gin.Context) { + teamID := getTeamID(c) + providerID := c.Param("id") + + var name, providerType, endpoint string + var apiKey *string + var headersJSON []byte + err := database.DB.QueryRow(` + SELECT name, provider, endpoint, api_key_encrypted, custom_headers + FROM api_configs + WHERE id = $1 AND team_id = $2 AND is_active = true + `, providerID, teamID).Scan(&name, &providerType, &endpoint, &apiKey, &headersJSON) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "provider not found"}) + return + } + + provider, err := providers.Get(providerType) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported provider"}) + return + } + + key := "" + if apiKey != nil { + key = *apiKey + } + + var customHeaders map[string]string + _ = json.Unmarshal(headersJSON, &customHeaders) + + modelList, err := provider.ListModels(c.Request.Context(), providers.ProviderConfig{ + Endpoint: endpoint, + APIKey: key, + CustomHeaders: customHeaders, + }) + if err != nil { + c.JSON(http.StatusBadGateway, gin.H{"error": "failed to fetch models: " + err.Error()}) + return + } + + type modelInfo struct { + ID string `json:"id"` + Capabilities providers.ModelCapabilities `json:"capabilities"` + } + + models := make([]modelInfo, 0, len(modelList)) + for _, m := range modelList { + caps := providers.MergeCapabilities(m.Capabilities, m.ID) + caps.MaxOutputTokens = providers.ResolveMaxOutput(m.ID, caps) + models = append(models, modelInfo{ID: m.ID, Capabilities: caps}) + } + + c.JSON(http.StatusOK, gin.H{"models": models, "provider": name}) +} + +// isTeamProvidersAllowed checks if team providers are enabled for a team. +// First checks the global allow_team_providers setting, then team.settings JSONB. +func isTeamProvidersAllowed(teamID string) bool { + // Check global setting + var globalVal string + err := database.DB.QueryRow(` + SELECT value FROM global_settings WHERE key = 'allow_team_providers' + `).Scan(&globalVal) + if err == nil && globalVal == "false" { + return false + } + // Default to true if not set + + // Check team-level override + var settingsJSON []byte + err = database.DB.QueryRow(` + SELECT settings FROM teams WHERE id = $1 + `, teamID).Scan(&settingsJSON) + if err != nil { + return true // default allow + } + + var settings map[string]interface{} + if err := json.Unmarshal(settingsJSON, &settings); err != nil { + return true + } + if v, ok := settings["allow_team_providers"]; ok { + if b, ok := v.(bool); ok { + return b + } + } + + return true +} diff --git a/server/handlers/teams.go b/server/handlers/teams.go index 2168e19..a44ebfe 100644 --- a/server/handlers/teams.go +++ b/server/handlers/teams.go @@ -2,7 +2,9 @@ package handlers import ( "database/sql" + "encoding/json" "fmt" + "log" "net/http" "strconv" "strings" @@ -10,6 +12,7 @@ import ( "github.com/gin-gonic/gin" "git.gobha.me/xcaliber/chat-switchboard/database" + "git.gobha.me/xcaliber/chat-switchboard/providers" ) // ── Request types ─────────────────────────── @@ -212,7 +215,7 @@ func (h *TeamHandler) UpdateTeam(c *gin.Context) { argN++ } if req.Settings != nil { - sets = append(sets, "settings = $"+strconv.Itoa(argN)+"::jsonb") + sets = append(sets, "settings = COALESCE(settings, '{}'::jsonb) || $"+strconv.Itoa(argN)+"::jsonb") args = append(args, *req.Settings) argN++ } @@ -461,20 +464,7 @@ func (h *TeamHandler) MyTeams(c *gin.Context) { // for team admins building presets. Requires RequireTeamAdmin middleware. // GET /api/v1/teams/:teamId/models func (h *TeamHandler) ListAvailableModels(c *gin.Context) { - rows, err := database.DB.Query(` - SELECT mc.id, mc.model_id, mc.display_name, mc.visibility, - ac.provider, ac.name as provider_name - FROM model_configs mc - JOIN api_configs ac ON mc.api_config_id = ac.id - WHERE mc.visibility IN ('enabled', 'team') - AND ac.is_active = true AND ac.user_id IS NULL - ORDER BY ac.name, mc.model_id - `) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"}) - return - } - defer rows.Close() + teamID := getTeamID(c) type availableModel struct { ID string `json:"id"` @@ -483,18 +473,89 @@ func (h *TeamHandler) ListAvailableModels(c *gin.Context) { Visibility string `json:"visibility"` Provider string `json:"provider"` ProviderName string `json:"provider_name"` + Source string `json:"source"` } models := make([]availableModel, 0) + + // ── 1. Global admin models (synced in model_configs) ── + rows, err := database.DB.Query(` + SELECT mc.id, mc.model_id, mc.display_name, mc.visibility, + ac.provider, ac.name as provider_name + FROM model_configs mc + JOIN api_configs ac ON mc.api_config_id = ac.id + WHERE mc.visibility IN ('enabled', 'team') + AND ac.is_active = true AND ac.is_global = true + ORDER BY ac.name, mc.model_id + `) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"}) + return + } + defer rows.Close() + for rows.Next() { var m availableModel if err := rows.Scan(&m.ID, &m.ModelID, &m.DisplayName, &m.Visibility, &m.Provider, &m.ProviderName); err != nil { continue } + m.Source = "global" models = append(models, m) } + // ── 2. Team provider models (live query) ── + teamRows, err := database.DB.Query(` + SELECT id, name, provider, endpoint, api_key_encrypted, custom_headers + FROM api_configs + WHERE team_id = $1 AND is_active = true + `, teamID) + if err == nil { + defer teamRows.Close() + for teamRows.Next() { + var cfgID, name, providerID, endpoint string + var apiKey *string + var headersJSON []byte + if err := teamRows.Scan(&cfgID, &name, &providerID, &endpoint, &apiKey, &headersJSON); err != nil { + continue + } + + provider, pErr := providers.Get(providerID) + if pErr != nil { + continue + } + + key := "" + if apiKey != nil { + key = *apiKey + } + + var customHeaders map[string]string + _ = json.Unmarshal(headersJSON, &customHeaders) + + provModels, lErr := provider.ListModels(c.Request.Context(), providers.ProviderConfig{ + Endpoint: endpoint, + APIKey: key, + CustomHeaders: customHeaders, + }) + if lErr != nil { + log.Printf("[models] team provider %q list failed: %v", name, lErr) + continue + } + + for _, pm := range provModels { + models = append(models, availableModel{ + ID: pm.ID, + ModelID: pm.ID, + Provider: providerID, + ProviderName: name, + Visibility: "enabled", + Source: "team", + }) + } + } + } + c.JSON(http.StatusOK, gin.H{"models": models}) } diff --git a/server/main.go b/server/main.go index 88b1008..562bf01 100644 --- a/server/main.go +++ b/server/main.go @@ -169,6 +169,13 @@ func main() { teamScoped.DELETE("/members/:memberId", teams.RemoveMember) teamScoped.GET("/models", teams.ListAvailableModels) + // Team providers + teamScoped.GET("/providers", teams.ListTeamProviders) + teamScoped.POST("/providers", teams.CreateTeamProvider) + teamScoped.PUT("/providers/:id", teams.UpdateTeamProvider) + teamScoped.DELETE("/providers/:id", teams.DeleteTeamProvider) + teamScoped.GET("/providers/:id/models", teams.ListTeamProviderModels) + // Team presets teamPresets := handlers.NewPresetHandler() teamScoped.GET("/presets", teamPresets.ListTeamPresets) diff --git a/src/index.html b/src/index.html index 6ac0745..c0a47ef 100644 --- a/src/index.html +++ b/src/index.html @@ -343,6 +343,39 @@ + +
+

Providers

+
+ + + +
+

Team Presets

@@ -444,6 +477,8 @@

Team members can only use providers marked as "private".

+ +

When disabled, team admins cannot add or modify team providers.

diff --git a/src/js/api.js b/src/js/api.js index 445dd83..1ea6c2d 100644 --- a/src/js/api.js +++ b/src/js/api.js @@ -341,6 +341,13 @@ const API = { teamDeletePreset(teamId, presetId) { return this._del(`/api/v1/teams/${teamId}/presets/${presetId}`); }, teamListAvailableModels(teamId) { return this._get(`/api/v1/teams/${teamId}/models`); }, + // ── Team Providers ────────────────────── + teamListProviders(teamId) { return this._get(`/api/v1/teams/${teamId}/providers`); }, + teamCreateProvider(teamId, provider) { return this._post(`/api/v1/teams/${teamId}/providers`, provider); }, + teamUpdateProvider(teamId, providerId, updates) { return this._put(`/api/v1/teams/${teamId}/providers/${providerId}`, updates); }, + teamDeleteProvider(teamId, providerId) { return this._del(`/api/v1/teams/${teamId}/providers/${providerId}`); }, + teamListProviderModels(teamId, providerId) { return this._get(`/api/v1/teams/${teamId}/providers/${providerId}/models`); }, + // ── User Presets ───────────────────────── listPresets() { return this._get('/api/v1/presets'); }, createPreset(preset) { return this._post('/api/v1/presets', preset); }, diff --git a/src/js/app.js b/src/js/app.js index 0e2347a..cc9e35a 100644 --- a/src/js/app.js +++ b/src/js/app.js @@ -11,6 +11,16 @@ const App = { abortController: null, serverSettings: {}, + // Find model by composite ID, with fallback to bare model_id match + findModel(id) { + if (!id) return null; + // Exact match on composite ID (configId:modelId) + let m = App.models.find(m => m.id === id); + if (m) return m; + // Fallback: bare model_id (backward compat with stored settings) + return App.models.find(m => m.baseModelId === id) || null; + }, + settings: { model: '', stream: true, @@ -211,8 +221,12 @@ async function fetchModels() { App.models = (data.models || []).map(m => { const isPreset = !!m.is_preset; - const id = isPreset ? (m.preset_id || m.id) : (m.model_id || m.id); const baseModelId = m.model_id || m.id; + // Presets use preset_id; base models use configId:modelId to avoid + // collisions when the same model exists across team/personal/global providers + const id = isPreset + ? (m.preset_id || m.id) + : (m.config_id ? `${m.config_id}:${baseModelId}` : baseModelId); return { id, baseModelId, @@ -225,6 +239,8 @@ async function fetchModels() { presetScope: m.preset_scope || null, presetAvatar: m.preset_avatar || null, presetTeamName: m.preset_team_name || null, + source: m.source || (isPreset ? 'preset' : 'global'), + teamName: m.preset_team_name || null, hidden: !isPreset && App.hiddenModels.has(baseModelId), }; }); @@ -337,7 +353,7 @@ async function sendMessage() { input.style.height = 'auto'; const selectedId = UI.getModelValue(); - const modelInfo = App.models.find(m => m.id === selectedId); + const modelInfo = App.findModel(selectedId); // For presets, send the base model ID; for regular models, send the model ID const model = modelInfo?.baseModelId || selectedId; const presetId = modelInfo?.isPreset ? modelInfo.presetId : null; @@ -430,7 +446,7 @@ async function regenerateMessage(messageId) { if (App.isGenerating || !App.currentChatId) return; const selectedId = UI.getModelValue(); - const modelInfo = App.models.find(m => m.id === selectedId); + const modelInfo = App.findModel(selectedId); const model = modelInfo?.baseModelId || selectedId; const presetId = modelInfo?.isPreset ? modelInfo.presetId : null; @@ -501,7 +517,7 @@ async function submitEdit(messageId, newContent) { if (!newContent.trim()) return; const selectedId = UI.getModelValue(); - const modelInfo = App.models.find(m => m.id === selectedId); + const modelInfo = App.findModel(selectedId); const model = modelInfo?.baseModelId || selectedId; const presetId = modelInfo?.isPreset ? modelInfo.presetId : null; @@ -876,7 +892,7 @@ function initListeners() { tab.addEventListener('click', () => UI.switchSettingsTab(tab.dataset.stab)); }); document.getElementById('settingsModel').addEventListener('change', function() { - const model = App.models.find(m => m.id === this.value); + const model = App.findModel(this.value); const caps = model?.capabilities || lookupKnownCaps(this.value) || {}; const hint = document.getElementById('settingsMaxHint'); if (hint) { @@ -1030,6 +1046,14 @@ function initListeners() { UI.toast(this.checked ? 'Private providers required' : 'Private provider policy removed'); } catch (e) { UI.toast(e.message, 'error'); this.checked = !this.checked; } }); + document.getElementById('adminTeamAllowProviders')?.addEventListener('change', async function() { + try { + await API.adminUpdateTeam(UI._teamEditId, { + settings: JSON.stringify({ allow_team_providers: this.checked }) + }); + UI.toast(this.checked ? 'Team providers enabled' : 'Team providers disabled'); + } catch (e) { UI.toast(e.message, 'error'); this.checked = !this.checked; } + }); document.getElementById('adminCancelMemberBtn')?.addEventListener('click', () => { document.getElementById('adminAddMemberForm').style.display = 'none'; }); @@ -1100,6 +1124,86 @@ function initListeners() { UI.loadTeamPresetModelDropdown(UI._managingTeamId); }); + // Team — providers + const defaultEndpoints = { + openai: 'https://api.openai.com/v1', + anthropic: 'https://api.anthropic.com', + venice: 'https://api.venice.ai/api/v1', + openrouter: 'https://openrouter.ai/api/v1', + }; + document.getElementById('settingsTeamAddProviderBtn')?.addEventListener('click', () => { + const form = document.getElementById('settingsTeamAddProvider'); + form.style.display = form.style.display === 'none' ? '' : 'none'; + // Populate provider type dropdown + const sel = document.getElementById('teamProviderType'); + if (sel && sel.options.length === 0) { + ['openai', 'anthropic', 'venice', 'openrouter'].forEach(p => { + const labels = { openai: 'OpenAI-compatible', anthropic: 'Anthropic', venice: 'Venice.ai', openrouter: 'OpenRouter' }; + const opt = document.createElement('option'); + opt.value = p; + opt.textContent = labels[p] || p; + sel.appendChild(opt); + }); + } + }); + document.getElementById('teamProviderType')?.addEventListener('change', function() { + const ep = document.getElementById('teamProviderEndpoint'); + if (!ep.value || Object.values(defaultEndpoints).includes(ep.value)) { + ep.value = defaultEndpoints[this.value] || ''; + } + }); + document.getElementById('settingsTeamCancelProvider')?.addEventListener('click', () => { + document.getElementById('settingsTeamAddProvider').style.display = 'none'; + }); + document.getElementById('settingsTeamAddProviderSubmit')?.addEventListener('click', async () => { + const teamId = UI._managingTeamId; + const name = document.getElementById('teamProviderName').value.trim(); + const provider = document.getElementById('teamProviderType').value; + const endpoint = document.getElementById('teamProviderEndpoint').value.trim(); + const apiKey = document.getElementById('teamProviderKey').value; + const isPrivate = document.getElementById('teamProviderPrivate').checked; + if (!name) return UI.toast('Name required', 'error'); + if (!endpoint) return UI.toast('Endpoint required', 'error'); + try { + await API.teamCreateProvider(teamId, { name, provider, endpoint, api_key: apiKey, is_private: isPrivate }); + document.getElementById('settingsTeamAddProvider').style.display = 'none'; + document.getElementById('teamProviderName').value = ''; + document.getElementById('teamProviderEndpoint').value = ''; + document.getElementById('teamProviderKey').value = ''; + document.getElementById('teamProviderPrivate').checked = false; + UI.toast('Provider added'); + await UI.loadTeamManageProviders(teamId); + await fetchModels(); + } catch (e) { UI.toast(e.message, 'error'); } + }); + + // Team — edit provider + document.getElementById('settingsTeamCancelEditProvider')?.addEventListener('click', () => { + document.getElementById('settingsTeamEditProvider').style.display = 'none'; + }); + document.getElementById('settingsTeamEditProviderSubmit')?.addEventListener('click', async () => { + const teamId = UI._managingTeamId; + const provId = UI._editingTeamProviderId; + if (!provId) return; + const updates = {}; + const name = document.getElementById('teamProviderEditName').value.trim(); + const endpoint = document.getElementById('teamProviderEditEndpoint').value.trim(); + const apiKey = document.getElementById('teamProviderEditKey').value; + const defaultModel = document.getElementById('teamProviderEditDefault').value.trim(); + const isPrivate = document.getElementById('teamProviderEditPrivate').checked; + if (name) updates.name = name; + if (endpoint) updates.endpoint = endpoint; + if (apiKey) updates.api_key = apiKey; + if (defaultModel) updates.model_default = defaultModel; + updates.is_private = isPrivate; + try { + await API.teamUpdateProvider(teamId, provId, updates); + document.getElementById('settingsTeamEditProvider').style.display = 'none'; + UI.toast('Provider updated'); + await UI.loadTeamManageProviders(teamId); + } catch (e) { UI.toast(e.message, 'error'); } + }); + // User — personal presets var _userPresetForm = null; document.getElementById('userAddPresetBtn')?.addEventListener('click', () => { @@ -1876,6 +1980,37 @@ async function settingsDeleteTeamPreset(teamId, presetId, name) { } catch (e) { UI.toast(e.message, 'error'); } } +async function settingsToggleTeamProvider(teamId, providerId, currentlyActive) { + try { + await API.teamUpdateProvider(teamId, providerId, { is_active: !currentlyActive }); + UI.toast(currentlyActive ? 'Provider deactivated' : 'Provider activated'); + await UI.loadTeamManageProviders(teamId); + await fetchModels(); + } catch (e) { UI.toast(e.message, 'error'); } +} + +async function settingsDeleteTeamProvider(teamId, providerId, name) { + if (!confirm(`Delete team provider "${name}"? This will remove all models from this provider for your team.`)) return; + try { + await API.teamDeleteProvider(teamId, providerId); + UI.toast('Provider deleted'); + await UI.loadTeamManageProviders(teamId); + await fetchModels(); + } catch (e) { UI.toast(e.message, 'error'); } +} + +function settingsEditTeamProvider(teamId, provId, name, endpoint, defaultModel, isPrivate) { + UI._editingTeamProviderId = provId; + document.getElementById('settingsTeamAddProvider').style.display = 'none'; + const form = document.getElementById('settingsTeamEditProvider'); + form.style.display = ''; + document.getElementById('teamProviderEditName').value = name; + document.getElementById('teamProviderEditEndpoint').value = endpoint; + document.getElementById('teamProviderEditKey').value = ''; + document.getElementById('teamProviderEditDefault').value = defaultModel; + document.getElementById('teamProviderEditPrivate').checked = isPrivate; +} + // ── Notes Panel ───────────────────────────── var _editingNoteId = null; diff --git a/src/js/ui.js b/src/js/ui.js index c424410..dd83afd 100644 --- a/src/js/ui.js +++ b/src/js/ui.js @@ -389,7 +389,7 @@ const UI = { document.getElementById('typingIndicator')?.remove(); const label = modelName || 'Assistant'; - const currentModel = App.models.find(m => m.id === App.settings.model); + const currentModel = App.findModel(App.settings.model); const streamAvatar = currentModel?.presetAvatar || null; const div = document.createElement('div'); div.className = 'message assistant'; @@ -557,10 +557,14 @@ const UI = { }); addGroup('🔧 My Presets', personalPresets); - addGroup('Models', models); - // Restore selection - const match = App.models.find(m => m.id === current); + // No team model groups — team providers are for preset building only. + // Team members access team models through curated presets. + addGroup('Models', models.filter(m => m.source !== 'personal')); + addGroup('🔑 My Providers', models.filter(m => m.source === 'personal')); + + // Restore selection (supports both composite and bare model IDs) + const match = App.findModel(current); if (match) { UI.setModelValue(match.id, match.name + (match.provider ? ` (${match.provider})` : '')); } else if (App.models.length > 0) { @@ -588,7 +592,8 @@ const UI = { div.className = 'model-dropdown-item'; div.dataset.value = m.id; const avatar = m.presetAvatar ? `` : ''; - div.innerHTML = `${avatar}${esc(m.name || m.id)}${m.provider ? `${esc(m.provider)}` : ''}`; + const teamBadge = m.source === 'team' && m.teamName ? `👥 ${esc(m.teamName)}` : ''; + div.innerHTML = `${avatar}${esc(m.name || m.id)}${teamBadge}${m.provider ? `${esc(m.provider)}` : ''}`; div.addEventListener('click', () => { UI.setModelValue(m.id, (m.name || m.id) + (m.provider ? ` (${m.provider})` : '')); UI._closeModelDropdown(); @@ -619,7 +624,7 @@ const UI = { getSelectedModelCaps() { const modelId = UI.getModelValue(); if (!modelId) return {}; - const model = App.models.find(m => m.id === modelId); + const model = App.findModel(modelId); // Model in list has resolved caps; fallback to client-side lookup if (model?.capabilities && Object.keys(model.capabilities).length > 0) { return model.capabilities; @@ -708,7 +713,7 @@ const UI = { document.getElementById('sendBtn').disabled = on; if (on) { const container = document.getElementById('chatMessages'); - const currentModel = App.models.find(m => m.id === App.settings.model); + const currentModel = App.findModel(App.settings.model); const typingAvatar = currentModel?.presetAvatar || null; const typing = document.createElement('div'); typing.id = 'typingIndicator'; @@ -921,7 +926,13 @@ const UI = { document.getElementById('settingsTeamManageName').textContent = teamName; document.getElementById('settingsTeamAddMember').style.display = 'none'; document.getElementById('settingsTeamAddPreset').style.display = 'none'; - await Promise.all([UI.loadTeamManageMembers(teamId), UI.loadTeamManagePresets(teamId)]); + document.getElementById('settingsTeamAddProvider').style.display = 'none'; + document.getElementById('settingsTeamEditProvider').style.display = 'none'; + await Promise.all([ + UI.loadTeamManageMembers(teamId), + UI.loadTeamManageProviders(teamId), + UI.loadTeamManagePresets(teamId), + ]); }, async loadTeamManageMembers(teamId) { @@ -948,6 +959,42 @@ const UI = { } catch (e) { el.innerHTML = `
${esc(e.message)}
`; } }, + async loadTeamManageProviders(teamId) { + const el = document.getElementById('settingsTeamProviders'); + if (!el) return; + el.innerHTML = '
Loading...
'; + const addBtn = document.getElementById('settingsTeamAddProviderBtn'); + try { + const resp = await API.teamListProviders(teamId); + const provs = resp.providers || []; + const allowed = resp.allow_team_providers !== false; + + // Show/hide add button and entire section based on policy + if (addBtn) addBtn.style.display = allowed ? '' : 'none'; + + if (!allowed && provs.length === 0) { + el.innerHTML = '
Team provider management is disabled by your administrator
'; + return; + } + el.innerHTML = provs.map(p => { + const statusCls = p.is_active ? 'badge-admin' : 'badge-user'; + const statusLabel = p.is_active ? 'active' : 'inactive'; + const privateBadge = p.is_private ? ' 🔒 private' : ''; + return `
+
+ ${esc(p.name)} +
${esc(p.provider)} · ${p.has_key ? '🔑' : 'no key'} ${statusLabel}${privateBadge}
+
+
+ + + +
+
`; + }).join('') || '
No team providers — add one to give your team access to additional models
'; + } catch (e) { el.innerHTML = `
${esc(e.message)}
`; } + }, + async loadTeamManagePresets(teamId) { const el = document.getElementById('settingsTeamPresets'); el.innerHTML = '
Loading...
'; @@ -975,13 +1022,27 @@ const UI = { const resp = await API.teamListAvailableModels(teamId); const models = resp.models || []; sel.innerHTML = ''; - models.forEach(m => { - const opt = document.createElement('option'); - opt.value = m.model_id || m.id; - const vis = m.visibility === 'team' ? ' [team-only]' : ''; - opt.textContent = (m.model_id || m.id) + (m.provider_name ? ` (${m.provider_name})` : '') + vis; - sel.appendChild(opt); - }); + + // Group by source for clarity + const globalModels = models.filter(m => m.source !== 'team'); + const teamModels = models.filter(m => m.source === 'team'); + + const addGroup = (label, items) => { + if (!items.length) return; + const group = document.createElement('optgroup'); + group.label = label; + items.forEach(m => { + const opt = document.createElement('option'); + opt.value = m.model_id || m.id; + const vis = m.visibility === 'team' ? ' [team-only]' : ''; + opt.textContent = (m.model_id || m.id) + (m.provider_name ? ` (${m.provider_name})` : '') + vis; + group.appendChild(opt); + }); + sel.appendChild(group); + }; + + addGroup('Global Models', globalModels); + addGroup('Team Provider Models', teamModels); } catch (e) { // Fallback to App.models if team endpoint fails sel.innerHTML = ''; @@ -1350,12 +1411,14 @@ const UI = { document.getElementById('adminTeamDetailName').textContent = teamName; document.getElementById('adminAddMemberForm').style.display = 'none'; - // Load team settings for policy checkbox + // Load team settings for policy checkboxes try { const team = await API.adminGetTeam(teamId); const settings = typeof team.settings === 'string' ? JSON.parse(team.settings || '{}') : (team.settings || {}); document.getElementById('adminTeamPrivatePolicy').checked = !!settings.require_private_providers; - } catch (e) { /* proceed with unchecked default */ } + // allow_team_providers defaults to true if not explicitly set + document.getElementById('adminTeamAllowProviders').checked = settings.allow_team_providers !== false; + } catch (e) { /* proceed with defaults */ } await this.loadTeamMembers(teamId); }, @@ -1493,7 +1556,8 @@ const UI = { if (caps.tool_calling) badges.push('🔧'); if (caps.vision) badges.push('👁'); if (caps.thinking) badges.push('💭'); - const src = m.source === 'personal' ? 'personal' : ''; + const src = m.source === 'personal' ? 'personal' + : m.source === 'team' ? `👥 ${esc(m.team_name || 'team')}` : ''; const hidden = App.hiddenModels.has(mid); const toggleCls = hidden ? '' : 'enabled'; const toggleLabel = hidden ? 'Hidden' : '✓ Visible';