diff --git a/internal/provider/account.go b/internal/provider/account.go index 309ad699..fa37458d 100644 --- a/internal/provider/account.go +++ b/internal/provider/account.go @@ -124,7 +124,8 @@ func (p Provider) ListedAPIs(model string) []Protocol { if p.IsPlugin() { return p.pluginAPIs(model) } - ms, _, _ := catalog.Live(p.ID) + // asked for every model of every agent: read once while a request holds + ms := heldOf("live:"+p.ID, func() []catalog.Model { ms, _, _ := catalog.Live(p.ID); return ms }) for _, m := range ms { if m.ID == model && len(m.APIs) > 0 { out := make([]Protocol, len(m.APIs)) diff --git a/internal/provider/hold.go b/internal/provider/hold.go index 2806330b..a5c7f8d9 100644 --- a/internal/provider/hold.go +++ b/internal/provider/hold.go @@ -7,6 +7,7 @@ import ( "github.com/yetone/magpie/internal/catalog" "github.com/yetone/magpie/internal/plugin" + "github.com/yetone/magpie/internal/settings" ) // The catalog is rebuilt from every provider's files on each Resolve (tens @@ -96,3 +97,8 @@ func heldEntries(build func() []Entry) []Entry { func heldPlugins() []plugin.Provider { return heldOf("plugins", func() []plugin.Provider { return slices.Clip(plugin.Cached()) }) } + +// heldSettings is the settings, read once while a request holds the +// catalog: a look at the agents asks them for every model of every agent. +// Only for reading — what it gives is shared by the request's look-ups. +func heldSettings() settings.Settings { return heldOf("settings", settings.Load) } diff --git a/internal/provider/hold_test.go b/internal/provider/hold_test.go index da1cc0b9..7afc1e4b 100644 --- a/internal/provider/hold_test.go +++ b/internal/provider/hold_test.go @@ -7,6 +7,8 @@ import ( "slices" "strings" "testing" + + "github.com/yetone/magpie/internal/settings" ) // A request that only reads builds the catalog once for all its look-ups @@ -40,6 +42,31 @@ func TestHoldBuildsOnce(t *testing.T) { } } +// The settings are read once while a request holds the catalog (a look at +// the agents read settings.json for every model of every agent), and a +// setting saved meanwhile is seen at once. +func TestHoldReadsSettingsOnce(t *testing.T) { + defer Hold()() + if _, ok := VisibleTo("held-agent"); ok { + t.Fatal("a visibility before any was set") + } + // written by something else: the held read stands + path := settings.Path() + os.MkdirAll(filepath.Dir(path), 0o755) + os.WriteFile(path, []byte(`{"visible":{"held-agent":["a"]}}`), 0o600) + if _, ok := VisibleTo("held-agent"); ok { + t.Fatal("settings.json read again while held") + } + s := settings.Load() + s.Visible = map[string][]string{"held-agent": {"b"}} + if err := settings.Save(s); err != nil { + t.Fatal(err) + } + if names, ok := VisibleTo("held-agent"); !ok || len(names) != 1 || names[0] != "b" { + t.Fatalf("a setting saved while held: %v %v", names, ok) + } +} + // A provider saved while a request holds the catalog is in it at once. func TestHoldSeesWrites(t *testing.T) { if err := Save(Provider{ID: "held", Name: "Held", Chat: "https://held.example/v1", Key: "k", Models: []string{"a"}}); err != nil { diff --git a/internal/provider/modelprefs.go b/internal/provider/modelprefs.go index bb1e60ac..93e6859c 100644 --- a/internal/provider/modelprefs.go +++ b/internal/provider/modelprefs.go @@ -415,7 +415,7 @@ func (p Provider) ModelAPI(model string) (Protocol, bool) { if len(p.Speaks()) < 2 { return "", false } - proto := Protocol(settings.Load().ModelAPIs[p.ID+"/"+model]) + proto := Protocol(heldSettings().ModelAPIs[p.ID+"/"+model]) if proto == "" || !slices.Contains(Protocols, proto) || p.Base(proto) == "" { return "", false } @@ -612,7 +612,7 @@ func (e Entry) Label() string { // is that name just as they wrote it, and the vendor's keep Label's. func Labels(es []Entry) []string { out := make([]string, len(es)) - s := settings.Load() + s := heldSettings() plain := s.PlainNames own := !plain && s.PlainOwnNames same := map[string]int{} diff --git a/internal/provider/visible.go b/internal/provider/visible.go index afd315ad..cc99f87f 100644 --- a/internal/provider/visible.go +++ b/internal/provider/visible.go @@ -31,8 +31,8 @@ func (e Entry) Names() []string { // VisibleTo is what agent's lists are narrowed to, and whether they are. func VisibleTo(agent string) ([]string, bool) { - names, ok := settings.Load().Visible[strings.ToLower(agent)] - return names, ok + names, ok := heldSettings().Visible[strings.ToLower(agent)] + return slices.Clone(names), ok } // Shows reports whether a visibility shows e. @@ -91,7 +91,7 @@ func ListedFor(agent string) (listed, kept []Entry) { // HiddenModels are the ids of the entries taken out of agent's lists. func HiddenModels(agent string) map[string]bool { - ids := settings.Load().HiddenModels[strings.ToLower(agent)] + ids := heldSettings().HiddenModels[strings.ToLower(agent)] out := make(map[string]bool, len(ids)) for _, id := range ids { out[id] = true diff --git a/internal/settings/settings.go b/internal/settings/settings.go index 50a31eca..81edaf4f 100644 --- a/internal/settings/settings.go +++ b/internal/settings/settings.go @@ -25,6 +25,7 @@ import ( "github.com/yetone/magpie/internal/appdir" "github.com/yetone/magpie/internal/catalog" "github.com/yetone/magpie/internal/edit" + "github.com/yetone/magpie/internal/filememo" "github.com/yetone/magpie/internal/redact" "github.com/yetone/magpie/internal/steady" ) @@ -591,7 +592,10 @@ func Load() Settings { fileMu.RLock() defer fileMu.RUnlock() var s Settings - if b, err := steady.ReadFile(Path()); err == nil { + // read again only once the file changed: a look at the agents asks for + // the settings for every model of every agent (hundreds of reads, a + // fifth of the Agents page's wait) + if b, err := filememo.Read("settings", Path(), func(b []byte) ([]byte, error) { return b, nil }); err == nil { _ = json.Unmarshal(bytes.TrimPrefix(b, []byte("\xef\xbb\xbf")), &s) } return s.normal() @@ -740,7 +744,14 @@ func Save(s Settings) error { if err := f.Close(); err != nil { return err } - return edit.WriteAtomic(Path(), append(b, '\n')) + if err := edit.WriteAtomic(Path(), append(b, '\n')); err != nil { + return err + } + // a request holding the catalog holds the settings too: it sees these + if catalog.Forget != nil { + catalog.Forget() + } + return nil } func (s Settings) normal() Settings {