Changeset 0.9.0 (#50)

This commit is contained in:
2026-02-23 01:57:28 +00:00
parent 15be26c516
commit 8264aa6016
94 changed files with 9812 additions and 8574 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -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")
}
}

View File

@@ -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
}

View File

@@ -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)
}
}
}

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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)
}
}

View File

@@ -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 {

File diff suppressed because it is too large Load Diff

View 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)
}

View File

@@ -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

View File

@@ -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)})
}

View 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
}

View File

@@ -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)
}
}

View File

@@ -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,
})
}

View File

@@ -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{}

View File

@@ -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)