Three changes let a user say something about one model the catalogue does not carry: what it costs, how large it is, and what the provider calls it upstream. They are separate pull requests and none needs another's feature, but all three need the two things here, so this lands first. **CheckModelKey** — those keys are `"<provider id>/<model id>"`, and `"<provider id>/*"` covers every model of a provider. A model id may contain slashes of its own, so only the part before the first is the provider's. A key that does not parse is one no lookup will ever match, which is worth refusing where the entry is written rather than leaving as a silent no-op. **One convention instead of two lists.** Settings saves by decoding the whole struct out of a request body and copying back the fields it knows about; renaming a provider moves the keys of the maps it knows about. Both were hand-maintained lists, and a per-model map added outside either was not saved by the first and not moved by the second. `settings` now walks its own fields for the convention — a field named `Model*` of type `map[string]X` — and does both jobs from it: `RenamePerModel` for a provider that changes its id, `CarryPerModel` for the settings page. A map added later needs no line in either file. A field whose keys are not a model's is not one of these and is not named `Model*`: `Visible`, which is by agent id, and `HiddenModels`, which is by agent and model, among them. `TestRenameMovesEveryPerModelMap` populates every per-model map at once and renames once, so it fails both for a map left out and for a map that never ran. `TestSettingsPageSaveKeepsWhatOtherPagesSet` drives the route that saves rather than a copy of it: with three per-model maps a hand-written list and this walk do the same thing, so only a helper that stops working is distinguishable today — the test earns its keep the day a fourth map appears and someone goes back to writing the list out.
151 lines
5.8 KiB
Go
151 lines
5.8 KiB
Go
package gui
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"path/filepath"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/yetone/magpie/internal/settings"
|
|
)
|
|
|
|
// The Settings page sends only its own choices. What the settings keep per
|
|
// model — every name, level, price and image answer the user gave of a model
|
|
// on a page of its own — is set elsewhere, so a save from this page has to
|
|
// carry it over. This drives the real handler: POST /api/settings with a body
|
|
// naming nothing but the theme.
|
|
//
|
|
// The tables are the settings' own — settings.PerModelKeys, the walk the
|
|
// save itself goes by — each seeded with an entry saying which table it was
|
|
// written for, so a table added to the settings later is carried and checked
|
|
// here without this test being changed.
|
|
func TestSettingsPageSaveCarriesEveryPerModelTable(t *testing.T) {
|
|
h := t.TempDir()
|
|
t.Setenv("HOME", h)
|
|
t.Setenv("XDG_CONFIG_HOME", filepath.Join(h, ".config"))
|
|
t.Setenv("XDG_CACHE_HOME", filepath.Join(h, ".cache"))
|
|
|
|
seed := settings.Load()
|
|
if seedPerModelTables(t, &seed) == 0 {
|
|
t.Fatal("the settings hold no per-model map: there is nothing here for a save to carry over")
|
|
}
|
|
if err := settings.Save(seed); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// what the settings say once written and read back, so what is compared
|
|
// after the request is what really reached the disk, not what was set
|
|
before := perModelTables(t)
|
|
|
|
rec := httptest.NewRecorder()
|
|
Handler(nil, nil).ServeHTTP(rec, httptest.NewRequest("POST", "/api/settings", strings.NewReader(`{"theme":"dark"}`)))
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("saving the theme: %d %s", rec.Code, rec.Body)
|
|
}
|
|
|
|
if s := settings.Load(); s.Theme != "dark" {
|
|
t.Fatalf("the theme was saved as %q, not dark", s.Theme)
|
|
}
|
|
now := perModelTables(t)
|
|
for name, was := range before {
|
|
got, ok := now[name]
|
|
if !ok {
|
|
t.Fatalf("%s is not one of the settings' per-model maps any more", name)
|
|
}
|
|
if !reflect.DeepEqual(was.Interface(), got.Interface()) {
|
|
t.Errorf("%s did not survive the Settings page's save:\n\tsaved %v\n\tnow %v", name, was.Interface(), got.Interface())
|
|
}
|
|
}
|
|
}
|
|
|
|
// CarryPerModel walks the Model* tables only. Which models an agent is shown
|
|
// is keyed by the agent, not by "<provider>/<model>", so Visible and
|
|
// HiddenModels are not among them and the save has to name them itself — the
|
|
// one place this page still lists what it keeps. A table added to that list
|
|
// later without a line here is dropped by a save; this says so rather than
|
|
// leaving it to be found.
|
|
func TestSettingsPageSaveKeepsWhichModelsAnAgentIsShown(t *testing.T) {
|
|
h := t.TempDir()
|
|
t.Setenv("HOME", h)
|
|
t.Setenv("XDG_CONFIG_HOME", filepath.Join(h, ".config"))
|
|
t.Setenv("XDG_CACHE_HOME", filepath.Join(h, ".cache"))
|
|
|
|
seed := settings.Load()
|
|
seed.Visible = map[string][]string{"claude": {"p/m"}}
|
|
seed.HiddenModels = map[string][]string{"codex": {"p/n", "group/g"}}
|
|
if err := settings.Save(seed); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
Handler(nil, nil).ServeHTTP(rec, httptest.NewRequest("POST", "/api/settings", strings.NewReader(`{"theme":"dark"}`)))
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("saving the theme: %d %s", rec.Code, rec.Body)
|
|
}
|
|
got := settings.Load()
|
|
if !reflect.DeepEqual(got.Visible, map[string][]string{"claude": {"p/m"}}) {
|
|
t.Errorf("Visible after a save of the Settings page: %v; want map[claude:[p/m]]", got.Visible)
|
|
}
|
|
if !reflect.DeepEqual(got.HiddenModels, map[string][]string{"codex": {"p/n", "group/g"}}) {
|
|
t.Errorf("HiddenModels after a save of the Settings page: %v; want map[codex:[p/n group/g]]", got.HiddenModels)
|
|
}
|
|
}
|
|
|
|
// perModelTables are the settings' per-model maps as they stand now, by
|
|
// name: the settings' own walk of them (settings.PerModelKeys, which is the
|
|
// walk the save goes by too) rather than a list written out here, which a
|
|
// table added to the settings later would not be in.
|
|
func perModelTables(t *testing.T) map[string]reflect.Value {
|
|
t.Helper()
|
|
s := settings.Load()
|
|
out := map[string]reflect.Value{}
|
|
settings.PerModelKeys(&s, func(name string, m reflect.Value) { out[name] = m })
|
|
return out
|
|
}
|
|
|
|
// seedPerModelTables puts an entry into every per-model map of s, saying
|
|
// which table it was written for, so a table that came back empty, or with
|
|
// another's entry in it, is told apart. It counts the tables it seeded.
|
|
func seedPerModelTables(t *testing.T, s *settings.Settings) int {
|
|
t.Helper()
|
|
var n int
|
|
settings.PerModelKeys(s, func(name string, m reflect.Value) {
|
|
m.Set(reflect.MakeMap(m.Type()))
|
|
m.SetMapIndex(reflect.ValueOf("p/"+name).Convert(m.Type().Key()), perModelValue(m.Type().Elem(), name))
|
|
n++
|
|
})
|
|
return n
|
|
}
|
|
|
|
// perModelValue is a value of a per-model table's own type, saying which
|
|
// table it was written for, so a table that came back empty, or with another's
|
|
// entry in it, is told apart. A type with nothing said of it is left zero:
|
|
// the key alone gives the table away.
|
|
func perModelValue(typ reflect.Type, table string) reflect.Value {
|
|
switch typ.Kind() {
|
|
case reflect.String:
|
|
return reflect.ValueOf("said of " + table).Convert(typ)
|
|
case reflect.Bool:
|
|
return reflect.ValueOf(true).Convert(typ)
|
|
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
|
return reflect.ValueOf(int64(len(table))).Convert(typ)
|
|
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
|
|
return reflect.ValueOf(uint64(len(table))).Convert(typ)
|
|
case reflect.Float32, reflect.Float64:
|
|
return reflect.ValueOf(float64(len(table))).Convert(typ)
|
|
case reflect.Slice:
|
|
out := reflect.MakeSlice(typ, 1, 1)
|
|
out.Index(0).Set(perModelValue(typ.Elem(), table))
|
|
return out
|
|
case reflect.Array:
|
|
out := reflect.New(typ).Elem()
|
|
out.Index(0).Set(perModelValue(typ.Elem(), table))
|
|
return out
|
|
case reflect.Map:
|
|
out := reflect.MakeMap(typ)
|
|
out.SetMapIndex(reflect.ValueOf(table).Convert(typ.Key()), perModelValue(typ.Elem(), table))
|
|
return out
|
|
}
|
|
return reflect.Zero(typ)
|
|
}
|