Changeset 0.28.8 (#194)
This commit is contained in:
@@ -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
118
server/events/tickets.go
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ────────────────
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user