Changeset 0.4.3 (#32)

This commit is contained in:
2026-02-18 17:47:21 +00:00
parent 2fc4f6980c
commit c98eb80950
14 changed files with 1134 additions and 17 deletions

View File

@@ -10,6 +10,7 @@ import (
"golang.org/x/crypto/bcrypt"
"git.gobha.me/xcaliber/chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/providers"
)
// ── Types ───────────────────────────────────
@@ -384,3 +385,283 @@ func (h *AdminHandler) GetStats(c *gin.Context) {
c.JSON(http.StatusOK, stats)
}
// ── Global API Configs ──────────────────────
type adminGlobalConfigResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Provider string `json:"provider"`
Endpoint string `json:"endpoint"`
ModelDefault *string `json:"model_default"`
IsActive bool `json:"is_active"`
HasKey bool `json:"has_key"`
CreatedAt string `json:"created_at"`
}
func (h *AdminHandler) ListGlobalConfigs(c *gin.Context) {
rows, err := database.DB.Query(`
SELECT id, name, provider, endpoint, model_default, is_active,
(api_key_encrypted IS NOT NULL AND api_key_encrypted != '') as has_key,
created_at
FROM api_configs
WHERE user_id IS NULL
ORDER BY created_at ASC
`)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list configs"})
return
}
defer rows.Close()
configs := make([]adminGlobalConfigResponse, 0)
for rows.Next() {
var cfg adminGlobalConfigResponse
if err := rows.Scan(
&cfg.ID, &cfg.Name, &cfg.Provider, &cfg.Endpoint,
&cfg.ModelDefault, &cfg.IsActive, &cfg.HasKey, &cfg.CreatedAt,
); err != nil {
continue
}
configs = append(configs, cfg)
}
c.JSON(http.StatusOK, gin.H{"configs": configs})
}
func (h *AdminHandler) CreateGlobalConfig(c *gin.Context) {
var req createAPIConfigRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var id string
err := database.DB.QueryRow(`
INSERT INTO api_configs (name, provider, endpoint, api_key_encrypted, model_default, user_id)
VALUES ($1, $2, $3, $4, $5, NULL)
RETURNING id
`, req.Name, req.Provider, req.Endpoint, req.APIKey, req.ModelDefault).Scan(&id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create config"})
return
}
c.JSON(http.StatusCreated, gin.H{"id": id, "message": "global config created"})
}
func (h *AdminHandler) DeleteGlobalConfig(c *gin.Context) {
configID := c.Param("id")
result, err := database.DB.Exec(
`DELETE FROM api_configs WHERE id = $1 AND user_id IS NULL`,
configID,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete config"})
return
}
rows, _ := result.RowsAffected()
if rows == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "global config not found"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "global config deleted"})
}
// ── Model Configs ───────────────────────────
type modelConfigResponse struct {
ID string `json:"id"`
APIConfigID string `json:"api_config_id"`
ProviderName string `json:"provider_name"`
ModelID string `json:"model_id"`
DisplayName *string `json:"display_name"`
IsEnabled bool `json:"is_enabled"`
Capabilities map[string]interface{} `json:"capabilities"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
type updateModelConfigRequest struct {
IsEnabled *bool `json:"is_enabled"`
DisplayName *string `json:"display_name"`
Capabilities map[string]interface{} `json:"capabilities"`
}
func (h *AdminHandler) ListModelConfigs(c *gin.Context) {
rows, err := database.DB.Query(`
SELECT mc.id, mc.api_config_id, ac.name, mc.model_id, mc.display_name,
mc.is_enabled, mc.capabilities, mc.created_at, mc.updated_at
FROM model_configs mc
JOIN api_configs ac ON mc.api_config_id = ac.id
WHERE ac.user_id IS NULL
ORDER BY ac.name, mc.model_id
`)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list models"})
return
}
defer rows.Close()
models := make([]modelConfigResponse, 0)
for rows.Next() {
var m modelConfigResponse
var capsJSON []byte
if err := rows.Scan(
&m.ID, &m.APIConfigID, &m.ProviderName, &m.ModelID, &m.DisplayName,
&m.IsEnabled, &capsJSON, &m.CreatedAt, &m.UpdatedAt,
); err != nil {
continue
}
_ = json.Unmarshal(capsJSON, &m.Capabilities)
models = append(models, m)
}
c.JSON(http.StatusOK, gin.H{"models": models})
}
func (h *AdminHandler) FetchModels(c *gin.Context) {
// Load all global api_configs
rows, err := database.DB.Query(`
SELECT id, provider, endpoint, api_key_encrypted
FROM api_configs
WHERE user_id IS NULL AND is_active = true
`)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list configs"})
return
}
defer rows.Close()
type fetchResult struct {
ConfigID string `json:"config_id"`
Provider string `json:"provider"`
Added int `json:"added"`
Skipped int `json:"skipped"`
Error string `json:"error,omitempty"`
}
results := make([]fetchResult, 0)
totalAdded := 0
for rows.Next() {
var cfgID, providerID, endpoint string
var apiKey *string
if err := rows.Scan(&cfgID, &providerID, &endpoint, &apiKey); err != nil {
continue
}
prov, err := providers.Get(providerID)
if err != nil {
results = append(results, fetchResult{ConfigID: cfgID, Provider: providerID, Error: err.Error()})
continue
}
key := ""
if apiKey != nil {
key = *apiKey
}
models, err := prov.ListModels(c.Request.Context(), providers.ProviderConfig{
Endpoint: endpoint,
APIKey: key,
})
if err != nil {
results = append(results, fetchResult{ConfigID: cfgID, Provider: providerID, Error: err.Error()})
continue
}
fr := fetchResult{ConfigID: cfgID, Provider: providerID}
for _, m := range models {
result, err := database.DB.Exec(`
INSERT INTO model_configs (api_config_id, model_id, display_name)
VALUES ($1, $2, $3)
ON CONFLICT (api_config_id, model_id) DO NOTHING
`, cfgID, m.ID, m.Name)
if err != nil {
fr.Skipped++
continue
}
affected, _ := result.RowsAffected()
if affected > 0 {
fr.Added++
totalAdded++
} else {
fr.Skipped++
}
}
results = append(results, fr)
}
c.JSON(http.StatusOK, gin.H{
"total_added": totalAdded,
"results": results,
})
}
func (h *AdminHandler) UpdateModelConfig(c *gin.Context) {
modelID := c.Param("id")
var req updateModelConfigRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Build dynamic update
if req.IsEnabled != nil {
_, err := database.DB.Exec(
`UPDATE model_configs SET is_enabled = $1, updated_at = NOW() WHERE id = $2`,
*req.IsEnabled, modelID,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update enabled"})
return
}
}
if req.DisplayName != nil {
_, err := database.DB.Exec(
`UPDATE model_configs SET display_name = $1, updated_at = NOW() WHERE id = $2`,
*req.DisplayName, modelID,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update name"})
return
}
}
if req.Capabilities != nil {
capsJSON, err := json.Marshal(req.Capabilities)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid capabilities"})
return
}
_, err = database.DB.Exec(
`UPDATE model_configs SET capabilities = $1, updated_at = NOW() WHERE id = $2`,
string(capsJSON), modelID,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update capabilities"})
return
}
}
c.JSON(http.StatusOK, gin.H{"message": "model updated"})
}
func (h *AdminHandler) DeleteModelConfig(c *gin.Context) {
modelID := c.Param("id")
result, err := database.DB.Exec(`DELETE FROM model_configs WHERE id = $1`, modelID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete model"})
return
}
rows, _ := result.RowsAffected()
if rows == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "model not found"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "model deleted"})
}

View File

@@ -411,6 +411,47 @@ func (h *APIConfigHandler) ListAllModels(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"models": allModels})
}
// ── List Enabled Models (from model_configs) ─
func (h *APIConfigHandler) ListEnabledModels(c *gin.Context) {
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 map[string]interface{} `json:"capabilities"`
}
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.is_enabled = true AND ac.is_active = true AND ac.user_id IS NULL
ORDER BY ac.name, mc.model_id
`)
if err != nil {
c.JSON(http.StatusOK, gin.H{"models": []interface{}{}})
return
}
defer rows.Close()
models := make([]enabledModel, 0)
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
}
m.Capabilities = make(map[string]interface{})
_ = json.Unmarshal(capsJSON, &m.Capabilities)
models = append(models, m)
}
c.JSON(http.StatusOK, gin.H{"models": models})
}
// ── Helpers ─────────────────────────────────
type scannable interface {

View File

@@ -99,6 +99,7 @@ func main() {
// Models (per-config and aggregate)
protected.GET("/api-configs/:id/models", apiCfg.ListModels)
protected.GET("/models", apiCfg.ListAllModels)
protected.GET("/models/enabled", apiCfg.ListEnabledModels)
// User Settings & Profile
settings := handlers.NewSettingsHandler()
@@ -131,6 +132,17 @@ func main() {
// Stats
admin.GET("/stats", adm.GetStats)
// Global API Configs
admin.GET("/configs", adm.ListGlobalConfigs)
admin.POST("/configs", adm.CreateGlobalConfig)
admin.DELETE("/configs/:id", adm.DeleteGlobalConfig)
// Model Configs
admin.GET("/models", adm.ListModelConfigs)
admin.POST("/models/fetch", adm.FetchModels)
admin.PUT("/models/:id", adm.UpdateModelConfig)
admin.DELETE("/models/:id", adm.DeleteModelConfig)
}
}

View File

@@ -96,6 +96,7 @@ func TestAllRoutesRegistered(t *testing.T) {
protected.DELETE("/api-configs/:id", apiCfg.DeleteConfig)
protected.GET("/api-configs/:id/models", apiCfg.ListModels)
protected.GET("/models", apiCfg.ListAllModels)
protected.GET("/models/enabled", apiCfg.ListEnabledModels)
// User Settings & Profile
protected.GET("/profile", settings.GetProfile)
@@ -118,6 +119,13 @@ func TestAllRoutesRegistered(t *testing.T) {
admin.GET("/settings/:key", adm.GetGlobalSetting)
admin.PUT("/settings/:key", adm.UpdateGlobalSetting)
admin.GET("/stats", adm.GetStats)
admin.GET("/configs", adm.ListGlobalConfigs)
admin.POST("/configs", adm.CreateGlobalConfig)
admin.DELETE("/configs/:id", adm.DeleteGlobalConfig)
admin.GET("/models", adm.ListModelConfigs)
admin.POST("/models/fetch", adm.FetchModels)
admin.PUT("/models/:id", adm.UpdateModelConfig)
admin.DELETE("/models/:id", adm.DeleteModelConfig)
}
routes := r.Routes()
@@ -153,6 +161,7 @@ func TestAllRoutesRegistered(t *testing.T) {
"GET /api/v1/api-configs/:id/models",
// Models
"GET /api/v1/models",
"GET /api/v1/models/enabled",
// Profile & Settings
"GET /api/v1/profile",
"PUT /api/v1/profile",
@@ -170,6 +179,13 @@ func TestAllRoutesRegistered(t *testing.T) {
"GET /api/v1/admin/settings/:key",
"PUT /api/v1/admin/settings/:key",
"GET /api/v1/admin/stats",
"GET /api/v1/admin/configs",
"POST /api/v1/admin/configs",
"DELETE /api/v1/admin/configs/:id",
"GET /api/v1/admin/models",
"POST /api/v1/admin/models/fetch",
"PUT /api/v1/admin/models/:id",
"DELETE /api/v1/admin/models/:id",
}
for _, e := range expected {