diff --git a/internal/gateway/admin.go b/internal/gateway/admin.go index 2268119..3ba3525 100644 --- a/internal/gateway/admin.go +++ b/internal/gateway/admin.go @@ -849,6 +849,11 @@ func (s *Server) adminModelVisibility(w http.ResponseWriter, r *http.Request) { writeJSON(w, 500, map[string]any{"ok": false, "error": err.Error()}) return } + // Keep the lane's probe + failover filters in sync: a newly hidden + // model must stop being probed and must not be auto-picked as a + // failover/rate-limit-switch target (issue #103). An explicit request + // naming it still reaches the model. + s.Lane.SetHidden(out) if s.logger != nil { action := "恢复显示" if in.Hidden { diff --git a/internal/gateway/server.go b/internal/gateway/server.go index 7e7adc6..3835d04 100644 --- a/internal/gateway/server.go +++ b/internal/gateway/server.go @@ -263,6 +263,11 @@ func New(l *lane.Lane, st *store.Store) *Server { // see the raw catalog. l.OnCatalogReload = s.applyStoredTags s.applyStoredTags() + // Seed the lane's hidden-model set from the persisted config so models the + // user unchecked on the 模型 page are skipped by probing and failover from + // the first boot (issue #103). adminModelVisibility keeps it in sync on + // every toggle. + s.Lane.SetHidden(st.Config().HiddenModels) return s } diff --git a/internal/lane/client.go b/internal/lane/client.go index 129bbe2..868d48c 100644 --- a/internal/lane/client.go +++ b/internal/lane/client.go @@ -94,6 +94,7 @@ type Lane struct { egress Egress defaultMaxTokens int exposeRegion bool + hidden map[string]bool // model ids the user unchecked on the 模型 page; skipped by probing and failover failoverEnabled bool failoverMax int smartRouting bool @@ -319,6 +320,22 @@ func (l *Lane) SetExposeRegion(b bool) { l.mu.Unlock() } +// SetHidden installs the set of model ids the user unchecked on the 模型 page +// (store.Config.HiddenModels). A hidden model is still reachable by an explicit +// request naming it, but the lane must not spend a probe slot on it nor pick it +// as an automatic failover/rate-limit-switch target (issue #103). The whole map +// is swapped, never mutated in place, so concurrent reads in ProbeRound and +// nextCandidate stay safe without holding the lock for the entire scan. +func (l *Lane) SetHidden(ids []string) { + set := make(map[string]bool, len(ids)) + for _, id := range ids { + set[id] = true + } + l.mu.Lock() + l.hidden = set + l.mu.Unlock() +} + // ProbingModel returns the id currently under probe ("" when idle). func (l *Lane) ProbingModel() string { l.mu.RLock() @@ -514,6 +531,7 @@ func (l *Lane) ProbeRound(ctx context.Context, manual bool) { l.probing.Set(true) cat := make([]ModelInfo, len(l.catalog)) copy(cat, l.catalog) + hidden := l.hidden l.mu.Unlock() defer l.probing.Set(false) @@ -526,6 +544,11 @@ func (l *Lane) ProbeRound(ctx context.Context, manual bool) { if ctx.Err() != nil { break } + if hidden[m.ID] { + // User unchecked this model on the 模型 page: don't burn a probe + // slot on it (issue #103). Explicit named calls still serve it. + continue + } if m.Channel == ChannelKilo { l.mu.Lock() prev, had := l.availability[m.ID] @@ -916,6 +939,7 @@ func (l *Lane) nextCandidate(needs Needs, tried map[string]bool) string { smart := l.smartRouting strategy := l.strategy ttftFn := l.ttft + hidden := l.hidden l.mu.RUnlock() best, bestKey := "", candidateKey{avail: 99} @@ -923,6 +947,12 @@ func (l *Lane) nextCandidate(needs Needs, tried map[string]bool) string { if m.SystemOne || m.Wire == "systemone" { continue } + if hidden[m.ID] { + // User unchecked this model on the 模型 page: never auto-pick it + // as a failover/rate-limit-switch target (issue #103). An explicit + // request naming it still goes straight to the model. + continue + } if tried[m.ID] || l.throttle.Throttled(m.ID) { continue } diff --git a/internal/lane/hidden_test.go b/internal/lane/hidden_test.go new file mode 100644 index 0000000..8411619 --- /dev/null +++ b/internal/lane/hidden_test.go @@ -0,0 +1,32 @@ +package lane + +import "testing" + +// TestNextCandidateSkipsHiddenModels covers issue #103: a model the user +// unchecked on the 模型 page must never be auto-picked as a failover / +// rate-limit-switch target. Without the hidden filter, nextCandidate would +// return space-bunny-free (the first catalog row) here. +func TestNextCandidateSkipsHiddenModels(t *testing.T) { + l := strategyLane() + l.SetHidden([]string{"space-bunny-free"}) + + got := l.nextCandidate(Needs{}, map[string]bool{}) + if got == "space-bunny-free" { + t.Fatalf("nextCandidate picked a hidden model: %s", got) + } + if got == "" { + t.Fatalf("nextCandidate returned nothing although unhidden models exist") + } +} + +// TestNextCandidateStillServesAfterUnhide confirms clearing the hidden set +// restores the model to the candidate pool. +func TestNextCandidateStillServesAfterUnhide(t *testing.T) { + l := strategyLane() + l.SetHidden([]string{"space-bunny-free"}) + l.SetHidden(nil) // unhide everything + + if got := l.nextCandidate(Needs{}, map[string]bool{}); got != "space-bunny-free" { + t.Fatalf("after unhide, expected space-bunny-free, got %s", got) + } +}