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