Changeset 0.28.8 (#194)

This commit is contained in:
2026-03-15 23:43:36 +00:00
parent 3237d55e0c
commit 128cbb8174
25 changed files with 1157 additions and 863 deletions

View File

@@ -7,6 +7,7 @@ import (
"os"
"path/filepath"
"strings"
"time"
_ "github.com/lib/pq"
_ "modernc.org/sqlite"
@@ -64,6 +65,8 @@ func connectPostgres(dsn string) error {
DB.SetMaxOpenConns(25)
DB.SetMaxIdleConns(5)
DB.SetConnMaxLifetime(5 * time.Minute) // recycle connections — prevents stale TCP after PG restart/network blip
DB.SetConnMaxIdleTime(1 * time.Minute) // close idle connections faster — reduces pool pressure after write bursts
if err := DB.Ping(); err != nil {
return fmt.Errorf("database ping: %w", err)

118
server/events/tickets.go Normal file
View File

@@ -0,0 +1,118 @@
package events
import (
"crypto/rand"
"encoding/hex"
"log"
"sync"
"time"
)
const (
// ticketTTL is how long a ticket remains valid after issuance.
ticketTTL = 30 * time.Second
// ticketReapInterval is how often the background reaper runs.
ticketReapInterval = 15 * time.Second
)
// ticket is a single-use opaque token that maps to a user ID.
type ticket struct {
userID string
expiresAt time.Time
}
// TicketStore manages short-lived, single-use WebSocket authentication tickets.
//
// Flow:
// 1. Client POSTs to /api/v1/ws/ticket (authenticated via JWT).
// 2. Server returns {"ticket": "<opaque>"}.
// 3. Client connects WebSocket with ?ticket=<opaque>.
// 4. Server validates and deletes the ticket atomically (single-use).
//
// This replaces passing the JWT as ?token= in the WebSocket URL, which
// exposed the token in server logs, proxy logs, and browser history.
type TicketStore struct {
mu sync.Mutex
tickets map[string]ticket
stopCh chan struct{}
}
// NewTicketStore creates a store and starts the background reaper.
func NewTicketStore() *TicketStore {
ts := &TicketStore{
tickets: make(map[string]ticket),
stopCh: make(chan struct{}),
}
go ts.reaper()
return ts
}
// Issue creates a new single-use ticket for the given user.
// Returns the opaque ticket string.
func (ts *TicketStore) Issue(userID string) (string, error) {
b := make([]byte, 16) // 128-bit random
if _, err := rand.Read(b); err != nil {
return "", err
}
id := hex.EncodeToString(b)
ts.mu.Lock()
ts.tickets[id] = ticket{
userID: userID,
expiresAt: time.Now().Add(ticketTTL),
}
ts.mu.Unlock()
return id, nil
}
// Validate checks a ticket, deletes it (single-use), and returns the user ID.
// Returns ("", false) if the ticket is invalid, expired, or already consumed.
func (ts *TicketStore) Validate(ticketID string) (string, bool) {
ts.mu.Lock()
defer ts.mu.Unlock()
t, ok := ts.tickets[ticketID]
if !ok {
return "", false
}
delete(ts.tickets, ticketID) // single-use: delete immediately
if time.Now().After(t.expiresAt) {
return "", false
}
return t.userID, true
}
// Stop shuts down the background reaper.
func (ts *TicketStore) Stop() {
close(ts.stopCh)
}
// reaper periodically removes expired tickets that were never validated.
func (ts *TicketStore) reaper() {
ticker := time.NewTicker(ticketReapInterval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
ts.mu.Lock()
now := time.Now()
reaped := 0
for id, t := range ts.tickets {
if now.After(t.expiresAt) {
delete(ts.tickets, id)
reaped++
}
}
ts.mu.Unlock()
if reaped > 0 {
log.Printf("[ws-tickets] reaped %d expired tickets", reaped)
}
case <-ts.stopCh:
return
}
}
}

View File

@@ -4,6 +4,7 @@ import (
"encoding/json"
"log"
"net/http"
"strings"
"sync"
"time"
@@ -11,12 +12,6 @@ import (
"github.com/gorilla/websocket"
)
var upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
CheckOrigin: func(r *http.Request) bool { return true },
}
const (
writeWait = 10 * time.Second
pongWait = 60 * time.Second
@@ -26,9 +21,10 @@ const (
// Hub manages WebSocket connections and bridges them to the Bus.
type Hub struct {
bus *Bus
mu sync.RWMutex
conns map[string]*Conn // connID → Conn
bus *Bus
mu sync.RWMutex
conns map[string]*Conn // connID → Conn
upgrader websocket.Upgrader
}
// Conn represents a single WebSocket connection.
@@ -43,11 +39,55 @@ type Conn struct {
}
// NewHub creates a Hub bound to an event bus.
func NewHub(bus *Bus) *Hub {
return &Hub{
// allowedOrigins controls the WebSocket upgrader's CheckOrigin behavior.
// Empty or "*" allows all origins (dev/test). Comma-separated list
// restricts to those origins (production).
func NewHub(bus *Bus, allowedOrigins string) *Hub {
h := &Hub{
bus: bus,
conns: make(map[string]*Conn),
}
h.upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
CheckOrigin: h.makeCheckOrigin(allowedOrigins),
}
return h
}
// makeCheckOrigin returns a CheckOrigin function that validates the
// request Origin header against the configured allowed origins.
func (h *Hub) makeCheckOrigin(allowedOrigins string) func(r *http.Request) bool {
origins := strings.TrimSpace(allowedOrigins)
// No restriction: dev/test or explicit "*"
if origins == "" || origins == "*" {
return func(r *http.Request) bool { return true }
}
// Parse comma-separated origin list
var allowed []string
for _, o := range strings.Split(origins, ",") {
o = strings.TrimSpace(o)
if o != "" {
allowed = append(allowed, o)
}
}
return func(r *http.Request) bool {
origin := r.Header.Get("Origin")
if origin == "" {
// No Origin header — same-origin request (non-browser client).
return true
}
for _, a := range allowed {
if a == origin {
return true
}
}
log.Printf("[ws] rejected WebSocket from origin %q (allowed: %s)", origin, origins)
return false
}
}
// HandleWebSocket is a Gin handler for WebSocket upgrade.
@@ -60,7 +100,7 @@ func (h *Hub) HandleWebSocket(c *gin.Context) {
return
}
ws, err := upgrader.Upgrade(c.Writer, c.Request, nil)
ws, err := h.upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
log.Printf("[ws] upgrade failed: %v", err)
return

View File

@@ -2,6 +2,7 @@ package handlers
import (
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
@@ -665,6 +666,10 @@ func (h *AdminHandler) FetchModels(c *gin.Context) {
added, updated, fetched, err := h.fetchModelsForProvider(c, cfg)
if err != nil {
if errors.Is(err, ErrUpstreamTimeout) {
c.JSON(http.StatusGatewayTimeout, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusBadGateway, gin.H{"error": "failed to fetch models: " + err.Error()})
return
}

View File

@@ -1,6 +1,7 @@
package handlers
import (
"errors"
"log"
"net/http"
@@ -279,6 +280,10 @@ func (h *ProviderConfigHandler) FetchModels(c *gin.Context) {
result, err := syncAndEnableProviderModels(c.Request.Context(), h.stores, cfg, apiKey)
if err != nil {
if errors.Is(err, ErrUpstreamTimeout) {
c.JSON(http.StatusGatewayTimeout, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusBadGateway, gin.H{"error": "failed to fetch models: " + err.Error()})
return
}

View File

@@ -3,14 +3,35 @@ package handlers
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"os"
"strconv"
"time"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/providers"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
// providerSyncTimeout is the maximum time allowed for an outbound provider
// HTTP call during model sync. Configurable via PROVIDER_SYNC_TIMEOUT env
// var (seconds). Default 30s.
var providerSyncTimeout = func() time.Duration {
if v := os.Getenv("PROVIDER_SYNC_TIMEOUT"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
return time.Duration(n) * time.Second
}
}
return 30 * time.Second
}()
// ErrUpstreamTimeout is returned when a provider API call exceeds the
// configured timeout. Handlers use this to distinguish upstream timeouts
// from other errors and return 504 instead of 502.
var ErrUpstreamTimeout = errors.New("upstream timeout")
// syncResult holds the outcome of a model sync operation.
type syncResult struct {
Added int `json:"added"`
@@ -62,8 +83,17 @@ func syncProviderModels(ctx context.Context, stores store.Stores, cfg *models.Pr
}
}
provModels, err := prov.ListModels(ctx, provCfg)
// Wrap context with timeout for the outbound provider call.
// All provider ListModels implementations use http.NewRequestWithContext,
// so the deadline propagates to the HTTP transport automatically.
syncCtx, cancel := context.WithTimeout(ctx, providerSyncTimeout)
defer cancel()
provModels, err := prov.ListModels(syncCtx, provCfg)
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
return syncResult{}, fmt.Errorf("%w: %s", ErrUpstreamTimeout, cfg.Provider)
}
return syncResult{}, err
}

View File

@@ -375,7 +375,11 @@ func main() {
base := r.Group(cfg.BasePath)
// ── WebSocket Hub ─────────────────────────
hub := events.NewHub(bus)
hub := events.NewHub(bus, middleware.GetAllowedOrigins(cfg))
// ── WebSocket Ticket Store (v0.28.8) ─────
ticketStore := events.NewTicketStore()
defer ticketStore.Stop()
// ── Notification Service (v0.20.0) ───────
var notifSvc *notifications.Service
@@ -429,7 +433,7 @@ func main() {
})
// WebSocket endpoint
base.GET("/ws", middleware.Auth(cfg, stores.Users, userCache), hub.HandleWebSocket)
base.GET("/ws", middleware.WsAuth(cfg, stores.Users, userCache, ticketStore), hub.HandleWebSocket)
// ── Auth routes (rate limited) ──────────────
authMode, err := auth.ParseMode(cfg.AuthMode)
@@ -515,6 +519,19 @@ func main() {
protected.Use(middleware.Auth(cfg, stores.Users, userCache))
protected.Use(middleware.ValidatePathParams())
{
// ── WebSocket Ticket (v0.28.8) ───────────
// Issue a single-use ticket for WebSocket auth.
// Client fetches this, then connects with ?ticket=<opaque>.
protected.POST("/ws/ticket", func(c *gin.Context) {
userID := c.GetString("user_id")
ticket, err := ticketStore.Issue(userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to issue ticket"})
return
}
c.JSON(http.StatusOK, gin.H{"ticket": ticket})
})
// Channels
channels := handlers.NewChannelHandler()
protected.GET("/channels", channels.ListChannels)

View File

@@ -99,7 +99,12 @@ func verifyUser(c *gin.Context, claims *Claims, users store.UserStore, cache *Us
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token (empty user_id)"})
return "", false
}
return verifyUserByID(c, userID, users, cache)
}
// verifyUserByID checks is_active and resolves the current DB role for a user ID.
// Used by both JWT auth (via verifyUser) and ticket auth.
func verifyUserByID(c *gin.Context, userID string, users store.UserStore, cache *UserStatusCache) (string, bool) {
// Cache hit
if entry, ok := cache.get(userID); ok {
if !entry.isActive {
@@ -227,9 +232,20 @@ func CORS(cfg *config.Config) gin.HandlerFunc {
}
}
// GetAllowedOrigins returns the resolved CORS origin string for use by
// other subsystems (e.g. WebSocket upgrader CheckOrigin). Call after CORS
// middleware is initialized.
func GetAllowedOrigins(cfg *config.Config) string {
return getAllowedOrigin(cfg)
}
func getAllowedOrigin(cfg *config.Config) string {
// Explicit env var overrides everything
if v := os.Getenv("CORS_ALLOWED_ORIGINS"); v != "" {
if v == "*" {
log.Println("[CORS] WARNING: CORS_ALLOWED_ORIGINS is explicitly set to '*' — all origins allowed. " +
"Set to a comma-separated list of allowed origins for production deployments.")
}
return v
}
// Production: restrict. Dev/test: allow all.
@@ -252,3 +268,114 @@ func originAllowed(origin, allowed string) bool {
}
return false
}
// ── WebSocket Auth (ticket exchange) ─────────────────────────
// TicketValidator validates a single-use WebSocket ticket.
// Implemented by events.TicketStore. Interface avoids circular import.
type TicketValidator interface {
Validate(ticketID string) (userID string, ok bool)
}
// WsAuth returns a Gin middleware for the WebSocket endpoint.
// It authenticates via three methods in priority order:
//
// 1. ?ticket=<opaque> — single-use ticket (preferred, v0.28.8+)
// 2. ?token=<jwt> — legacy JWT in query param (deprecated)
// 3. Authorization header — standard Bearer JWT
//
// When ?token= is used, a deprecation notice is logged. The ticket
// path avoids exposing the JWT in server logs, proxy logs, and
// browser history.
func WsAuth(cfg *config.Config, users store.UserStore, cache *UserStatusCache, tickets TicketValidator) gin.HandlerFunc {
return func(c *gin.Context) {
if !database.IsConnected() {
c.Next()
return
}
// Path 1: Ticket exchange (preferred)
if ticketID := c.Query("ticket"); ticketID != "" {
userID, ok := tickets.Validate(ticketID)
if !ok {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": "invalid or expired ticket",
})
return
}
// Ticket is valid — resolve role from DB (same as JWT path)
role, ok := verifyUserByID(c, userID, users, cache)
if !ok {
return
}
c.Set("user_id", userID)
c.Set("role", role)
c.Set("ws_auth", "ticket")
c.Next()
return
}
// Path 2: Legacy ?token= (deprecated — log warning)
if qToken := c.Query("token"); qToken != "" {
log.Printf("[ws] DEPRECATED: client using ?token= query param for WebSocket auth — migrate to ticket exchange")
claims, ok := parseAndValidateJWT(qToken, cfg.JWTSecret)
if !ok {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": "invalid or expired token",
})
return
}
role, ok := verifyUser(c, claims, users, cache)
if !ok {
return
}
c.Set("user_id", claims.UserID)
c.Set("email", claims.Email)
c.Set("role", role)
c.Set("ws_auth", "token_legacy")
c.Next()
return
}
// Path 3: Authorization header (non-browser clients)
header := c.GetHeader("Authorization")
if header == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": "missing authentication — use ticket exchange or Authorization header",
})
return
}
tokenString := strings.TrimPrefix(header, "Bearer ")
if tokenString == header {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": "invalid authorization format",
})
return
}
claims, ok := parseAndValidateJWT(tokenString, cfg.JWTSecret)
if !ok {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": "invalid or expired token",
})
return
}
role, ok := verifyUser(c, claims, users, cache)
if !ok {
return
}
c.Set("user_id", claims.UserID)
c.Set("email", claims.Email)
c.Set("role", role)
c.Set("ws_auth", "bearer")
c.Next()
}
}

View File

@@ -7,6 +7,7 @@ import (
"log"
"net/http"
"net/url"
"sync"
"time"
"git.gobha.me/xcaliber/chat-switchboard/models"
@@ -51,12 +52,30 @@ type ProviderConfig struct {
ProxyURL string // Only used when ProxyMode == "custom"
}
// ── HTTP Client Pool ───────────────────────
//
// Provider HTTP clients are pooled by (ProxyMode, ProxyURL) tuple.
// Previously Client() created a new http.Transport per call, leaking
// idle connections and preventing TCP reuse across requests to the same
// provider. With pooling, a Venice model sync followed by a completion
// reuses the same TCP connection to api.venice.ai.
// clientPool holds shared *http.Client instances keyed by proxy config.
var clientPool sync.Map // map[string]*http.Client
// Client returns an *http.Client configured for this provider's proxy settings.
// Clients are cached and reused across calls with the same proxy configuration.
// system = http.ProxyFromEnvironment (Go default), direct = no proxy,
// custom = explicit proxy URL (http/https/socks5).
func (c ProviderConfig) Client() *http.Client {
key := c.ProxyMode + "|" + c.ProxyURL
if v, ok := clientPool.Load(key); ok {
return v.(*http.Client)
}
transport := &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
}
@@ -81,7 +100,10 @@ func (c ProviderConfig) Client() *http.Client {
default: // "system" or empty
transport.Proxy = http.ProxyFromEnvironment
}
return &http.Client{Transport: transport}
client := &http.Client{Transport: transport}
actual, _ := clientPool.LoadOrStore(key, client)
return actual.(*http.Client)
}
// ── Request / Response Types ────────────────

View File

@@ -25,50 +25,88 @@ const catalogColsMC = `mc.id, mc.provider_config_id, mc.model_id, mc.display_nam
// UpsertFromSync bulk-inserts or updates catalog entries from a provider API fetch.
// New models default to 'disabled' visibility (secure by default).
//
// Runs in a single transaction: one SELECT to identify existing models,
// then N upserts via a prepared INSERT ON CONFLICT statement, one COMMIT.
// Previously this was N×2 auto-committed queries (SELECT + INSERT/UPDATE per model),
// which caused 300+ round-trips and WAL flushes on a typical Venice sync.
func (s *CatalogStore) UpsertFromSync(ctx context.Context, providerConfigID string, entries []store.CatalogSyncEntry) (added, updated int, err error) {
if len(entries) == 0 {
return 0, 0, nil
}
tx, err := DB.BeginTx(ctx, nil)
if err != nil {
return 0, 0, fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback() // no-op after commit
// Pre-fetch existing model_ids for this provider to track added vs updated.
existing := make(map[string]bool)
rows, err := tx.QueryContext(ctx,
"SELECT model_id FROM model_catalog WHERE provider_config_id = $1",
providerConfigID)
if err != nil {
return 0, 0, fmt.Errorf("list existing: %w", err)
}
for rows.Next() {
var mid string
if err := rows.Scan(&mid); err != nil {
rows.Close()
return 0, 0, fmt.Errorf("scan existing: %w", err)
}
existing[mid] = true
}
rows.Close()
if err := rows.Err(); err != nil {
return 0, 0, fmt.Errorf("list existing: %w", err)
}
// Prepare the upsert statement once, execute N times.
// INSERT creates new rows with visibility='disabled' (secure by default).
// ON CONFLICT updates metadata but preserves visibility (admin controls that).
stmt, err := tx.PrepareContext(ctx, `
INSERT INTO model_catalog (provider_config_id, model_id, display_name,
model_type, capabilities, pricing, visibility, last_synced_at)
VALUES ($1, $2, $3, $4, $5, $6, 'disabled', $7)
ON CONFLICT (provider_config_id, model_id) DO UPDATE SET
display_name = EXCLUDED.display_name,
model_type = EXCLUDED.model_type,
capabilities = EXCLUDED.capabilities,
pricing = EXCLUDED.pricing,
last_synced_at = EXCLUDED.last_synced_at`)
if err != nil {
return 0, 0, fmt.Errorf("prepare upsert: %w", err)
}
defer stmt.Close()
now := time.Now()
for _, e := range entries {
capsJSON := ToJSON(e.Capabilities)
pricingJSON := ToJSON(e.Pricing) // nil → "{}", always valid JSONB
pricingJSON := ToJSON(e.Pricing)
// Normalize model type: empty → "chat" (the default)
modelType := e.ModelType
if modelType == "" {
modelType = "chat"
}
var existingID string
err := DB.QueryRowContext(ctx,
"SELECT id FROM model_catalog WHERE provider_config_id = $1 AND model_id = $2",
providerConfigID, e.ModelID,
).Scan(&existingID)
if _, err := stmt.ExecContext(ctx,
providerConfigID, e.ModelID, e.DisplayName,
modelType, capsJSON, pricingJSON, now,
); err != nil {
return added, updated, fmt.Errorf("upsert %s: %w", e.ModelID, err)
}
if err == sql.ErrNoRows {
// Insert new (disabled by default)
_, err = DB.ExecContext(ctx, `
INSERT INTO model_catalog (provider_config_id, model_id, display_name, model_type,
capabilities, pricing, visibility, last_synced_at)
VALUES ($1, $2, $3, $4, $5, $6, 'disabled', $7)`,
providerConfigID, e.ModelID, e.DisplayName, modelType, capsJSON, pricingJSON, now)
if err != nil {
return added, updated, fmt.Errorf("insert %s: %w", e.ModelID, err)
}
added++
} else if err == nil {
// Update existing (preserve visibility)
_, err = DB.ExecContext(ctx, `
UPDATE model_catalog SET display_name = $1, model_type = $2, capabilities = $3,
pricing = $4, last_synced_at = $5
WHERE id = $6`,
e.DisplayName, modelType, capsJSON, pricingJSON, now, existingID)
if err != nil {
return added, updated, fmt.Errorf("update %s: %w", e.ModelID, err)
}
if existing[e.ModelID] {
updated++
} else {
return added, updated, fmt.Errorf("check %s: %w", e.ModelID, err)
added++
}
}
if err := tx.Commit(); err != nil {
return 0, 0, fmt.Errorf("commit: %w", err)
}
return added, updated, nil
}

View File

@@ -25,50 +25,87 @@ const catalogColsMC = `mc.id, mc.provider_config_id, mc.model_id, mc.display_nam
// UpsertFromSync bulk-inserts or updates catalog entries from a provider API fetch.
// New models default to 'disabled' visibility (secure by default).
//
// Runs in a single transaction: one SELECT to identify existing models,
// then N upserts via a prepared INSERT ON CONFLICT statement, one COMMIT.
// SQLite with WAL benefits enormously — one journal cycle instead of N.
func (s *CatalogStore) UpsertFromSync(ctx context.Context, providerConfigID string, entries []store.CatalogSyncEntry) (added, updated int, err error) {
now := time.Now()
if len(entries) == 0 {
return 0, 0, nil
}
tx, err := DB.BeginTx(ctx, nil)
if err != nil {
return 0, 0, fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback()
// Pre-fetch existing model_ids for this provider to track added vs updated.
existing := make(map[string]bool)
rows, err := tx.QueryContext(ctx,
"SELECT model_id FROM model_catalog WHERE provider_config_id = ?",
providerConfigID)
if err != nil {
return 0, 0, fmt.Errorf("list existing: %w", err)
}
for rows.Next() {
var mid string
if err := rows.Scan(&mid); err != nil {
rows.Close()
return 0, 0, fmt.Errorf("scan existing: %w", err)
}
existing[mid] = true
}
rows.Close()
if err := rows.Err(); err != nil {
return 0, 0, fmt.Errorf("list existing: %w", err)
}
// Prepare the upsert statement once, execute N times.
// SQLite needs an explicit id value (TEXT PK, no auto-generation).
// On conflict the id is preserved — only metadata columns update.
stmt, err := tx.PrepareContext(ctx, `
INSERT INTO model_catalog (id, provider_config_id, model_id, display_name,
model_type, capabilities, pricing, visibility, last_synced_at)
VALUES (?, ?, ?, ?, ?, ?, ?, 'disabled', ?)
ON CONFLICT (provider_config_id, model_id) DO UPDATE SET
display_name = excluded.display_name,
model_type = excluded.model_type,
capabilities = excluded.capabilities,
pricing = excluded.pricing,
last_synced_at = excluded.last_synced_at`)
if err != nil {
return 0, 0, fmt.Errorf("prepare upsert: %w", err)
}
defer stmt.Close()
now := time.Now().UTC().Format("2006-01-02 15:04:05")
for _, e := range entries {
capsJSON := ToJSON(e.Capabilities)
pricingJSON := ToJSON(e.Pricing) // nil → "{}", always valid JSONB
pricingJSON := ToJSON(e.Pricing)
// Normalize model type: empty → "chat" (the default)
modelType := e.ModelType
if modelType == "" {
modelType = "chat"
}
var existingID string
err := DB.QueryRowContext(ctx,
"SELECT id FROM model_catalog WHERE provider_config_id = ? AND model_id = ?",
providerConfigID, e.ModelID,
).Scan(&existingID)
if _, err := stmt.ExecContext(ctx,
store.NewID(), providerConfigID, e.ModelID, e.DisplayName,
modelType, capsJSON, pricingJSON, now,
); err != nil {
return added, updated, fmt.Errorf("upsert %s: %w", e.ModelID, err)
}
if err == sql.ErrNoRows {
// Insert new (disabled by default)
_, err = DB.ExecContext(ctx, `
INSERT INTO model_catalog (id, provider_config_id, model_id, display_name, model_type,
capabilities, pricing, visibility, last_synced_at)
VALUES (?, ?, ?, ?, ?, ?, ?, 'disabled', ?)`,
store.NewID(), providerConfigID, e.ModelID, e.DisplayName, modelType, capsJSON, pricingJSON, now)
if err != nil {
return added, updated, fmt.Errorf("insert %s: %w", e.ModelID, err)
}
added++
} else if err == nil {
// Update existing (preserve visibility)
_, err = DB.ExecContext(ctx, `
UPDATE model_catalog SET display_name = ?, model_type = ?, capabilities = ?,
pricing = ?, last_synced_at = ?
WHERE id = ?`,
e.DisplayName, modelType, capsJSON, pricingJSON, now, existingID)
if err != nil {
return added, updated, fmt.Errorf("update %s: %w", e.ModelID, err)
}
if existing[e.ModelID] {
updated++
} else {
return added, updated, fmt.Errorf("check %s: %w", e.ModelID, err)
added++
}
}
if err := tx.Commit(); err != nil {
return 0, 0, fmt.Errorf("commit: %w", err)
}
return added, updated, nil
}