Files
ModelRouter/internal/provider/registry.go
dev 18c2385c61 feat: add per-source TTFB and tokens/s metrics to status page
- Req: add FirstByteMs field (ms to first byte, tracked for streaming)
- Stat: add FirstByteSum for aggregation
- SourceAverages(): new method computing per-source avg TTFB and tokens/s
  from the in-memory ring (300s window)
- SourceStatus: add AvgFirstByteMs and AvgTokPerS fields
- pumpStream: record FirstByteMs after first SSE chunk sent to client
- singleChat/singleChatAuto: set FirstByteMs = LatMs (non-streaming)
- handleStatusAPI: populate the new SourceStatus fields from SourceAverages()
- WebUI source table: two new columns showing TTFB (s) and Tokens/s
2026-08-25 08:24:08 +08:00

276 lines
7.9 KiB
Go

// 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 -> providers (one per source)
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
m := map[string][]*Provider{}
for _, p := range providers {
for _, model := range p.Models() {
key := strings.ToLower(model)
m[key] = append(m[key], p)
}
}
r.byModel = m
}
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 — may be served by multiple sources
if ps, ok := r.byModel[strings.ToLower(model)]; ok {
out := make([]*Provider, len(ps))
copy(out, ps)
return out
}
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).
// When the same model exists on multiple sources, returns the first (source
// config order). Callers that need a specific source use ProviderForSlot.
func (r *Registry) ProviderForModel(model string) *Provider {
r.mu.RLock()
defer r.mu.RUnlock()
ps, ok := r.byModel[strings.ToLower(strings.TrimSpace(model))]
if !ok || len(ps) == 0 {
return nil
}
return ps[0]
}
// ProviderForSlot returns the provider for a (model, source) slot. When
// source is empty it behaves like ProviderForModel (first 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 == "" {
ps, ok := r.byModel[model]
if !ok || len(ps) == 0 {
return nil
}
return ps[0]
}
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"`
// RecentOK / RecentErr count real gateway requests served by this source
// within the last 300s (drives the status column so a source that is
// actually serving traffic can never be shown as down just because a
// probe was rate-limited by the upstream).
RecentOK int64 `json:"recent_ok,omitempty"`
RecentErr int64 `json:"recent_err,omitempty"`
// AvgFirstByteMs is the mean time-to-first-byte (ms) for recently
// successful requests served by this source (0 when unmeasured).
AvgFirstByteMs int64 `json:"avg_first_byte_ms,omitempty"`
// AvgTokPerS is the aggregate completion throughput (tokens/s) for
// recently successful requests served by this source (0 when no data).
AvgTokPerS int64 `json:"avg_tok_per_s,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
}