// Package provider also provides the top-level registry that owns all sources, // routes model requests (explicit or AUTO), and supports hot reload. package provider import ( "context" "sort" "strings" "sync" "time" ) // Registry holds all configured providers and routes model requests. type Registry struct { mu sync.RWMutex providers []*Provider byModel map[string]*Provider // modelID -> provider defaultM string // default model id ("" means AUTO) } func NewRegistry(providers []*Provider, defaultModel string) *Registry { r := &Registry{byModel: map[string]*Provider{}, defaultM: defaultModel} r.set(providers) return r } // Replace atomically swaps the provider set (hot reload). func (r *Registry) Replace(providers []*Provider) { r.mu.Lock() defer r.mu.Unlock() r.set(providers) } func (r *Registry) set(providers []*Provider) { r.providers = providers r.byModel = map[string]*Provider{} for _, p := range providers { for _, m := range p.Models() { r.byModel[strings.ToLower(m)] = p } } } func (r *Registry) Providers() []*Provider { r.mu.RLock() defer r.mu.RUnlock() out := make([]*Provider, len(r.providers)) copy(out, r.providers) return out } func (r *Registry) Count() int { r.mu.RLock() defer r.mu.RUnlock() return len(r.providers) } // ModelList returns all exposed model ids (chat + image). func (r *Registry) ModelList() []string { r.mu.RLock() defer r.mu.RUnlock() seen := map[string]bool{} for _, p := range r.providers { for _, m := range p.Models() { if !seen[m] { seen[m] = true } } } out := make([]string, 0, len(seen)) for m := range seen { out = append(out, m) } sort.Strings(out) return out } // Resolve returns the provider (or providers) serving a requested model, // owning no AUTO scheduling logic anymore: AUTO chat scheduling is driven by // the scheduler chain built from the runtime rules (see core/SaveAutoRules // and scheduler.Chain). // // model "" or "AUTO" -> every provider in configured order. Used only by the // image path (which then filters to image-capable sources) and tool-call // anchoring; chat AUTO requests go through the chain instead. // Otherwise the owning provider; "source-model"/"source:model"/"source/model" // pinning resolves first; an unknown model resolves to nil (gateway answers // 404) instead of silently falling back to the AUTO chain. func (r *Registry) Resolve(model string) []*Provider { r.mu.RLock() defer r.mu.RUnlock() model = strings.TrimSpace(model) if model == "" || strings.EqualFold(model, "AUTO") { out := make([]*Provider, len(r.providers)) copy(out, r.providers) return out } // "source-model" / "source:model" / "source/model" pinning — disambiguates // duplicate model ids across sources. if p := r.ResolvePinned(model); p != nil { return []*Provider{p} } // explicit model if p, ok := r.byModel[strings.ToLower(model)]; ok { // switch to the owning source but pin the model via request return []*Provider{p} } return nil } // EffectiveModel strips a "source-model" / "source:model" / "source/model" // pinning prefix and returns the bare model id when that source serves it; // otherwise it returns the input unchanged. func (r *Registry) EffectiveModel(model string) string { sep := strings.IndexAny(model, "-:/") if sep < 1 || sep == len(model)-1 { return model } src, m := model[:sep], model[sep+1:] r.mu.RLock() defer r.mu.RUnlock() for _, p := range r.providers { if strings.EqualFold(p.Name(), src) && p.ModelByID(m) != nil { return m } } return model } // ResolvePinned resolves "source-model" / "source:model" / "source/model" to // the exact source, or nil if the source does not serve that model. func (r *Registry) ResolvePinned(model string) *Provider { sep := strings.IndexAny(model, "-:/") if sep < 1 || sep == len(model)-1 { return nil } src, m := model[:sep], model[sep+1:] r.mu.RLock() defer r.mu.RUnlock() for _, p := range r.providers { if !strings.EqualFold(p.Name(), src) { continue } if p.ModelByID(m) != nil { return p } } return nil } // ProviderForModel returns the provider owning the model id (nil if unknown). func (r *Registry) ProviderForModel(model string) *Provider { r.mu.RLock() defer r.mu.RUnlock() p, ok := r.byModel[strings.ToLower(strings.TrimSpace(model))] if !ok { return nil } return p } // ProviderForSlot returns the provider for a (model, source) slot. When // source is empty it behaves like ProviderForModel (owner of the model id); // when source is set it returns only that exact source (nil if the source // does not serve the model). func (r *Registry) ProviderForSlot(model, source string) *Provider { model = strings.ToLower(strings.TrimSpace(model)) source = strings.TrimSpace(source) r.mu.RLock() defer r.mu.RUnlock() if source == "" { p, ok := r.byModel[model] if !ok { return nil } return p } for _, p := range r.providers { if !strings.EqualFold(p.Name(), source) { continue } for _, m := range p.cfg.Models { if strings.EqualFold(m.ID, model) { return p } } return nil } return nil } // ModelStatus is a web-UI friendly snapshot per source. type SourceStatus struct { Name string `json:"name"` Adapter string `json:"adapter"` BaseURL string `json:"base_url"` Models []string `json:"models"` Available bool `json:"available"` Healthy bool `json:"healthy"` MaxConcurrent int `json:"max_concurrent"` LiveAvailable bool `json:"live_available"` LastError string `json:"last_error,omitempty"` LastChecked int64 `json:"last_checked,omitempty"` FailCount int `json:"fail_count,omitempty"` BackoffUntil int64 `json:"backoff_until,omitempty"` Permanent bool `json:"permanent,omitempty"` } // ProbeAll runs a live reachability check for every provider (in parallel). func (r *Registry) ProbeAll(ctx context.Context) { r.mu.RLock() providers := append([]*Provider(nil), r.providers...) r.mu.RUnlock() var wg sync.WaitGroup for _, p := range providers { wg.Add(1) go func(p *Provider) { defer wg.Done() probeCtx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() p.Probe(probeCtx) }(p) } wg.Wait() } func (r *Registry) Status() []SourceStatus { r.mu.RLock() defer r.mu.RUnlock() out := make([]SourceStatus, 0, len(r.providers)) for _, p := range r.providers { live, lastErr, lastAt := p.LastProbe() fails, until, perm := p.HealthInfo() backoffUntil := int64(0) if !until.IsZero() { backoffUntil = until.Unix() } s := SourceStatus{ Name: p.Name(), Adapter: p.Adapter(), BaseURL: p.Config().BaseURL, Models: p.Models(), Available: p.Available(), Healthy: p.Available(), MaxConcurrent: p.MaxConcurrent(), LiveAvailable: live, LastError: lastErr, LastChecked: lastAt, FailCount: fails, BackoffUntil: backoffUntil, Permanent: perm, } out = append(out, s) } return out }