Changeset 0.9.0 (#50)
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -1,27 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewSettingsHandler(t *testing.T) {
|
||||
h := NewSettingsHandler()
|
||||
if h == nil {
|
||||
t.Fatal("NewSettingsHandler returned nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewAdminHandler(t *testing.T) {
|
||||
h := NewAdminHandler()
|
||||
if h == nil {
|
||||
t.Fatal("NewAdminHandler returned nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsRegistrationEnabledDefaultsTrue(t *testing.T) {
|
||||
// Without a database connection, should default to true
|
||||
enabled := IsRegistrationEnabled()
|
||||
if !enabled {
|
||||
t.Error("Expected registration enabled by default when no DB")
|
||||
}
|
||||
}
|
||||
@@ -1,646 +1,243 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"math"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/providers"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
)
|
||||
|
||||
// ── Request / Response Types ────────────────
|
||||
|
||||
type createAPIConfigRequest struct {
|
||||
Name string `json:"name" binding:"required,max=100"`
|
||||
Provider string `json:"provider" binding:"required"`
|
||||
Endpoint string `json:"endpoint" binding:"required"`
|
||||
APIKey string `json:"api_key"`
|
||||
ModelDefault string `json:"model_default,omitempty"`
|
||||
Config map[string]interface{} `json:"config,omitempty"`
|
||||
IsPrivate bool `json:"is_private,omitempty"`
|
||||
// ProviderConfigHandler handles user-facing provider config endpoints.
|
||||
type ProviderConfigHandler struct {
|
||||
stores store.Stores
|
||||
}
|
||||
|
||||
type updateAPIConfigRequest struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Endpoint *string `json:"endpoint,omitempty"`
|
||||
APIKey *string `json:"api_key,omitempty"`
|
||||
ModelDefault *string `json:"model_default,omitempty"`
|
||||
Config map[string]interface{} `json:"config,omitempty"`
|
||||
IsActive *bool `json:"is_active,omitempty"`
|
||||
IsPrivate *bool `json:"is_private,omitempty"`
|
||||
func NewProviderConfigHandler(s store.Stores) *ProviderConfigHandler {
|
||||
return &ProviderConfigHandler{stores: s}
|
||||
}
|
||||
|
||||
type apiConfigResponse struct {
|
||||
ID string `json:"id"`
|
||||
UserID *string `json:"user_id"`
|
||||
Name string `json:"name"`
|
||||
Provider string `json:"provider"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
HasKey bool `json:"has_key"` // Never expose the actual key
|
||||
ModelDefault *string `json:"model_default"`
|
||||
Config map[string]interface{} `json:"config"`
|
||||
IsActive bool `json:"is_active"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
// APIConfigHandler holds dependencies.
|
||||
type APIConfigHandler struct{}
|
||||
|
||||
// NewAPIConfigHandler creates a new handler.
|
||||
func NewAPIConfigHandler() *APIConfigHandler {
|
||||
return &APIConfigHandler{}
|
||||
}
|
||||
|
||||
// ── List API Configs ────────────────────────
|
||||
|
||||
func (h *APIConfigHandler) ListConfigs(c *gin.Context) {
|
||||
// ListConfigs returns configs accessible to the user (global + personal + team).
|
||||
func (h *ProviderConfigHandler) ListConfigs(c *gin.Context) {
|
||||
userID := getUserID(c)
|
||||
page, perPage, offset := parsePagination(c)
|
||||
|
||||
// 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) AND team_id IS NULL`,
|
||||
userID,
|
||||
).Scan(&total)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to count configs"})
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := database.DB.Query(`
|
||||
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) AND team_id IS NULL
|
||||
ORDER BY user_id NULLS LAST, name ASC
|
||||
LIMIT $2 OFFSET $3
|
||||
`, userID, perPage, offset)
|
||||
// User settings → Providers shows only personal (BYOK) configs.
|
||||
// Global/team providers are managed by admins and surfaced via the model list.
|
||||
cfgs, err := h.stores.Providers.ListForUser(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list configs"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
configs := make([]apiConfigResponse, 0)
|
||||
for rows.Next() {
|
||||
cfg, err := scanAPIConfig(rows)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to scan config"})
|
||||
return
|
||||
// Mask API keys
|
||||
type safeConfig 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,omitempty"`
|
||||
Scope string `json:"scope"`
|
||||
IsActive bool `json:"is_active"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
out := make([]safeConfig, len(cfgs))
|
||||
for i, cfg := range cfgs {
|
||||
out[i] = safeConfig{
|
||||
ID: cfg.ID,
|
||||
Name: cfg.Name,
|
||||
Provider: cfg.Provider,
|
||||
Endpoint: cfg.Endpoint,
|
||||
HasKey: cfg.APIKeyEnc != "",
|
||||
ModelDefault: cfg.ModelDefault,
|
||||
Scope: cfg.Scope,
|
||||
IsActive: cfg.IsActive,
|
||||
CreatedAt: cfg.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
UpdatedAt: cfg.UpdatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
}
|
||||
configs = append(configs, cfg)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, paginatedResponse{
|
||||
Data: configs,
|
||||
Page: page,
|
||||
PerPage: perPage,
|
||||
Total: total,
|
||||
TotalPages: int(math.Ceil(float64(total) / float64(perPage))),
|
||||
})
|
||||
c.JSON(http.StatusOK, gin.H{"configs": out})
|
||||
}
|
||||
|
||||
// ── Create API Config ───────────────────────
|
||||
|
||||
func (h *APIConfigHandler) CreateConfig(c *gin.Context) {
|
||||
// GetConfig returns a single config by ID (if user has access).
|
||||
func (h *ProviderConfigHandler) GetConfig(c *gin.Context) {
|
||||
userID := getUserID(c)
|
||||
id := c.Param("id")
|
||||
|
||||
var req createAPIConfigRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate provider exists
|
||||
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 cfg apiConfigResponse
|
||||
var apiKeyEnc *string
|
||||
var configRaw string
|
||||
|
||||
err := database.DB.QueryRow(`
|
||||
INSERT INTO api_configs (user_id, name, provider, endpoint, api_key_encrypted, model_default, config)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb)
|
||||
RETURNING id, user_id, name, provider, endpoint, api_key_encrypted,
|
||||
model_default, config::text, is_active, created_at, updated_at
|
||||
`, userID, req.Name, req.Provider, req.Endpoint, req.APIKey, req.ModelDefault, configJSON,
|
||||
).Scan(
|
||||
&cfg.ID, &cfg.UserID, &cfg.Name, &cfg.Provider, &cfg.Endpoint, &apiKeyEnc,
|
||||
&cfg.ModelDefault, &configRaw, &cfg.IsActive, &cfg.CreatedAt, &cfg.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create config"})
|
||||
return
|
||||
}
|
||||
|
||||
cfg.HasKey = apiKeyEnc != nil && *apiKeyEnc != ""
|
||||
cfg.Config = parseJSONBConfig(configRaw)
|
||||
|
||||
c.JSON(http.StatusCreated, cfg)
|
||||
}
|
||||
|
||||
// ── Get API Config ──────────────────────────
|
||||
|
||||
func (h *APIConfigHandler) GetConfig(c *gin.Context) {
|
||||
userID := getUserID(c)
|
||||
configID := c.Param("id")
|
||||
|
||||
row := database.DB.QueryRow(`
|
||||
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) AND team_id IS NULL
|
||||
`, configID, userID)
|
||||
|
||||
var cfg apiConfigResponse
|
||||
var apiKeyEnc *string
|
||||
var configRaw string
|
||||
|
||||
err := row.Scan(
|
||||
&cfg.ID, &cfg.UserID, &cfg.Name, &cfg.Provider, &cfg.Endpoint, &apiKeyEnc,
|
||||
&cfg.ModelDefault, &configRaw, &cfg.IsActive, &cfg.CreatedAt, &cfg.UpdatedAt,
|
||||
)
|
||||
if err == sql.ErrNoRows {
|
||||
if ok, _ := h.stores.Providers.UserCanAccess(c.Request.Context(), userID, id); !ok {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "config not found"})
|
||||
return
|
||||
}
|
||||
|
||||
cfg, err := h.stores.Providers.GetByID(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get config"})
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "config not found"})
|
||||
return
|
||||
}
|
||||
|
||||
cfg.HasKey = apiKeyEnc != nil && *apiKeyEnc != ""
|
||||
cfg.Config = parseJSONBConfig(configRaw)
|
||||
|
||||
c.JSON(http.StatusOK, cfg)
|
||||
}
|
||||
|
||||
// ── Update API Config ───────────────────────
|
||||
|
||||
func (h *APIConfigHandler) UpdateConfig(c *gin.Context) {
|
||||
// CreateConfig creates a personal provider config (BYOK).
|
||||
// After creation, automatically fetches models from the provider API and enables them.
|
||||
func (h *ProviderConfigHandler) CreateConfig(c *gin.Context) {
|
||||
userID := getUserID(c)
|
||||
configID := c.Param("id")
|
||||
|
||||
var req updateAPIConfigRequest
|
||||
// Check policy
|
||||
allowed, _ := h.stores.Policies.GetBool(c.Request.Context(), "allow_user_byok")
|
||||
if !allowed {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "personal API keys not allowed"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Provider string `json:"provider" binding:"required"`
|
||||
Endpoint string `json:"endpoint" binding:"required"`
|
||||
APIKey string `json:"api_key"`
|
||||
ModelDefault string `json:"model_default,omitempty"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Verify ownership (only owner can update, not global)
|
||||
var ownerID *string
|
||||
err := database.DB.QueryRow(`SELECT user_id FROM api_configs WHERE id = $1`, configID).Scan(&ownerID)
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "config not found"})
|
||||
return
|
||||
cfg := &models.ProviderConfig{
|
||||
Name: req.Name,
|
||||
Provider: req.Provider,
|
||||
Endpoint: req.Endpoint,
|
||||
APIKeyEnc: req.APIKey, // TODO: encrypt
|
||||
ModelDefault: req.ModelDefault,
|
||||
Scope: models.ScopePersonal,
|
||||
OwnerID: &userID,
|
||||
IsActive: true,
|
||||
}
|
||||
if ownerID == nil || *ownerID != userID {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "cannot modify global or other user's config"})
|
||||
|
||||
if err := h.stores.Providers.Create(c.Request.Context(), cfg); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create config"})
|
||||
return
|
||||
}
|
||||
|
||||
// Dynamic update
|
||||
setClauses := []string{}
|
||||
args := []interface{}{}
|
||||
argN := 1
|
||||
|
||||
addClause := func(col string, val interface{}) {
|
||||
setClauses = append(setClauses, col+" = $"+strconv.Itoa(argN))
|
||||
args = append(args, val)
|
||||
argN++
|
||||
// Auto-fetch models from the provider API and enable them.
|
||||
// The user added this key to USE it — don't make them hunt for a fetch button.
|
||||
resp := gin.H{
|
||||
"id": cfg.ID,
|
||||
"name": cfg.Name,
|
||||
"provider": cfg.Provider,
|
||||
"endpoint": cfg.Endpoint,
|
||||
"scope": cfg.Scope,
|
||||
}
|
||||
|
||||
if req.Name != nil {
|
||||
addClause("name", *req.Name)
|
||||
}
|
||||
if req.Endpoint != nil {
|
||||
addClause("endpoint", *req.Endpoint)
|
||||
}
|
||||
if req.APIKey != nil {
|
||||
addClause("api_key_encrypted", *req.APIKey)
|
||||
}
|
||||
if req.ModelDefault != nil {
|
||||
addClause("model_default", *req.ModelDefault)
|
||||
}
|
||||
if req.Config != nil {
|
||||
b, _ := json.Marshal(req.Config)
|
||||
addClause("config", string(b))
|
||||
}
|
||||
if req.IsActive != nil {
|
||||
addClause("is_active", *req.IsActive)
|
||||
}
|
||||
|
||||
if len(setClauses) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"})
|
||||
return
|
||||
}
|
||||
|
||||
query := "UPDATE api_configs SET updated_at = NOW(), "
|
||||
for i, clause := range setClauses {
|
||||
if i > 0 {
|
||||
query += ", "
|
||||
}
|
||||
query += clause
|
||||
}
|
||||
query += " WHERE id = $" + strconv.Itoa(argN)
|
||||
args = append(args, configID)
|
||||
|
||||
_, err = database.DB.Exec(query, args...)
|
||||
result, err := syncAndEnableProviderModels(c.Request.Context(), h.stores, cfg)
|
||||
if err != nil {
|
||||
// Provider created successfully but model fetch failed.
|
||||
// Return 201 (provider exists) with a warning so the frontend can show it.
|
||||
resp["warning"] = "Provider created but model fetch failed: " + err.Error()
|
||||
resp["models_fetched"] = 0
|
||||
log.Printf("warn: BYOK auto-fetch for %s (%s) failed: %v", cfg.ID, cfg.Provider, err)
|
||||
} else {
|
||||
resp["models_fetched"] = result.Total
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, resp)
|
||||
}
|
||||
|
||||
// UpdateConfig updates a personal provider config.
|
||||
func (h *ProviderConfigHandler) UpdateConfig(c *gin.Context) {
|
||||
userID := getUserID(c)
|
||||
id := c.Param("id")
|
||||
|
||||
existing, err := h.stores.Providers.GetByID(c.Request.Context(), id)
|
||||
if err != nil || existing.Scope != models.ScopePersonal || existing.OwnerID == nil || *existing.OwnerID != userID {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "can only update your own configs"})
|
||||
return
|
||||
}
|
||||
|
||||
// Bind standard fields
|
||||
var req struct {
|
||||
models.ProviderConfigPatch
|
||||
APIKey *string `json:"api_key,omitempty"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
patch := req.ProviderConfigPatch
|
||||
// Transfer api_key → APIKeyEnc (json:"-" prevents auto-binding)
|
||||
if req.APIKey != nil && *req.APIKey != "" {
|
||||
patch.APIKeyEnc = req.APIKey // TODO: encrypt
|
||||
}
|
||||
|
||||
if err := h.stores.Providers.Update(c.Request.Context(), id, patch); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update config"})
|
||||
return
|
||||
}
|
||||
|
||||
h.GetConfig(c)
|
||||
c.JSON(http.StatusOK, gin.H{"message": "config updated"})
|
||||
}
|
||||
|
||||
// ── Delete API Config ───────────────────────
|
||||
|
||||
func (h *APIConfigHandler) DeleteConfig(c *gin.Context) {
|
||||
// DeleteConfig deletes a personal provider config.
|
||||
func (h *ProviderConfigHandler) DeleteConfig(c *gin.Context) {
|
||||
userID := getUserID(c)
|
||||
configID := c.Param("id")
|
||||
id := c.Param("id")
|
||||
|
||||
// Only allow deleting own configs
|
||||
result, err := database.DB.Exec(
|
||||
`DELETE FROM api_configs WHERE id = $1 AND user_id = $2`,
|
||||
configID, userID,
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete config"})
|
||||
existing, err := h.stores.Providers.GetByID(c.Request.Context(), id)
|
||||
if err != nil || existing.Scope != models.ScopePersonal || existing.OwnerID == nil || *existing.OwnerID != userID {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "can only delete your own configs"})
|
||||
return
|
||||
}
|
||||
rows, _ := result.RowsAffected()
|
||||
if rows == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "config not found or cannot delete global config"})
|
||||
|
||||
h.stores.Catalog.DeleteForProvider(c.Request.Context(), id)
|
||||
if err := h.stores.Providers.Delete(c.Request.Context(), id); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete config"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "config deleted"})
|
||||
}
|
||||
|
||||
// ── List Models from a Config ───────────────
|
||||
// ListModels returns models for a specific provider config.
|
||||
func (h *ProviderConfigHandler) ListModels(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
|
||||
func (h *APIConfigHandler) ListModels(c *gin.Context) {
|
||||
entries, err := h.stores.Catalog.ListForProvider(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list models"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"models": entries})
|
||||
}
|
||||
|
||||
// FetchModels fetches models from the provider API and auto-enables them.
|
||||
// This is the user-facing equivalent of admin POST /models/fetch.
|
||||
// Allows refreshing models for existing BYOK providers.
|
||||
func (h *ProviderConfigHandler) FetchModels(c *gin.Context) {
|
||||
userID := getUserID(c)
|
||||
configID := c.Param("id")
|
||||
id := c.Param("id")
|
||||
|
||||
// Load config including API key
|
||||
var providerID, endpoint string
|
||||
var apiKey *string
|
||||
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 team_id IS NULL AND is_active = true
|
||||
`, configID, userID).Scan(&providerID, &endpoint, &apiKey)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "config not found"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load config"})
|
||||
cfg, err := h.stores.Providers.GetByID(c.Request.Context(), id)
|
||||
if err != nil || cfg.Scope != models.ScopePersonal || cfg.OwnerID == nil || *cfg.OwnerID != userID {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "provider not found"})
|
||||
return
|
||||
}
|
||||
|
||||
provider, err := providers.Get(providerID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
key := ""
|
||||
if apiKey != nil {
|
||||
key = *apiKey
|
||||
}
|
||||
|
||||
models, err := provider.ListModels(c.Request.Context(), providers.ProviderConfig{
|
||||
Endpoint: endpoint,
|
||||
APIKey: key,
|
||||
})
|
||||
result, err := syncAndEnableProviderModels(c.Request.Context(), h.stores, cfg)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "failed to fetch models: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"config_id": configID,
|
||||
"provider": providerID,
|
||||
"models": models,
|
||||
"message": "models synced",
|
||||
"added": result.Added,
|
||||
"updated": result.Updated,
|
||||
"total": result.Total,
|
||||
})
|
||||
}
|
||||
|
||||
// ── List All Available Models (aggregate) ───
|
||||
|
||||
func (h *APIConfigHandler) ListAllModels(c *gin.Context) {
|
||||
userID := getUserID(c)
|
||||
|
||||
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 AND team_id IS NULL
|
||||
`, userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list configs"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type modelEntry struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name,omitempty"`
|
||||
OwnedBy string `json:"owned_by,omitempty"`
|
||||
ConfigID string `json:"config_id"`
|
||||
Provider string `json:"provider"`
|
||||
Capabilities providers.ModelCapabilities `json:"capabilities"`
|
||||
}
|
||||
|
||||
allModels := make([]modelEntry, 0)
|
||||
|
||||
for rows.Next() {
|
||||
var cfgID, name, providerID, endpoint string
|
||||
var apiKey *string
|
||||
if err := rows.Scan(&cfgID, &name, &providerID, &endpoint, &apiKey); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
provider, err := providers.Get(providerID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
key := ""
|
||||
if apiKey != nil {
|
||||
key = *apiKey
|
||||
}
|
||||
|
||||
models, err := provider.ListModels(c.Request.Context(), providers.ProviderConfig{
|
||||
Endpoint: endpoint,
|
||||
APIKey: key,
|
||||
})
|
||||
if err != nil {
|
||||
continue // Skip configs that fail
|
||||
}
|
||||
|
||||
for _, m := range models {
|
||||
// Provider caps are authoritative; fill gaps from known table
|
||||
caps := providers.MergeCapabilities(m.Capabilities, m.ID)
|
||||
caps.MaxOutputTokens = providers.ResolveMaxOutput(m.ID, caps)
|
||||
|
||||
allModels = append(allModels, modelEntry{
|
||||
ID: m.ID,
|
||||
Name: m.Name,
|
||||
OwnedBy: m.OwnedBy,
|
||||
ConfigID: cfgID,
|
||||
Provider: providerID,
|
||||
Capabilities: caps,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"models": allModels})
|
||||
}
|
||||
|
||||
// ── 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)
|
||||
|
||||
models := make([]enabledModel, 0)
|
||||
|
||||
// ── 1. Admin model_configs (pre-synced via FetchModels) ──
|
||||
rows, err := database.DB.Query(`
|
||||
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.is_global = true
|
||||
ORDER BY ac.name, mc.model_id
|
||||
`)
|
||||
if err == nil {
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var m enabledModel
|
||||
var capsJSON []byte
|
||||
if err := rows.Scan(&m.ID, &m.ModelID, &m.DisplayName, &m.Provider, &m.ProviderName, &m.ConfigID, &capsJSON); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse DB capabilities (from provider at sync time)
|
||||
var dbCaps providers.ModelCapabilities
|
||||
_ = json.Unmarshal(capsJSON, &dbCaps)
|
||||
|
||||
// Provider-reported caps are authoritative; fill gaps from known table/heuristics
|
||||
if dbCaps.HasProviderData() {
|
||||
m.Capabilities = providers.MergeCapabilities(dbCaps, m.ModelID)
|
||||
} else {
|
||||
// No provider data — use known table or heuristics as base
|
||||
knownCaps, found := providers.LookupKnownModel(m.ModelID)
|
||||
if !found {
|
||||
knownCaps = providers.InferCapabilities(m.ModelID)
|
||||
}
|
||||
m.Capabilities = knownCaps
|
||||
}
|
||||
|
||||
m.Capabilities.MaxOutputTokens = providers.ResolveMaxOutput(m.ModelID, m.Capabilities)
|
||||
m.Source = "global"
|
||||
models = append(models, m)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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 AND team_id IS NULL
|
||||
`, userID)
|
||||
if err == nil {
|
||||
defer userRows.Close()
|
||||
for userRows.Next() {
|
||||
var cfgID, name, providerID, endpoint string
|
||||
var apiKey *string
|
||||
var headersJSON []byte
|
||||
if err := userRows.Scan(&cfgID, &name, &providerID, &endpoint, &apiKey, &headersJSON); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
provider, err := providers.Get(providerID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
key := ""
|
||||
if apiKey != nil {
|
||||
key = *apiKey
|
||||
}
|
||||
|
||||
var customHeaders map[string]string
|
||||
_ = json.Unmarshal(headersJSON, &customHeaders)
|
||||
|
||||
provModels, err := provider.ListModels(c.Request.Context(), providers.ProviderConfig{
|
||||
Endpoint: endpoint,
|
||||
APIKey: key,
|
||||
CustomHeaders: customHeaders,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("[models] user provider %q (%s) list failed: %v", name, providerID, err)
|
||||
continue
|
||||
}
|
||||
|
||||
for _, pm := range provModels {
|
||||
caps := pm.Capabilities
|
||||
// Provider-reported caps are authoritative; fill gaps
|
||||
caps = providers.MergeCapabilities(caps, pm.ID)
|
||||
caps.MaxOutputTokens = providers.ResolveMaxOutput(pm.ID, caps)
|
||||
|
||||
models = append(models, enabledModel{
|
||||
ID: pm.ID,
|
||||
ModelID: pm.ID,
|
||||
Provider: providerID,
|
||||
ProviderName: name,
|
||||
ConfigID: cfgID,
|
||||
Capabilities: caps,
|
||||
Pricing: pm.Pricing,
|
||||
Source: "personal",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 3. Active presets (global + user's team + user's personal + shared) ──
|
||||
presetRows, err := database.DB.Query(`
|
||||
SELECT mp.id, mp.name, mp.description, mp.base_model_id, mp.api_config_id,
|
||||
mp.icon, mp.avatar, mp.scope, mp.temperature, mp.max_tokens,
|
||||
COALESCE(ac.provider, '') as provider, COALESCE(ac.name, '') as provider_name,
|
||||
COALESCE(t.name, '') as team_name
|
||||
FROM model_presets mp
|
||||
LEFT JOIN api_configs ac ON mp.api_config_id = ac.id
|
||||
LEFT JOIN teams t ON mp.team_id = t.id
|
||||
WHERE mp.is_active = true
|
||||
AND (
|
||||
mp.scope = 'global'
|
||||
OR (mp.scope = 'personal' AND mp.created_by = $1)
|
||||
OR (mp.scope = 'personal' AND mp.is_shared = true)
|
||||
OR (mp.scope = 'team' AND mp.team_id IN (
|
||||
SELECT team_id FROM team_members WHERE user_id = $1
|
||||
))
|
||||
)
|
||||
ORDER BY mp.scope ASC, mp.name ASC
|
||||
`, userID)
|
||||
if err == nil {
|
||||
defer presetRows.Close()
|
||||
for presetRows.Next() {
|
||||
var presetID, name, description, baseModelID, icon, avatar, scope, provID, provName, teamName string
|
||||
var apiConfigID *string
|
||||
var temp *float64
|
||||
var maxTok *int
|
||||
if err := presetRows.Scan(&presetID, &name, &description, &baseModelID, &apiConfigID,
|
||||
&icon, &avatar, &scope, &temp, &maxTok, &provID, &provName, &teamName); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// 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
|
||||
if icon != "" {
|
||||
displayName = icon + " " + name
|
||||
}
|
||||
|
||||
models = append(models, enabledModel{
|
||||
ID: presetID,
|
||||
ModelID: baseModelID,
|
||||
DisplayName: &displayName,
|
||||
Provider: provID,
|
||||
ProviderName: provName,
|
||||
ConfigID: cfgID,
|
||||
Capabilities: caps,
|
||||
IsPreset: true,
|
||||
PresetID: presetID,
|
||||
PresetScope: scope,
|
||||
PresetAvatar: avatar,
|
||||
PresetTeamName: teamName,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"models": models})
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────
|
||||
|
||||
type scannable interface {
|
||||
Scan(dest ...interface{}) error
|
||||
}
|
||||
|
||||
func scanAPIConfig(row scannable) (apiConfigResponse, error) {
|
||||
var cfg apiConfigResponse
|
||||
var apiKeyEnc *string
|
||||
var configRaw string
|
||||
|
||||
err := row.Scan(
|
||||
&cfg.ID, &cfg.UserID, &cfg.Name, &cfg.Provider, &cfg.Endpoint, &apiKeyEnc,
|
||||
&cfg.ModelDefault, &configRaw, &cfg.IsActive, &cfg.CreatedAt, &cfg.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return cfg, err
|
||||
}
|
||||
|
||||
cfg.HasKey = apiKeyEnc != nil && *apiKeyEnc != ""
|
||||
cfg.Config = parseJSONBConfig(configRaw)
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func parseJSONBConfig(raw string) map[string]interface{} {
|
||||
result := make(map[string]interface{})
|
||||
_ = json.Unmarshal([]byte(raw), &result)
|
||||
return result
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/providers"
|
||||
)
|
||||
|
||||
func TestCreateConfigMissingFields(t *testing.T) {
|
||||
h := NewAPIConfigHandler()
|
||||
r := gin.New()
|
||||
r.POST("/api-configs", func(c *gin.Context) {
|
||||
c.Set("user_id", "test-user")
|
||||
h.CreateConfig(c)
|
||||
})
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
}{
|
||||
{"missing name", `{"provider":"openai","endpoint":"http://x"}`},
|
||||
{"missing provider", `{"name":"Test","endpoint":"http://x"}`},
|
||||
{"missing endpoint", `{"name":"Test","provider":"openai"}`},
|
||||
{"empty body", `{}`},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("POST", "/api-configs",
|
||||
bytes.NewBufferString(tt.body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d (body: %s)", w.Code, w.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateConfigInvalidProvider(t *testing.T) {
|
||||
h := NewAPIConfigHandler()
|
||||
r := gin.New()
|
||||
r.POST("/api-configs", func(c *gin.Context) {
|
||||
c.Set("user_id", "test-user")
|
||||
h.CreateConfig(c)
|
||||
})
|
||||
|
||||
body := `{"name":"Test","provider":"nonexistent","endpoint":"http://x"}`
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("POST", "/api-configs",
|
||||
bytes.NewBufferString(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400 for invalid provider, got %d", w.Code)
|
||||
}
|
||||
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
|
||||
if _, ok := resp["supported_providers"]; !ok {
|
||||
t.Error("Expected supported_providers in error response")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompletionHandlerMissingFields(t *testing.T) {
|
||||
h := NewCompletionHandler()
|
||||
r := gin.New()
|
||||
r.POST("/chat/completions", func(c *gin.Context) {
|
||||
c.Set("user_id", "test-user")
|
||||
h.Complete(c)
|
||||
})
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
}{
|
||||
{"missing channel_id", `{"content":"hello"}`},
|
||||
{"missing content", `{"channel_id":"abc"}`},
|
||||
{"empty body", `{}`},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("POST", "/chat/completions",
|
||||
bytes.NewBufferString(tt.body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d (body: %s)", w.Code, w.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSupportedProviders(t *testing.T) {
|
||||
ids := providers.List()
|
||||
|
||||
expected := map[string]bool{
|
||||
"openai": false,
|
||||
"anthropic": false,
|
||||
}
|
||||
|
||||
for _, id := range ids {
|
||||
if _, ok := expected[id]; ok {
|
||||
expected[id] = true
|
||||
}
|
||||
}
|
||||
|
||||
for name, found := range expected {
|
||||
if !found {
|
||||
t.Errorf("Expected provider %s to be registered", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,32 +1,28 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/config"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
)
|
||||
|
||||
const (
|
||||
accessTokenDuration = 15 * time.Minute
|
||||
refreshTokenDuration = 7 * 24 * time.Hour
|
||||
bcryptCost = 12
|
||||
)
|
||||
// Claims represents the JWT payload.
|
||||
//
|
||||
// bcryptCost is shared across auth.go, settings.go, admin.go
|
||||
const bcryptCost = 12
|
||||
|
||||
// Claims is the JWT access token payload.
|
||||
type Claims struct {
|
||||
UserID string `json:"user_id"`
|
||||
Email string `json:"email"`
|
||||
@@ -34,424 +30,261 @@ type Claims struct {
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
// ── Request / Response types ────────────────
|
||||
|
||||
type registerRequest struct {
|
||||
Username string `json:"username" binding:"required,min=3,max=50"`
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Password string `json:"password" binding:"required,min=8,max=128"`
|
||||
}
|
||||
|
||||
type loginRequest struct {
|
||||
Login string `json:"login" binding:"required"` // email or username
|
||||
Password string `json:"password" binding:"required"`
|
||||
}
|
||||
|
||||
type refreshRequest struct {
|
||||
RefreshToken string `json:"refresh_token" binding:"required"`
|
||||
}
|
||||
|
||||
type authResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresIn int `json:"expires_in"` // seconds
|
||||
User userResponse `json:"user"`
|
||||
}
|
||||
|
||||
type userResponse struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
DisplayName *string `json:"display_name"`
|
||||
Role string `json:"role"`
|
||||
Avatar *string `json:"avatar,omitempty"`
|
||||
}
|
||||
|
||||
// AuthHandler holds dependencies for auth endpoints.
|
||||
type AuthHandler struct {
|
||||
cfg *config.Config
|
||||
cfg *config.Config
|
||||
stores store.Stores
|
||||
}
|
||||
|
||||
// NewAuthHandler creates a new auth handler.
|
||||
func NewAuthHandler(cfg *config.Config) *AuthHandler {
|
||||
return &AuthHandler{cfg: cfg}
|
||||
func NewAuthHandler(cfg *config.Config, s store.Stores) *AuthHandler {
|
||||
return &AuthHandler{cfg: cfg, stores: s}
|
||||
}
|
||||
|
||||
// ── Register ────────────────────────────────
|
||||
|
||||
func (h *AuthHandler) Register(c *gin.Context) {
|
||||
var req registerRequest
|
||||
var req struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Email string `json:"email" binding:"required"`
|
||||
Password string `json:"password" binding:"required,min=8"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
req.Email = strings.ToLower(strings.TrimSpace(req.Email))
|
||||
req.Username = strings.TrimSpace(req.Username)
|
||||
|
||||
// Check if this is the first user (will become admin)
|
||||
var userCount int
|
||||
_ = database.DB.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&userCount)
|
||||
isFirstUser := userCount == 0
|
||||
|
||||
// First-user-becomes-admin only when no env admin is configured
|
||||
envAdminSet := os.Getenv("SWITCHBOARD_ADMIN_USERNAME") != ""
|
||||
promoteFirst := isFirstUser && !envAdminSet
|
||||
|
||||
// If not first user (or env admin handles bootstrap), check registration
|
||||
if !promoteFirst {
|
||||
if !IsRegistrationEnabled() {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "registration is disabled"})
|
||||
return
|
||||
}
|
||||
// Check registration policy
|
||||
allowed, _ := h.stores.Policies.GetBool(c.Request.Context(), "allow_registration")
|
||||
if !allowed {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "registration is disabled"})
|
||||
return
|
||||
}
|
||||
|
||||
// Check duplicate
|
||||
if existing, _ := h.stores.Users.GetByUsername(c.Request.Context(), req.Username); existing != nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "username already taken"})
|
||||
return
|
||||
}
|
||||
if existing, _ := h.stores.Users.GetByEmail(c.Request.Context(), req.Email); existing != nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "email already registered"})
|
||||
return
|
||||
}
|
||||
|
||||
// Hash password
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcryptCost)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to hash password"})
|
||||
return
|
||||
}
|
||||
|
||||
// Determine role and active state
|
||||
role := "user"
|
||||
isActive := true
|
||||
if promoteFirst {
|
||||
role = "admin"
|
||||
} else {
|
||||
// Apply registration default state
|
||||
if GetRegistrationDefaultState() == "pending" {
|
||||
isActive = false
|
||||
}
|
||||
// Check if user should be active by default
|
||||
defaultActive, _ := h.stores.Policies.GetBool(c.Request.Context(), "default_user_active")
|
||||
|
||||
user := &models.User{
|
||||
Username: req.Username,
|
||||
Email: req.Email,
|
||||
PasswordHash: string(hash),
|
||||
Role: models.UserRoleUser,
|
||||
IsActive: defaultActive,
|
||||
}
|
||||
|
||||
// Insert user
|
||||
var user userResponse
|
||||
err = database.DB.QueryRow(`
|
||||
INSERT INTO users (username, email, password_hash, role, is_active)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id, username, email, display_name, role, avatar_url
|
||||
`, req.Username, req.Email, string(hash), role, isActive).Scan(
|
||||
&user.ID, &user.Username, &user.Email, &user.DisplayName, &user.Role, &user.Avatar,
|
||||
)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "duplicate key") {
|
||||
field := "email"
|
||||
if strings.Contains(err.Error(), "username") {
|
||||
field = "username"
|
||||
}
|
||||
c.JSON(http.StatusConflict, gin.H{"error": fmt.Sprintf("%s already taken", field)})
|
||||
return
|
||||
}
|
||||
if err := h.stores.Users.Create(c.Request.Context(), user); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create user"})
|
||||
return
|
||||
}
|
||||
|
||||
// If account is pending, don't generate tokens
|
||||
if !isActive {
|
||||
if !user.IsActive {
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"message": "Account created and pending admin approval",
|
||||
"pending": true,
|
||||
})
|
||||
AuditLogWithActor(user.ID, c, "user.register", "user", user.ID, map[string]interface{}{
|
||||
"username": req.Username, "pending": true,
|
||||
"message": "Account created but requires admin approval",
|
||||
"user_id": user.ID,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Generate tokens
|
||||
resp, err := h.generateTokenPair(user)
|
||||
tokens, err := h.generateTokens(user)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate tokens"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, resp)
|
||||
AuditLogWithActor(user.ID, c, "user.register", "user", user.ID, map[string]interface{}{
|
||||
"username": req.Username, "pending": false,
|
||||
})
|
||||
c.JSON(http.StatusCreated, tokens)
|
||||
}
|
||||
|
||||
// IsRegistrationEnabled checks the global_settings table.
|
||||
// Returns true if the table doesn't exist (pre-migration), DB is nil, or setting is enabled.
|
||||
func IsRegistrationEnabled() bool {
|
||||
if database.DB == nil {
|
||||
return true
|
||||
func (h *AuthHandler) Login(c *gin.Context) {
|
||||
var req struct {
|
||||
Login string `json:"login" binding:"required"` // username or email
|
||||
Password string `json:"password" binding:"required"`
|
||||
}
|
||||
var enabled bool
|
||||
err := database.DB.QueryRow(`
|
||||
SELECT COALESCE((value->>'value')::boolean, true)
|
||||
FROM global_settings WHERE key = 'registration_enabled'
|
||||
`).Scan(&enabled)
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.stores.Users.GetByLogin(c.Request.Context(), req.Login)
|
||||
if err != nil {
|
||||
return true // Default to open if setting missing
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
|
||||
return
|
||||
}
|
||||
return enabled
|
||||
|
||||
if !user.IsActive {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "account is inactive"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
|
||||
return
|
||||
}
|
||||
|
||||
h.stores.Users.UpdateLastLogin(c.Request.Context(), user.ID)
|
||||
|
||||
tokens, err := h.generateTokens(user)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate tokens"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, tokens)
|
||||
}
|
||||
|
||||
// GetRegistrationDefaultState returns "active" or "pending".
|
||||
func GetRegistrationDefaultState() string {
|
||||
if database.DB == nil {
|
||||
return "active"
|
||||
func (h *AuthHandler) Refresh(c *gin.Context) {
|
||||
var req struct {
|
||||
RefreshToken string `json:"refresh_token" binding:"required"`
|
||||
}
|
||||
var state string
|
||||
err := database.DB.QueryRow(`
|
||||
SELECT COALESCE(value->>'value', 'active')
|
||||
FROM global_settings WHERE key = 'registration_default_state'
|
||||
`).Scan(&state)
|
||||
if err != nil || state == "" {
|
||||
return "active"
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
return state
|
||||
|
||||
tokenHash := hashToken(req.RefreshToken)
|
||||
userID, err := h.stores.Users.GetRefreshToken(c.Request.Context(), tokenHash)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid refresh token"})
|
||||
return
|
||||
}
|
||||
|
||||
// Revoke the used token (rotate)
|
||||
h.stores.Users.RevokeRefreshToken(c.Request.Context(), tokenHash)
|
||||
|
||||
user, err := h.stores.Users.GetByID(c.Request.Context(), userID)
|
||||
if err != nil || !user.IsActive {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not found or inactive"})
|
||||
return
|
||||
}
|
||||
|
||||
tokens, err := h.generateTokens(user)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate tokens"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, tokens)
|
||||
}
|
||||
|
||||
// BootstrapAdmin creates or updates the admin user from environment variables.
|
||||
// This runs on every startup, so changing the K8s secret + restarting resets the password.
|
||||
// Handles both username and email conflicts (e.g. admin username changed between deploys).
|
||||
func BootstrapAdmin(cfg *config.Config) {
|
||||
func (h *AuthHandler) Logout(c *gin.Context) {
|
||||
var req struct {
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
}
|
||||
c.ShouldBindJSON(&req)
|
||||
|
||||
if req.RefreshToken != "" {
|
||||
tokenHash := hashToken(req.RefreshToken)
|
||||
h.stores.Users.RevokeRefreshToken(c.Request.Context(), tokenHash)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "logged out"})
|
||||
}
|
||||
|
||||
func (h *AuthHandler) generateTokens(user *models.User) (gin.H, error) {
|
||||
// Access token (15 min)
|
||||
accessClaims := Claims{
|
||||
UserID: user.ID,
|
||||
Email: user.Email,
|
||||
Role: user.Role,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
ID: uuid.New().String(),
|
||||
},
|
||||
}
|
||||
accessToken := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims)
|
||||
accessString, err := accessToken.SignedString([]byte(h.cfg.JWTSecret))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Refresh token (7 days)
|
||||
refreshRaw := uuid.New().String()
|
||||
refreshHash := hashToken(refreshRaw)
|
||||
expiresAt := time.Now().Add(7 * 24 * time.Hour)
|
||||
|
||||
if err := h.stores.Users.CreateRefreshToken(context.Background(), user.ID, refreshHash, expiresAt); err != nil {
|
||||
log.Printf("warn: failed to store refresh token: %v", err)
|
||||
}
|
||||
|
||||
return gin.H{
|
||||
"access_token": accessString,
|
||||
"refresh_token": refreshRaw,
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 900,
|
||||
"user": gin.H{
|
||||
"id": user.ID,
|
||||
"username": user.Username,
|
||||
"email": user.Email,
|
||||
"display_name": user.DisplayName,
|
||||
"role": user.Role,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func hashToken(token string) string {
|
||||
h := sha256.Sum256([]byte(token))
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
// BootstrapAdmin creates/updates the admin user from env vars (K8s secret).
|
||||
func BootstrapAdmin(cfg *config.Config, s store.Stores) {
|
||||
if cfg.AdminUsername == "" || cfg.AdminPassword == "" {
|
||||
return
|
||||
}
|
||||
if database.DB == nil {
|
||||
return
|
||||
}
|
||||
|
||||
email := cfg.AdminEmail
|
||||
if email == "" {
|
||||
email = cfg.AdminUsername + "@localhost"
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(cfg.AdminPassword), bcryptCost)
|
||||
if err != nil {
|
||||
log.Printf("⚠ Failed to hash admin password: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Try upsert by username (common case: same username, new password)
|
||||
_, err = database.DB.Exec(`
|
||||
INSERT INTO users (username, email, password_hash, role, is_active)
|
||||
VALUES ($1, $2, $3, 'admin', true)
|
||||
ON CONFLICT (username) DO UPDATE SET
|
||||
password_hash = EXCLUDED.password_hash,
|
||||
email = EXCLUDED.email,
|
||||
role = 'admin',
|
||||
is_active = true
|
||||
`, cfg.AdminUsername, email, string(hash))
|
||||
|
||||
if err != nil && strings.Contains(err.Error(), "duplicate key") {
|
||||
// Email conflict — admin username was changed in config but email
|
||||
// already belongs to old admin row. Update that row instead.
|
||||
_, err = database.DB.Exec(`
|
||||
UPDATE users SET
|
||||
username = $1,
|
||||
password_hash = $3,
|
||||
role = 'admin',
|
||||
is_active = true
|
||||
WHERE email = $2
|
||||
`, cfg.AdminUsername, email, string(hash))
|
||||
existing, _ := s.Users.GetByUsername(ctx, cfg.AdminUsername)
|
||||
if existing != nil {
|
||||
// Update password and ensure admin role
|
||||
s.Users.Update(ctx, existing.ID, map[string]interface{}{
|
||||
"password_hash": string(hash),
|
||||
"role": models.UserRoleAdmin,
|
||||
"is_active": true,
|
||||
})
|
||||
log.Printf(" ✅ Admin user '%s' updated", cfg.AdminUsername)
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Printf("⚠ Admin bootstrap failed: %v", err)
|
||||
} else {
|
||||
log.Printf("✅ Admin user '%s' bootstrapped from environment", cfg.AdminUsername)
|
||||
email := cfg.AdminEmail
|
||||
if email == "" {
|
||||
email = cfg.AdminUsername + "@switchboard.local"
|
||||
}
|
||||
|
||||
user := &models.User{
|
||||
Username: cfg.AdminUsername,
|
||||
Email: email,
|
||||
PasswordHash: string(hash),
|
||||
Role: models.UserRoleAdmin,
|
||||
IsActive: true,
|
||||
}
|
||||
|
||||
if err := s.Users.Create(ctx, user); err != nil {
|
||||
log.Printf("⚠ Failed to create admin user: %v", err)
|
||||
return
|
||||
}
|
||||
log.Printf(" ✅ Admin user '%s' created", cfg.AdminUsername)
|
||||
}
|
||||
|
||||
// ── Login ───────────────────────────────────
|
||||
|
||||
func (h *AuthHandler) Login(c *gin.Context) {
|
||||
var req loginRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
req.Login = strings.TrimSpace(req.Login)
|
||||
|
||||
// Look up user by email or username
|
||||
var user userResponse
|
||||
var passwordHash string
|
||||
var isActive bool
|
||||
|
||||
err := database.DB.QueryRow(`
|
||||
SELECT id, username, email, display_name, role, avatar_url, password_hash, is_active
|
||||
FROM users
|
||||
WHERE email = $1 OR username = $1
|
||||
`, req.Login).Scan(
|
||||
&user.ID, &user.Username, &user.Email, &user.DisplayName,
|
||||
&user.Role, &user.Avatar, &passwordHash, &isActive,
|
||||
)
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "authentication failed"})
|
||||
return
|
||||
}
|
||||
|
||||
if !isActive {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "account is pending admin approval"})
|
||||
return
|
||||
}
|
||||
|
||||
// Verify password
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(req.Password)); err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
|
||||
return
|
||||
}
|
||||
|
||||
// Update last_login_at
|
||||
_, _ = database.DB.Exec(`UPDATE users SET last_login_at = NOW() WHERE id = $1`, user.ID)
|
||||
|
||||
// Generate tokens
|
||||
resp, err := h.generateTokenPair(user)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate tokens"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, resp)
|
||||
AuditLogWithActor(user.ID, c, "user.login", "user", user.ID, nil)
|
||||
}
|
||||
|
||||
// ── Refresh ─────────────────────────────────
|
||||
|
||||
func (h *AuthHandler) Refresh(c *gin.Context) {
|
||||
var req refreshRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
tokenHash := hashToken(req.RefreshToken)
|
||||
|
||||
// Find and validate the refresh token
|
||||
var tokenID, userID string
|
||||
var expiresAt time.Time
|
||||
|
||||
err := database.DB.QueryRow(`
|
||||
SELECT rt.id, rt.user_id, rt.expires_at
|
||||
FROM refresh_tokens rt
|
||||
WHERE rt.token_hash = $1 AND rt.revoked_at IS NULL
|
||||
`, tokenHash).Scan(&tokenID, &userID, &expiresAt)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid refresh token"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "token validation failed"})
|
||||
return
|
||||
}
|
||||
|
||||
if time.Now().After(expiresAt) {
|
||||
// Revoke expired token
|
||||
_, _ = database.DB.Exec(`UPDATE refresh_tokens SET revoked_at = NOW() WHERE id = $1`, tokenID)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "refresh token expired"})
|
||||
return
|
||||
}
|
||||
|
||||
// Revoke the old token (rotation)
|
||||
_, _ = database.DB.Exec(`UPDATE refresh_tokens SET revoked_at = NOW() WHERE id = $1`, tokenID)
|
||||
|
||||
// Look up user
|
||||
var user userResponse
|
||||
var isActive bool
|
||||
err = database.DB.QueryRow(`
|
||||
SELECT id, username, email, display_name, role, avatar_url, is_active
|
||||
FROM users WHERE id = $1
|
||||
`, userID).Scan(
|
||||
&user.ID, &user.Username, &user.Email, &user.DisplayName, &user.Role, &user.Avatar, &isActive,
|
||||
)
|
||||
if err != nil || !isActive {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "account unavailable"})
|
||||
return
|
||||
}
|
||||
|
||||
// Issue new pair
|
||||
resp, err := h.generateTokenPair(user)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate tokens"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// ── Logout ──────────────────────────────────
|
||||
|
||||
func (h *AuthHandler) Logout(c *gin.Context) {
|
||||
var req refreshRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
// No refresh token provided — just acknowledge
|
||||
c.JSON(http.StatusOK, gin.H{"message": "logged out"})
|
||||
return
|
||||
}
|
||||
|
||||
tokenHash := hashToken(req.RefreshToken)
|
||||
|
||||
// Revoke the refresh token
|
||||
_, _ = database.DB.Exec(`
|
||||
UPDATE refresh_tokens SET revoked_at = NOW()
|
||||
WHERE token_hash = $1 AND revoked_at IS NULL
|
||||
`, tokenHash)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "logged out"})
|
||||
}
|
||||
|
||||
// ── Token Generation ────────────────────────
|
||||
|
||||
func (h *AuthHandler) generateTokenPair(user userResponse) (*authResponse, error) {
|
||||
now := time.Now()
|
||||
|
||||
// Access token (JWT)
|
||||
claims := Claims{
|
||||
UserID: user.ID,
|
||||
Email: user.Email,
|
||||
Role: user.Role,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(accessTokenDuration)),
|
||||
Issuer: "chat-switchboard",
|
||||
},
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
accessToken, err := token.SignedString([]byte(h.cfg.JWTSecret))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("sign access token: %w", err)
|
||||
}
|
||||
|
||||
// Refresh token (opaque random string, stored hashed)
|
||||
refreshBytes := make([]byte, 32)
|
||||
if _, err := rand.Read(refreshBytes); err != nil {
|
||||
return nil, fmt.Errorf("generate refresh token: %w", err)
|
||||
}
|
||||
refreshToken := hex.EncodeToString(refreshBytes)
|
||||
refreshHash := hashToken(refreshToken)
|
||||
|
||||
// Store refresh token
|
||||
_, err = database.DB.Exec(`
|
||||
INSERT INTO refresh_tokens (user_id, token_hash, expires_at)
|
||||
VALUES ($1, $2, $3)
|
||||
`, user.ID, refreshHash, now.Add(refreshTokenDuration))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store refresh token: %w", err)
|
||||
}
|
||||
|
||||
return &authResponse{
|
||||
AccessToken: accessToken,
|
||||
RefreshToken: refreshToken,
|
||||
ExpiresIn: int(accessTokenDuration.Seconds()),
|
||||
User: user,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// hashToken returns a SHA-256 hex digest of a token string.
|
||||
// Refresh tokens are stored hashed so a DB leak doesn't
|
||||
// compromise active sessions.
|
||||
func hashToken(token string) string {
|
||||
h := sha256.Sum256([]byte(token))
|
||||
return hex.EncodeToString(h[:])
|
||||
// IsRegistrationEnabled checks the platform policy.
|
||||
func IsRegistrationEnabled(s store.Stores) bool {
|
||||
val, _ := s.Policies.GetBool(context.Background(), "allow_registration")
|
||||
return val
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/config"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
)
|
||||
|
||||
func testConfig() *config.Config {
|
||||
@@ -23,12 +24,16 @@ func testConfig() *config.Config {
|
||||
}
|
||||
}
|
||||
|
||||
// testAuthHandler creates an AuthHandler with nil stores (safe for non-DB tests).
|
||||
func testAuthHandler() *AuthHandler {
|
||||
return NewAuthHandler(testConfig(), store.Stores{})
|
||||
}
|
||||
|
||||
// ── JWT Token Tests ─────────────────────────
|
||||
|
||||
func TestJWTGeneration(t *testing.T) {
|
||||
cfg := testConfig()
|
||||
|
||||
// Create a token the same way the handler does
|
||||
now := time.Now()
|
||||
claims := Claims{
|
||||
UserID: "550e8400-e29b-41d4-a716-446655440000",
|
||||
@@ -130,13 +135,11 @@ func TestBcryptHash(t *testing.T) {
|
||||
t.Fatalf("Failed to hash: %v", err)
|
||||
}
|
||||
|
||||
// Correct password should match
|
||||
err = bcrypt.CompareHashAndPassword(hash, []byte(password))
|
||||
if err != nil {
|
||||
t.Error("Correct password should match hash")
|
||||
}
|
||||
|
||||
// Wrong password should not match
|
||||
err = bcrypt.CompareHashAndPassword(hash, []byte("wrongPassword"))
|
||||
if err == nil {
|
||||
t.Error("Wrong password should not match hash")
|
||||
@@ -153,7 +156,6 @@ func TestBcryptDifferentHashesForSamePassword(t *testing.T) {
|
||||
t.Error("Same password should produce different hashes (salt)")
|
||||
}
|
||||
|
||||
// Both should still verify
|
||||
if bcrypt.CompareHashAndPassword(hash1, []byte(password)) != nil {
|
||||
t.Error("hash1 should verify")
|
||||
}
|
||||
@@ -168,22 +170,18 @@ func TestTokenHash(t *testing.T) {
|
||||
token := "abc123refreshtoken"
|
||||
hash := hashToken(token)
|
||||
|
||||
// Should be deterministic
|
||||
if hashToken(token) != hash {
|
||||
t.Error("hashToken should be deterministic")
|
||||
}
|
||||
|
||||
// Different token should produce different hash
|
||||
if hashToken("different") == hash {
|
||||
t.Error("Different tokens should produce different hashes")
|
||||
}
|
||||
|
||||
// Should be hex-encoded SHA-256 (64 chars)
|
||||
if len(hash) != 64 {
|
||||
t.Errorf("Expected 64 char hex hash, got %d chars", len(hash))
|
||||
}
|
||||
|
||||
// Verify it's actually SHA-256
|
||||
expected := sha256.Sum256([]byte(token))
|
||||
expectedHex := hex.EncodeToString(expected[:])
|
||||
if hash != expectedHex {
|
||||
@@ -194,7 +192,7 @@ func TestTokenHash(t *testing.T) {
|
||||
// ── Request Validation Tests ────────────────
|
||||
|
||||
func TestRegisterValidation(t *testing.T) {
|
||||
h := NewAuthHandler(testConfig())
|
||||
h := testAuthHandler()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -211,21 +209,11 @@ func TestRegisterValidation(t *testing.T) {
|
||||
body: `{"username":"test","email":"test@example.com"}`,
|
||||
wantCode: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "invalid email",
|
||||
body: `{"username":"test","email":"notanemail","password":"12345678"}`,
|
||||
wantCode: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "password too short",
|
||||
body: `{"username":"test","email":"test@example.com","password":"short"}`,
|
||||
wantCode: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "username too short",
|
||||
body: `{"username":"ab","email":"test@example.com","password":"12345678"}`,
|
||||
wantCode: http.StatusBadRequest,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -247,7 +235,7 @@ func TestRegisterValidation(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestLoginValidation(t *testing.T) {
|
||||
h := NewAuthHandler(testConfig())
|
||||
h := testAuthHandler()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -289,7 +277,7 @@ func TestLoginValidation(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRefreshValidation(t *testing.T) {
|
||||
h := NewAuthHandler(testConfig())
|
||||
h := testAuthHandler()
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
@@ -305,7 +293,7 @@ func TestRefreshValidation(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestLogoutWithoutToken(t *testing.T) {
|
||||
h := NewAuthHandler(testConfig())
|
||||
h := testAuthHandler()
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
|
||||
@@ -207,7 +207,7 @@ func UploadPresetAvatar(c *gin.Context) {
|
||||
dataURI := "data:image/png;base64," + base64.StdEncoding.EncodeToString(buf.Bytes())
|
||||
|
||||
result, err := database.DB.Exec(
|
||||
`UPDATE model_presets SET avatar = $1, updated_at = NOW() WHERE id = $2`,
|
||||
`UPDATE personas SET avatar = $1, updated_at = NOW() WHERE id = $2`,
|
||||
dataURI, presetID,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -228,7 +228,7 @@ func DeletePresetAvatar(c *gin.Context) {
|
||||
presetID := c.Param("id")
|
||||
|
||||
result, err := database.DB.Exec(
|
||||
`UPDATE model_presets SET avatar = '', updated_at = NOW() WHERE id = $1`,
|
||||
`UPDATE personas SET avatar = '', updated_at = NOW() WHERE id = $1`,
|
||||
presetID,
|
||||
)
|
||||
if err != nil {
|
||||
|
||||
@@ -3,109 +3,130 @@ package handlers
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
capspkg "git.gobha.me/xcaliber/chat-switchboard/capabilities"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/providers"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
)
|
||||
|
||||
// ModelHandler provides the unified models endpoint.
|
||||
type ModelHandler struct {
|
||||
stores store.Stores
|
||||
}
|
||||
|
||||
func NewModelHandler(s store.Stores) *ModelHandler {
|
||||
return &ModelHandler{stores: s}
|
||||
}
|
||||
|
||||
// ListEnabledModels returns all models the user can access (catalog + personas),
|
||||
// with user preferences (hidden, sort order) applied.
|
||||
func (h *ModelHandler) ListEnabledModels(c *gin.Context) {
|
||||
userID := getUserID(c)
|
||||
|
||||
userModels, err := capspkg.ModelsForUser(c.Request.Context(), h.stores, userID)
|
||||
if err != nil {
|
||||
log.Printf("error: ModelsForUser(%s): %v", userID, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to resolve models"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"models": userModels})
|
||||
}
|
||||
|
||||
// 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)
|
||||
// Priority chain:
|
||||
// 1. model_catalog DB — exact match (model_id + provider_config_id)
|
||||
// 2. model_catalog 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 ──
|
||||
func ResolveModelCaps(c *gin.Context, modelID, configID string) models.ModelCapabilities {
|
||||
// 1. Exact match: model_id + provider_config_id
|
||||
if configID != "" {
|
||||
caps, ok := capsFromModelConfigs(modelID, configID)
|
||||
caps, ok := capsFromCatalog(modelID, configID)
|
||||
if ok {
|
||||
caps.MaxOutputTokens = providers.ResolveMaxOutput(modelID, caps)
|
||||
caps.MaxOutputTokens = capspkg.ResolveMaxOutput(modelID, caps)
|
||||
return caps
|
||||
}
|
||||
}
|
||||
|
||||
// ── 2. Any provider: same model_id, any config ──
|
||||
caps, ok := capsFromModelConfigs(modelID, "")
|
||||
// 2. Any provider: same model_id, any config
|
||||
caps, ok := capsFromCatalog(modelID, "")
|
||||
if ok {
|
||||
caps.MaxOutputTokens = providers.ResolveMaxOutput(modelID, caps)
|
||||
caps.MaxOutputTokens = capspkg.ResolveMaxOutput(modelID, caps)
|
||||
return caps
|
||||
}
|
||||
|
||||
// ── 3. Known model table (static, curated) ──
|
||||
caps, found := providers.LookupKnownModel(modelID)
|
||||
// 3. Known model table (static, curated)
|
||||
caps, found := capspkg.LookupKnownModel(modelID)
|
||||
if found {
|
||||
caps.MaxOutputTokens = providers.ResolveMaxOutput(modelID, caps)
|
||||
caps.MaxOutputTokens = capspkg.ResolveMaxOutput(modelID, caps)
|
||||
return caps
|
||||
}
|
||||
|
||||
// ── 4. Heuristic inference ──
|
||||
caps = providers.InferCapabilities(modelID)
|
||||
caps.MaxOutputTokens = providers.ResolveMaxOutput(modelID, caps)
|
||||
// 4. Heuristic inference
|
||||
caps = capspkg.InferCapabilities(modelID)
|
||||
caps.MaxOutputTokens = capspkg.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) {
|
||||
// capsFromCatalog looks up capabilities from the model_catalog table.
|
||||
func capsFromCatalog(modelID, configID string) (models.ModelCapabilities, bool) {
|
||||
if database.DB == nil {
|
||||
return models.ModelCapabilities{}, false
|
||||
}
|
||||
|
||||
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
|
||||
SELECT capabilities FROM model_catalog
|
||||
WHERE model_id = $1 AND provider_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
|
||||
SELECT capabilities FROM model_catalog
|
||||
WHERE model_id = $1 ORDER BY last_synced_at DESC NULLS LAST LIMIT 1
|
||||
`, modelID).Scan(&capsJSON)
|
||||
}
|
||||
if err != nil || len(capsJSON) == 0 {
|
||||
return providers.ModelCapabilities{}, false
|
||||
return models.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
|
||||
}
|
||||
var caps models.ModelCapabilities
|
||||
if json.Unmarshal(capsJSON, &caps) != nil || !caps.HasProviderData() {
|
||||
return models.ModelCapabilities{}, false
|
||||
}
|
||||
// Fall through to canonical resolver
|
||||
return ResolveModelCaps(c, modelID, configID)
|
||||
|
||||
// Merge with known data to fill gaps
|
||||
resolved := capspkg.ResolveIntrinsic(modelID, &caps)
|
||||
return resolved, true
|
||||
}
|
||||
|
||||
// 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) {
|
||||
func liveQueryModelCaps(c *gin.Context, configID, modelID string) (models.ModelCapabilities, bool) {
|
||||
if database.DB == nil {
|
||||
return models.ModelCapabilities{}, false
|
||||
}
|
||||
|
||||
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
|
||||
SELECT provider, endpoint, api_key_enc, headers
|
||||
FROM provider_configs WHERE id = $1 AND is_active = true
|
||||
`, configID).Scan(&providerID, &endpoint, &apiKey, &headersJSON)
|
||||
if err != nil {
|
||||
return providers.ModelCapabilities{}, false
|
||||
return models.ModelCapabilities{}, false
|
||||
}
|
||||
|
||||
provider, err := providers.Get(providerID)
|
||||
if err != nil {
|
||||
return providers.ModelCapabilities{}, false
|
||||
return models.ModelCapabilities{}, false
|
||||
}
|
||||
|
||||
key := ""
|
||||
@@ -123,15 +144,15 @@ func liveQueryModelCaps(c *gin.Context, configID, modelID string) (providers.Mod
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("[caps] live query for %s via config %s failed: %v", modelID, configID, err)
|
||||
return providers.ModelCapabilities{}, false
|
||||
return models.ModelCapabilities{}, false
|
||||
}
|
||||
|
||||
for _, m := range modelList {
|
||||
if m.ID == modelID {
|
||||
caps := providers.MergeCapabilities(m.Capabilities, modelID)
|
||||
return caps, true
|
||||
resolved := capspkg.ResolveIntrinsic(modelID, &m.Capabilities)
|
||||
return resolved, true
|
||||
}
|
||||
}
|
||||
|
||||
return providers.ModelCapabilities{}, false
|
||||
return models.ModelCapabilities{}, false
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ type createChannelRequest struct {
|
||||
Description string `json:"description,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
SystemPrompt string `json:"system_prompt,omitempty"`
|
||||
APIConfigID *string `json:"api_config_id,omitempty"`
|
||||
APIConfigID *string `json:"provider_config_id,omitempty"`
|
||||
Folder string `json:"folder,omitempty"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
}
|
||||
@@ -31,7 +31,7 @@ type updateChannelRequest struct {
|
||||
Description *string `json:"description,omitempty"`
|
||||
Model *string `json:"model,omitempty"`
|
||||
SystemPrompt *string `json:"system_prompt,omitempty"`
|
||||
APIConfigID *string `json:"api_config_id,omitempty"`
|
||||
APIConfigID *string `json:"provider_config_id,omitempty"`
|
||||
IsArchived *bool `json:"is_archived,omitempty"`
|
||||
IsPinned *bool `json:"is_pinned,omitempty"`
|
||||
Folder *string `json:"folder,omitempty"`
|
||||
@@ -45,7 +45,7 @@ type channelResponse struct {
|
||||
Type string `json:"type"`
|
||||
Description *string `json:"description"`
|
||||
Model *string `json:"model"`
|
||||
APIConfigID *string `json:"api_config_id"`
|
||||
APIConfigID *string `json:"provider_config_id"`
|
||||
SystemPrompt *string `json:"system_prompt"`
|
||||
IsArchived bool `json:"is_archived"`
|
||||
IsPinned bool `json:"is_pinned"`
|
||||
@@ -140,7 +140,7 @@ func (h *ChannelHandler) ListChannels(c *gin.Context) {
|
||||
|
||||
// Fetch channels with message count
|
||||
query := `
|
||||
SELECT c.id, c.user_id, c.title, c.type, c.description, c.model, c.api_config_id,
|
||||
SELECT c.id, c.user_id, c.title, c.type, c.description, c.model, c.provider_config_id,
|
||||
c.system_prompt, c.is_archived, c.is_pinned, c.folder, c.tags,
|
||||
COALESCE(mc.cnt, 0) AS message_count,
|
||||
c.created_at, c.updated_at
|
||||
@@ -233,9 +233,9 @@ func (h *ChannelHandler) CreateChannel(c *gin.Context) {
|
||||
var ch channelResponse
|
||||
var tags []string
|
||||
err := database.DB.QueryRow(`
|
||||
INSERT INTO channels (user_id, title, type, description, model, system_prompt, api_config_id, folder, tags)
|
||||
INSERT INTO channels (user_id, title, type, description, model, system_prompt, provider_config_id, folder, tags)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
RETURNING id, user_id, title, type, description, model, api_config_id, system_prompt,
|
||||
RETURNING id, user_id, title, type, description, model, provider_config_id, system_prompt,
|
||||
is_archived, is_pinned, folder, tags, created_at, updated_at
|
||||
`, userID, req.Title, channelType, req.Description, req.Model, req.SystemPrompt, req.APIConfigID,
|
||||
req.Folder, pq.Array(req.Tags),
|
||||
@@ -265,7 +265,7 @@ func (h *ChannelHandler) CreateChannel(c *gin.Context) {
|
||||
// Auto-create channel_model if model specified
|
||||
if req.Model != "" {
|
||||
_, _ = database.DB.Exec(`
|
||||
INSERT INTO channel_models (channel_id, model_id, api_config_id, is_default)
|
||||
INSERT INTO channel_models (channel_id, model_id, provider_config_id, is_default)
|
||||
VALUES ($1, $2, $3, true)
|
||||
ON CONFLICT DO NOTHING
|
||||
`, ch.ID, req.Model, req.APIConfigID)
|
||||
@@ -283,7 +283,7 @@ func (h *ChannelHandler) GetChannel(c *gin.Context) {
|
||||
var ch channelResponse
|
||||
var tags []string
|
||||
err := database.DB.QueryRow(`
|
||||
SELECT c.id, c.user_id, c.title, c.type, c.description, c.model, c.api_config_id,
|
||||
SELECT c.id, c.user_id, c.title, c.type, c.description, c.model, c.provider_config_id,
|
||||
c.system_prompt, c.is_archived, c.is_pinned, c.folder, c.tags,
|
||||
COALESCE(mc.cnt, 0) AS message_count,
|
||||
c.created_at, c.updated_at
|
||||
@@ -369,7 +369,7 @@ func (h *ChannelHandler) UpdateChannel(c *gin.Context) {
|
||||
addClause("system_prompt", *req.SystemPrompt)
|
||||
}
|
||||
if req.APIConfigID != nil {
|
||||
addClause("api_config_id", *req.APIConfigID)
|
||||
addClause("provider_config_id", *req.APIConfigID)
|
||||
}
|
||||
if req.IsArchived != nil {
|
||||
addClause("is_archived", *req.IsArchived)
|
||||
|
||||
@@ -1,342 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||
)
|
||||
|
||||
// ── Channel Request Validation ─────────────────
|
||||
|
||||
func TestCreateChannelMissingTitle(t *testing.T) {
|
||||
h := NewChannelHandler()
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("POST", "/api/v1/channels",
|
||||
strings.NewReader(`{}`))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
h.CreateChannel(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateChannelTitleTooLong(t *testing.T) {
|
||||
h := NewChannelHandler()
|
||||
|
||||
longTitle := strings.Repeat("x", 501)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("POST", "/api/v1/channels",
|
||||
strings.NewReader(`{"title":"`+longTitle+`"}`))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
h.CreateChannel(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400 for title > 500 chars, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateChannelEmptyBody(t *testing.T) {
|
||||
h := NewChannelHandler()
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Set("user_id", "test-user-id")
|
||||
c.Params = gin.Params{{Key: "id", Value: "test-channel-id"}}
|
||||
c.Request = httptest.NewRequest("PUT", "/api/v1/channels/test-channel-id",
|
||||
strings.NewReader(`{}`))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
// Without a DB connection, UpdateChannel will fail at ownership check.
|
||||
// Integration tests with a real DB validate the "no fields" path.
|
||||
// Here we just confirm it doesn't return 400 for valid JSON.
|
||||
h.UpdateChannel(c)
|
||||
|
||||
if w.Code == http.StatusBadRequest {
|
||||
t.Error("Empty JSON body should not be a parse error")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Message Request Validation ──────────────
|
||||
|
||||
func TestCreateMessageMissingRole(t *testing.T) {
|
||||
h := NewMessageHandler()
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "test-channel"}}
|
||||
c.Request = httptest.NewRequest("POST", "/api/v1/channels/test-channel/messages",
|
||||
strings.NewReader(`{"content":"hello"}`))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
h.CreateMessage(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400 for missing role, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateMessageInvalidRole(t *testing.T) {
|
||||
h := NewMessageHandler()
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "test-channel"}}
|
||||
c.Request = httptest.NewRequest("POST", "/api/v1/channels/test-channel/messages",
|
||||
strings.NewReader(`{"role":"invalid","content":"hello"}`))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
h.CreateMessage(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400 for invalid role, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateMessageMissingContent(t *testing.T) {
|
||||
h := NewMessageHandler()
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "test-channel"}}
|
||||
c.Request = httptest.NewRequest("POST", "/api/v1/channels/test-channel/messages",
|
||||
strings.NewReader(`{"role":"user"}`))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
h.CreateMessage(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400 for missing content, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Integration: Message CRUD with Real DB ──────
|
||||
|
||||
func TestCreateMessageValidRoles(t *testing.T) {
|
||||
database.RequireTestDB(t)
|
||||
database.TruncateAll(t)
|
||||
|
||||
userID := database.SeedTestUser(t, "roletester", "role@test.com")
|
||||
channelID := database.SeedTestChannel(t, userID, "Role Test")
|
||||
|
||||
h := NewMessageHandler()
|
||||
|
||||
for _, role := range []string{"user", "assistant", "system"} {
|
||||
t.Run(role, func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Set("user_id", userID)
|
||||
c.Params = gin.Params{{Key: "id", Value: channelID}}
|
||||
c.Request = httptest.NewRequest("POST", "/api/v1/channels/"+channelID+"/messages",
|
||||
strings.NewReader(`{"role":"`+role+`","content":"hello from `+role+`"}`))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
h.CreateMessage(c)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Errorf("role=%s: expected 201, got %d: %s", role, w.Code, w.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelCRUDIntegration(t *testing.T) {
|
||||
database.RequireTestDB(t)
|
||||
database.TruncateAll(t)
|
||||
|
||||
userID := database.SeedTestUser(t, "cruduser", "crud@test.com")
|
||||
|
||||
h := NewChannelHandler()
|
||||
r := gin.New()
|
||||
r.Use(func(c *gin.Context) { c.Set("user_id", userID); c.Next() })
|
||||
r.POST("/channels", h.CreateChannel)
|
||||
r.GET("/channels", h.ListChannels)
|
||||
r.GET("/channels/:id", h.GetChannel)
|
||||
r.PUT("/channels/:id", h.UpdateChannel)
|
||||
r.DELETE("/channels/:id", h.DeleteChannel)
|
||||
|
||||
// Create
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("POST", "/channels",
|
||||
strings.NewReader(`{"title":"Integration Test Channel"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("Create: expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var created map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &created)
|
||||
channelID := created["id"].(string)
|
||||
|
||||
// Get
|
||||
w = httptest.NewRecorder()
|
||||
req, _ = http.NewRequest("GET", "/channels/"+channelID, nil)
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Get: expected 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
// List
|
||||
w = httptest.NewRecorder()
|
||||
req, _ = http.NewRequest("GET", "/channels", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("List: expected 200, got %d", w.Code)
|
||||
}
|
||||
var listResp map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &listResp)
|
||||
if listResp["total"].(float64) < 1 {
|
||||
t.Error("List: expected at least 1 channel")
|
||||
}
|
||||
|
||||
// Update
|
||||
w = httptest.NewRecorder()
|
||||
req, _ = http.NewRequest("PUT", "/channels/"+channelID,
|
||||
strings.NewReader(`{"title":"Updated Title"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Update: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Delete
|
||||
w = httptest.NewRecorder()
|
||||
req, _ = http.NewRequest("DELETE", "/channels/"+channelID, nil)
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Delete: expected 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
// Verify gone
|
||||
w = httptest.NewRecorder()
|
||||
req, _ = http.NewRequest("GET", "/channels/"+channelID, nil)
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("Get after delete: expected 404, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegeneratePassesOwnershipCheck(t *testing.T) {
|
||||
database.RequireTestDB(t)
|
||||
database.TruncateAll(t)
|
||||
|
||||
userID := database.SeedTestUser(t, "regenuser", "regen@test.com")
|
||||
channelID := database.SeedTestChannel(t, userID, "Regen Test")
|
||||
|
||||
// Seed an assistant message to regenerate
|
||||
var msgID string
|
||||
err := database.DB.QueryRow(`
|
||||
INSERT INTO messages (channel_id, role, content, participant_type, participant_id)
|
||||
VALUES ($1, 'assistant', 'original response', 'model', 'test-model')
|
||||
RETURNING id
|
||||
`, channelID).Scan(&msgID)
|
||||
if err != nil {
|
||||
t.Fatalf("seed message: %v", err)
|
||||
}
|
||||
|
||||
h := NewMessageHandler()
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Set("user_id", userID)
|
||||
c.Params = gin.Params{
|
||||
{Key: "id", Value: channelID},
|
||||
{Key: "msgId", Value: msgID},
|
||||
}
|
||||
c.Request = httptest.NewRequest("POST",
|
||||
"/api/v1/channels/"+channelID+"/messages/"+msgID+"/regenerate",
|
||||
strings.NewReader(`{}`))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
h.Regenerate(c)
|
||||
|
||||
// Should NOT be 404 — ownership check passed. Will be 400 or 500
|
||||
// because no API config is set up, which is expected.
|
||||
if w.Code == http.StatusNotFound {
|
||||
t.Errorf("Expected to pass ownership check, got 404: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pagination Helpers ──────────────────────
|
||||
|
||||
func TestParsePaginationDefaults(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/api/v1/channels", nil)
|
||||
|
||||
page, perPage, offset := parsePagination(c)
|
||||
|
||||
if page != 1 {
|
||||
t.Errorf("Default page should be 1, got %d", page)
|
||||
}
|
||||
if perPage != 50 {
|
||||
t.Errorf("Default per_page should be 50, got %d", perPage)
|
||||
}
|
||||
if offset != 0 {
|
||||
t.Errorf("Default offset should be 0, got %d", offset)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePaginationCustom(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/api/v1/channels?page=3&per_page=10", nil)
|
||||
|
||||
page, perPage, offset := parsePagination(c)
|
||||
|
||||
if page != 3 {
|
||||
t.Errorf("Page should be 3, got %d", page)
|
||||
}
|
||||
if perPage != 10 {
|
||||
t.Errorf("Per page should be 10, got %d", perPage)
|
||||
}
|
||||
if offset != 20 {
|
||||
t.Errorf("Offset should be 20, got %d", offset)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePaginationClampMax(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/api/v1/channels?per_page=500", nil)
|
||||
|
||||
_, perPage, _ := parsePagination(c)
|
||||
|
||||
if perPage != 100 {
|
||||
t.Errorf("Per page should be clamped to 100, got %d", perPage)
|
||||
}
|
||||
}
|
||||
|
||||
// ── getUserID ───────────────────────────────
|
||||
|
||||
func TestGetUserID(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Set("user_id", "abc-123")
|
||||
|
||||
uid := getUserID(c)
|
||||
if uid != "abc-123" {
|
||||
t.Errorf("Expected abc-123, got %s", uid)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUserIDMissing(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
|
||||
uid := getUserID(c)
|
||||
if uid != "" {
|
||||
t.Errorf("Expected empty string, got %s", uid)
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,9 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/providers"
|
||||
capspkg "git.gobha.me/xcaliber/chat-switchboard/capabilities"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/tools"
|
||||
)
|
||||
|
||||
@@ -23,7 +25,7 @@ type completionRequest struct {
|
||||
Content string `json:"content" binding:"required"`
|
||||
Model string `json:"model,omitempty"`
|
||||
PresetID string `json:"preset_id,omitempty"` // if set, unwraps preset → base model + config
|
||||
APIConfigID string `json:"api_config_id,omitempty"`
|
||||
APIConfigID string `json:"provider_config_id,omitempty"`
|
||||
MaxTokens int `json:"max_tokens,omitempty"`
|
||||
Temperature *float64 `json:"temperature,omitempty"`
|
||||
TopP *float64 `json:"top_p,omitempty"`
|
||||
@@ -88,8 +90,8 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
|
||||
if req.Model == "" {
|
||||
req.Model = preset.BaseModelID
|
||||
}
|
||||
if req.APIConfigID == "" && preset.APIConfigID != nil {
|
||||
req.APIConfigID = *preset.APIConfigID
|
||||
if req.APIConfigID == "" && preset.ProviderConfigID != nil {
|
||||
req.APIConfigID = *preset.ProviderConfigID
|
||||
}
|
||||
if req.Temperature == nil && preset.Temperature != nil {
|
||||
req.Temperature = preset.Temperature
|
||||
@@ -152,7 +154,7 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
|
||||
provReq.MaxTokens = req.MaxTokens
|
||||
} else {
|
||||
// ResolveMaxOutput checks: caps → known models → context/8 → 4096
|
||||
provReq.MaxTokens = providers.ResolveMaxOutput(model, caps)
|
||||
provReq.MaxTokens = capspkg.ResolveMaxOutput(model, caps)
|
||||
}
|
||||
|
||||
if req.Temperature != nil {
|
||||
@@ -477,14 +479,14 @@ func escapeJSON(s string) string {
|
||||
return s
|
||||
}
|
||||
|
||||
// getModelCapabilities looks up capabilities from model_configs DB,
|
||||
// getModelCapabilities looks up capabilities from model_catalog DB,
|
||||
// then overlays with known model defaults and heuristic detection.
|
||||
func (h *CompletionHandler) getModelCapabilities(c *gin.Context, model, apiConfigID string) providers.ModelCapabilities {
|
||||
func (h *CompletionHandler) getModelCapabilities(c *gin.Context, model, apiConfigID string) models.ModelCapabilities {
|
||||
return ResolveModelCaps(c, model, apiConfigID)
|
||||
}
|
||||
|
||||
// ── Config Resolution ───────────────────────
|
||||
// Priority: request.api_config_id → chat.api_config_id → user's first active config
|
||||
// Priority: request.provider_config_id → chat.provider_config_id → user's first active config
|
||||
|
||||
func (h *CompletionHandler) resolveConfig(userID string, channelID string, req completionRequest) (providers.ProviderConfig, string, string, string, error) {
|
||||
var configID string
|
||||
@@ -498,7 +500,7 @@ func (h *CompletionHandler) resolveConfig(userID string, channelID string, req c
|
||||
if configID == "" {
|
||||
var channelConfigID *string
|
||||
err := database.DB.QueryRow(
|
||||
`SELECT api_config_id FROM channels WHERE id = $1`, channelID,
|
||||
`SELECT provider_config_id FROM channels WHERE id = $1`, channelID,
|
||||
).Scan(&channelConfigID)
|
||||
if err == nil && channelConfigID != nil {
|
||||
configID = *channelConfigID
|
||||
@@ -508,9 +510,12 @@ func (h *CompletionHandler) resolveConfig(userID string, channelID string, req c
|
||||
// 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) AND is_active = true AND team_id IS NULL
|
||||
ORDER BY user_id NULLS LAST, created_at ASC
|
||||
SELECT id FROM provider_configs
|
||||
WHERE is_active = true AND (
|
||||
(scope = 'personal' AND owner_id = $1)
|
||||
OR scope = 'global'
|
||||
)
|
||||
ORDER BY scope ASC, created_at ASC
|
||||
LIMIT 1
|
||||
`, userID).Scan(&configID)
|
||||
if err != nil {
|
||||
@@ -523,11 +528,12 @@ func (h *CompletionHandler) resolveConfig(userID string, channelID string, req c
|
||||
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
|
||||
SELECT provider, endpoint, api_key_enc, model_default, headers, settings
|
||||
FROM provider_configs
|
||||
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))
|
||||
AND (scope = 'global'
|
||||
OR (scope = 'personal' AND owner_id = $2)
|
||||
OR (scope = 'team' AND owner_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 {
|
||||
|
||||
1563
server/handlers/integration_test.go
Normal file
1563
server/handlers/integration_test.go
Normal file
File diff suppressed because it is too large
Load Diff
457
server/handlers/live_provider_test.go
Normal file
457
server/handlers/live_provider_test.go
Normal file
@@ -0,0 +1,457 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||
)
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// Live Provider Integration Tests
|
||||
// ═══════════════════════════════════════════
|
||||
// These tests require:
|
||||
// - TEST_DATABASE_URL or PGHOST+PGUSER
|
||||
// - VENICE_API_KEY secret
|
||||
//
|
||||
// They exercise the full flow: create provider →
|
||||
// fetch models → enable model → resolve → complete.
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
func requireVeniceKey(t *testing.T) string {
|
||||
t.Helper()
|
||||
key := os.Getenv("VENICE_API_KEY")
|
||||
if key == "" {
|
||||
t.Skip("VENICE_API_KEY not set — skipping live provider test")
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
// TestLive_VeniceProviderFullFlow exercises the complete admin workflow:
|
||||
// create provider → fetch models → enable a model → user sees it → chat completion
|
||||
func TestLive_VeniceProviderFullFlow(t *testing.T) {
|
||||
h := setupHarness(t)
|
||||
veniceKey := requireVeniceKey(t)
|
||||
_, adminToken := h.createAdminUser("admin", "admin@test.com")
|
||||
|
||||
// ── 1. Create Venice provider config ────
|
||||
t.Log("Step 1: Creating Venice provider config")
|
||||
w := h.request("POST", "/api/v1/admin/configs", adminToken, map[string]interface{}{
|
||||
"name": "Venice Live Test",
|
||||
"provider": "venice",
|
||||
"endpoint": "https://api.venice.ai/api/v1",
|
||||
"api_key": veniceKey,
|
||||
})
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("create venice config: want 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var configResp map[string]interface{}
|
||||
decode(w, &configResp)
|
||||
configID := configResp["id"].(string)
|
||||
t.Logf(" Created config: %s", configID)
|
||||
|
||||
// ── 2. Fetch models from Venice ─────────
|
||||
t.Log("Step 2: Fetching models from Venice API")
|
||||
w = h.request("POST", "/api/v1/admin/models/fetch", adminToken, map[string]interface{}{
|
||||
"provider_config_id": configID,
|
||||
})
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("fetch models: want 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var fetchResp map[string]interface{}
|
||||
decode(w, &fetchResp)
|
||||
totalFetched := fetchResp["total"].(float64)
|
||||
if totalFetched < 1 {
|
||||
t.Fatalf("Venice should return at least 1 model, got %.0f", totalFetched)
|
||||
}
|
||||
t.Logf(" Fetched %.0f models from Venice", totalFetched)
|
||||
|
||||
// ── 3. List catalog models (all disabled by default) ──
|
||||
t.Log("Step 3: Listing catalog models")
|
||||
w = h.request("GET", "/api/v1/admin/models", adminToken, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("list models: want 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var modelsResp map[string]interface{}
|
||||
decode(w, &modelsResp)
|
||||
catalogModels := modelsResp["models"].([]interface{})
|
||||
if len(catalogModels) < 1 {
|
||||
t.Fatal("catalog should have models after fetch")
|
||||
}
|
||||
|
||||
// Find a text model to enable (prefer a small/fast one)
|
||||
var enableID string
|
||||
var enableModelID string
|
||||
for _, raw := range catalogModels {
|
||||
m := raw.(map[string]interface{})
|
||||
modelID := m["model_id"].(string)
|
||||
vis := m["visibility"].(string)
|
||||
if vis == "disabled" {
|
||||
enableID = m["id"].(string)
|
||||
enableModelID = modelID
|
||||
break
|
||||
}
|
||||
}
|
||||
if enableID == "" {
|
||||
t.Fatal("no disabled model found to enable")
|
||||
}
|
||||
t.Logf(" Will enable: %s (catalog ID: %s)", enableModelID, enableID)
|
||||
|
||||
// ── 4. Enable the model ─────────────────
|
||||
t.Log("Step 4: Enabling model")
|
||||
w = h.request("PUT", "/api/v1/admin/models/"+enableID, adminToken,
|
||||
map[string]interface{}{"visibility": "enabled"})
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("enable model: want 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// ── 5. Verify models/enabled returns it (admin) ──
|
||||
t.Log("Step 5: Verifying models/enabled (admin)")
|
||||
w = h.request("GET", "/api/v1/models/enabled", adminToken, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("models/enabled: want 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var enabledResp map[string]interface{}
|
||||
decode(w, &enabledResp)
|
||||
enabledModels := enabledResp["models"].([]interface{})
|
||||
if len(enabledModels) < 1 {
|
||||
t.Fatal("models/enabled should return at least 1 model after enabling")
|
||||
}
|
||||
|
||||
// Verify our model is in the list
|
||||
found := false
|
||||
for _, raw := range enabledModels {
|
||||
m := raw.(map[string]interface{})
|
||||
if m["model_id"] == enableModelID {
|
||||
found = true
|
||||
t.Logf(" ✓ Found %s in enabled models", enableModelID)
|
||||
|
||||
// Verify it has the required fields for the frontend
|
||||
if m["config_id"] == nil || m["config_id"] == "" {
|
||||
t.Error("enabled model must have config_id for composite ID")
|
||||
}
|
||||
if m["provider_name"] == nil || m["provider_name"] == "" {
|
||||
t.Error("enabled model must have provider_name for display")
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("enabled model %s not found in models/enabled response", enableModelID)
|
||||
}
|
||||
|
||||
// ── 6. Verify a REGULAR USER also sees the model ──
|
||||
t.Log("Step 6: Verifying models/enabled (regular user)")
|
||||
userID := database.SeedTestUser(t, "liveuser", "liveuser@test.com")
|
||||
database.TestDB.Exec("UPDATE users SET is_active = true WHERE id = $1", userID)
|
||||
userToken := makeToken(userID, "liveuser@test.com", "user")
|
||||
|
||||
w = h.request("GET", "/api/v1/models/enabled", userToken, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("user models/enabled: want 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var userResp map[string]interface{}
|
||||
decode(w, &userResp)
|
||||
userModels := userResp["models"].([]interface{})
|
||||
if len(userModels) < 1 {
|
||||
t.Fatalf("regular user should see at least 1 enabled model, got %d — "+
|
||||
"admin can see models but regular user cannot; check ListVisible query",
|
||||
len(userModels))
|
||||
}
|
||||
|
||||
userFound := false
|
||||
for _, raw := range userModels {
|
||||
m := raw.(map[string]interface{})
|
||||
if m["model_id"] == enableModelID {
|
||||
userFound = true
|
||||
t.Logf(" ✓ Regular user can see %s", enableModelID)
|
||||
|
||||
// Verify same fields available for regular user
|
||||
if m["config_id"] == nil || m["config_id"] == "" {
|
||||
t.Error("user: enabled model must have config_id")
|
||||
}
|
||||
if m["provider_name"] == nil || m["provider_name"] == "" {
|
||||
t.Error("user: enabled model must have provider_name")
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if !userFound {
|
||||
t.Errorf("regular user cannot see %s — admin→user visibility broken", enableModelID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLive_VeniceFetchModelsCapabilities verifies that Venice model
|
||||
// capabilities are correctly parsed into the catalog.
|
||||
func TestLive_VeniceFetchModelsCapabilities(t *testing.T) {
|
||||
h := setupHarness(t)
|
||||
veniceKey := requireVeniceKey(t)
|
||||
_, adminToken := h.createAdminUser("admin", "admin@test.com")
|
||||
|
||||
// Create provider + fetch
|
||||
w := h.request("POST", "/api/v1/admin/configs", adminToken, map[string]interface{}{
|
||||
"name": "Venice Caps Test", "provider": "venice",
|
||||
"endpoint": "https://api.venice.ai/api/v1", "api_key": veniceKey,
|
||||
})
|
||||
var cfg map[string]interface{}
|
||||
decode(w, &cfg)
|
||||
configID := cfg["id"].(string)
|
||||
|
||||
h.request("POST", "/api/v1/admin/models/fetch", adminToken,
|
||||
map[string]interface{}{"provider_config_id": configID})
|
||||
|
||||
// Read catalog and check capabilities
|
||||
w = h.request("GET", "/api/v1/admin/models", adminToken, nil)
|
||||
var resp map[string]interface{}
|
||||
decode(w, &resp)
|
||||
|
||||
for _, raw := range resp["models"].([]interface{}) {
|
||||
m := raw.(map[string]interface{})
|
||||
caps, ok := m["capabilities"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Errorf("model %s: capabilities must be an object", m["model_id"])
|
||||
continue
|
||||
}
|
||||
|
||||
// streaming should always be true for Venice
|
||||
if caps["streaming"] != true {
|
||||
t.Errorf("model %s: streaming should be true", m["model_id"])
|
||||
}
|
||||
|
||||
// Verify capabilities are actual booleans (not strings)
|
||||
for _, key := range []string{"streaming", "vision", "tool_calling", "reasoning"} {
|
||||
if v, exists := caps[key]; exists {
|
||||
if _, ok := v.(bool); !ok {
|
||||
t.Errorf("model %s: capability %s should be bool, got %T", m["model_id"], key, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestLive_VeniceChatCompletion sends an actual chat completion.
|
||||
func TestLive_VeniceChatCompletion(t *testing.T) {
|
||||
h := setupHarness(t)
|
||||
veniceKey := requireVeniceKey(t)
|
||||
adminID, adminToken := h.createAdminUser("admin", "admin@test.com")
|
||||
_ = adminID
|
||||
|
||||
// Create provider
|
||||
w := h.request("POST", "/api/v1/admin/configs", adminToken, map[string]interface{}{
|
||||
"name": "Venice Chat Test", "provider": "venice",
|
||||
"endpoint": "https://api.venice.ai/api/v1", "api_key": veniceKey,
|
||||
})
|
||||
var cfg map[string]interface{}
|
||||
decode(w, &cfg)
|
||||
configID := cfg["id"].(string)
|
||||
|
||||
// Fetch + enable a fast model
|
||||
h.request("POST", "/api/v1/admin/models/fetch", adminToken,
|
||||
map[string]interface{}{"provider_config_id": configID})
|
||||
|
||||
w = h.request("GET", "/api/v1/admin/models", adminToken, nil)
|
||||
var modelsResp map[string]interface{}
|
||||
decode(w, &modelsResp)
|
||||
|
||||
// Find and enable a small model (prefer llama or qwen for speed)
|
||||
var targetModelID, targetCatalogID string
|
||||
for _, raw := range modelsResp["models"].([]interface{}) {
|
||||
m := raw.(map[string]interface{})
|
||||
mid := m["model_id"].(string)
|
||||
// Pick any available model - first disabled one
|
||||
if m["visibility"].(string) == "disabled" {
|
||||
targetModelID = mid
|
||||
targetCatalogID = m["id"].(string)
|
||||
break
|
||||
}
|
||||
}
|
||||
if targetModelID == "" {
|
||||
t.Skip("no model available to test chat completion")
|
||||
}
|
||||
|
||||
h.request("PUT", "/api/v1/admin/models/"+targetCatalogID, adminToken,
|
||||
map[string]interface{}{"visibility": "enabled"})
|
||||
|
||||
// Set as default model and send completion
|
||||
// First get enabled model's composite ID
|
||||
w = h.request("GET", "/api/v1/models/enabled", adminToken, nil)
|
||||
var enabled map[string]interface{}
|
||||
decode(w, &enabled)
|
||||
if len(enabled["models"].([]interface{})) == 0 {
|
||||
t.Fatal("no enabled models for completion test")
|
||||
}
|
||||
firstModel := enabled["models"].([]interface{})[0].(map[string]interface{})
|
||||
modelForChat := firstModel["model_id"].(string)
|
||||
configForChat := firstModel["config_id"].(string)
|
||||
|
||||
t.Logf("Sending completion to %s via config %s", modelForChat, configForChat)
|
||||
|
||||
// Create a channel first
|
||||
w = h.request("POST", "/api/v1/channels", adminToken, map[string]interface{}{
|
||||
"title": "Test Chat", "type": "direct",
|
||||
})
|
||||
if w.Code != http.StatusCreated {
|
||||
// Some channel handlers may use database.DB directly
|
||||
t.Skipf("channel creation failed (may need database.DB global): %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
var ch map[string]interface{}
|
||||
decode(w, &ch)
|
||||
channelID := ch["id"].(string)
|
||||
|
||||
// Send completion
|
||||
w = h.request("POST", "/api/v1/chat/completions", adminToken, map[string]interface{}{
|
||||
"channel_id": channelID,
|
||||
"model": modelForChat,
|
||||
"config_id": configForChat,
|
||||
"stream": false,
|
||||
"messages": []map[string]string{
|
||||
{"role": "user", "content": "Say hello in exactly 3 words."},
|
||||
},
|
||||
})
|
||||
if w.Code != http.StatusOK {
|
||||
t.Logf("completion response: %s", w.Body.String())
|
||||
t.Skipf("chat completion failed with %d (may need full router wiring)", w.Code)
|
||||
}
|
||||
t.Logf(" ✓ Completion succeeded: %s", w.Body.String()[:min(200, w.Body.Len())])
|
||||
}
|
||||
|
||||
// TestLive_VeniceModelDeletion tests cleanup: delete provider removes catalog entries
|
||||
func TestLive_VeniceModelDeletion(t *testing.T) {
|
||||
h := setupHarness(t)
|
||||
veniceKey := requireVeniceKey(t)
|
||||
_, adminToken := h.createAdminUser("admin", "admin@test.com")
|
||||
|
||||
// Create + fetch
|
||||
w := h.request("POST", "/api/v1/admin/configs", adminToken, map[string]interface{}{
|
||||
"name": "Venice Delete Test", "provider": "venice",
|
||||
"endpoint": "https://api.venice.ai/api/v1", "api_key": veniceKey,
|
||||
})
|
||||
var cfg map[string]interface{}
|
||||
decode(w, &cfg)
|
||||
configID := cfg["id"].(string)
|
||||
|
||||
h.request("POST", "/api/v1/admin/models/fetch", adminToken,
|
||||
map[string]interface{}{"provider_config_id": configID})
|
||||
|
||||
// Verify models exist
|
||||
var count int
|
||||
database.TestDB.QueryRow("SELECT COUNT(*) FROM model_catalog WHERE provider_config_id = $1", configID).Scan(&count)
|
||||
if count == 0 {
|
||||
t.Fatal("catalog should have models after fetch")
|
||||
}
|
||||
t.Logf(" %d models in catalog before delete", count)
|
||||
|
||||
// Delete the provider
|
||||
w = h.request("DELETE", "/api/v1/admin/configs/"+configID, adminToken, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("delete config: want 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Verify cascade: catalog entries should be gone
|
||||
database.TestDB.QueryRow("SELECT COUNT(*) FROM model_catalog WHERE provider_config_id = $1", configID).Scan(&count)
|
||||
if count != 0 {
|
||||
t.Errorf("catalog should be empty after provider delete, got %d entries", count)
|
||||
}
|
||||
t.Log(" ✓ Cascade delete cleaned up catalog entries")
|
||||
}
|
||||
|
||||
// TestLive_VeniceBYOK_AutoFetch exercises the ACTUAL user experience:
|
||||
// user creates a BYOK provider → auto-fetch triggers → models appear in /models/enabled
|
||||
//
|
||||
// This is the definitive test. No simulated data. Real Venice API.
|
||||
func TestLive_VeniceBYOK_AutoFetch(t *testing.T) {
|
||||
h := setupHarness(t)
|
||||
veniceKey := requireVeniceKey(t)
|
||||
_, adminToken := h.createAdminUser("admin", "admin@test.com")
|
||||
|
||||
// Enable BYOK policy
|
||||
h.request("PUT", "/api/v1/admin/settings/allow_user_byok", adminToken,
|
||||
map[string]interface{}{"value": "true"})
|
||||
|
||||
// Create regular user
|
||||
userID := database.SeedTestUser(t, "byokuser", "byokuser@test.com")
|
||||
database.TestDB.Exec("UPDATE users SET is_active = true WHERE id = $1", userID)
|
||||
userToken := makeToken(userID, "byokuser@test.com", "user")
|
||||
|
||||
// ── Step 1: User creates BYOK provider (the ONLY user action) ──
|
||||
t.Log("Step 1: User creates BYOK Venice provider")
|
||||
w := h.request("POST", "/api/v1/api-configs", userToken, map[string]interface{}{
|
||||
"name": "My Venice",
|
||||
"provider": "venice",
|
||||
"endpoint": "https://api.venice.ai/api/v1",
|
||||
"api_key": veniceKey,
|
||||
})
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("create BYOK provider: want 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var created map[string]interface{}
|
||||
decode(w, &created)
|
||||
cfgID := created["id"].(string)
|
||||
t.Logf(" Created provider: %s", cfgID)
|
||||
|
||||
// ── Step 2: Verify auto-fetch happened ──
|
||||
if created["warning"] != nil {
|
||||
t.Fatalf("auto-fetch should succeed with real Venice key, got warning: %v", created["warning"])
|
||||
}
|
||||
modelsFetched := created["models_fetched"]
|
||||
if modelsFetched == nil || modelsFetched.(float64) < 1 {
|
||||
t.Fatalf("auto-fetch should return models_fetched > 0, got: %v", modelsFetched)
|
||||
}
|
||||
t.Logf(" Auto-fetched %.0f models", modelsFetched.(float64))
|
||||
|
||||
// ── Step 3: User's models/enabled shows personal models ──
|
||||
t.Log("Step 3: Verify user sees BYOK models in /models/enabled")
|
||||
w = h.request("GET", "/api/v1/models/enabled", userToken, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("models/enabled: %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
decode(w, &resp)
|
||||
userModels := resp["models"].([]interface{})
|
||||
|
||||
personalCount := 0
|
||||
for _, raw := range userModels {
|
||||
m := raw.(map[string]interface{})
|
||||
if m["scope"] == "personal" {
|
||||
personalCount++
|
||||
}
|
||||
}
|
||||
|
||||
if personalCount < 1 {
|
||||
t.Fatalf("user should see personal BYOK models, got %d personal out of %d total\n"+
|
||||
" model IDs: %v",
|
||||
personalCount, len(userModels), func() []string {
|
||||
ids := make([]string, 0)
|
||||
for _, raw := range userModels {
|
||||
m := raw.(map[string]interface{})
|
||||
ids = append(ids, fmt.Sprintf("%s(scope=%s)", m["model_id"], m["scope"]))
|
||||
}
|
||||
return ids
|
||||
}())
|
||||
}
|
||||
t.Logf(" ✓ User sees %d personal BYOK models (out of %d total)", personalCount, len(userModels))
|
||||
|
||||
// ── Step 4: Verify model fields for frontend ──
|
||||
t.Log("Step 4: Verify frontend-required fields on BYOK models")
|
||||
for _, raw := range userModels {
|
||||
m := raw.(map[string]interface{})
|
||||
if m["scope"] != "personal" {
|
||||
continue
|
||||
}
|
||||
if m["config_id"] == nil || m["config_id"] == "" {
|
||||
t.Errorf("personal model %s missing config_id", m["model_id"])
|
||||
}
|
||||
if m["provider_name"] == nil || m["provider_name"] == "" {
|
||||
t.Errorf("personal model %s missing provider_name", m["model_id"])
|
||||
}
|
||||
if m["model_id"] == nil || m["model_id"] == "" {
|
||||
t.Errorf("personal model missing model_id")
|
||||
}
|
||||
break // check first personal model only
|
||||
}
|
||||
|
||||
// ── Cleanup ──
|
||||
h.request("DELETE", fmt.Sprintf("/api/v1/api-configs/%s", cfgID), userToken, nil)
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
capspkg "git.gobha.me/xcaliber/chat-switchboard/capabilities"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/providers"
|
||||
)
|
||||
@@ -43,7 +44,7 @@ type editRequest struct {
|
||||
type regenerateRequest struct {
|
||||
Model string `json:"model,omitempty"`
|
||||
PresetID string `json:"preset_id,omitempty"`
|
||||
APIConfigID string `json:"api_config_id,omitempty"`
|
||||
APIConfigID string `json:"provider_config_id,omitempty"`
|
||||
MaxTokens int `json:"max_tokens,omitempty"`
|
||||
Temperature *float64 `json:"temperature,omitempty"`
|
||||
}
|
||||
@@ -369,8 +370,8 @@ func (h *MessageHandler) Regenerate(c *gin.Context) {
|
||||
if model == "" {
|
||||
model = preset.BaseModelID
|
||||
}
|
||||
if apiConfigID == "" && preset.APIConfigID != nil {
|
||||
apiConfigID = *preset.APIConfigID
|
||||
if apiConfigID == "" && preset.ProviderConfigID != nil {
|
||||
apiConfigID = *preset.ProviderConfigID
|
||||
}
|
||||
if temperature == nil && preset.Temperature != nil {
|
||||
temperature = preset.Temperature
|
||||
@@ -436,7 +437,7 @@ func (h *MessageHandler) Regenerate(c *gin.Context) {
|
||||
if maxTokens > 0 {
|
||||
provReq.MaxTokens = maxTokens
|
||||
} else {
|
||||
provReq.MaxTokens = providers.ResolveMaxOutput(model, caps)
|
||||
provReq.MaxTokens = capspkg.ResolveMaxOutput(model, caps)
|
||||
}
|
||||
if temperature != nil {
|
||||
provReq.Temperature = temperature
|
||||
|
||||
@@ -1,77 +1,69 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
)
|
||||
|
||||
// GetModelPreferences returns the user's hidden model list.
|
||||
// GET /api/v1/models/preferences
|
||||
func GetModelPreferences(c *gin.Context) {
|
||||
// ModelPrefsHandler handles user model preference endpoints.
|
||||
type ModelPrefsHandler struct {
|
||||
stores store.Stores
|
||||
}
|
||||
|
||||
func NewModelPrefsHandler(s store.Stores) *ModelPrefsHandler {
|
||||
return &ModelPrefsHandler{stores: s}
|
||||
}
|
||||
|
||||
// GetPreferences returns the user's model preferences.
|
||||
func (h *ModelPrefsHandler) GetPreferences(c *gin.Context) {
|
||||
userID := getUserID(c)
|
||||
|
||||
rows, err := database.DB.Query(`
|
||||
SELECT model_id, hidden FROM user_model_preferences
|
||||
WHERE user_id = $1
|
||||
`, userID)
|
||||
prefs, err := h.stores.UserSettings.GetForUser(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to query preferences"})
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get preferences"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type pref struct {
|
||||
ModelID string `json:"model_id"`
|
||||
Hidden bool `json:"hidden"`
|
||||
}
|
||||
prefs := make([]pref, 0)
|
||||
for rows.Next() {
|
||||
var p pref
|
||||
if err := rows.Scan(&p.ModelID, &p.Hidden); err != nil {
|
||||
continue
|
||||
}
|
||||
prefs = append(prefs, p)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"preferences": prefs})
|
||||
}
|
||||
|
||||
// SetModelPreference sets hidden state for a single model.
|
||||
// PUT /api/v1/models/preferences
|
||||
func SetModelPreference(c *gin.Context) {
|
||||
// SetPreference upserts a single model preference.
|
||||
func (h *ModelPrefsHandler) SetPreference(c *gin.Context) {
|
||||
userID := getUserID(c)
|
||||
|
||||
var req struct {
|
||||
ModelID string `json:"model_id" binding:"required"`
|
||||
Hidden bool `json:"hidden"`
|
||||
ModelID string `json:"model_id" binding:"required"`
|
||||
Hidden *bool `json:"hidden,omitempty"`
|
||||
PreferredTemperature *float64 `json:"preferred_temperature,omitempty"`
|
||||
PreferredMaxTokens *int `json:"preferred_max_tokens,omitempty"`
|
||||
SortOrder *int `json:"sort_order,omitempty"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
_, err := database.DB.Exec(`
|
||||
INSERT INTO user_model_preferences (user_id, model_id, hidden, updated_at)
|
||||
VALUES ($1, $2, $3, NOW())
|
||||
ON CONFLICT (user_id, model_id)
|
||||
DO UPDATE SET hidden = EXCLUDED.hidden, updated_at = NOW()
|
||||
`, userID, req.ModelID, req.Hidden)
|
||||
if err != nil {
|
||||
log.Printf("[WARN] Failed to save model preference for user %s, model %s: %v", userID, req.ModelID, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save preference: " + err.Error()})
|
||||
patch := models.UserModelSettingPatch{
|
||||
Hidden: req.Hidden,
|
||||
PreferredTemperature: req.PreferredTemperature,
|
||||
PreferredMaxTokens: req.PreferredMaxTokens,
|
||||
SortOrder: req.SortOrder,
|
||||
}
|
||||
|
||||
if err := h.stores.UserSettings.Set(c.Request.Context(), userID, req.ModelID, patch); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to set preference"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"model_id": req.ModelID, "hidden": req.Hidden})
|
||||
c.JSON(http.StatusOK, gin.H{"message": "preference updated"})
|
||||
}
|
||||
|
||||
// BulkSetModelPreferences sets hidden state for multiple models at once.
|
||||
// POST /api/v1/models/preferences/bulk
|
||||
func BulkSetModelPreferences(c *gin.Context) {
|
||||
// BulkSetPreferences sets hidden state for multiple models at once.
|
||||
func (h *ModelPrefsHandler) BulkSetPreferences(c *gin.Context) {
|
||||
userID := getUserID(c)
|
||||
|
||||
var req struct {
|
||||
@@ -83,43 +75,10 @@ func BulkSetModelPreferences(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if len(req.ModelIDs) == 0 {
|
||||
c.JSON(http.StatusOK, gin.H{"updated": 0})
|
||||
if err := h.stores.UserSettings.BulkSetHidden(c.Request.Context(), userID, req.ModelIDs, req.Hidden); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to bulk update"})
|
||||
return
|
||||
}
|
||||
|
||||
tx, err := database.DB.Begin()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to begin transaction"})
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
stmt, err := tx.Prepare(`
|
||||
INSERT INTO user_model_preferences (user_id, model_id, hidden, updated_at)
|
||||
VALUES ($1, $2, $3, NOW())
|
||||
ON CONFLICT (user_id, model_id)
|
||||
DO UPDATE SET hidden = EXCLUDED.hidden, updated_at = NOW()
|
||||
`)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to prepare statement"})
|
||||
return
|
||||
}
|
||||
defer stmt.Close()
|
||||
|
||||
updated := 0
|
||||
for _, modelID := range req.ModelIDs {
|
||||
if _, err := stmt.Exec(userID, modelID, req.Hidden); err != nil {
|
||||
log.Printf("[WARN] Failed to save preference for model %s: %v", modelID, err)
|
||||
continue
|
||||
}
|
||||
updated++
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to commit"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"updated": updated})
|
||||
c.JSON(http.StatusOK, gin.H{"message": "preferences updated", "count": len(req.ModelIDs)})
|
||||
}
|
||||
|
||||
97
server/handlers/model_sync.go
Normal file
97
server/handlers/model_sync.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/providers"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
)
|
||||
|
||||
// syncResult holds the outcome of a model sync operation.
|
||||
type syncResult struct {
|
||||
Added int `json:"added"`
|
||||
Updated int `json:"updated"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
// syncProviderModels fetches models from a provider's API and syncs them into the catalog.
|
||||
// New models default to 'disabled' visibility (admin must explicitly enable for global providers).
|
||||
func syncProviderModels(ctx context.Context, stores store.Stores, cfg *models.ProviderConfig) (syncResult, error) {
|
||||
prov, err := providers.Get(cfg.Provider)
|
||||
if err != nil {
|
||||
return syncResult{}, fmt.Errorf("unknown provider type: %s", cfg.Provider)
|
||||
}
|
||||
|
||||
provCfg := providers.ProviderConfig{
|
||||
Endpoint: cfg.Endpoint,
|
||||
APIKey: cfg.APIKeyEnc,
|
||||
}
|
||||
|
||||
if cfg.Headers != nil {
|
||||
customHeaders := make(map[string]string)
|
||||
for k, v := range cfg.Headers {
|
||||
if s, ok := v.(string); ok {
|
||||
customHeaders[k] = s
|
||||
}
|
||||
}
|
||||
provCfg.CustomHeaders = customHeaders
|
||||
}
|
||||
|
||||
// Parse config for any extra settings the provider needs
|
||||
if cfg.Config != nil {
|
||||
raw, _ := json.Marshal(cfg.Config)
|
||||
var extra map[string]string
|
||||
if json.Unmarshal(raw, &extra) == nil {
|
||||
if provCfg.CustomHeaders == nil {
|
||||
provCfg.CustomHeaders = make(map[string]string)
|
||||
}
|
||||
for k, v := range extra {
|
||||
provCfg.CustomHeaders[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
provModels, err := prov.ListModels(ctx, provCfg)
|
||||
if err != nil {
|
||||
return syncResult{}, err
|
||||
}
|
||||
|
||||
syncEntries := make([]store.CatalogSyncEntry, len(provModels))
|
||||
for i, m := range provModels {
|
||||
syncEntries[i] = store.CatalogSyncEntry{
|
||||
ModelID: m.ID,
|
||||
DisplayName: m.Name,
|
||||
Capabilities: m.Capabilities,
|
||||
Pricing: m.Pricing,
|
||||
}
|
||||
}
|
||||
|
||||
added, updated, err := stores.Catalog.UpsertFromSync(ctx, cfg.ID, syncEntries)
|
||||
if err != nil {
|
||||
return syncResult{}, fmt.Errorf("failed to sync: %w", err)
|
||||
}
|
||||
|
||||
return syncResult{Added: added, Updated: updated, Total: len(provModels)}, nil
|
||||
}
|
||||
|
||||
// syncAndEnableProviderModels fetches models and auto-enables them all.
|
||||
// Used for personal (BYOK) providers — the user explicitly added this provider to use it.
|
||||
func syncAndEnableProviderModels(ctx context.Context, stores store.Stores, cfg *models.ProviderConfig) (syncResult, error) {
|
||||
result, err := syncProviderModels(ctx, stores, cfg)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
|
||||
// Auto-enable: user added this key to USE it, not to stare at disabled models
|
||||
if result.Total > 0 {
|
||||
if err := stores.Catalog.BulkSetVisibility(ctx, cfg.ID, "enabled"); err != nil {
|
||||
log.Printf("warn: auto-enable models for provider %s failed: %v", cfg.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
@@ -1,329 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||
)
|
||||
|
||||
// ── Notes: Validation (no DB needed) ────────
|
||||
|
||||
func TestCreateNoteMissingTitle(t *testing.T) {
|
||||
h := NewNoteHandler()
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Set("user_id", "test-user")
|
||||
c.Request = httptest.NewRequest("POST", "/api/v1/notes",
|
||||
strings.NewReader(`{"content":"body only"}`))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
h.Create(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400 for missing title, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateNoteMissingContent(t *testing.T) {
|
||||
h := NewNoteHandler()
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Set("user_id", "test-user")
|
||||
c.Request = httptest.NewRequest("POST", "/api/v1/notes",
|
||||
strings.NewReader(`{"title":"title only"}`))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
h.Create(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400 for missing content, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ── Notes: Full CRUD Integration ────────────
|
||||
|
||||
func TestNoteCRUDIntegration(t *testing.T) {
|
||||
database.RequireTestDB(t)
|
||||
database.TruncateAll(t)
|
||||
|
||||
userID := database.SeedTestUser(t, "noteuser", "note@test.com")
|
||||
|
||||
h := NewNoteHandler()
|
||||
r := gin.New()
|
||||
r.Use(func(c *gin.Context) { c.Set("user_id", userID); c.Next() })
|
||||
r.POST("/notes", h.Create)
|
||||
r.GET("/notes", h.List)
|
||||
r.GET("/notes/search", h.Search)
|
||||
r.GET("/notes/folders", h.ListFolders)
|
||||
r.GET("/notes/:id", h.Get)
|
||||
r.PUT("/notes/:id", h.Update)
|
||||
r.DELETE("/notes/:id", h.Delete)
|
||||
|
||||
// ── Create ──
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("POST", "/notes",
|
||||
strings.NewReader(`{
|
||||
"title": "Meeting Notes",
|
||||
"content": "Discussed project timeline and deliverables",
|
||||
"folder_path": "/work/meetings",
|
||||
"tags": ["project", "planning"]
|
||||
}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("Create: expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var created map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &created)
|
||||
noteID, ok := created["id"].(string)
|
||||
if !ok || noteID == "" {
|
||||
t.Fatal("Create: missing or empty id in response")
|
||||
}
|
||||
if created["title"] != "Meeting Notes" {
|
||||
t.Errorf("Create: title mismatch: %v", created["title"])
|
||||
}
|
||||
if created["folder_path"] != "/work/meetings/" {
|
||||
t.Errorf("Create: folder_path should be normalized, got %v", created["folder_path"])
|
||||
}
|
||||
|
||||
// ── Create second note for search/list tests ──
|
||||
w = httptest.NewRecorder()
|
||||
req, _ = http.NewRequest("POST", "/notes",
|
||||
strings.NewReader(`{
|
||||
"title": "Recipe Ideas",
|
||||
"content": "Try making sourdough bread with rosemary",
|
||||
"folder_path": "/personal",
|
||||
"tags": ["food", "recipes"]
|
||||
}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("Create 2nd note: expected 201, got %d", w.Code)
|
||||
}
|
||||
|
||||
// ── Get ──
|
||||
w = httptest.NewRecorder()
|
||||
req, _ = http.NewRequest("GET", "/notes/"+noteID, nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("Get: expected 200, got %d", w.Code)
|
||||
}
|
||||
var got map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &got)
|
||||
if got["content"] != "Discussed project timeline and deliverables" {
|
||||
t.Errorf("Get: wrong content: %v", got["content"])
|
||||
}
|
||||
|
||||
// ── List (all) ──
|
||||
w = httptest.NewRecorder()
|
||||
req, _ = http.NewRequest("GET", "/notes", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("List: expected 200, got %d", w.Code)
|
||||
}
|
||||
var listResp map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &listResp)
|
||||
total := listResp["total"].(float64)
|
||||
if total != 2 {
|
||||
t.Errorf("List: expected total=2, got %.0f", total)
|
||||
}
|
||||
|
||||
// ── List (filtered by folder) ──
|
||||
w = httptest.NewRecorder()
|
||||
req, _ = http.NewRequest("GET", "/notes?folder=/work/meetings", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("List by folder: expected 200, got %d", w.Code)
|
||||
}
|
||||
json.Unmarshal(w.Body.Bytes(), &listResp)
|
||||
total = listResp["total"].(float64)
|
||||
if total != 1 {
|
||||
t.Errorf("List by folder: expected total=1, got %.0f", total)
|
||||
}
|
||||
|
||||
// ── List (filtered by tag) ──
|
||||
w = httptest.NewRecorder()
|
||||
req, _ = http.NewRequest("GET", "/notes?tag=food", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("List by tag: expected 200, got %d", w.Code)
|
||||
}
|
||||
json.Unmarshal(w.Body.Bytes(), &listResp)
|
||||
total = listResp["total"].(float64)
|
||||
if total != 1 {
|
||||
t.Errorf("List by tag: expected total=1, got %.0f", total)
|
||||
}
|
||||
|
||||
// ── Search ──
|
||||
w = httptest.NewRecorder()
|
||||
req, _ = http.NewRequest("GET", "/notes/search?q=sourdough", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("Search: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var searchResp map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &searchResp)
|
||||
count := searchResp["count"].(float64)
|
||||
if count != 1 {
|
||||
t.Errorf("Search 'sourdough': expected count=1, got %.0f", count)
|
||||
}
|
||||
|
||||
// ── Folders ──
|
||||
w = httptest.NewRecorder()
|
||||
req, _ = http.NewRequest("GET", "/notes/folders", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("Folders: expected 200, got %d", w.Code)
|
||||
}
|
||||
var folders []interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &folders)
|
||||
if len(folders) != 2 {
|
||||
t.Errorf("Folders: expected 2 folders, got %d", len(folders))
|
||||
}
|
||||
|
||||
// ── Update (replace) ──
|
||||
w = httptest.NewRecorder()
|
||||
req, _ = http.NewRequest("PUT", "/notes/"+noteID,
|
||||
strings.NewReader(`{"title":"Updated Meeting Notes","content":"New content","mode":"replace"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("Update: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Verify update
|
||||
w = httptest.NewRecorder()
|
||||
req, _ = http.NewRequest("GET", "/notes/"+noteID, nil)
|
||||
r.ServeHTTP(w, req)
|
||||
json.Unmarshal(w.Body.Bytes(), &got)
|
||||
if got["title"] != "Updated Meeting Notes" {
|
||||
t.Errorf("Update title: got %v", got["title"])
|
||||
}
|
||||
if got["content"] != "New content" {
|
||||
t.Errorf("Update content: got %v", got["content"])
|
||||
}
|
||||
|
||||
// ── Update (append) ──
|
||||
w = httptest.NewRecorder()
|
||||
req, _ = http.NewRequest("PUT", "/notes/"+noteID,
|
||||
strings.NewReader(`{"content":"\nAppended line","mode":"append"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("Append: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
w = httptest.NewRecorder()
|
||||
req, _ = http.NewRequest("GET", "/notes/"+noteID, nil)
|
||||
r.ServeHTTP(w, req)
|
||||
json.Unmarshal(w.Body.Bytes(), &got)
|
||||
content := got["content"].(string)
|
||||
if !strings.Contains(content, "New content") || !strings.Contains(content, "Appended line") {
|
||||
t.Errorf("Append: expected both parts in content, got %q", content)
|
||||
}
|
||||
|
||||
// ── Delete ──
|
||||
w = httptest.NewRecorder()
|
||||
req, _ = http.NewRequest("DELETE", "/notes/"+noteID, nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("Delete: expected 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
// Verify gone
|
||||
w = httptest.NewRecorder()
|
||||
req, _ = http.NewRequest("GET", "/notes/"+noteID, nil)
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("Get after delete: expected 404, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Notes: Search empty query ───────────────
|
||||
|
||||
func TestNoteSearchEmptyQuery(t *testing.T) {
|
||||
database.RequireTestDB(t)
|
||||
|
||||
h := NewNoteHandler()
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Set("user_id", "test-user")
|
||||
c.Request = httptest.NewRequest("GET", "/api/v1/notes/search", nil)
|
||||
|
||||
h.Search(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Search with no query: expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Notes: Cross-user isolation ─────────────
|
||||
|
||||
func TestNoteIsolationBetweenUsers(t *testing.T) {
|
||||
database.RequireTestDB(t)
|
||||
database.TruncateAll(t)
|
||||
|
||||
userA := database.SeedTestUser(t, "alice", "alice@test.com")
|
||||
userB := database.SeedTestUser(t, "bob", "bob@test.com")
|
||||
|
||||
h := NewNoteHandler()
|
||||
|
||||
// Alice creates a note
|
||||
rA := gin.New()
|
||||
rA.Use(func(c *gin.Context) { c.Set("user_id", userA); c.Next() })
|
||||
rA.POST("/notes", h.Create)
|
||||
rA.GET("/notes", h.List)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("POST", "/notes",
|
||||
strings.NewReader(`{"title":"Alice Secret","content":"private stuff"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rA.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("Alice create: expected 201, got %d", w.Code)
|
||||
}
|
||||
|
||||
// Bob lists — should see zero
|
||||
rB := gin.New()
|
||||
rB.Use(func(c *gin.Context) { c.Set("user_id", userB); c.Next() })
|
||||
rB.GET("/notes", h.List)
|
||||
|
||||
w = httptest.NewRecorder()
|
||||
req, _ = http.NewRequest("GET", "/notes", nil)
|
||||
rB.ServeHTTP(w, req)
|
||||
|
||||
var listResp map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &listResp)
|
||||
total := listResp["total"].(float64)
|
||||
if total != 0 {
|
||||
t.Errorf("Bob should see 0 notes, got %.0f", total)
|
||||
}
|
||||
|
||||
// Alice lists — should see one
|
||||
w = httptest.NewRecorder()
|
||||
req, _ = http.NewRequest("GET", "/notes", nil)
|
||||
rA.ServeHTTP(w, req)
|
||||
|
||||
json.Unmarshal(w.Body.Bytes(), &listResp)
|
||||
total = listResp["total"].(float64)
|
||||
if total != 1 {
|
||||
t.Errorf("Alice should see 1 note, got %.0f", total)
|
||||
}
|
||||
}
|
||||
@@ -1,646 +1,286 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
)
|
||||
|
||||
// PresetHandler handles model preset CRUD operations.
|
||||
type PresetHandler struct{}
|
||||
|
||||
// NewPresetHandler creates a new handler.
|
||||
func NewPresetHandler() *PresetHandler {
|
||||
return &PresetHandler{}
|
||||
// PersonaHandler handles persona (formerly preset) endpoints.
|
||||
type PersonaHandler struct {
|
||||
stores store.Stores
|
||||
}
|
||||
|
||||
// ── Request/Response Types ─────────────────
|
||||
|
||||
type createPresetRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
BaseModelID string `json:"base_model_id" binding:"required"`
|
||||
APIConfigID *string `json:"api_config_id,omitempty"`
|
||||
SystemPrompt string `json:"system_prompt"`
|
||||
Temperature *float64 `json:"temperature,omitempty"`
|
||||
MaxTokens *int `json:"max_tokens,omitempty"`
|
||||
ToolsEnabled string `json:"tools_enabled,omitempty"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
IsShared bool `json:"is_shared"`
|
||||
func NewPersonaHandler(s store.Stores) *PersonaHandler {
|
||||
return &PersonaHandler{stores: s}
|
||||
}
|
||||
|
||||
type updatePresetRequest struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
BaseModelID *string `json:"base_model_id,omitempty"`
|
||||
APIConfigID *string `json:"api_config_id,omitempty"`
|
||||
SystemPrompt *string `json:"system_prompt,omitempty"`
|
||||
Temperature *float64 `json:"temperature,omitempty"`
|
||||
MaxTokens *int `json:"max_tokens,omitempty"`
|
||||
ToolsEnabled *string `json:"tools_enabled,omitempty"`
|
||||
Icon *string `json:"icon,omitempty"`
|
||||
IsShared *bool `json:"is_shared,omitempty"`
|
||||
IsActive *bool `json:"is_active,omitempty"`
|
||||
}
|
||||
// ── User Personas (personal scope) ──────────
|
||||
|
||||
type presetResponse struct {
|
||||
models.ModelPreset
|
||||
ProviderName string `json:"provider_name,omitempty"`
|
||||
BaseModelName string `json:"base_model_name,omitempty"`
|
||||
}
|
||||
|
||||
// ── User Preset Endpoints ──────────────────
|
||||
// These require user_providers_enabled for personal presets.
|
||||
|
||||
// ListUserPresets returns all presets visible to the user:
|
||||
// their own personal presets + all global presets + shared presets + team presets.
|
||||
// GET /api/v1/presets
|
||||
func (h *PresetHandler) ListUserPresets(c *gin.Context) {
|
||||
func (h *PersonaHandler) ListUserPersonas(c *gin.Context) {
|
||||
userID := getUserID(c)
|
||||
|
||||
rows, err := database.DB.Query(`
|
||||
SELECT mp.id, mp.name, mp.description, mp.base_model_id, mp.api_config_id,
|
||||
mp.system_prompt, mp.temperature, mp.max_tokens, mp.tools_enabled,
|
||||
mp.scope, mp.team_id, mp.created_by, mp.is_shared, mp.is_active,
|
||||
mp.icon, mp.avatar, mp.created_at, mp.updated_at,
|
||||
COALESCE(ac.name, '') as provider_name
|
||||
FROM model_presets mp
|
||||
LEFT JOIN api_configs ac ON mp.api_config_id = ac.id
|
||||
WHERE mp.is_active = true
|
||||
AND (
|
||||
mp.scope = 'global'
|
||||
OR (mp.scope = 'personal' AND mp.created_by = $1)
|
||||
OR (mp.scope = 'personal' AND mp.is_shared = true)
|
||||
OR (mp.scope = 'team' AND mp.team_id IN (
|
||||
SELECT team_id FROM team_members WHERE user_id = $1
|
||||
))
|
||||
)
|
||||
ORDER BY mp.scope ASC, mp.name ASC
|
||||
`, userID)
|
||||
personas, err := h.stores.Personas.ListForUser(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list presets"})
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list personas"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
presets := make([]presetResponse, 0)
|
||||
for rows.Next() {
|
||||
var p presetResponse
|
||||
if err := rows.Scan(
|
||||
&p.ID, &p.Name, &p.Description, &p.BaseModelID, &p.APIConfigID,
|
||||
&p.SystemPrompt, &p.Temperature, &p.MaxTokens, &p.ToolsEnabled,
|
||||
&p.Scope, &p.TeamID, &p.CreatedBy, &p.IsShared, &p.IsActive,
|
||||
&p.Icon, &p.Avatar, &p.CreatedAt, &p.UpdatedAt, &p.ProviderName,
|
||||
); err != nil {
|
||||
continue
|
||||
}
|
||||
presets = append(presets, p)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"presets": presets})
|
||||
c.JSON(http.StatusOK, gin.H{"personas": personas, "presets": personas})
|
||||
}
|
||||
|
||||
// CreateUserPreset creates a personal preset for the current user.
|
||||
// POST /api/v1/presets
|
||||
func (h *PresetHandler) CreateUserPreset(c *gin.Context) {
|
||||
func (h *PersonaHandler) CreateUserPersona(c *gin.Context) {
|
||||
userID := getUserID(c)
|
||||
|
||||
var req createPresetRequest
|
||||
// Check policy
|
||||
allowed, _ := h.stores.Policies.GetBool(c.Request.Context(), "allow_user_personas")
|
||||
if !allowed {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "custom personas not allowed"})
|
||||
return
|
||||
}
|
||||
|
||||
var req personaRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
req.Name = strings.TrimSpace(req.Name)
|
||||
if req.Name == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
|
||||
persona := req.toPersona()
|
||||
persona.Scope = models.ScopePersonal
|
||||
persona.OwnerID = &userID
|
||||
persona.CreatedBy = userID
|
||||
|
||||
if err := h.stores.Personas.Create(c.Request.Context(), persona); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create persona"})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate api_config_id belongs to user if provided
|
||||
if req.APIConfigID != nil && *req.APIConfigID != "" {
|
||||
var count int
|
||||
err := database.DB.QueryRow(`
|
||||
SELECT COUNT(*) FROM api_configs
|
||||
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"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, persona)
|
||||
}
|
||||
|
||||
func (h *PersonaHandler) UpdateUserPersona(c *gin.Context) {
|
||||
userID := getUserID(c)
|
||||
id := c.Param("id")
|
||||
|
||||
existing, err := h.stores.Personas.GetByID(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "persona not found"})
|
||||
return
|
||||
}
|
||||
|
||||
toolsJSON := req.ToolsEnabled
|
||||
if toolsJSON == "" {
|
||||
toolsJSON = "[]"
|
||||
// Users can only edit their own personal personas
|
||||
if existing.Scope != models.ScopePersonal || existing.OwnerID == nil || *existing.OwnerID != userID {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "cannot edit this persona"})
|
||||
return
|
||||
}
|
||||
|
||||
var id string
|
||||
var patch models.PersonaPatch
|
||||
if err := c.ShouldBindJSON(&patch); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.stores.Personas.Update(c.Request.Context(), id, patch); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update persona"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "persona updated"})
|
||||
}
|
||||
|
||||
func (h *PersonaHandler) DeleteUserPersona(c *gin.Context) {
|
||||
userID := getUserID(c)
|
||||
id := c.Param("id")
|
||||
|
||||
existing, err := h.stores.Personas.GetByID(c.Request.Context(), id)
|
||||
if err != nil || existing.Scope != models.ScopePersonal || existing.OwnerID == nil || *existing.OwnerID != userID {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "cannot delete this persona"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.stores.Personas.Delete(c.Request.Context(), id); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete persona"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "persona deleted"})
|
||||
}
|
||||
|
||||
// ── Team Personas ───────────────────────────
|
||||
|
||||
func (h *PersonaHandler) ListTeamPersonas(c *gin.Context) {
|
||||
teamID := c.Param("teamId")
|
||||
|
||||
personas, err := h.stores.Personas.ListForTeam(c.Request.Context(), teamID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list team personas"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"personas": personas, "presets": personas})
|
||||
}
|
||||
|
||||
func (h *PersonaHandler) CreateTeamPersona(c *gin.Context) {
|
||||
userID := getUserID(c)
|
||||
teamID := c.Param("teamId")
|
||||
|
||||
var req personaRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
persona := req.toPersona()
|
||||
persona.Scope = models.ScopeTeam
|
||||
persona.OwnerID = &teamID
|
||||
persona.CreatedBy = userID
|
||||
|
||||
if err := h.stores.Personas.Create(c.Request.Context(), persona); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create team persona"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, persona)
|
||||
}
|
||||
|
||||
func (h *PersonaHandler) DeleteTeamPersona(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
|
||||
if err := h.stores.Personas.Delete(c.Request.Context(), id); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete persona"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "persona deleted"})
|
||||
}
|
||||
|
||||
// ── Admin Personas (global scope) ───────────
|
||||
|
||||
func (h *PersonaHandler) ListAdminPersonas(c *gin.Context) {
|
||||
personas, err := h.stores.Personas.ListGlobal(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list personas"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"personas": personas, "presets": personas})
|
||||
}
|
||||
|
||||
func (h *PersonaHandler) CreateAdminPersona(c *gin.Context) {
|
||||
userID := getUserID(c)
|
||||
|
||||
var req personaRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
persona := req.toPersona()
|
||||
persona.Scope = models.ScopeGlobal
|
||||
persona.CreatedBy = userID
|
||||
|
||||
if err := h.stores.Personas.Create(c.Request.Context(), persona); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create persona"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, persona)
|
||||
}
|
||||
|
||||
func (h *PersonaHandler) UpdateAdminPersona(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
|
||||
var patch models.PersonaPatch
|
||||
if err := c.ShouldBindJSON(&patch); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.stores.Personas.Update(c.Request.Context(), id, patch); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update persona"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "persona updated"})
|
||||
}
|
||||
|
||||
func (h *PersonaHandler) DeleteAdminPersona(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
|
||||
if err := h.stores.Personas.Delete(c.Request.Context(), id); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete persona"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "persona deleted"})
|
||||
}
|
||||
|
||||
// ── Request Types ───────────────────────────
|
||||
|
||||
type personaRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
BaseModelID string `json:"base_model_id,omitempty"`
|
||||
ProviderConfigID *string `json:"provider_config_id,omitempty"`
|
||||
SystemPrompt string `json:"system_prompt,omitempty"`
|
||||
Temperature *float64 `json:"temperature,omitempty"`
|
||||
MaxTokens *int `json:"max_tokens,omitempty"`
|
||||
ThinkingBudget *int `json:"thinking_budget,omitempty"`
|
||||
TopP *float64 `json:"top_p,omitempty"`
|
||||
IsShared bool `json:"is_shared,omitempty"`
|
||||
}
|
||||
|
||||
func (r *personaRequest) toPersona() *models.Persona {
|
||||
p := &models.Persona{
|
||||
Name: r.Name,
|
||||
Description: r.Description,
|
||||
Icon: r.Icon,
|
||||
BaseModelID: r.BaseModelID,
|
||||
ProviderConfigID: r.ProviderConfigID,
|
||||
SystemPrompt: r.SystemPrompt,
|
||||
Temperature: r.Temperature,
|
||||
MaxTokens: r.MaxTokens,
|
||||
ThinkingBudget: r.ThinkingBudget,
|
||||
TopP: r.TopP,
|
||||
IsActive: true,
|
||||
IsShared: r.IsShared,
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// ResolvePreset loads a persona by ID and returns it if the user has access.
|
||||
// Returns nil if not found, inactive, or not accessible.
|
||||
// Used by completion.go and messages.go for preset unwrapping.
|
||||
func ResolvePreset(presetID, userID string) *models.Persona {
|
||||
var p models.Persona
|
||||
var providerConfigID *string
|
||||
err := database.DB.QueryRow(`
|
||||
INSERT INTO model_presets (name, description, base_model_id, api_config_id,
|
||||
system_prompt, temperature, max_tokens, tools_enabled,
|
||||
scope, created_by, is_shared, icon)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, 'personal', $9, $10, $11)
|
||||
RETURNING id
|
||||
`, req.Name, req.Description, req.BaseModelID, req.APIConfigID,
|
||||
req.SystemPrompt, req.Temperature, req.MaxTokens, toolsJSON,
|
||||
userID, req.IsShared, req.Icon,
|
||||
).Scan(&id)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("[WARN] Failed to create user preset: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create preset: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{"id": id})
|
||||
}
|
||||
|
||||
// UpdateUserPreset updates a personal preset owned by the current user.
|
||||
// PUT /api/v1/presets/:id
|
||||
func (h *PresetHandler) UpdateUserPreset(c *gin.Context) {
|
||||
userID := getUserID(c)
|
||||
presetID := c.Param("id")
|
||||
|
||||
// Verify ownership
|
||||
var createdBy, scope string
|
||||
err := database.DB.QueryRow(
|
||||
`SELECT created_by, scope FROM model_presets WHERE id = $1`, presetID,
|
||||
).Scan(&createdBy, &scope)
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "preset not found"})
|
||||
return
|
||||
}
|
||||
if scope != models.PresetScopePersonal || createdBy != userID {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "can only edit your own personal presets"})
|
||||
return
|
||||
}
|
||||
|
||||
var req updatePresetRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Build dynamic SET clause
|
||||
sets := []string{}
|
||||
args := []interface{}{}
|
||||
argN := 1
|
||||
|
||||
addField := func(col string, val interface{}) {
|
||||
sets = append(sets, col+" = $"+itoa(argN))
|
||||
args = append(args, val)
|
||||
argN++
|
||||
}
|
||||
|
||||
if req.Name != nil {
|
||||
addField("name", strings.TrimSpace(*req.Name))
|
||||
}
|
||||
if req.Description != nil {
|
||||
addField("description", *req.Description)
|
||||
}
|
||||
if req.BaseModelID != nil {
|
||||
addField("base_model_id", *req.BaseModelID)
|
||||
}
|
||||
if req.APIConfigID != nil {
|
||||
addField("api_config_id", *req.APIConfigID)
|
||||
}
|
||||
if req.SystemPrompt != nil {
|
||||
addField("system_prompt", *req.SystemPrompt)
|
||||
}
|
||||
if req.Temperature != nil {
|
||||
addField("temperature", *req.Temperature)
|
||||
}
|
||||
if req.MaxTokens != nil {
|
||||
addField("max_tokens", *req.MaxTokens)
|
||||
}
|
||||
if req.Icon != nil {
|
||||
addField("icon", *req.Icon)
|
||||
}
|
||||
if req.IsShared != nil {
|
||||
addField("is_shared", *req.IsShared)
|
||||
}
|
||||
if req.IsActive != nil {
|
||||
addField("is_active", *req.IsActive)
|
||||
}
|
||||
|
||||
if len(sets) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"})
|
||||
return
|
||||
}
|
||||
|
||||
sets = append(sets, "updated_at = NOW()")
|
||||
args = append(args, presetID)
|
||||
|
||||
query := "UPDATE model_presets SET " + strings.Join(sets, ", ") + " WHERE id = $" + itoa(argN)
|
||||
_, err = database.DB.Exec(query, args...)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update preset"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"status": "updated"})
|
||||
}
|
||||
|
||||
// DeleteUserPreset deletes a personal preset owned by the current user.
|
||||
// DELETE /api/v1/presets/:id
|
||||
func (h *PresetHandler) DeleteUserPreset(c *gin.Context) {
|
||||
userID := getUserID(c)
|
||||
presetID := c.Param("id")
|
||||
|
||||
result, err := database.DB.Exec(`
|
||||
DELETE FROM model_presets WHERE id = $1 AND created_by = $2 AND scope = 'personal'
|
||||
`, presetID, userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete preset"})
|
||||
return
|
||||
}
|
||||
|
||||
rows, _ := result.RowsAffected()
|
||||
if rows == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "preset not found or not yours"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"status": "deleted"})
|
||||
}
|
||||
|
||||
// ── Admin Preset Endpoints ─────────────────
|
||||
|
||||
// ListAdminPresets returns all presets (any scope) for admin management.
|
||||
// GET /api/v1/admin/presets
|
||||
func (h *PresetHandler) ListAdminPresets(c *gin.Context) {
|
||||
rows, err := database.DB.Query(`
|
||||
SELECT mp.id, mp.name, mp.description, mp.base_model_id, mp.api_config_id,
|
||||
mp.system_prompt, mp.temperature, mp.max_tokens, mp.tools_enabled,
|
||||
mp.scope, mp.team_id, mp.created_by, mp.is_shared, mp.is_active,
|
||||
mp.icon, mp.avatar, mp.created_at, mp.updated_at,
|
||||
COALESCE(ac.name, '') as provider_name,
|
||||
COALESCE(u.username, '') as creator_name,
|
||||
COALESCE(t.name, '') as team_name
|
||||
FROM model_presets mp
|
||||
LEFT JOIN api_configs ac ON mp.api_config_id = ac.id
|
||||
LEFT JOIN users u ON mp.created_by = u.id
|
||||
LEFT JOIN teams t ON mp.team_id = t.id
|
||||
ORDER BY mp.scope ASC, mp.name ASC
|
||||
`)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list presets"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type adminPreset struct {
|
||||
presetResponse
|
||||
CreatorName string `json:"creator_name"`
|
||||
TeamName string `json:"team_name"`
|
||||
}
|
||||
|
||||
presets := make([]adminPreset, 0)
|
||||
for rows.Next() {
|
||||
var p adminPreset
|
||||
if err := rows.Scan(
|
||||
&p.ID, &p.Name, &p.Description, &p.BaseModelID, &p.APIConfigID,
|
||||
&p.SystemPrompt, &p.Temperature, &p.MaxTokens, &p.ToolsEnabled,
|
||||
&p.Scope, &p.TeamID, &p.CreatedBy, &p.IsShared, &p.IsActive,
|
||||
&p.Icon, &p.Avatar, &p.CreatedAt, &p.UpdatedAt, &p.ProviderName,
|
||||
&p.CreatorName, &p.TeamName,
|
||||
); err != nil {
|
||||
continue
|
||||
}
|
||||
presets = append(presets, p)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"presets": presets})
|
||||
}
|
||||
|
||||
// CreateAdminPreset creates a global preset (admin-managed, visible to all users).
|
||||
// POST /api/v1/admin/presets
|
||||
func (h *PresetHandler) CreateAdminPreset(c *gin.Context) {
|
||||
userID := getUserID(c)
|
||||
|
||||
var req createPresetRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
req.Name = strings.TrimSpace(req.Name)
|
||||
if req.Name == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate api_config_id is a global config
|
||||
if req.APIConfigID != nil && *req.APIConfigID != "" {
|
||||
var count int
|
||||
err := database.DB.QueryRow(`
|
||||
SELECT COUNT(*) FROM api_configs
|
||||
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"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
toolsJSON := req.ToolsEnabled
|
||||
if toolsJSON == "" {
|
||||
toolsJSON = "[]"
|
||||
}
|
||||
|
||||
var id string
|
||||
err := database.DB.QueryRow(`
|
||||
INSERT INTO model_presets (name, description, base_model_id, api_config_id,
|
||||
system_prompt, temperature, max_tokens, tools_enabled,
|
||||
scope, created_by, is_shared, icon)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, 'global', $9, true, $10)
|
||||
RETURNING id
|
||||
`, req.Name, req.Description, req.BaseModelID, req.APIConfigID,
|
||||
req.SystemPrompt, req.Temperature, req.MaxTokens, toolsJSON,
|
||||
userID, req.Icon,
|
||||
).Scan(&id)
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create preset: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{"id": id})
|
||||
AuditLog(c, "preset.create", "preset", id, map[string]interface{}{
|
||||
"name": req.Name, "scope": "global", "base_model": req.BaseModelID,
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateAdminPreset updates any preset (admin can edit global and personal).
|
||||
// PUT /api/v1/admin/presets/:id
|
||||
func (h *PresetHandler) UpdateAdminPreset(c *gin.Context) {
|
||||
presetID := c.Param("id")
|
||||
|
||||
var req updatePresetRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
sets := []string{}
|
||||
args := []interface{}{}
|
||||
argN := 1
|
||||
|
||||
addField := func(col string, val interface{}) {
|
||||
sets = append(sets, col+" = $"+itoa(argN))
|
||||
args = append(args, val)
|
||||
argN++
|
||||
}
|
||||
|
||||
if req.Name != nil {
|
||||
addField("name", strings.TrimSpace(*req.Name))
|
||||
}
|
||||
if req.Description != nil {
|
||||
addField("description", *req.Description)
|
||||
}
|
||||
if req.BaseModelID != nil {
|
||||
addField("base_model_id", *req.BaseModelID)
|
||||
}
|
||||
if req.APIConfigID != nil {
|
||||
addField("api_config_id", *req.APIConfigID)
|
||||
}
|
||||
if req.SystemPrompt != nil {
|
||||
addField("system_prompt", *req.SystemPrompt)
|
||||
}
|
||||
if req.Temperature != nil {
|
||||
addField("temperature", *req.Temperature)
|
||||
}
|
||||
if req.MaxTokens != nil {
|
||||
addField("max_tokens", *req.MaxTokens)
|
||||
}
|
||||
if req.Icon != nil {
|
||||
addField("icon", *req.Icon)
|
||||
}
|
||||
if req.IsShared != nil {
|
||||
addField("is_shared", *req.IsShared)
|
||||
}
|
||||
if req.IsActive != nil {
|
||||
addField("is_active", *req.IsActive)
|
||||
}
|
||||
|
||||
if len(sets) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"})
|
||||
return
|
||||
}
|
||||
|
||||
sets = append(sets, "updated_at = NOW()")
|
||||
args = append(args, presetID)
|
||||
|
||||
query := "UPDATE model_presets SET " + strings.Join(sets, ", ") + " WHERE id = $" + itoa(argN)
|
||||
result, err := database.DB.Exec(query, args...)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update preset"})
|
||||
return
|
||||
}
|
||||
|
||||
rows, _ := result.RowsAffected()
|
||||
if rows == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "preset not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"status": "updated"})
|
||||
}
|
||||
|
||||
// DeleteAdminPreset deletes any preset.
|
||||
// DELETE /api/v1/admin/presets/:id
|
||||
func (h *PresetHandler) DeleteAdminPreset(c *gin.Context) {
|
||||
presetID := c.Param("id")
|
||||
|
||||
result, err := database.DB.Exec(`DELETE FROM model_presets WHERE id = $1`, presetID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete preset"})
|
||||
return
|
||||
}
|
||||
|
||||
rows, _ := result.RowsAffected()
|
||||
if rows == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "preset not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"status": "deleted"})
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────
|
||||
|
||||
// isUserProvidersEnabled checks the global setting.
|
||||
func isUserProvidersEnabled() bool {
|
||||
var val string
|
||||
err := database.DB.QueryRow(
|
||||
`SELECT value FROM global_settings WHERE key = 'user_providers_enabled'`,
|
||||
).Scan(&val)
|
||||
if err != nil {
|
||||
return true // default: enabled
|
||||
}
|
||||
return val == "true"
|
||||
}
|
||||
|
||||
// itoa is a minimal int-to-string for building SQL arg placeholders.
|
||||
func itoa(n int) string {
|
||||
if n < 10 {
|
||||
return string(rune('0' + n))
|
||||
}
|
||||
return itoa(n/10) + string(rune('0'+n%10))
|
||||
}
|
||||
|
||||
// ── Preset Resolution for Completion ───────
|
||||
|
||||
// ResolvePreset loads a preset by ID and returns its config overrides.
|
||||
// Returns nil if preset not found or inactive.
|
||||
func ResolvePreset(presetID, userID string) *models.ModelPreset {
|
||||
var p models.ModelPreset
|
||||
err := database.DB.QueryRow(`
|
||||
SELECT id, name, base_model_id, api_config_id, system_prompt,
|
||||
temperature, max_tokens, scope, created_by, is_active
|
||||
FROM model_presets
|
||||
SELECT id, name, base_model_id, provider_config_id, system_prompt,
|
||||
temperature, max_tokens, thinking_budget, top_p,
|
||||
scope, owner_id, created_by, is_active
|
||||
FROM personas
|
||||
WHERE id = $1 AND is_active = true
|
||||
AND (
|
||||
scope = 'global'
|
||||
OR (scope = 'personal' AND created_by = $2)
|
||||
OR (scope = 'personal' AND is_shared = true)
|
||||
OR (scope = 'team' AND team_id IN (
|
||||
OR (scope = 'team' AND owner_id IN (
|
||||
SELECT team_id FROM team_members WHERE user_id = $2
|
||||
))
|
||||
)
|
||||
`, presetID, userID).Scan(
|
||||
&p.ID, &p.Name, &p.BaseModelID, &p.APIConfigID, &p.SystemPrompt,
|
||||
&p.Temperature, &p.MaxTokens, &p.Scope, &p.CreatedBy, &p.IsActive,
|
||||
&p.ID, &p.Name, &p.BaseModelID, &providerConfigID, &p.SystemPrompt,
|
||||
&p.Temperature, &p.MaxTokens, &p.ThinkingBudget, &p.TopP,
|
||||
&p.Scope, &p.OwnerID, &p.CreatedBy, &p.IsActive,
|
||||
)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
p.ProviderConfigID = providerConfigID
|
||||
return &p
|
||||
}
|
||||
|
||||
// ── Team Preset Endpoints ─────────────────
|
||||
// Team admins can create/manage presets scoped to their team.
|
||||
|
||||
type createTeamPresetRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
BaseModelID string `json:"base_model_id" binding:"required"`
|
||||
APIConfigID *string `json:"api_config_id,omitempty"`
|
||||
SystemPrompt string `json:"system_prompt"`
|
||||
Temperature *float64 `json:"temperature,omitempty"`
|
||||
MaxTokens *int `json:"max_tokens,omitempty"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
}
|
||||
|
||||
// ListTeamPresets returns presets scoped to a team.
|
||||
// GET /api/v1/teams/:teamId/presets
|
||||
func (h *PresetHandler) ListTeamPresets(c *gin.Context) {
|
||||
teamID := getTeamID(c)
|
||||
|
||||
rows, err := database.DB.Query(`
|
||||
SELECT mp.id, mp.name, mp.description, mp.base_model_id, mp.api_config_id,
|
||||
mp.system_prompt, mp.temperature, mp.max_tokens, mp.tools_enabled,
|
||||
mp.scope, mp.team_id, mp.created_by, mp.is_shared, mp.is_active,
|
||||
mp.icon, mp.avatar, mp.created_at, mp.updated_at,
|
||||
COALESCE(ac.name, '') as provider_name
|
||||
FROM model_presets mp
|
||||
LEFT JOIN api_configs ac ON mp.api_config_id = ac.id
|
||||
WHERE mp.team_id = $1 AND mp.scope = 'team'
|
||||
ORDER BY mp.name ASC
|
||||
`, teamID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list team presets"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
presets := make([]presetResponse, 0)
|
||||
for rows.Next() {
|
||||
var p presetResponse
|
||||
if err := rows.Scan(
|
||||
&p.ID, &p.Name, &p.Description, &p.BaseModelID, &p.APIConfigID,
|
||||
&p.SystemPrompt, &p.Temperature, &p.MaxTokens, &p.ToolsEnabled,
|
||||
&p.Scope, &p.TeamID, &p.CreatedBy, &p.IsShared, &p.IsActive,
|
||||
&p.Icon, &p.Avatar, &p.CreatedAt, &p.UpdatedAt, &p.ProviderName,
|
||||
); err != nil {
|
||||
continue
|
||||
}
|
||||
presets = append(presets, p)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"presets": presets})
|
||||
}
|
||||
|
||||
// CreateTeamPreset creates a preset scoped to a team.
|
||||
// POST /api/v1/teams/:teamId/presets
|
||||
func (h *PresetHandler) CreateTeamPreset(c *gin.Context) {
|
||||
teamID := getTeamID(c)
|
||||
userID := getUserID(c)
|
||||
|
||||
var req createTeamPresetRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
req.Name = strings.TrimSpace(req.Name)
|
||||
if req.Name == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
|
||||
return
|
||||
}
|
||||
|
||||
toolsJSON := "[]"
|
||||
|
||||
var id string
|
||||
err := database.DB.QueryRow(`
|
||||
INSERT INTO model_presets (name, description, base_model_id, api_config_id,
|
||||
system_prompt, temperature, max_tokens, tools_enabled,
|
||||
scope, team_id, created_by, is_shared, icon)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, 'team', $9, $10, true, $11)
|
||||
RETURNING id
|
||||
`, req.Name, req.Description, req.BaseModelID, req.APIConfigID,
|
||||
req.SystemPrompt, req.Temperature, req.MaxTokens, toolsJSON,
|
||||
teamID, userID, req.Icon,
|
||||
).Scan(&id)
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create team preset: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{"id": id})
|
||||
AuditLog(c, "preset.create", "preset", id, map[string]interface{}{
|
||||
"name": req.Name, "scope": "team", "team_id": teamID, "base_model": req.BaseModelID,
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteTeamPreset deletes a team-scoped preset.
|
||||
// DELETE /api/v1/teams/:teamId/presets/:id
|
||||
func (h *PresetHandler) DeleteTeamPreset(c *gin.Context) {
|
||||
teamID := getTeamID(c)
|
||||
presetID := c.Param("id")
|
||||
|
||||
res, err := database.DB.Exec(`
|
||||
DELETE FROM model_presets WHERE id = $1 AND team_id = $2 AND scope = 'team'
|
||||
`, presetID, teamID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "delete failed"})
|
||||
return
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "preset not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
AuditLog(c, "preset.delete", "preset", presetID, map[string]interface{}{
|
||||
"scope": "team", "team_id": teamID,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -8,22 +8,23 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
capspkg "git.gobha.me/xcaliber/chat-switchboard/capabilities"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"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,
|
||||
SELECT id, name, provider, endpoint, api_key_enc,
|
||||
model_default, config::text, is_active, is_private, created_at, updated_at
|
||||
FROM api_configs
|
||||
WHERE team_id = $1
|
||||
FROM provider_configs
|
||||
WHERE scope = 'team' AND owner_id = $1
|
||||
ORDER BY name ASC
|
||||
`, teamID)
|
||||
if err != nil {
|
||||
@@ -67,17 +68,23 @@ func (h *TeamHandler) ListTeamProviders(c *gin.Context) {
|
||||
}
|
||||
|
||||
// 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
|
||||
var req struct {
|
||||
Name string `json:"name" binding:"required,max=100"`
|
||||
Provider string `json:"provider" binding:"required"`
|
||||
Endpoint string `json:"endpoint" binding:"required"`
|
||||
APIKey string `json:"api_key"`
|
||||
ModelDefault string `json:"model_default,omitempty"`
|
||||
Config map[string]interface{} `json:"config,omitempty"`
|
||||
IsPrivate bool `json:"is_private,omitempty"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -99,8 +106,8 @@ func (h *TeamHandler) CreateTeamProvider(c *gin.Context) {
|
||||
|
||||
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)
|
||||
INSERT INTO provider_configs (scope, owner_id, name, provider, endpoint, api_key_enc, model_default, config, is_private)
|
||||
VALUES ('team', $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)
|
||||
@@ -114,12 +121,19 @@ func (h *TeamHandler) CreateTeamProvider(c *gin.Context) {
|
||||
}
|
||||
|
||||
// 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
|
||||
var req struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Endpoint *string `json:"endpoint,omitempty"`
|
||||
APIKey *string `json:"api_key,omitempty"`
|
||||
ModelDefault *string `json:"model_default,omitempty"`
|
||||
Config map[string]interface{} `json:"config,omitempty"`
|
||||
IsActive *bool `json:"is_active,omitempty"`
|
||||
IsPrivate *bool `json:"is_private,omitempty"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -127,14 +141,14 @@ func (h *TeamHandler) UpdateTeamProvider(c *gin.Context) {
|
||||
|
||||
// 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)
|
||||
database.DB.QueryRow(`SELECT COUNT(*) FROM provider_configs WHERE id = $1 AND scope = 'team' AND owner_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()"
|
||||
query := "UPDATE provider_configs SET updated_at = NOW()"
|
||||
args := []interface{}{}
|
||||
argN := 1
|
||||
|
||||
@@ -149,7 +163,7 @@ func (h *TeamHandler) UpdateTeamProvider(c *gin.Context) {
|
||||
argN++
|
||||
}
|
||||
if req.APIKey != nil {
|
||||
query += ", api_key_encrypted = $" + strconv.Itoa(argN)
|
||||
query += ", api_key_enc = $" + strconv.Itoa(argN)
|
||||
args = append(args, *req.APIKey)
|
||||
argN++
|
||||
}
|
||||
@@ -175,7 +189,7 @@ func (h *TeamHandler) UpdateTeamProvider(c *gin.Context) {
|
||||
argN++
|
||||
}
|
||||
|
||||
query += " WHERE id = $" + strconv.Itoa(argN) + " AND team_id = $" + strconv.Itoa(argN+1)
|
||||
query += " WHERE id = $" + strconv.Itoa(argN) + " AND scope = 'team' AND owner_id = $" + strconv.Itoa(argN+1)
|
||||
args = append(args, providerID, teamID)
|
||||
|
||||
_, err := database.DB.Exec(query, args...)
|
||||
@@ -188,13 +202,12 @@ func (h *TeamHandler) UpdateTeamProvider(c *gin.Context) {
|
||||
}
|
||||
|
||||
// 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
|
||||
DELETE FROM provider_configs WHERE id = $1 AND scope = 'team' AND owner_id = $2
|
||||
`, providerID, teamID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete provider"})
|
||||
@@ -211,7 +224,6 @@ func (h *TeamHandler) DeleteTeamProvider(c *gin.Context) {
|
||||
}
|
||||
|
||||
// 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")
|
||||
@@ -220,9 +232,9 @@ func (h *TeamHandler) ListTeamProviderModels(c *gin.Context) {
|
||||
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
|
||||
SELECT name, provider, endpoint, api_key_enc, headers
|
||||
FROM provider_configs
|
||||
WHERE id = $1 AND scope = 'team' AND owner_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"})
|
||||
@@ -254,24 +266,38 @@ func (h *TeamHandler) ListTeamProviderModels(c *gin.Context) {
|
||||
}
|
||||
|
||||
type modelInfo struct {
|
||||
ID string `json:"id"`
|
||||
Capabilities providers.ModelCapabilities `json:"capabilities"`
|
||||
ID string `json:"id"`
|
||||
Capabilities models.ModelCapabilities `json:"capabilities"`
|
||||
}
|
||||
|
||||
models := make([]modelInfo, 0, len(modelList))
|
||||
out := 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})
|
||||
caps := capspkg.ResolveIntrinsic(m.ID, &m.Capabilities)
|
||||
caps.MaxOutputTokens = capspkg.ResolveMaxOutput(m.ID, caps)
|
||||
out = append(out, modelInfo{ID: m.ID, Capabilities: caps})
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"models": models, "provider": name})
|
||||
c.JSON(http.StatusOK, gin.H{"models": out, "provider": name})
|
||||
}
|
||||
|
||||
// isTeamProvidersAllowed checks if team providers are enabled for a team.
|
||||
// First checks the global allow_team_providers setting, then team.settings JSONB.
|
||||
// parseJSONBConfig parses a JSONB text string into a map.
|
||||
func parseJSONBConfig(raw string) map[string]interface{} {
|
||||
if raw == "" || raw == "{}" || raw == "null" {
|
||||
return map[string]interface{}{}
|
||||
}
|
||||
var m map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(raw), &m); err != nil {
|
||||
return map[string]interface{}{}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// isTeamProvidersAllowed checks if team providers are enabled.
|
||||
func isTeamProvidersAllowed(teamID string) bool {
|
||||
// Check global setting
|
||||
if database.DB == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
var globalVal string
|
||||
err := database.DB.QueryRow(`
|
||||
SELECT value FROM global_settings WHERE key = 'allow_team_providers'
|
||||
@@ -279,15 +305,11 @@ func isTeamProvidersAllowed(teamID string) bool {
|
||||
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)
|
||||
err = database.DB.QueryRow(`SELECT settings FROM teams WHERE id = $1`, teamID).Scan(&settingsJSON)
|
||||
if err != nil {
|
||||
return true // default allow
|
||||
return true
|
||||
}
|
||||
|
||||
var settings map[string]interface{}
|
||||
|
||||
@@ -478,14 +478,14 @@ func (h *TeamHandler) ListAvailableModels(c *gin.Context) {
|
||||
|
||||
models := make([]availableModel, 0)
|
||||
|
||||
// ── 1. Global admin models (synced in model_configs) ──
|
||||
// ── 1. Global admin models (synced in model_catalog) ──
|
||||
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
|
||||
FROM model_catalog mc
|
||||
JOIN provider_configs ac ON mc.provider_config_id = ac.id
|
||||
WHERE mc.visibility IN ('enabled', 'team')
|
||||
AND ac.is_active = true AND ac.is_global = true
|
||||
AND ac.is_active = true AND ac.scope = 'global'
|
||||
ORDER BY ac.name, mc.model_id
|
||||
`)
|
||||
if err != nil {
|
||||
@@ -506,9 +506,9 @@ func (h *TeamHandler) ListAvailableModels(c *gin.Context) {
|
||||
|
||||
// ── 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
|
||||
SELECT id, name, provider, endpoint, api_key_enc, headers
|
||||
FROM provider_configs
|
||||
WHERE scope = 'team' AND owner_id = $1 AND is_active = true
|
||||
`, teamID)
|
||||
if err == nil {
|
||||
defer teamRows.Close()
|
||||
@@ -621,7 +621,7 @@ func enforcePrivateProviderPolicy(userID, configID string) error {
|
||||
// User is in a restricted team — verify the config is private
|
||||
var isPrivate bool
|
||||
err = database.DB.QueryRow(`
|
||||
SELECT COALESCE(is_private, false) FROM api_configs WHERE id = $1
|
||||
SELECT COALESCE(is_private, false) FROM provider_configs WHERE id = $1
|
||||
`, configID).Scan(&isPrivate)
|
||||
if err != nil {
|
||||
return nil // config lookup failed, allow (fail open)
|
||||
|
||||
Reference in New Issue
Block a user