Files
localmodelroutex/backend/internal/store/provider_models_admin.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

121 lines
4.8 KiB
Go

package store
import (
"context"
"time"
"github.com/google/uuid"
)
// AdminModelRow is a catalog entry for the admin view (includes blocked /
// unavailable rows, with both discovered and override pricing).
type AdminModelRow struct {
ID uuid.UUID `json:"id"`
ProviderName string `json:"provider_name"`
Slug string `json:"slug"`
DisplayName string `json:"display_name"`
Source string `json:"source"`
Available bool `json:"available"`
Enabled bool `json:"enabled"`
Blocked bool `json:"blocked"`
ContextLength *int `json:"context_length"`
ContextLengthOvr *int `json:"context_length_override"`
LastSeenAt *time.Time `json:"last_seen_at"`
DiscInputPerMTok *float64 `json:"disc_input_per_mtok"`
DiscOutputPerMTok *float64 `json:"disc_output_per_mtok"`
PriceInputPerMTok *float64 `json:"price_input_per_mtok"`
PriceOutputPerMTok *float64 `json:"price_output_per_mtok"`
PriceCachedPerMTok *float64 `json:"price_cached_per_mtok"`
RPMLimit *int `json:"rpm_limit"`
TPMLimit *int `json:"tpm_limit"`
FallbackModelID *uuid.UUID `json:"fallback_model_id"`
// FallbackAddress is the backup model's "<provider>/<slug>" for display.
FallbackAddress *string `json:"fallback_address"`
}
// ListModelsAdmin lists every catalog entry (no servability filter) for admin.
func (s *Store) ListModelsAdmin(ctx context.Context) ([]AdminModelRow, error) {
rows, err := s.pool.Query(ctx, `
SELECT pm.id, p.name, pm.slug, COALESCE(pm.display_name,''), pm.source,
pm.available, pm.enabled, pm.blocked, pm.context_length, pm.context_length_override, pm.last_seen_at,
pm.disc_input_price_per_mtok, pm.disc_output_price_per_mtok,
pm.price_input_per_mtok, pm.price_output_per_mtok, pm.price_cached_per_mtok,
pm.rpm_limit, pm.tpm_limit,
pm.fallback_model_id, fp.name || '/' || fb.slug
FROM provider_models pm
JOIN providers p ON p.id = pm.provider_id
LEFT JOIN provider_models fb ON fb.id = pm.fallback_model_id
LEFT JOIN providers fp ON fp.id = fb.provider_id
ORDER BY p.name, pm.slug`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []AdminModelRow
for rows.Next() {
var m AdminModelRow
if err := rows.Scan(&m.ID, &m.ProviderName, &m.Slug, &m.DisplayName, &m.Source,
&m.Available, &m.Enabled, &m.Blocked, &m.ContextLength, &m.ContextLengthOvr, &m.LastSeenAt,
&m.DiscInputPerMTok, &m.DiscOutputPerMTok,
&m.PriceInputPerMTok, &m.PriceOutputPerMTok, &m.PriceCachedPerMTok,
&m.RPMLimit, &m.TPMLimit, &m.FallbackModelID, &m.FallbackAddress); err != nil {
return nil, err
}
out = append(out, m)
}
return out, rows.Err()
}
// Patch is an optional column update. Set=false leaves the column unchanged.
// Set=true with a nil Value clears the column to NULL.
type Patch[T any] struct {
Set bool
Value *T
}
// ModelOverride carries admin override fields; unset patches are left unchanged.
type ModelOverride struct {
Enabled *bool
Blocked *bool
ParamOverrides []byte // jsonb; nil leaves unchanged
InputPrice Patch[float64]
OutputPrice Patch[float64]
CachedPrice Patch[float64]
RPMLimit Patch[int]
TPMLimit Patch[int]
// ContextLength overrides the provider-reported context window.
ContextLength Patch[int]
// FallbackModelID is the backup model served when this one fails.
FallbackModelID Patch[uuid.UUID]
}
func (s *Store) UpdateModelOverride(ctx context.Context, id uuid.UUID, o ModelOverride) error {
var params any = o.ParamOverrides
if len(o.ParamOverrides) == 0 {
params = nil
}
_, err := s.pool.Exec(ctx, `
UPDATE provider_models SET
enabled = COALESCE($2, enabled),
blocked = COALESCE($3, blocked),
param_overrides = COALESCE($4, param_overrides),
price_input_per_mtok = CASE WHEN $5::boolean THEN $6 ELSE price_input_per_mtok END,
price_output_per_mtok = CASE WHEN $7::boolean THEN $8 ELSE price_output_per_mtok END,
price_cached_per_mtok = CASE WHEN $9::boolean THEN $10 ELSE price_cached_per_mtok END,
rpm_limit = CASE WHEN $11::boolean THEN $12 ELSE rpm_limit END,
tpm_limit = CASE WHEN $13::boolean THEN $14 ELSE tpm_limit END,
context_length_override = CASE WHEN $15::boolean THEN $16 ELSE context_length_override END,
fallback_model_id = CASE WHEN $17::boolean THEN $18 ELSE fallback_model_id END
WHERE id = $1`,
id, o.Enabled, o.Blocked, params,
o.InputPrice.Set, o.InputPrice.Value,
o.OutputPrice.Set, o.OutputPrice.Value,
o.CachedPrice.Set, o.CachedPrice.Value,
o.RPMLimit.Set, o.RPMLimit.Value,
o.TPMLimit.Set, o.TPMLimit.Value,
o.ContextLength.Set, o.ContextLength.Value,
o.FallbackModelID.Set, o.FallbackModelID.Value,
)
return err
}