Files
localmodelroutex/backend/internal/store/provider_models.go
LovroG05 8bb62a1c20
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
feat: serve a backup model when the requested model fails
Any model can name another model as its backup. The gateway tries the
backup when the model is offline, rate limited, or its upstream fails
before the client has received anything. The response reports the
requested model, so the client sees no switch. The request log records
each failed attempt and the address that served the request, and the
cost uses the backup's prices.
2026-10-15 09:56:01 +02:00

139 lines
4.6 KiB
Go

package store
import (
"context"
"time"
"github.com/google/uuid"
)
// DiscoveredModel is one entry from a provider's model listing, captured by the
// catalog syncer. Pointer fields are NULL when the provider does not report them.
type DiscoveredModel struct {
Slug string
DisplayName string
ContextLength *int
InputPricePerMTok *float64
OutputPricePerMTok *float64
// Capabilities are the modalities the provider reports for the model
// ("chat", "images", ...). Empty means the column default applies.
Capabilities []string
Raw []byte
}
// UpsertDiscovered inserts/updates discovered models for a provider in one
// transaction, then marks any previously-discovered model not seen in this sync
// (older last_seen_at) as unavailable. Admin override columns are never touched.
func (s *Store) UpsertDiscovered(ctx context.Context, providerID uuid.UUID, syncedAt time.Time, models []DiscoveredModel) error {
tx, err := s.pool.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx) //nolint:errcheck // no-op after Commit
for _, m := range models {
raw := m.Raw
if raw == nil {
raw = []byte("{}")
}
caps := m.Capabilities
if len(caps) == 0 {
caps = []string{"chat"}
}
if _, err := tx.Exec(ctx, `
INSERT INTO provider_models
(provider_id, slug, display_name, context_length,
disc_input_price_per_mtok, disc_output_price_per_mtok,
source, available, last_seen_at, raw, capabilities)
VALUES ($1, $2, $3, $4, $5, $6, 'discovered', true, $7, $8, $9)
ON CONFLICT (provider_id, slug) DO UPDATE SET
display_name = EXCLUDED.display_name,
context_length = EXCLUDED.context_length,
disc_input_price_per_mtok = EXCLUDED.disc_input_price_per_mtok,
disc_output_price_per_mtok = EXCLUDED.disc_output_price_per_mtok,
available = true,
last_seen_at = EXCLUDED.last_seen_at,
raw = EXCLUDED.raw,
capabilities = EXCLUDED.capabilities`,
providerID, m.Slug, nullString(m.DisplayName), m.ContextLength,
m.InputPricePerMTok, m.OutputPricePerMTok, syncedAt, raw, caps,
); err != nil {
return err
}
}
if _, err := tx.Exec(ctx, `
UPDATE provider_models SET available = false
WHERE provider_id = $1 AND source = 'discovered'
AND (last_seen_at IS NULL OR last_seen_at < $2)`,
providerID, syncedAt,
); err != nil {
return err
}
return tx.Commit(ctx)
}
// CatalogRow is a catalog entry joined with its provider, with effective pricing
// resolved (override else discovered else 0).
type CatalogRow struct {
ID uuid.UUID // provider_models.id
CreatedAt time.Time
ProviderID uuid.UUID
ProviderName string
ProviderType string
Slug string
DisplayName string
Capabilities []string
ContextLength *int
Enabled bool
Blocked bool
Available bool
ParamOverrides []byte
InputPricePerMTok float64
OutputPricePerMTok float64
CachedPricePerMTok float64
RPMLimit *int
TPMLimit *int
// FallbackModelID is the admin-configured backup model, when set.
FallbackModelID *uuid.UUID
}
// ListCatalog returns every catalog entry belonging to an enabled provider.
// The context length is the admin override when set, else the discovered
// value.
func (s *Store) ListCatalog(ctx context.Context) ([]CatalogRow, error) {
rows, err := s.pool.Query(ctx, `
SELECT pm.id, pm.created_at, pm.provider_id, p.name, p.type, pm.slug,
COALESCE(pm.display_name, ''),
pm.capabilities, COALESCE(pm.context_length_override, pm.context_length),
pm.enabled, pm.blocked, pm.available, pm.param_overrides,
COALESCE(pm.price_input_per_mtok, pm.disc_input_price_per_mtok, 0)::double precision,
COALESCE(pm.price_output_per_mtok, pm.disc_output_price_per_mtok, 0)::double precision,
COALESCE(pm.price_cached_per_mtok, 0)::double precision,
pm.rpm_limit, pm.tpm_limit, pm.fallback_model_id
FROM provider_models pm
JOIN providers p ON p.id = pm.provider_id
WHERE p.enabled = true`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []CatalogRow
for rows.Next() {
var r CatalogRow
if err := rows.Scan(
&r.ID, &r.CreatedAt, &r.ProviderID, &r.ProviderName, &r.ProviderType, &r.Slug,
&r.DisplayName, &r.Capabilities, &r.ContextLength,
&r.Enabled, &r.Blocked, &r.Available, &r.ParamOverrides,
&r.InputPricePerMTok, &r.OutputPricePerMTok, &r.CachedPricePerMTok,
&r.RPMLimit, &r.TPMLimit, &r.FallbackModelID,
); err != nil {
return nil, err
}
out = append(out, r)
}
return out, rows.Err()
}