feat: AUTO chain rewrite — silent failover+busy skip+pref round-robin+503 tier summary; chain edits reset slot cooldowns (P0/P1); stats by_status + audit jsonl rotation; UI priority-page health badges & status-code card; ctx-menu capture-phase close (outside-press guard); main.go ops warnings; local bundled-Lua verified tests (3 latent bugs fixed); plan.md

This commit is contained in:
JianFeeeee
2026-08-10 23:58:31 +08:00
parent 397af36fbb
commit 88802f9ef6
17 changed files with 2060 additions and 433 deletions

View File

@ -11,6 +11,7 @@ import (
"path/filepath"
"sort"
"strings"
"sync/atomic"
"time"
"llmsproxy/internal/config"
@ -26,6 +27,7 @@ type Core struct {
store *config.Store
scheduler *scheduler.Scheduler
registry *provider.Registry
autoChain atomic.Pointer[scheduler.Chain]
}
// New builds the core from a config file plus runtime overlay.
@ -138,6 +140,11 @@ func (c *Core) Scheduler() *scheduler.Scheduler { return c.scheduler }
func (c *Core) Registry() *provider.Registry { return c.registry }
// AutoChain returns the current AUTO scheduling chain (immutable after build;
// a rebuilt chain is swapped in atomically). nil before the first build or
// when no auto slots could be resolved.
func (c *Core) AutoChain() *scheduler.Chain { return c.autoChain.Load() }
func (c *Core) DefaultModel() string { return c.cfg.DefaultModel }
func (c *Core) GatewayKeys() []string { return c.cfg.GatewayKeys }
@ -228,9 +235,34 @@ func cleanScopes(entries []config.ModelScope) []config.ModelScope {
return clean
}
// SaveAutoRules persists the AUTO scheduling slots.
// SaveAutoRules persists the AUTO scheduling slots, rebuilds the chain and
// clears the cooldown of every slot in it — preference scores are kept, so a
// reliably good model keeps its edge while an edited chain applies
// immediately. Providers are NOT rebuilt here (their per-model state survives
// the edit, plan 2.4 lifecycle); rebuildRegistry covers source edits.
func (c *Core) SaveAutoRules(entries []config.ModelScope) error {
return c.store.SaveAutoRules(cleanScopes(entries))
if err := c.store.SaveAutoRules(cleanScopes(entries)); err != nil {
return err
}
c.buildAutoChain()
if ch := c.autoChain.Load(); ch != nil {
for _, tn := range ch.Tiers {
for _, sl := range tn.Slots {
if p := c.registry.ProviderForSlot(sl.Model, sl.Source); p != nil {
p.ResetModelCooldown(sl.Model)
}
}
}
}
return nil
}
// ResetHealth clears the scheduling backoff state of every provider (admin
// UI action). Unlike SaveAutoRules this does not touch the chain itself.
func (c *Core) ResetHealth() {
for _, p := range c.registry.Providers() {
p.ResetHealth()
}
}
// Registry resolves model -> owning provider.
@ -303,9 +335,88 @@ func (c *Core) rebuildRegistry() error {
} else {
c.registry.Replace(providers)
}
c.buildAutoChain()
return nil
}
// buildAutoChain rebuilds the AUTO chain snapshot from the persisted rules
// against the current providers. Slots whose (model, source) no longer exists
// and image-kind models are dropped; a chain with no slots makes AUTO
// requests answer "no auto slot configured".
func (c *Core) buildAutoChain() {
prov := func(model, source string) scheduler.Provider {
p := c.registry.ProviderForSlot(model, source)
if p == nil {
return nil
}
if m := p.ModelByID(model); m != nil && m.Kind == "image" {
return nil
}
return p
}
rules := c.store.AutoRules()
sr := make([]scheduler.Rule, 0, len(rules))
for _, e := range rules {
r := scheduler.Rule{
Model: e.Model,
Source: e.Source,
Tier: e.Tier,
Quota: e.TokenQuota,
Period: e.Period,
Hours: e.Hours,
}
if r.Source == "" {
// canonicalize to the owning source so summaries/audit/quota
// windows always carry a real source name
if p := c.registry.ProviderForSlot(e.Model, ""); p != nil {
r.Source = p.Name()
}
}
sr = append(sr, r)
}
c.autoChain.Store(scheduler.BuildChain(sr, prov))
}
// AutoSlotState is the UI-facing health snapshot of one AUTO chain slot.
type AutoSlotState struct {
Model string `json:"model"`
Source string `json:"source"`
Pref int64 `json:"pref"`
FailCount int64 `json:"fail_count"`
CooldownUntil int64 `json:"cooldown_until"`
Cooling bool `json:"cooling"`
}
// AutoSlotStates returns per-slot health (preference, failure count,
// cooldown) for every slot of the current AUTO chain, mirroring the chain
// order so the priority-page UI can annotate its blocks.
func (c *Core) AutoSlotStates() []AutoSlotState {
ch := c.autoChain.Load()
if ch == nil {
return nil
}
now := time.Now().Unix()
var out []AutoSlotState
for _, tn := range ch.Tiers {
for _, sl := range tn.Slots {
pp, ok := sl.Prov.(*provider.Provider)
if !ok {
continue
}
pref, fail, until := pp.ModelHealthInfo(sl.Model)
out = append(out, AutoSlotState{
Model: sl.Model,
Source: sl.Source,
Pref: pref,
FailCount: fail,
CooldownUntil: until,
Cooling: until > now,
})
}
}
return out
}
// Reload re-reads the runtime store and rebuilds sources (adapter reload is not
// strictly needed since adapters are loaded into the VM at startup; uploaded
// adapters are placed in the adapter dir and loaded by the web UI).
@ -408,4 +519,4 @@ func (c *Core) Close() {
if c.vm != nil {
c.vm.Stop()
}
}
}