Changeset 0.8.6 (#49)
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
|
||||
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
|
||||
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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"})
|
||||
|
||||
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 (
|
||||
"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})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user