Changeset 0.8.6 (#49)
This commit is contained in:
28
ROADMAP.md
28
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
|
- [x] User personal provider models: simple enable/disable toggle
|
||||||
(same visibility toggle — hide from selector)
|
(same visibility toggle — hide from selector)
|
||||||
|
|
||||||
**Team Providers (0.8.6+)**
|
**Team Providers (0.8.6)**
|
||||||
- [ ] `team_id` column on `api_configs` (nullable, symmetric with `user_id`)
|
- [x] `team_id` column on `api_configs` (nullable FK, indexed)
|
||||||
- [ ] Team admins manage team-scoped provider configs
|
- [x] Team admins manage team-scoped provider configs
|
||||||
(add/remove API keys, toggle active, same UI as personal providers)
|
(add/remove API keys, toggle active, same UI as personal providers)
|
||||||
- [ ] `allow_team_providers` flag in `teams.settings` JSONB
|
- [x] `allow_team_providers` check: global_settings + team.settings JSONB
|
||||||
— admin controls which teams can add their own API keys
|
- [x] Team provider models available in team preset builder (ListAvailableModels)
|
||||||
- [ ] Team provider models use same 3-state visibility: enabled/disabled/team
|
— grouped by source: Global Models / Team Provider Models (optgroup)
|
||||||
— enabled: team members see in model selector (direct use)
|
- [x] Team provider models NOT exposed directly in model selector
|
||||||
— team: team admin can use for presets only (not direct member access)
|
— team members access team models ONLY through curated presets
|
||||||
— disabled: hidden from everyone
|
— keeps model selector clean, avoids visibility confusion
|
||||||
Same cycle button, same bulk actions, scoped to team admin panel
|
- [x] Three-tier provider hierarchy: global → team (presets only) → personal
|
||||||
- [ ] `ListEnabledModels` adds section: team provider models where
|
- [x] ListConfigs excludes team-scoped providers (team_id IS NULL filter)
|
||||||
`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
|
|
||||||
|
|
||||||
**Usage / Cost Tracking**
|
**Usage / Cost Tracking**
|
||||||
- [ ] Capture from provider responses: prompt_tokens, completion_tokens,
|
- [ ] Capture from provider responses: prompt_tokens, completion_tokens,
|
||||||
|
|||||||
@@ -138,6 +138,7 @@ echo ""
|
|||||||
echo "Provider capabilities:"
|
echo "Provider capabilities:"
|
||||||
check_column "api_configs" "custom_headers"
|
check_column "api_configs" "custom_headers"
|
||||||
check_column "api_configs" "is_global"
|
check_column "api_configs" "is_global"
|
||||||
|
check_column "api_configs" "team_id"
|
||||||
check_table "user_model_preferences"
|
check_table "user_model_preferences"
|
||||||
check_column "user_model_preferences" "user_id"
|
check_column "user_model_preferences" "user_id"
|
||||||
check_column "user_model_preferences" "model_config_id"
|
check_column "user_model_preferences" "model_config_id"
|
||||||
|
|||||||
16
server/database/migrations/021_team_providers.sql
Normal file
16
server/database/migrations/021_team_providers.sql
Normal file
@@ -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';
|
||||||
@@ -523,7 +523,7 @@ func (h *AdminHandler) ListGlobalConfigs(c *gin.Context) {
|
|||||||
(api_key_encrypted IS NOT NULL AND api_key_encrypted != '') as has_key,
|
(api_key_encrypted IS NOT NULL AND api_key_encrypted != '') as has_key,
|
||||||
created_at
|
created_at
|
||||||
FROM api_configs
|
FROM api_configs
|
||||||
WHERE user_id IS NULL
|
WHERE user_id IS NULL AND team_id IS NULL
|
||||||
ORDER BY created_at ASC
|
ORDER BY created_at ASC
|
||||||
`)
|
`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -605,7 +605,7 @@ func (h *AdminHandler) UpdateGlobalConfig(c *gin.Context) {
|
|||||||
|
|
||||||
sets = append(sets, "updated_at = NOW()")
|
sets = append(sets, "updated_at = NOW()")
|
||||||
args = append(args, configID)
|
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...)
|
result, err := database.DB.Exec(query, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update config"})
|
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")
|
configID := c.Param("id")
|
||||||
|
|
||||||
result, err := database.DB.Exec(
|
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,
|
configID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -666,7 +666,7 @@ func (h *AdminHandler) ListModelConfigs(c *gin.Context) {
|
|||||||
mc.visibility, mc.capabilities, mc.created_at, mc.updated_at
|
mc.visibility, mc.capabilities, mc.created_at, mc.updated_at
|
||||||
FROM model_configs mc
|
FROM model_configs mc
|
||||||
JOIN api_configs ac ON mc.api_config_id = ac.id
|
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
|
ORDER BY ac.name, mc.model_id
|
||||||
`)
|
`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -697,7 +697,7 @@ func (h *AdminHandler) FetchModels(c *gin.Context) {
|
|||||||
rows, err := database.DB.Query(`
|
rows, err := database.DB.Query(`
|
||||||
SELECT id, provider, endpoint, api_key_encrypted, custom_headers
|
SELECT id, provider, endpoint, api_key_encrypted, custom_headers
|
||||||
FROM api_configs
|
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 {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list configs"})
|
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(
|
result, err := database.DB.Exec(
|
||||||
`UPDATE model_configs SET visibility = $1, updated_at = NOW()
|
`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,
|
req.Visibility,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -64,10 +64,10 @@ func (h *APIConfigHandler) ListConfigs(c *gin.Context) {
|
|||||||
userID := getUserID(c)
|
userID := getUserID(c)
|
||||||
page, perPage, offset := parsePagination(c)
|
page, perPage, offset := parsePagination(c)
|
||||||
|
|
||||||
// Count: user's configs + global configs
|
// Count: user's configs + global configs (exclude team-scoped)
|
||||||
var total int
|
var total int
|
||||||
err := database.DB.QueryRow(
|
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,
|
userID,
|
||||||
).Scan(&total)
|
).Scan(&total)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -79,7 +79,7 @@ func (h *APIConfigHandler) ListConfigs(c *gin.Context) {
|
|||||||
SELECT id, user_id, name, provider, endpoint, api_key_encrypted,
|
SELECT id, user_id, name, provider, endpoint, api_key_encrypted,
|
||||||
model_default, config, is_active, created_at, updated_at
|
model_default, config, is_active, created_at, updated_at
|
||||||
FROM api_configs
|
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
|
ORDER BY user_id NULLS LAST, name ASC
|
||||||
LIMIT $2 OFFSET $3
|
LIMIT $2 OFFSET $3
|
||||||
`, userID, perPage, offset)
|
`, userID, perPage, offset)
|
||||||
@@ -169,7 +169,7 @@ func (h *APIConfigHandler) GetConfig(c *gin.Context) {
|
|||||||
SELECT id, user_id, name, provider, endpoint, api_key_encrypted,
|
SELECT id, user_id, name, provider, endpoint, api_key_encrypted,
|
||||||
model_default, config::text, is_active, created_at, updated_at
|
model_default, config::text, is_active, created_at, updated_at
|
||||||
FROM api_configs
|
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)
|
`, configID, userID)
|
||||||
|
|
||||||
var cfg apiConfigResponse
|
var cfg apiConfigResponse
|
||||||
@@ -310,7 +310,7 @@ func (h *APIConfigHandler) ListModels(c *gin.Context) {
|
|||||||
err := database.DB.QueryRow(`
|
err := database.DB.QueryRow(`
|
||||||
SELECT provider, endpoint, api_key_encrypted
|
SELECT provider, endpoint, api_key_encrypted
|
||||||
FROM api_configs
|
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)
|
`, configID, userID).Scan(&providerID, &endpoint, &apiKey)
|
||||||
|
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
@@ -357,7 +357,7 @@ func (h *APIConfigHandler) ListAllModels(c *gin.Context) {
|
|||||||
rows, err := database.DB.Query(`
|
rows, err := database.DB.Query(`
|
||||||
SELECT id, name, provider, endpoint, api_key_encrypted
|
SELECT id, name, provider, endpoint, api_key_encrypted
|
||||||
FROM api_configs
|
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)
|
`, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list configs"})
|
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) ─
|
// ── 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) {
|
func (h *APIConfigHandler) ListEnabledModels(c *gin.Context) {
|
||||||
userID := getUserID(c)
|
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)
|
models := make([]enabledModel, 0)
|
||||||
|
|
||||||
// ── 1. Admin model_configs (pre-synced via FetchModels) ──
|
// ── 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
|
SELECT mc.id, mc.model_id, mc.display_name, ac.provider, ac.name, mc.api_config_id, mc.capabilities
|
||||||
FROM model_configs mc
|
FROM model_configs mc
|
||||||
JOIN api_configs ac ON mc.api_config_id = ac.id
|
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
|
ORDER BY ac.name, mc.model_id
|
||||||
`)
|
`)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -484,10 +487,13 @@ func (h *APIConfigHandler) ListEnabledModels(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── 2. User provider models (live query) ──
|
// ── 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(`
|
userRows, err := database.DB.Query(`
|
||||||
SELECT id, name, provider, endpoint, api_key_encrypted, custom_headers
|
SELECT id, name, provider, endpoint, api_key_encrypted, custom_headers
|
||||||
FROM api_configs
|
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)
|
`, userID)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
defer userRows.Close()
|
defer userRows.Close()
|
||||||
@@ -574,41 +580,12 @@ func (h *APIConfigHandler) ListEnabledModels(c *gin.Context) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inherit capabilities from the base model already loaded in sections 1/2
|
// Inherit capabilities from base model via shared resolver
|
||||||
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 := ""
|
cfgID := ""
|
||||||
if apiConfigID != nil {
|
if apiConfigID != nil {
|
||||||
cfgID = *apiConfigID
|
cfgID = *apiConfigID
|
||||||
}
|
}
|
||||||
|
caps := ResolveModelCapsFromLoaded(c, baseModelID, cfgID, models)
|
||||||
|
|
||||||
// Build display name: "icon Name (base-model)"
|
// Build display name: "icon Name (base-model)"
|
||||||
displayName := name
|
displayName := name
|
||||||
@@ -665,3 +642,5 @@ func parseJSONBConfig(raw string) map[string]interface{} {
|
|||||||
_ = json.Unmarshal([]byte(raw), &result)
|
_ = json.Unmarshal([]byte(raw), &result)
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
137
server/handlers/capabilities.go
Normal file
137
server/handlers/capabilities.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -146,7 +146,7 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Resolve capabilities for this model — auto-set defaults
|
// Resolve capabilities for this model — auto-set defaults
|
||||||
caps := h.getModelCapabilities(model, configID)
|
caps := h.getModelCapabilities(c, model, configID)
|
||||||
|
|
||||||
if req.MaxTokens > 0 {
|
if req.MaxTokens > 0 {
|
||||||
provReq.MaxTokens = req.MaxTokens
|
provReq.MaxTokens = req.MaxTokens
|
||||||
@@ -479,32 +479,8 @@ func escapeJSON(s string) string {
|
|||||||
|
|
||||||
// getModelCapabilities looks up capabilities from model_configs DB,
|
// getModelCapabilities looks up capabilities from model_configs DB,
|
||||||
// then overlays with known model defaults and heuristic detection.
|
// then overlays with known model defaults and heuristic detection.
|
||||||
func (h *CompletionHandler) getModelCapabilities(model, apiConfigID string) providers.ModelCapabilities {
|
func (h *CompletionHandler) getModelCapabilities(c *gin.Context, model, apiConfigID string) providers.ModelCapabilities {
|
||||||
// Start with known table or heuristic
|
return ResolveModelCaps(c, model, apiConfigID)
|
||||||
// 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Config Resolution ───────────────────────
|
// ── 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 == "" {
|
if configID == "" {
|
||||||
err := database.DB.QueryRow(`
|
err := database.DB.QueryRow(`
|
||||||
SELECT id FROM api_configs
|
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
|
ORDER BY user_id NULLS LAST, created_at ASC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
`, userID).Scan(&configID)
|
`, 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 providerID, endpoint string
|
||||||
var apiKey, modelDefault *string
|
var apiKey, modelDefault *string
|
||||||
var customHeadersJSON, providerSettingsJSON []byte
|
var customHeadersJSON, providerSettingsJSON []byte
|
||||||
err := database.DB.QueryRow(`
|
err := database.DB.QueryRow(`
|
||||||
SELECT provider, endpoint, api_key_encrypted, model_default, custom_headers, provider_settings
|
SELECT provider, endpoint, api_key_encrypted, model_default, custom_headers, provider_settings
|
||||||
FROM api_configs
|
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)
|
`, configID, userID).Scan(&providerID, &endpoint, &apiKey, &modelDefault, &customHeadersJSON, &providerSettingsJSON)
|
||||||
|
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
|
|||||||
@@ -432,7 +432,7 @@ func (h *MessageHandler) Regenerate(c *gin.Context) {
|
|||||||
Messages: llmMessages,
|
Messages: llmMessages,
|
||||||
}
|
}
|
||||||
|
|
||||||
caps := comp.getModelCapabilities(model, configID)
|
caps := comp.getModelCapabilities(c, model, configID)
|
||||||
if maxTokens > 0 {
|
if maxTokens > 0 {
|
||||||
provReq.MaxTokens = maxTokens
|
provReq.MaxTokens = maxTokens
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ func (h *PresetHandler) CreateUserPreset(c *gin.Context) {
|
|||||||
var count int
|
var count int
|
||||||
err := database.DB.QueryRow(`
|
err := database.DB.QueryRow(`
|
||||||
SELECT COUNT(*) FROM api_configs
|
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)
|
`, *req.APIConfigID, userID).Scan(&count)
|
||||||
if err != nil || count == 0 {
|
if err != nil || count == 0 {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid or inaccessible API config"})
|
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
|
var count int
|
||||||
err := database.DB.QueryRow(`
|
err := database.DB.QueryRow(`
|
||||||
SELECT COUNT(*) FROM api_configs
|
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)
|
`, *req.APIConfigID).Scan(&count)
|
||||||
if err != nil || count == 0 {
|
if err != nil || count == 0 {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "global presets must use a global API config"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "global presets must use a global API config"})
|
||||||
|
|||||||
304
server/handlers/team_providers.go
Normal file
304
server/handlers/team_providers.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -2,7 +2,9 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -10,6 +12,7 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||||
|
"git.gobha.me/xcaliber/chat-switchboard/providers"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── Request types ───────────────────────────
|
// ── Request types ───────────────────────────
|
||||||
@@ -212,7 +215,7 @@ func (h *TeamHandler) UpdateTeam(c *gin.Context) {
|
|||||||
argN++
|
argN++
|
||||||
}
|
}
|
||||||
if req.Settings != nil {
|
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)
|
args = append(args, *req.Settings)
|
||||||
argN++
|
argN++
|
||||||
}
|
}
|
||||||
@@ -461,20 +464,7 @@ func (h *TeamHandler) MyTeams(c *gin.Context) {
|
|||||||
// for team admins building presets. Requires RequireTeamAdmin middleware.
|
// for team admins building presets. Requires RequireTeamAdmin middleware.
|
||||||
// GET /api/v1/teams/:teamId/models
|
// GET /api/v1/teams/:teamId/models
|
||||||
func (h *TeamHandler) ListAvailableModels(c *gin.Context) {
|
func (h *TeamHandler) ListAvailableModels(c *gin.Context) {
|
||||||
rows, err := database.DB.Query(`
|
teamID := getTeamID(c)
|
||||||
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()
|
|
||||||
|
|
||||||
type availableModel struct {
|
type availableModel struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
@@ -483,18 +473,89 @@ func (h *TeamHandler) ListAvailableModels(c *gin.Context) {
|
|||||||
Visibility string `json:"visibility"`
|
Visibility string `json:"visibility"`
|
||||||
Provider string `json:"provider"`
|
Provider string `json:"provider"`
|
||||||
ProviderName string `json:"provider_name"`
|
ProviderName string `json:"provider_name"`
|
||||||
|
Source string `json:"source"`
|
||||||
}
|
}
|
||||||
|
|
||||||
models := make([]availableModel, 0)
|
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() {
|
for rows.Next() {
|
||||||
var m availableModel
|
var m availableModel
|
||||||
if err := rows.Scan(&m.ID, &m.ModelID, &m.DisplayName, &m.Visibility,
|
if err := rows.Scan(&m.ID, &m.ModelID, &m.DisplayName, &m.Visibility,
|
||||||
&m.Provider, &m.ProviderName); err != nil {
|
&m.Provider, &m.ProviderName); err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
m.Source = "global"
|
||||||
models = append(models, m)
|
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})
|
c.JSON(http.StatusOK, gin.H{"models": models})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -169,6 +169,13 @@ func main() {
|
|||||||
teamScoped.DELETE("/members/:memberId", teams.RemoveMember)
|
teamScoped.DELETE("/members/:memberId", teams.RemoveMember)
|
||||||
teamScoped.GET("/models", teams.ListAvailableModels)
|
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
|
// Team presets
|
||||||
teamPresets := handlers.NewPresetHandler()
|
teamPresets := handlers.NewPresetHandler()
|
||||||
teamScoped.GET("/presets", teamPresets.ListTeamPresets)
|
teamScoped.GET("/presets", teamPresets.ListTeamPresets)
|
||||||
|
|||||||
@@ -343,6 +343,39 @@
|
|||||||
</div>
|
</div>
|
||||||
<button class="btn-small" id="settingsTeamAddMemberBtn" style="margin-top:6px">+ Add Member</button>
|
<button class="btn-small" id="settingsTeamAddMemberBtn" style="margin-top:6px">+ Add Member</button>
|
||||||
</section>
|
</section>
|
||||||
|
<!-- Team Providers -->
|
||||||
|
<section class="settings-section">
|
||||||
|
<h4 style="font-size:13px;margin-bottom:4px">Providers</h4>
|
||||||
|
<div id="settingsTeamProviders"></div>
|
||||||
|
<div id="settingsTeamEditProvider" style="display:none;margin-top:8px" class="admin-inline-form">
|
||||||
|
<div class="form-grid-2col">
|
||||||
|
<div class="form-group"><label>Name</label><input id="teamProviderEditName"></div>
|
||||||
|
<div class="form-group"><label>Endpoint</label><input id="teamProviderEditEndpoint"></div>
|
||||||
|
<div class="form-group"><label>API Key</label><input id="teamProviderEditKey" type="password" placeholder="(unchanged)"></div>
|
||||||
|
<div class="form-group"><label>Default Model</label><input id="teamProviderEditDefault" placeholder="optional"></div>
|
||||||
|
</div>
|
||||||
|
<div style="margin:6px 0"><label><input type="checkbox" id="teamProviderEditPrivate"> Private provider (data stays on-prem)</label></div>
|
||||||
|
<div class="form-row">
|
||||||
|
<button class="btn-small btn-primary" id="settingsTeamEditProviderSubmit">Update</button>
|
||||||
|
<button class="btn-small" id="settingsTeamCancelEditProvider">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="settingsTeamAddProvider" style="display:none;margin-top:8px" class="admin-inline-form">
|
||||||
|
<div class="form-grid-2col">
|
||||||
|
<div class="form-group"><label>Name</label><input id="teamProviderName" placeholder="e.g. Team OpenAI"></div>
|
||||||
|
<div class="form-group"><label>Provider</label><select id="teamProviderType"></select></div>
|
||||||
|
<div class="form-group"><label>Endpoint</label><input id="teamProviderEndpoint" placeholder="https://api.openai.com/v1"></div>
|
||||||
|
<div class="form-group"><label>API Key</label><input id="teamProviderKey" type="password" placeholder="sk-..."></div>
|
||||||
|
</div>
|
||||||
|
<div style="margin:6px 0"><label><input type="checkbox" id="teamProviderPrivate"> Private provider (data stays on-prem)</label></div>
|
||||||
|
<div class="form-row">
|
||||||
|
<button class="btn-small btn-primary" id="settingsTeamAddProviderSubmit">Add Provider</button>
|
||||||
|
<button class="btn-small" id="settingsTeamCancelProvider">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn-small" id="settingsTeamAddProviderBtn" style="margin-top:6px">+ Add Provider</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Team Presets -->
|
<!-- Team Presets -->
|
||||||
<section class="settings-section">
|
<section class="settings-section">
|
||||||
<h4 style="font-size:13px;margin-bottom:6px">Team Presets</h4>
|
<h4 style="font-size:13px;margin-bottom:6px">Team Presets</h4>
|
||||||
@@ -444,6 +477,8 @@
|
|||||||
<section class="settings-section" style="margin-bottom:12px;padding:10px;background:rgba(255,255,255,0.03);border-radius:8px">
|
<section class="settings-section" style="margin-bottom:12px;padding:10px;background:rgba(255,255,255,0.03);border-radius:8px">
|
||||||
<label class="checkbox-label"><input type="checkbox" id="adminTeamPrivatePolicy"> Require private providers (data stays on-prem)</label>
|
<label class="checkbox-label"><input type="checkbox" id="adminTeamPrivatePolicy"> Require private providers (data stays on-prem)</label>
|
||||||
<p class="section-hint" style="margin:4px 0 0">Team members can only use providers marked as "private".</p>
|
<p class="section-hint" style="margin:4px 0 0">Team members can only use providers marked as "private".</p>
|
||||||
|
<label class="checkbox-label" style="margin-top:8px"><input type="checkbox" id="adminTeamAllowProviders" checked> Allow team admins to manage providers</label>
|
||||||
|
<p class="section-hint" style="margin:4px 0 0">When disabled, team admins cannot add or modify team providers.</p>
|
||||||
</section>
|
</section>
|
||||||
<div class="admin-toolbar">
|
<div class="admin-toolbar">
|
||||||
<button class="btn-small btn-primary" id="adminAddMemberBtn">+ Add Member</button>
|
<button class="btn-small btn-primary" id="adminAddMemberBtn">+ Add Member</button>
|
||||||
|
|||||||
@@ -341,6 +341,13 @@ const API = {
|
|||||||
teamDeletePreset(teamId, presetId) { return this._del(`/api/v1/teams/${teamId}/presets/${presetId}`); },
|
teamDeletePreset(teamId, presetId) { return this._del(`/api/v1/teams/${teamId}/presets/${presetId}`); },
|
||||||
teamListAvailableModels(teamId) { return this._get(`/api/v1/teams/${teamId}/models`); },
|
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 ─────────────────────────
|
// ── User Presets ─────────────────────────
|
||||||
listPresets() { return this._get('/api/v1/presets'); },
|
listPresets() { return this._get('/api/v1/presets'); },
|
||||||
createPreset(preset) { return this._post('/api/v1/presets', preset); },
|
createPreset(preset) { return this._post('/api/v1/presets', preset); },
|
||||||
|
|||||||
145
src/js/app.js
145
src/js/app.js
@@ -11,6 +11,16 @@ const App = {
|
|||||||
abortController: null,
|
abortController: null,
|
||||||
serverSettings: {},
|
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: {
|
settings: {
|
||||||
model: '',
|
model: '',
|
||||||
stream: true,
|
stream: true,
|
||||||
@@ -211,8 +221,12 @@ async function fetchModels() {
|
|||||||
|
|
||||||
App.models = (data.models || []).map(m => {
|
App.models = (data.models || []).map(m => {
|
||||||
const isPreset = !!m.is_preset;
|
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;
|
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 {
|
return {
|
||||||
id,
|
id,
|
||||||
baseModelId,
|
baseModelId,
|
||||||
@@ -225,6 +239,8 @@ async function fetchModels() {
|
|||||||
presetScope: m.preset_scope || null,
|
presetScope: m.preset_scope || null,
|
||||||
presetAvatar: m.preset_avatar || null,
|
presetAvatar: m.preset_avatar || null,
|
||||||
presetTeamName: m.preset_team_name || 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),
|
hidden: !isPreset && App.hiddenModels.has(baseModelId),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -337,7 +353,7 @@ async function sendMessage() {
|
|||||||
input.style.height = 'auto';
|
input.style.height = 'auto';
|
||||||
|
|
||||||
const selectedId = UI.getModelValue();
|
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
|
// For presets, send the base model ID; for regular models, send the model ID
|
||||||
const model = modelInfo?.baseModelId || selectedId;
|
const model = modelInfo?.baseModelId || selectedId;
|
||||||
const presetId = modelInfo?.isPreset ? modelInfo.presetId : null;
|
const presetId = modelInfo?.isPreset ? modelInfo.presetId : null;
|
||||||
@@ -430,7 +446,7 @@ async function regenerateMessage(messageId) {
|
|||||||
if (App.isGenerating || !App.currentChatId) return;
|
if (App.isGenerating || !App.currentChatId) return;
|
||||||
|
|
||||||
const selectedId = UI.getModelValue();
|
const selectedId = UI.getModelValue();
|
||||||
const modelInfo = App.models.find(m => m.id === selectedId);
|
const modelInfo = App.findModel(selectedId);
|
||||||
const model = modelInfo?.baseModelId || selectedId;
|
const model = modelInfo?.baseModelId || selectedId;
|
||||||
const presetId = modelInfo?.isPreset ? modelInfo.presetId : null;
|
const presetId = modelInfo?.isPreset ? modelInfo.presetId : null;
|
||||||
|
|
||||||
@@ -501,7 +517,7 @@ async function submitEdit(messageId, newContent) {
|
|||||||
if (!newContent.trim()) return;
|
if (!newContent.trim()) return;
|
||||||
|
|
||||||
const selectedId = UI.getModelValue();
|
const selectedId = UI.getModelValue();
|
||||||
const modelInfo = App.models.find(m => m.id === selectedId);
|
const modelInfo = App.findModel(selectedId);
|
||||||
const model = modelInfo?.baseModelId || selectedId;
|
const model = modelInfo?.baseModelId || selectedId;
|
||||||
const presetId = modelInfo?.isPreset ? modelInfo.presetId : null;
|
const presetId = modelInfo?.isPreset ? modelInfo.presetId : null;
|
||||||
|
|
||||||
@@ -876,7 +892,7 @@ function initListeners() {
|
|||||||
tab.addEventListener('click', () => UI.switchSettingsTab(tab.dataset.stab));
|
tab.addEventListener('click', () => UI.switchSettingsTab(tab.dataset.stab));
|
||||||
});
|
});
|
||||||
document.getElementById('settingsModel').addEventListener('change', function() {
|
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 caps = model?.capabilities || lookupKnownCaps(this.value) || {};
|
||||||
const hint = document.getElementById('settingsMaxHint');
|
const hint = document.getElementById('settingsMaxHint');
|
||||||
if (hint) {
|
if (hint) {
|
||||||
@@ -1030,6 +1046,14 @@ function initListeners() {
|
|||||||
UI.toast(this.checked ? 'Private providers required' : 'Private provider policy removed');
|
UI.toast(this.checked ? 'Private providers required' : 'Private provider policy removed');
|
||||||
} catch (e) { UI.toast(e.message, 'error'); this.checked = !this.checked; }
|
} 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('adminCancelMemberBtn')?.addEventListener('click', () => {
|
||||||
document.getElementById('adminAddMemberForm').style.display = 'none';
|
document.getElementById('adminAddMemberForm').style.display = 'none';
|
||||||
});
|
});
|
||||||
@@ -1100,6 +1124,86 @@ function initListeners() {
|
|||||||
UI.loadTeamPresetModelDropdown(UI._managingTeamId);
|
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
|
// User — personal presets
|
||||||
var _userPresetForm = null;
|
var _userPresetForm = null;
|
||||||
document.getElementById('userAddPresetBtn')?.addEventListener('click', () => {
|
document.getElementById('userAddPresetBtn')?.addEventListener('click', () => {
|
||||||
@@ -1876,6 +1980,37 @@ async function settingsDeleteTeamPreset(teamId, presetId, name) {
|
|||||||
} catch (e) { UI.toast(e.message, 'error'); }
|
} 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 ─────────────────────────────
|
// ── Notes Panel ─────────────────────────────
|
||||||
|
|
||||||
var _editingNoteId = null;
|
var _editingNoteId = null;
|
||||||
|
|||||||
100
src/js/ui.js
100
src/js/ui.js
@@ -389,7 +389,7 @@ const UI = {
|
|||||||
document.getElementById('typingIndicator')?.remove();
|
document.getElementById('typingIndicator')?.remove();
|
||||||
|
|
||||||
const label = modelName || 'Assistant';
|
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 streamAvatar = currentModel?.presetAvatar || null;
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.className = 'message assistant';
|
div.className = 'message assistant';
|
||||||
@@ -557,10 +557,14 @@ const UI = {
|
|||||||
});
|
});
|
||||||
|
|
||||||
addGroup('🔧 My Presets', personalPresets);
|
addGroup('🔧 My Presets', personalPresets);
|
||||||
addGroup('Models', models);
|
|
||||||
|
|
||||||
// Restore selection
|
// No team model groups — team providers are for preset building only.
|
||||||
const match = App.models.find(m => m.id === current);
|
// 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) {
|
if (match) {
|
||||||
UI.setModelValue(match.id, match.name + (match.provider ? ` (${match.provider})` : ''));
|
UI.setModelValue(match.id, match.name + (match.provider ? ` (${match.provider})` : ''));
|
||||||
} else if (App.models.length > 0) {
|
} else if (App.models.length > 0) {
|
||||||
@@ -588,7 +592,8 @@ const UI = {
|
|||||||
div.className = 'model-dropdown-item';
|
div.className = 'model-dropdown-item';
|
||||||
div.dataset.value = m.id;
|
div.dataset.value = m.id;
|
||||||
const avatar = m.presetAvatar ? `<img src="${m.presetAvatar}" class="dropdown-avatar" alt="">` : '';
|
const avatar = m.presetAvatar ? `<img src="${m.presetAvatar}" class="dropdown-avatar" alt="">` : '';
|
||||||
div.innerHTML = `${avatar}<span class="item-label">${esc(m.name || m.id)}</span>${m.provider ? `<span class="item-provider">${esc(m.provider)}</span>` : ''}`;
|
const teamBadge = m.source === 'team' && m.teamName ? `<span class="badge-team" style="font-size:9px;padding:0 4px">👥 ${esc(m.teamName)}</span>` : '';
|
||||||
|
div.innerHTML = `${avatar}<span class="item-label">${esc(m.name || m.id)}</span>${teamBadge}${m.provider ? `<span class="item-provider">${esc(m.provider)}</span>` : ''}`;
|
||||||
div.addEventListener('click', () => {
|
div.addEventListener('click', () => {
|
||||||
UI.setModelValue(m.id, (m.name || m.id) + (m.provider ? ` (${m.provider})` : ''));
|
UI.setModelValue(m.id, (m.name || m.id) + (m.provider ? ` (${m.provider})` : ''));
|
||||||
UI._closeModelDropdown();
|
UI._closeModelDropdown();
|
||||||
@@ -619,7 +624,7 @@ const UI = {
|
|||||||
getSelectedModelCaps() {
|
getSelectedModelCaps() {
|
||||||
const modelId = UI.getModelValue();
|
const modelId = UI.getModelValue();
|
||||||
if (!modelId) return {};
|
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
|
// Model in list has resolved caps; fallback to client-side lookup
|
||||||
if (model?.capabilities && Object.keys(model.capabilities).length > 0) {
|
if (model?.capabilities && Object.keys(model.capabilities).length > 0) {
|
||||||
return model.capabilities;
|
return model.capabilities;
|
||||||
@@ -708,7 +713,7 @@ const UI = {
|
|||||||
document.getElementById('sendBtn').disabled = on;
|
document.getElementById('sendBtn').disabled = on;
|
||||||
if (on) {
|
if (on) {
|
||||||
const container = document.getElementById('chatMessages');
|
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 typingAvatar = currentModel?.presetAvatar || null;
|
||||||
const typing = document.createElement('div');
|
const typing = document.createElement('div');
|
||||||
typing.id = 'typingIndicator';
|
typing.id = 'typingIndicator';
|
||||||
@@ -921,7 +926,13 @@ const UI = {
|
|||||||
document.getElementById('settingsTeamManageName').textContent = teamName;
|
document.getElementById('settingsTeamManageName').textContent = teamName;
|
||||||
document.getElementById('settingsTeamAddMember').style.display = 'none';
|
document.getElementById('settingsTeamAddMember').style.display = 'none';
|
||||||
document.getElementById('settingsTeamAddPreset').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) {
|
async loadTeamManageMembers(teamId) {
|
||||||
@@ -948,6 +959,42 @@ const UI = {
|
|||||||
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
|
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async loadTeamManageProviders(teamId) {
|
||||||
|
const el = document.getElementById('settingsTeamProviders');
|
||||||
|
if (!el) return;
|
||||||
|
el.innerHTML = '<div class="loading">Loading...</div>';
|
||||||
|
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 = '<div class="empty-hint">Team provider management is disabled by your administrator</div>';
|
||||||
|
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 ? ' <span class="badge-user" style="font-size:9px">🔒 private</span>' : '';
|
||||||
|
return `<div class="admin-preset-row" style="padding:6px 0">
|
||||||
|
<div class="preset-info">
|
||||||
|
<strong>${esc(p.name)}</strong>
|
||||||
|
<div class="preset-meta" style="font-size:11px">${esc(p.provider)} · ${p.has_key ? '🔑' : 'no key'} <span class="${statusCls}" style="font-size:10px">${statusLabel}</span>${privateBadge}</div>
|
||||||
|
</div>
|
||||||
|
<div class="preset-actions">
|
||||||
|
<button class="btn-small" onclick="settingsEditTeamProvider('${teamId}','${p.id}','${esc(p.name)}','${esc(p.endpoint || '')}','${esc(p.model_default || '')}',${!!p.is_private})" title="Edit">✎</button>
|
||||||
|
<button class="admin-model-toggle ${p.is_active ? 'enabled' : ''}" onclick="settingsToggleTeamProvider('${teamId}','${p.id}',${p.is_active})">${p.is_active ? '✓ Active' : 'Inactive'}</button>
|
||||||
|
<button class="btn-delete" onclick="settingsDeleteTeamProvider('${teamId}','${p.id}','${esc(p.name)}')" title="Delete">✕</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}).join('') || '<div class="empty-hint">No team providers — add one to give your team access to additional models</div>';
|
||||||
|
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
|
||||||
|
},
|
||||||
|
|
||||||
async loadTeamManagePresets(teamId) {
|
async loadTeamManagePresets(teamId) {
|
||||||
const el = document.getElementById('settingsTeamPresets');
|
const el = document.getElementById('settingsTeamPresets');
|
||||||
el.innerHTML = '<div class="loading">Loading...</div>';
|
el.innerHTML = '<div class="loading">Loading...</div>';
|
||||||
@@ -975,13 +1022,27 @@ const UI = {
|
|||||||
const resp = await API.teamListAvailableModels(teamId);
|
const resp = await API.teamListAvailableModels(teamId);
|
||||||
const models = resp.models || [];
|
const models = resp.models || [];
|
||||||
sel.innerHTML = '<option value="">Select base model...</option>';
|
sel.innerHTML = '<option value="">Select base model...</option>';
|
||||||
models.forEach(m => {
|
|
||||||
const opt = document.createElement('option');
|
// Group by source for clarity
|
||||||
opt.value = m.model_id || m.id;
|
const globalModels = models.filter(m => m.source !== 'team');
|
||||||
const vis = m.visibility === 'team' ? ' [team-only]' : '';
|
const teamModels = models.filter(m => m.source === 'team');
|
||||||
opt.textContent = (m.model_id || m.id) + (m.provider_name ? ` (${m.provider_name})` : '') + vis;
|
|
||||||
sel.appendChild(opt);
|
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) {
|
} catch (e) {
|
||||||
// Fallback to App.models if team endpoint fails
|
// Fallback to App.models if team endpoint fails
|
||||||
sel.innerHTML = '<option value="">Select base model...</option>';
|
sel.innerHTML = '<option value="">Select base model...</option>';
|
||||||
@@ -1350,12 +1411,14 @@ const UI = {
|
|||||||
document.getElementById('adminTeamDetailName').textContent = teamName;
|
document.getElementById('adminTeamDetailName').textContent = teamName;
|
||||||
document.getElementById('adminAddMemberForm').style.display = 'none';
|
document.getElementById('adminAddMemberForm').style.display = 'none';
|
||||||
|
|
||||||
// Load team settings for policy checkbox
|
// Load team settings for policy checkboxes
|
||||||
try {
|
try {
|
||||||
const team = await API.adminGetTeam(teamId);
|
const team = await API.adminGetTeam(teamId);
|
||||||
const settings = typeof team.settings === 'string' ? JSON.parse(team.settings || '{}') : (team.settings || {});
|
const settings = typeof team.settings === 'string' ? JSON.parse(team.settings || '{}') : (team.settings || {});
|
||||||
document.getElementById('adminTeamPrivatePolicy').checked = !!settings.require_private_providers;
|
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);
|
await this.loadTeamMembers(teamId);
|
||||||
},
|
},
|
||||||
@@ -1493,7 +1556,8 @@ const UI = {
|
|||||||
if (caps.tool_calling) badges.push('<span class="cap-badge cap-accent">🔧</span>');
|
if (caps.tool_calling) badges.push('<span class="cap-badge cap-accent">🔧</span>');
|
||||||
if (caps.vision) badges.push('<span class="cap-badge cap-accent">👁</span>');
|
if (caps.vision) badges.push('<span class="cap-badge cap-accent">👁</span>');
|
||||||
if (caps.thinking) badges.push('<span class="cap-badge cap-accent">💭</span>');
|
if (caps.thinking) badges.push('<span class="cap-badge cap-accent">💭</span>');
|
||||||
const src = m.source === 'personal' ? '<span class="badge-user" style="font-size:10px">personal</span>' : '';
|
const src = m.source === 'personal' ? '<span class="badge-user" style="font-size:10px">personal</span>'
|
||||||
|
: m.source === 'team' ? `<span class="badge-team" style="font-size:10px">👥 ${esc(m.team_name || 'team')}</span>` : '';
|
||||||
const hidden = App.hiddenModels.has(mid);
|
const hidden = App.hiddenModels.has(mid);
|
||||||
const toggleCls = hidden ? '' : 'enabled';
|
const toggleCls = hidden ? '' : 'enabled';
|
||||||
const toggleLabel = hidden ? 'Hidden' : '✓ Visible';
|
const toggleLabel = hidden ? 'Hidden' : '✓ Visible';
|
||||||
|
|||||||
Reference in New Issue
Block a user