refactor(api): split Provider into ProviderGroup and APIKey models

Restructure the provider management system by separating the monolithic
Provider model into two distinct entities:

- ProviderGroup: defines shared upstream configuration (type, base_url,
  google settings, models, status)
- APIKey: represents individual credentials within a group (api_key,
  weight, status, auto_ban, ban settings)

This change also updates:
- Binding model to reference GroupID instead of RouteGroup string
- All CRUD handlers for the new provider-group and api-key endpoints
- Sync service to rebuild provider snapshots from joined tables
- Model registry to aggregate capabilities across group/key pairs
- Access handler to validate namespace existence and subset constraints
- Migration importer to handle the new schema structure
- All related tests to use the new model relationships

BREAKING CHANGE: Provider API endpoints replaced with /provider-groups
and /api-keys endpoints; Binding.RouteGroup replaced with Binding.GroupID
This commit is contained in:
zenfun
2025-12-24 02:15:52 +08:00
parent cd5616dc26
commit dea8363e41
27 changed files with 1222 additions and 1625 deletions

View File

@@ -18,7 +18,6 @@ import (
"time"
"github.com/ez-api/ez-api/internal/model"
groupx "github.com/ez-api/foundation/group"
"github.com/ez-api/foundation/jsoncodec"
"github.com/ez-api/foundation/modelcap"
"github.com/ez-api/foundation/routing"
@@ -373,6 +372,33 @@ type upstreamCap struct {
SupportsTools boolVal
}
func boolValEqual(a, b boolVal) bool {
if a.Known != b.Known {
return false
}
if !a.Known {
return true
}
return a.Val == b.Val
}
func intValEqual(a, b intVal) bool {
if a.Known != b.Known {
return false
}
if !a.Known {
return true
}
return a.Val == b.Val
}
func capsEqual(a, b upstreamCap) bool {
return boolValEqual(a.SupportsVision, b.SupportsVision) &&
boolValEqual(a.SupportsTools, b.SupportsTools) &&
intValEqual(a.ContextWindow, b.ContextWindow) &&
intValEqual(a.MaxOutputTokens, b.MaxOutputTokens)
}
type modelsDevRegistry struct {
ByProviderModel map[string]upstreamCap // key: providerID|modelID
ByModel map[string]upstreamCap // fallback: modelID
@@ -707,9 +733,13 @@ func (a *capAgg) finalize(name string) modelcap.Model {
}
func (s *ModelRegistryService) buildBindingModels(ctx context.Context, reg *modelsDevRegistry) (map[string]modelcap.Model, map[string]string, error) {
var providers []model.Provider
if err := s.db.Find(&providers).Error; err != nil {
return nil, nil, fmt.Errorf("load providers: %w", err)
var groups []model.ProviderGroup
if err := s.db.Find(&groups).Error; err != nil {
return nil, nil, fmt.Errorf("load provider groups: %w", err)
}
var apiKeys []model.APIKey
if err := s.db.Find(&apiKeys).Error; err != nil {
return nil, nil, fmt.Errorf("load api keys: %w", err)
}
var bindings []model.Binding
if err := s.db.Find(&bindings).Error; err != nil {
@@ -718,21 +748,29 @@ func (s *ModelRegistryService) buildBindingModels(ctx context.Context, reg *mode
type providerLite struct {
id uint
group string
ptype string
models []string
}
providersByGroup := make(map[string][]providerLite)
providersByGroupID := make(map[uint]providerLite)
now := time.Now().Unix()
for _, p := range providers {
if strings.TrimSpace(p.Status) != "" && strings.TrimSpace(p.Status) != "active" {
activeKeys := make(map[uint]bool)
for _, k := range apiKeys {
if strings.TrimSpace(k.Status) != "" && strings.TrimSpace(k.Status) != "active" {
continue
}
if p.BanUntil != nil && p.BanUntil.UTC().Unix() > now {
if k.BanUntil != nil && k.BanUntil.UTC().Unix() > now {
continue
}
group := groupx.Normalize(p.Group)
rawModels := strings.Split(p.Models, ",")
activeKeys[k.GroupID] = true
}
for _, g := range groups {
if strings.TrimSpace(g.Status) != "" && strings.TrimSpace(g.Status) != "active" {
continue
}
if !activeKeys[g.ID] {
continue
}
rawModels := strings.Split(g.Models, ",")
var outModels []string
for _, m := range rawModels {
m = strings.TrimSpace(m)
@@ -740,19 +778,20 @@ func (s *ModelRegistryService) buildBindingModels(ctx context.Context, reg *mode
outModels = append(outModels, m)
}
}
if group == "" || len(outModels) == 0 {
if len(outModels) == 0 {
continue
}
providersByGroup[group] = append(providersByGroup[group], providerLite{
id: p.ID,
group: group,
ptype: strings.TrimSpace(p.Type),
providersByGroupID[g.ID] = providerLite{
id: g.ID,
ptype: strings.TrimSpace(g.Type),
models: outModels,
})
}
}
modelsOut := make(map[string]modelcap.Model)
payloads := make(map[string]string)
capBaseline := make(map[string]upstreamCap)
capBaselineOK := make(map[string]bool)
for _, b := range bindings {
if strings.TrimSpace(b.Status) != "" && strings.TrimSpace(b.Status) != "active" {
@@ -764,12 +803,8 @@ func (s *ModelRegistryService) buildBindingModels(ctx context.Context, reg *mode
continue
}
key := ns + "." + pm
rg := groupx.Normalize(b.RouteGroup)
if rg == "" {
continue
}
pgroup := providersByGroup[rg]
if len(pgroup) == 0 {
group := providersByGroupID[b.GroupID]
if group.id == 0 {
continue
}
@@ -782,13 +817,25 @@ func (s *ModelRegistryService) buildBindingModels(ctx context.Context, reg *mode
selectorType := routing.SelectorType(strings.TrimSpace(b.SelectorType))
selectorValue := strings.TrimSpace(b.SelectorValue)
for _, p := range pgroup {
up, err := routing.ResolveUpstreamModel(selectorType, selectorValue, pm, p.models)
if err != nil {
continue
up, err := routing.ResolveUpstreamModel(selectorType, selectorValue, pm, group.models)
if err == nil {
cap, ok := lookupModelsDevCap(reg, modelsDevProviderKey(group.ptype), up)
if baseOK, seen := capBaselineOK[key]; seen {
if !ok || !baseOK || !capsEqual(capBaseline[key], cap) {
return nil, nil, fmt.Errorf("bindingKey %s has inconsistent capabilities", key)
}
} else {
capBaselineOK[key] = ok
if ok {
capBaseline[key] = cap
}
}
cap, ok := lookupModelsDevCap(reg, modelsDevProviderKey(p.ptype), up)
agg.merge(cap, ok)
} else {
if _, seen := capBaselineOK[key]; seen {
return nil, nil, fmt.Errorf("bindingKey %s has inconsistent capabilities", key)
}
capBaselineOK[key] = false
}
out := agg.finalize(key)