diff --git a/README.md b/README.md
index a4879224..735fb605 100644
--- a/README.md
+++ b/README.md
@@ -57,6 +57,10 @@ and there is a terminal version (`magpie tui`) and a plain CLI.
background once it goes stale. Choose which models each provider exposes,
or expose them all — a model released this morning is in the picker on
the next refresh.
+- **Each agent's own model list.** Under an agent's name on the Agents page,
+ "Showing 5 / 32 models" opens its list: click a model to take it out of
+ that agent's picker (Codex's `/model` included, its ChatGPT models too) or
+ put it back; other agents still use it, and a new model is shown.
- **Profiles.** Snapshot every agent's settings under a name and switch all of
them back in one move.
- **Real logos, no framework.** Plain HTML over the system webview; brand
diff --git a/internal/agent/codex.go b/internal/agent/codex.go
index 16c514c1..e0479186 100644
--- a/internal/agent/codex.go
+++ b/internal/agent/codex.go
@@ -284,7 +284,7 @@ func codexIn(at place) *Agent {
}
}
case viaBase():
- if err := codexStaleCache(filepath.Join(dir, "models_cache.json"), codexcat.Tag(provider.CodexListed())); err != nil {
+ if err := codexStaleCache(filepath.Join(dir, "models_cache.json"), provider.CodexListTag()); err != nil {
return err
}
default:
diff --git a/internal/gateway/codex_backend.go b/internal/gateway/codex_backend.go
index fe847a46..d1b9d2f2 100644
--- a/internal/gateway/codex_backend.go
+++ b/internal/gateway/codex_backend.go
@@ -524,9 +524,21 @@ func (s *Server) codexModels(w http.ResponseWriter, r *http.Request) {
}
own = kept
}
+ // and the ones taken out of Codex's list on the Agents page
+ if off := provider.CodexNativeHidden(); len(off) > 0 {
+ kept := own[:0]
+ for _, m := range own {
+ o, _ := m.(map[string]any)
+ if slug, _ := o["slug"].(string); off[slug] {
+ continue
+ }
+ kept = append(kept, m)
+ }
+ own = kept
+ }
ms := provider.CodexListed()
// the list is the backend's and magpie's, and so is its ETag
- w.Header().Set("ETag", codexcat.WithTag(etag, codexcat.Tag(ms)))
+ w.Header().Set("ETag", codexcat.WithTag(etag, provider.CodexListTag()))
writeJSON(w, 200, map[string]any{"models": append(own, codexcat.Entries(ms, len(own)+100)...)})
}
@@ -535,7 +547,7 @@ func (s *Server) codexModels(w http.ResponseWriter, r *http.Request) {
// change to either has Codex ask for the list again.
func modelsEtag(h http.Header) {
if v := h.Get("X-Models-Etag"); v != "" {
- h.Set("X-Models-Etag", codexcat.WithTag(v, codexcat.Tag(provider.CodexListed())))
+ h.Set("X-Models-Etag", codexcat.WithTag(v, provider.CodexListTag()))
}
}
diff --git a/internal/gateway/codex_backend_test.go b/internal/gateway/codex_backend_test.go
index e2703320..9d39eedb 100644
--- a/internal/gateway/codex_backend_test.go
+++ b/internal/gateway/codex_backend_test.go
@@ -395,6 +395,60 @@ func TestCodexModelListNarrowedByPicks(t *testing.T) {
}
}
+// A native model taken out of Codex's list on the Agents page is dropped
+// from the backend's list too, and the ETag changes so Codex asks again.
+func TestCodexModelListHidesOnAgentsPage(t *testing.T) {
+ codexSignedIn(t)
+ if err := provider.Save(provider.Provider{ID: "codex", Models: []string{"gpt-5.5", "gpt-5-codex"}}); err != nil {
+ t.Fatal(err)
+ }
+ up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("ETag", `"v1"`)
+ io.WriteString(w, `{"models":[{"slug":"gpt-5.5","priority":1},{"slug":"gpt-5-codex","priority":2}]}`)
+ }))
+ defer up.Close()
+ was := provider.CodexBase
+ provider.CodexBase = up.URL + "/backend-api/codex"
+ defer func() { provider.CodexBase = was }()
+ list := func() ([]string, string) {
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest("GET", CodexPath+"/models", nil)
+ req.Header.Set("Authorization", "Bearer chatgpt-token")
+ New().Handler().ServeHTTP(rec, req)
+ var got struct {
+ Models []map[string]any `json:"models"`
+ }
+ json.Unmarshal(rec.Body.Bytes(), &got)
+ var native []string
+ for _, m := range got.Models {
+ if slug, _ := m["slug"].(string); strings.HasPrefix(slug, "gpt-") {
+ native = append(native, slug)
+ }
+ }
+ return native, rec.Header().Get("ETag")
+ }
+ all, before := list()
+ if len(all) != 2 {
+ t.Fatalf("native models %v", all)
+ }
+ var id string
+ for _, e := range provider.Catalog() {
+ if acc := e.Provider.Account; acc != nil && acc.Agent == "codex" && e.Model == "gpt-5-codex" {
+ id = e.ID
+ }
+ }
+ if id == "" {
+ t.Fatal("no catalog entry for the account's gpt-5-codex")
+ }
+ if err := provider.SetHiddenModels("codex", []string{id}); err != nil {
+ t.Fatal(err)
+ }
+ native, after := list()
+ if len(native) != 1 || native[0] != "gpt-5.5" || after == before {
+ t.Errorf("native %v, ETag %q then %q", native, before, after)
+ }
+}
+
// A reply's X-Models-Etag carries magpie's list too: Codex refetches its
// model list on a new one, and only on the backend's it never would when a
// provider was added.
diff --git a/internal/gui/agent_models.go b/internal/gui/agent_models.go
new file mode 100644
index 00000000..910dfa9c
--- /dev/null
+++ b/internal/gui/agent_models.go
@@ -0,0 +1,136 @@
+package gui
+
+import (
+ "encoding/json"
+ "net/http"
+ "strings"
+
+ "github.com/yetone/magpie/internal/agent"
+ "github.com/yetone/magpie/internal/provider"
+)
+
+// Which of the catalog an agent's lists show, picked one model at a time
+// on the Agents page (settings.HiddenModels): the line under an agent's
+// name counts them, and opens the list to pick in.
+
+// modelCountJSON is how many models an agent's lists show, of those its
+// visibility gives it.
+type modelCountJSON struct {
+ Shown int `json:"shown"`
+ Listed int `json:"listed"`
+}
+
+// agentModelJSON is a model an agent may be shown, as the list draws it.
+type agentModelJSON struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Group string `json:"group"` // its provider's name, or "Routing groups"
+ Icon string `json:"icon,omitempty"`
+ Icons []string `json:"icons,omitempty"`
+ Context int `json:"context,omitempty"`
+ Hidden bool `json:"hidden,omitempty"`
+ // InUse: the agent is set to it, so it can't be taken out
+ InUse bool `json:"inUse,omitempty"`
+}
+
+// takesCatalog reports whether an agent picks among magpie's catalog: some
+// option of a field of its is a catalog entry.
+func takesCatalog(fields []fieldJSON) bool {
+ for _, f := range fields {
+ for _, o := range f.Options {
+ if o.Ref != "" {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+func modelCount(id string) *modelCountJSON {
+ listed, _ := provider.ListedFor(id)
+ off := provider.HiddenModels(id)
+ c := &modelCountJSON{Listed: len(listed)}
+ for _, e := range listed {
+ if !off[e.ID] {
+ c.Shown++
+ }
+ }
+ return c
+}
+
+// usedBy reports whether one of the agent's values is the entry: its id,
+// with a prefix the agent's files give it (magpie/…), or, for the agent's
+// own account, the vendor's model id alone.
+func usedBy(a *agent.Agent, vals map[string]string, e provider.Entry) bool {
+ for _, v := range vals {
+ if v == "" {
+ continue
+ }
+ if v == e.ID || strings.HasSuffix(v, "/"+e.ID) {
+ return true
+ }
+ if acc := e.Provider.Account; e.Group == "" && acc != nil && acc.Agent == a.ID && v == e.Model {
+ return true
+ }
+ }
+ return false
+}
+
+func agentModelList(a *agent.Agent) []agentModelJSON {
+ listed, _ := provider.ListedFor(a.ID)
+ off := provider.HiddenModels(a.ID)
+ vals := a.Values()
+ out := []agentModelJSON{}
+ for _, e := range listed {
+ m := agentModelJSON{ID: e.ID, Name: e.Name, Group: e.Provider.Name, Icon: e.Provider.Icon, Context: e.Context,
+ Hidden: off[e.ID], InUse: usedBy(a, vals, e)}
+ if m.Name == "" {
+ m.Name = e.Model
+ }
+ if e.Group != "" {
+ m.Group, m.Icons = agent.RoutingGroups, e.Icons
+ }
+ out = append(out, m)
+ }
+ return out
+}
+
+func agentModelsAPI(mux *http.ServeMux) {
+ mux.HandleFunc("GET /api/agent-models/{id}", func(rw http.ResponseWriter, r *http.Request) {
+ a, err := agent.Find(r.PathValue("id"))
+ if err != nil {
+ fail(rw, err)
+ return
+ }
+ writeJSON(rw, map[string]any{"models": agentModelList(a)})
+ })
+ // hidden is every entry to take out of the agent's lists; the others
+ // are shown, and one the agent is set to is kept in whatever is asked
+ mux.HandleFunc("POST /api/agent-models/{id}", func(rw http.ResponseWriter, r *http.Request) {
+ var in struct{ Hidden []string }
+ if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
+ fail(rw, err)
+ return
+ }
+ a, err := agent.Find(r.PathValue("id"))
+ if err != nil {
+ fail(rw, err)
+ return
+ }
+ used := map[string]bool{}
+ for _, m := range agentModelList(a) {
+ used[m.ID] = m.InUse
+ }
+ var hidden []string
+ for _, id := range in.Hidden {
+ if !used[id] {
+ hidden = append(hidden, id)
+ }
+ }
+ if err := provider.SetHiddenModels(a.ID, hidden); err != nil {
+ fail(rw, err)
+ return
+ }
+ writeJSON(rw, map[string]any{"models": agentModelList(a), "count": modelCount(a.ID)})
+ })
+}
diff --git a/internal/gui/agent_models_test.go b/internal/gui/agent_models_test.go
new file mode 100644
index 00000000..ffa60d24
--- /dev/null
+++ b/internal/gui/agent_models_test.go
@@ -0,0 +1,77 @@
+package gui
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "slices"
+ "strings"
+ "testing"
+
+ "github.com/yetone/magpie/internal/provider"
+ "github.com/yetone/magpie/internal/settings"
+)
+
+// The Agents page's model list for an agent: every model it may be shown,
+// the one it's set to marked and never taken out.
+func TestAgentModelsAPI(t *testing.T) {
+ home := t.TempDir()
+ t.Setenv("HOME", home)
+ t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
+ t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
+ t.Setenv("CODEX_HOME", filepath.Join(home, ".codex"))
+ os.MkdirAll(filepath.Join(home, ".codex"), 0o755)
+ os.WriteFile(filepath.Join(home, ".codex", "config.toml"), []byte("model = \"magpie/relay/m1\"\n"), 0o600)
+ if err := provider.Save(provider.Provider{ID: "relay", Name: "Relay", Key: "k", Chat: "http://127.0.0.1:1/v1", Models: []string{"m1", "m2", "m3"}}); err != nil {
+ t.Fatal(err)
+ }
+ mux := http.NewServeMux()
+ agentModelsAPI(mux)
+ call := func(method, body string) (out struct {
+ Models []agentModelJSON
+ Count *modelCountJSON
+ }) {
+ t.Helper()
+ w := httptest.NewRecorder()
+ mux.ServeHTTP(w, httptest.NewRequest(method, "/api/agent-models/codex", strings.NewReader(body)))
+ if w.Code != 200 {
+ t.Fatalf("%s %d %s", method, w.Code, w.Body)
+ }
+ json.Unmarshal(w.Body.Bytes(), &out)
+ return out
+ }
+ find := func(ms []agentModelJSON, id string) agentModelJSON {
+ for _, m := range ms {
+ if m.ID == id {
+ return m
+ }
+ }
+ t.Fatalf("no %s in %+v", id, ms)
+ return agentModelJSON{}
+ }
+ got := call("GET", "")
+ if m := find(got.Models, "relay/m1"); !m.InUse || m.Hidden || m.Group != "Relay" {
+ t.Fatalf("%+v", m)
+ }
+ if m := find(got.Models, "relay/m2"); m.InUse || m.Hidden {
+ t.Fatalf("%+v", m)
+ }
+ // the one in use stays whatever is asked
+ got = call("POST", `{"hidden":["relay/m1","relay/m2"]}`)
+ if saved := settings.Load().HiddenModels["codex"]; !slices.Equal(saved, []string{"relay/m2"}) {
+ t.Fatalf("saved %v", saved)
+ }
+ if !find(got.Models, "relay/m2").Hidden || find(got.Models, "relay/m1").Hidden ||
+ got.Count == nil || got.Count.Shown != got.Count.Listed-1 {
+ t.Fatalf("%+v %+v", got.Models, got.Count)
+ }
+ if c := modelCount("codex"); *c != *got.Count {
+ t.Fatalf("count %+v, answered %+v", c, got.Count)
+ }
+ got = call("POST", `{"hidden":[]}`)
+ if got.Count.Shown != got.Count.Listed {
+ t.Fatalf("%+v", got.Count)
+ }
+}
diff --git a/internal/gui/api.go b/internal/gui/api.go
index 54c24252..871ef009 100644
--- a/internal/gui/api.go
+++ b/internal/gui/api.go
@@ -86,6 +86,9 @@ type agentJSON struct {
// Launch: the command that starts an agent taking the gateway only
// from its environment (agy) on magpie, to copy
Launch string `json:"launch,omitempty"`
+ // Models: how many of the catalog its lists show, for an agent that
+ // picks among it (agent_models.go)
+ Models *modelCountJSON `json:"models,omitempty"`
}
// clientJSON is an agent, or another client the gateway knows, as a
@@ -630,6 +633,7 @@ func Handler(w Windows, gw *gateway.Server) http.Handler {
}
rw.WriteHeader(http.StatusNoContent)
})
+ agentModelsAPI(mux)
devListen(mux)
return mux
}
@@ -655,6 +659,9 @@ func state() stateJSON {
}
aj.Fields = append(aj.Fields, fieldJSON{Key: f.Key, Label: f.Label, Value: vals[f.Key], Options: opts})
}
+ if takesCatalog(aj.Fields) {
+ aj.Models = modelCount(a.ID)
+ }
aj.Drift = a.Drift()
if a.Import != nil {
aj.Import, aj.Added = a.Import(), a.Added != nil && a.Added()
diff --git a/internal/gui/assets/app.css b/internal/gui/assets/app.css
index d2d50af3..ed1a375a 100644
--- a/internal/gui/assets/app.css
+++ b/internal/gui/assets/app.css
@@ -1810,3 +1810,60 @@ body.panel .modal { padding: 16px 12px; }
.status.ok, .status.err, .status.warn { background: var(--bg); }
.status.lift { background: var(--card); }
}
+
+/* ---- an agent's model list ------------------------------------------------ */
+/* the line under an agent's name: quiet words, a pill only on hover */
+body.window .row.agent .who.with-models { flex-wrap: wrap; row-gap: 0; }
+.ag-models-line { flex: 0 0 100%; min-width: 0; display: flex; }
+.ag-models { min-width: 0; display: inline-flex; align-items: center; gap: 4px; height: 20px; margin: 1px 0 -2px -6px; padding: 0 6px; border: 0; border-radius: 6px; background: none; color: var(--muted); font: inherit; font-size: 11.5px; line-height: 1; white-space: nowrap; cursor: pointer; transition: background-color .15s, color .15s; }
+.ag-models b { font-weight: 600; color: var(--fg-2); font-variant-numeric: tabular-nums; }
+.ag-models .chev { display: inline-flex; color: var(--faint); transition: transform .2s var(--ease, ease); }
+.ag-models:hover, .ag-models.open { background: var(--pill); color: var(--fg-2); }
+.ag-models.open .chev { transform: rotate(90deg); }
+.ag-models:focus-visible { outline: 1.5px solid var(--accent); outline-offset: 1px; }
+
+.pop.am-pop { overflow: visible; max-height: 560px; padding: 14px 6px 8px; border-radius: 14px; background: var(--pop-bg); }
+/* the arrow at the line it grows from */
+.pop.am-pop::before { content: ""; position: absolute; left: calc(var(--ox, 24px) - 6px); top: -5px; width: 12px; height: 12px; background: var(--pop-bg); transform: rotate(45deg); border-radius: 2px 0 0 0; box-shadow: -.5px -.5px 0 color-mix(in srgb, var(--fg) 10%, transparent); }
+.pop.am-pop.up::before { top: auto; bottom: -5px; border-radius: 0 0 2px 0; box-shadow: .5px .5px 0 color-mix(in srgb, var(--fg) 10%, transparent); }
+.am-head { padding: 0 12px; }
+.am-t { font-size: 14px; font-weight: 600; letter-spacing: -.01em; color: var(--fg); }
+.am-d { margin-top: 3px; font-size: 12px; line-height: 1.45; color: var(--muted); }
+.am-tools { display: flex; align-items: center; gap: 8px; margin: 12px 12px 6px; }
+.am-search { flex: 1 1 auto; min-width: 0; display: flex; align-items: center; gap: 6px; height: 28px; padding: 0 9px; border-radius: 8px; background: var(--pill); color: var(--faint); }
+.pop.am-pop .am-search input { flex: 1; min-width: 0; height: 28px; border: 0; outline: none; background: none; color: var(--fg); font: inherit; font-size: 12.5px; }
+.am-search:focus-within { box-shadow: 0 0 0 1.5px color-mix(in srgb, var(--accent) 55%, transparent); }
+.am-seg { display: inline-flex; flex: none; padding: 2px; border-radius: 8px; background: var(--seg-track); }
+.am-seg button { height: 24px; padding: 0 9px; border: 0; border-radius: 6px; background: none; color: var(--muted); font: inherit; font-size: 12px; cursor: pointer; transition: background-color .15s, color .15s; }
+.am-seg button.on { background: var(--seg-thumb); color: var(--fg); box-shadow: var(--seg-edge); }
+.am-list { flex: 1 1 auto; min-height: 0; overflow-y: auto; overscroll-behavior: contain; padding-bottom: 2px; }
+.am-gh { display: flex; align-items: center; margin: 6px 2px 0; }
+.am-fold { flex: 1 1 auto; min-width: 0; display: flex; align-items: center; gap: 8px; height: 30px; padding: 0 10px; border: 0; border-radius: 8px; background: none; color: var(--muted); font: inherit; font-size: 12px; text-align: left; cursor: pointer; }
+.am-fold > .ic, .am-fold > svg { width: 14px; height: 14px; flex: none; }
+.am-fold .tw { display: inline-flex; width: 10px; color: var(--faint); }
+.am-fold .tw svg { transition: transform .2s var(--ease, ease); }
+.am-g.shut .am-fold .tw svg { transform: rotate(-90deg); }
+.am-fold .gn { color: var(--fg-2); font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.am-fold .c { font-variant-numeric: tabular-nums; }
+.am-all { flex: none; height: 24px; margin-right: 6px; padding: 0 6px; border: 0; border-radius: 6px; background: none; color: var(--accent); font: inherit; font-size: 11.5px; cursor: pointer; opacity: 0; transition: opacity .15s, background-color .15s; }
+.am-gh:hover .am-all { opacity: 1; }
+.am-all:focus-visible { opacity: 1; }
+.am-all:hover { background: var(--accent-soft); }
+@media (hover: none) { .am-all { opacity: 1; } }
+.am-mr { display: flex; align-items: center; gap: 10px; width: calc(100% - 4px); height: 32px; margin: 0 2px; padding: 0 12px; border: 0; border-radius: 8px; background: none; color: var(--fg); font: inherit; font-size: 13px; text-align: left; cursor: pointer; transition: background-color .12s; }
+.am-mr:hover { background: var(--pill); }
+.am-mr:focus-visible { outline: 1.5px solid var(--accent); outline-offset: -1.5px; }
+.am-mr .n { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; transition: color .15s; }
+.am-mr .x { flex: none; font-size: 11.5px; color: var(--faint); font-variant-numeric: tabular-nums; }
+.am-mr .ck { flex: none; display: inline-flex; justify-content: center; width: 16px; color: var(--accent); }
+.am-mr.off .n { color: var(--faint); }
+.am-mr.lock { cursor: default; }
+.am-mr.lock:hover { background: none; }
+.am-mr.lock .ck { opacity: .45; }
+.am-tag { margin-left: 6px; padding: 1px 6px; border-radius: 5px; background: var(--accent-soft); color: var(--accent); font-size: 10.5px; font-weight: 500; }
+.am-none { padding: 18px 12px; color: var(--muted); font-size: 12.5px; text-align: center; }
+.am-foot { display: flex; align-items: center; gap: 8px; margin: 8px 8px 0; padding: 10px 6px 2px; border-top: 1px solid var(--line-2); font-size: 11.5px; color: var(--muted); }
+.am-foot .sp { flex: 1; }
+.am-reset { border: 0; background: none; padding: 2px 4px; border-radius: 5px; color: var(--fg-2); font: inherit; font-size: 11.5px; cursor: pointer; }
+.am-reset:hover:not(:disabled) { color: var(--accent); }
+.am-reset:disabled { color: var(--faint); cursor: default; }
diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js
index f1e2e11d..b2e59452 100644
--- a/internal/gui/assets/app.js
+++ b/internal/gui/assets/app.js
@@ -363,6 +363,13 @@ function renderAgents() {
// the CLI's version, and an update when one is out (#202); the panel's
// name column has no room for it
if (mode !== "panel") who.append(cliTag(a));
+ // which of magpie's models its lists show, on a line under the name
+ if (mode !== "panel" && a.models) {
+ const line = el("div", "ag-models-line");
+ line.append(modelsEntry(a));
+ who.append(line);
+ who.classList.add("with-models");
+ }
row.append(agentHandle(a, row, inFold), who);
if (sum) row.append(sum, ...(openBox ? [openBox] : []));
else row.append(fields);
@@ -783,6 +790,235 @@ function dragAgent(e, handle, row) {
handle.addEventListener("pointercancel", up);
}
+// ---------- an agent's model list ----------
+
+// modelsEntry: the line under an agent's name counting the models its
+// lists show ("All 41 models", or "Showing 5 / 32 models"); it opens the
+// list to take models out of them and put them back.
+function modelsEntry(a) {
+ const b = el("button", "ag-models");
+ b.type = "button";
+ fillModelsEntry(b, a);
+ b.onclick = (ev) => openAgentModels(a, b, ev);
+ // the rows drawn again while its list is open: the list stays, held to
+ // the new line
+ if (agentModels?.a.id === a.id) {
+ b.classList.add("open");
+ agentModels.anchor = b;
+ if (agentModels.count) { a.models = agentModels.count; fillModelsEntry(b, a); }
+ }
+ return b;
+}
+function fillModelsEntry(b, a) {
+ const c = a.models;
+ b.replaceChildren();
+ // the words in one box, so the flex line keeps the spaces round the number
+ const words = el("span");
+ if (c.shown >= c.listed) words.append(t("All {n} models", { n: c.listed }));
+ else {
+ const [pre, post] = t("Showing {shown} / {listed} models", { listed: c.listed }).split("{shown}");
+ words.append(pre, el("b", "", String(c.shown)), post || "");
+ }
+ b.append(words);
+ const ch = el("span", "chev");
+ ch.append(svg(CHEV_R, 10, 1.6));
+ b.append(ch);
+ b.title = t("Pick which models {agent} lists", { agent: a.name });
+}
+
+const ctxShort = (n) => !n ? "" : n >= 1e6 ? (n % 1e6 ? (n / 1e6).toFixed(1) : n / 1e6) + "M" : Math.round(n / 1e3) + "K";
+
+let agentModels = null;
+function closeAgentModels() {
+ const m = agentModels;
+ if (!m) return;
+ agentModels = null;
+ m.anchor.classList.remove("open");
+ popGhost(m.box);
+ m.box.remove();
+ document.removeEventListener("mousedown", m.outside, true);
+ document.removeEventListener("keydown", m.keys, true);
+ document.removeEventListener("scroll", m.scrolled, true);
+ removeEventListener("resize", closeAgentModels);
+ // the agents' pickers list what's left once the writes are in
+ if (m.changed) m.saving.then(async () => { state = await api("state"); renderAgents(); }).catch(() => {});
+}
+
+async function openAgentModels(a, anchor, ev) {
+ ev.stopPropagation();
+ const again = agentModels?.a.id === a.id;
+ closeAgentModels();
+ closePicker();
+ if (again) return;
+ let models;
+ try { models = (await api("agent-models/" + encodeURIComponent(a.id))).models; }
+ catch (e) { status(e.message, "err"); return; }
+ if (!anchor.isConnected) return;
+ const box = el("div", "pop am-pop");
+ box.setAttribute("role", "dialog");
+ box.setAttribute("aria-label", t("{agent}'s model list", { agent: a.name }));
+ const head = el("div", "am-head");
+ head.append(el("div", "am-t", t("{agent}'s model list", { agent: a.name })),
+ el("div", "am-d", t("Models turned off don't show in {agent}'s model picker; other agents can still use them.", { agent: a.name })));
+ const tools = el("div", "am-tools");
+ const search = el("label", "am-search");
+ search.innerHTML = '';
+ const q = el("input");
+ q.type = "text";
+ q.spellcheck = false;
+ q.autocomplete = "off";
+ q.placeholder = t("Search models");
+ search.append(q);
+ const seg = el("div", "am-seg");
+ const segAll = el("button", "on", t("All")), segOn = el("button", "", t("Shown"));
+ segAll.type = segOn.type = "button";
+ seg.append(segAll, segOn);
+ tools.append(search, seg);
+ const list = el("div", "am-list");
+ const foot = el("div", "am-foot");
+ const reset = el("button", "am-reset", t("Show all again"));
+ reset.type = "button";
+ foot.append(el("span", "", t("New models are shown")), el("span", "sp"), reset);
+ box.append(head, tools, list, foot);
+
+ // groups as the catalog has them, routing groups first; a long one
+ // starts folded, unless the agent is set to a model in it
+ const groups = [];
+ for (const m of models) {
+ let g = groups.find((x) => x.name === m.group);
+ if (!g) groups.push(g = { name: m.group, icon: m.icon, models: [] });
+ g.models.push(m);
+ }
+ groups.sort((x, y) => (y.name === ROUTING_GROUPS) - (x.name === ROUTING_GROUPS));
+ const shut = new Set(groups.filter((g) => groups.length > 1 && g.models.length > 8 && !g.models.some((m) => m.inUse)).map((g) => g.name));
+ // under "Shown", one just turned off stays until the view changes
+ let onlyShown = false, kept = new Set();
+
+ const me = agentModels = { a, anchor, box, saving: Promise.resolve(), changed: false };
+ const save = () => {
+ me.changed = true;
+ const hidden = models.filter((m) => m.hidden).map((m) => m.id);
+ const count = { shown: models.length - hidden.length, listed: models.length };
+ me.count = a.models = count;
+ fillModelsEntry(me.anchor, a);
+ me.saving = me.saving.then(() => api("agent-models/" + encodeURIComponent(a.id), { hidden }))
+ .catch((e) => status(e.message, "err"));
+ };
+ const draw = () => {
+ const top = list.scrollTop;
+ list.replaceChildren();
+ const words = q.value.trim().toLowerCase();
+ for (const g of groups) {
+ const rows = g.models.filter((m) => (!onlyShown || !m.hidden || kept.has(m.id)) &&
+ (!words || [m.name, m.id, g.name].some((s) => s.toLowerCase().includes(words))));
+ if (!rows.length) continue;
+ const on = g.models.filter((m) => !m.hidden).length;
+ const folded = !words && shut.has(g.name);
+ const sec = el("section", "am-g" + (folded ? " shut" : ""));
+ const gh = el("div", "am-gh");
+ const fold = el("button", "am-fold");
+ fold.type = "button";
+ fold.setAttribute("aria-expanded", String(!folded));
+ const tw = el("span", "tw");
+ tw.append(svg(CHEV, 10, 1.7));
+ fold.append(tw, g.name === ROUTING_GROUPS ? svg(FAN, 14, 1.5) : icon(g.icon || "generic"),
+ el("span", "gn", g.name === ROUTING_GROUPS ? t(g.name) : g.name), el("span", "c", `${on} / ${g.models.length}`));
+ fold.onclick = () => { shut.has(g.name) ? shut.delete(g.name) : shut.add(g.name); draw(); };
+ const free = g.models.filter((m) => !m.inUse);
+ const allOn = free.every((m) => !m.hidden);
+ const all = el("button", "am-all", allOn ? t("Hide all") : t("Show all"));
+ all.type = "button";
+ all.hidden = !free.length;
+ all.onclick = () => {
+ for (const m of free) { m.hidden = allOn; if (allOn) kept.add(m.id); }
+ save();
+ draw();
+ };
+ gh.append(fold, all);
+ sec.append(gh);
+ if (!folded) {
+ const body = el("div", "am-rows");
+ for (const m of rows) {
+ const r = el("button", "am-mr" + (m.hidden ? " off" : "") + (m.inUse ? " lock" : ""));
+ r.type = "button";
+ r.setAttribute("role", "menuitemcheckbox");
+ r.setAttribute("aria-checked", String(!m.hidden));
+ r.title = m.inUse ? t("{agent} is set to it, so it stays", { agent: a.name }) : m.id;
+ const n = el("span", "n", m.name);
+ if (m.inUse) n.append(el("span", "am-tag", t("Current")));
+ const ck = el("span", "ck");
+ if (!m.hidden) ck.append(svg("m3.5 8.5 3 3 6-7", 12, 1.9));
+ r.append(n, el("span", "x", ctxShort(m.context)), ck);
+ if (m.inUse) r.setAttribute("aria-disabled", "true");
+ else r.onclick = () => {
+ m.hidden = !m.hidden;
+ if (m.hidden) kept.add(m.id);
+ save();
+ draw();
+ };
+ body.append(r);
+ }
+ sec.append(body);
+ }
+ list.append(sec);
+ }
+ if (!list.childNodes.length) list.append(el("div", "am-none", t("No matches.")));
+ list.scrollTop = top;
+ reset.disabled = !models.some((m) => m.hidden);
+ };
+ q.oninput = () => { list.scrollTop = 0; draw(); };
+ const view = (shown) => {
+ onlyShown = shown;
+ kept = new Set();
+ segAll.classList.toggle("on", !shown);
+ segOn.classList.toggle("on", shown);
+ list.scrollTop = 0;
+ draw();
+ };
+ segAll.onclick = () => view(false);
+ segOn.onclick = () => view(true);
+ reset.onclick = () => {
+ for (const m of models) m.hidden = false;
+ save();
+ draw();
+ };
+ draw();
+
+ document.body.append(box);
+ // under the line, or over it where there's no room; the list scrolls
+ const r = anchor.getBoundingClientRect(), pad = 8;
+ const w = Math.min(380, innerWidth - pad * 2);
+ box.style.width = w + "px";
+ const x = Math.max(pad, Math.min(r.left - 10, innerWidth - w - pad));
+ box.style.left = x + "px";
+ box.style.setProperty("--ox", Math.max(18, Math.min(w - 18, r.left + Math.min(r.width, 60) / 2 - x)) + "px");
+ const below = innerHeight - r.bottom - 8 - pad, above = r.top - 8 - pad;
+ const want = Math.min(560, box.scrollHeight);
+ if (want > below && above > below) {
+ box.classList.add("up");
+ box.style.bottom = innerHeight - r.top + 8 + "px";
+ box.style.maxHeight = Math.min(560, above) + "px";
+ } else {
+ box.style.top = r.bottom + 8 + "px";
+ box.style.maxHeight = Math.min(560, below) + "px";
+ }
+ anchor.classList.add("open");
+ me.outside = (e) => { if (!box.contains(e.target) && !me.anchor.contains(e.target)) closeAgentModels(); };
+ me.keys = (e) => {
+ if (e.key !== "Escape") return;
+ e.preventDefault();
+ e.stopPropagation();
+ closeAgentModels();
+ me.anchor.focus({ preventScroll: true });
+ };
+ me.scrolled = (e) => { if (!box.contains(e.target)) closeAgentModels(); };
+ document.addEventListener("mousedown", me.outside, true);
+ document.addEventListener("keydown", me.keys, true);
+ document.addEventListener("scroll", me.scrolled, true);
+ addEventListener("resize", closeAgentModels);
+ q.focus({ preventScroll: true });
+}
+
let agentMenu = null;
function closeAgentMenu() {
if (!agentMenu) return;
@@ -4667,7 +4903,7 @@ function renderAccounts(a) {
}
row.append(dot, el("span", "n", l.user), el("span", "plan", accountPlan({ agent: a.agent, plan: l.plan })), el("span", "grow"));
if (l.active) {
- row.append(el("span", "using", l.paused ? t("Paused") : several ? t("First") : t("In use")));
+ row.append(el("span", "using", l.paused ? t("Paused") : several ? t("First") : t("Current")));
if (a.agent === "qoder" || l.own) {
const forget = el("button", "text quiet", t("Remove"));
if (l.own) forget.title = forgetOwnTitle(a);
@@ -5083,7 +5319,7 @@ function renderKeyAccounts(p) {
rm.onclick = () => accountAction("keys/remove", { id: p.id, ref: k.id }, t("Key removed"));
row.append(rm);
}
- if (k.active) row.append(el("span", "using", several ? t("First") : t("In use")));
+ if (k.active) row.append(el("span", "using", several ? t("First") : t("Current")));
else if (k.on) {
const first = el("button", "text", t("Make first"));
first.onclick = () => { first.classList.add("busy"); accountAction("keys/use", { id: p.id, ref: k.id }, t("{name} tries {key} first", { name: p.name, key: k.name || k.masked })); };
@@ -8739,6 +8975,7 @@ function show(v) {
const back = () => backToReader($("#view-" + v));
requestAnimationFrame(back);
closePicker();
+ closeAgentModels();
if (v !== "providers" && editing !== null) cancelEdit();
if (v === "providers" || v === "gateway" || v === "routing") loadProviders().then(back, (e) => status(e.message, "err"));
if (v === "usage") loadUsage().then(back, (e) => status(e.message, "err"));
diff --git a/internal/gui/assets/i18n.js b/internal/gui/assets/i18n.js
index 4789f243..a3589002 100644
--- a/internal/gui/assets/i18n.js
+++ b/internal/gui/assets/i18n.js
@@ -22,6 +22,20 @@ const I18N = {
"Install Claude Code, Codex, Gemini CLI, OpenCode… and magpie will list them here.": "安装 Claude Code、Codex、Gemini CLI、OpenCode…… 之后,magpie 会在这里列出它们。",
"Show {n} more": "显示其余 {n} 个",
"Show less": "收起",
+ // an agent's model list
+ "All {n} models": "全部 {n} 个模型",
+ "Showing {shown} / {listed} models": "显示 {shown} / {listed} 个模型",
+ "Pick which models {agent} lists": "选择 {agent} 列出哪些模型",
+ "{agent}'s model list": "{agent} 的模型列表",
+ "Models turned off don't show in {agent}'s model picker; other agents can still use them.": "关掉的模型不会出现在 {agent} 的模型列表里,别的 Agent 照常能用。",
+ "Search models": "搜索模型",
+ "Shown": "已显示",
+ "Show all": "全部显示",
+ "Hide all": "全部隐藏",
+ "Current": "在用",
+ "{agent} is set to it, so it stays": "{agent} 正在用它,所以不能隐藏",
+ "New models are shown": "新模型默认显示",
+ "Show all again": "恢复全部显示",
"{n} hidden": "已隐藏 {n} 个",
"{n} hidden agents": "已隐藏的 Agent({n} 个)",
"Show {n} more ({h} hidden)": "显示其余 {n} 个(含 {h} 个已隐藏)",
diff --git a/internal/gui/tests/README.md b/internal/gui/tests/README.md
index 1f959b2d..9cc1c65b 100644
--- a/internal/gui/tests/README.md
+++ b/internal/gui/tests/README.md
@@ -29,6 +29,16 @@ scrolled to its end, in the tray panel (one agent open) and in the window:
them and never back up; "Show less", and a row opened and closed, leave what
was clicked where it is on every frame.
+`agent-models.test.cjs` opens Codex's model list from the line under its
+name, in Chromium and WebKit, English and Chinese: the line reads "Showing 8 /
+31 models" under the name, opening it moves nothing and puts it on the screen
+whole, routing groups come first and OpenRouter's 24 start folded, the model
+Codex is set to can't be taken out, a click takes one out at once with the
+whole list sent and the count under the name following, a search opens a
+folded group, "Shown" keeps one just turned off till the view changes, a
+group's "Show all" comes on hover, "Show all again" sends none, and Esc or a
+click elsewhere closes it.
+
`gateway-fold.test.cjs` folds Connect on the Gateway page with the view
scrolled: its fields hide, the head keeps the base URL and a copy button, the
head stays where it was, and the fold is remembered across a reload.
diff --git a/internal/gui/tests/agent-models.test.cjs b/internal/gui/tests/agent-models.test.cjs
new file mode 100644
index 00000000..eb0a0b64
--- /dev/null
+++ b/internal/gui/tests/agent-models.test.cjs
@@ -0,0 +1,166 @@
+// Run with Node's test runner and Playwright on the module path; see README.md.
+// An agent's model list (@ChongkaiX on X: pick which models show in Codex's
+// /model): the line under its name counts them and opens the list, where a
+// click on a row takes a model out or puts it back, at once.
+const assert = require("node:assert/strict");
+const fs = require("node:fs/promises");
+const path = require("node:path");
+const { test } = require("node:test");
+const { chromium, webkit } = require("playwright");
+
+const assets = path.resolve(__dirname, "../assets");
+const models = () => [
+ { id: "group:fast", name: "fast", group: "Routing groups", icons: ["openai", "anthropic"] },
+ ...["gpt-5.5", "gpt-5.5-mini", "gpt-5.4", "gpt-5.3-codex", "o4", "o4-mini"].map((m, i) =>
+ ({ id: "openai/" + m, name: m.toUpperCase(), group: "OpenAI", icon: "openai", context: i ? 400e3 : 1e6, inUse: i === 0 })),
+ ...Array.from({ length: 24 }, (_, i) => ({ id: "openrouter/m" + i, name: "Router Model " + i, group: "OpenRouter", icon: "openrouter", context: 128e3, hidden: i > 0 })),
+];
+
+function fixture(lang) {
+ const list = models();
+ const posts = [];
+ const count = () => ({ shown: list.filter((m) => !m.hidden).length, listed: list.length });
+ const state = () => ({
+ agents: [{
+ id: "codex", name: "Codex", path: "/test/config.toml", icon: "codex-color",
+ fields: [{ key: "model", label: "model", value: "magpie/openai/gpt-5.5", options: [{ value: "magpie/openai/gpt-5.5", label: "GPT-5.5", ref: "openai/gpt-5.5" }] }],
+ models: count(),
+ }],
+ profiles: [], settings: { lang, theme: "light" },
+ });
+ async function serve(route) {
+ const req = route.request();
+ const url = new URL(req.url());
+ const json = (data) => route.fulfill({ json: data });
+ if (url.pathname === "/boot.js") return route.fulfill({ contentType: "text/javascript", body: `window.bootPrefs = {lang:"${lang}",theme:"light",web:true};` });
+ if (url.pathname === "/wails/runtime.js") return route.fulfill({ contentType: "text/javascript", body: "export const Window = {};" });
+ if (url.pathname === "/api/state") return json(state());
+ if (url.pathname === "/api/agent-models/codex") {
+ if (req.method() === "POST") {
+ const { hidden } = req.postDataJSON();
+ posts.push(hidden);
+ for (const m of list) m.hidden = hidden.includes(m.id) && !m.inUse;
+ return json({ models: list, count: count() });
+ }
+ return json({ models: list });
+ }
+ if (url.pathname === "/api/groups") return json({ groups: [] });
+ if (url.pathname === "/api/providers") return json({ providers: [], gateway: { running: true, window: true } });
+ if (url.pathname.startsWith("/api/")) return json({});
+ const file = path.join(assets, url.pathname === "/" ? "index.html" : url.pathname);
+ const contentType = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".png": "image/png" }[path.extname(file)];
+ await route.fulfill({ body: await fs.readFile(file), contentType });
+ }
+ return { serve, posts };
+}
+
+const W = {
+ en: { entry: "Showing 8 / 31 models", after: "Showing 7 / 31 models", all: "All 31 models", title: "Codex's model list", current: "Current", shown: "Shown", reset: "Show all again", showAll: "Show all" },
+ zh: { entry: "显示 8 / 31 个模型", after: "显示 7 / 31 个模型", all: "全部 31 个模型", title: "Codex 的模型列表", current: "在用", shown: "已显示", reset: "恢复全部显示", showAll: "全部显示" },
+};
+
+for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) {
+ for (const lang of ["en", "zh"]) {
+ test(`${engine} ${lang}: an agent's model list`, async (t) => {
+ const w = W[lang];
+ const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" }));
+ const page = await browser.newPage({ viewport: { width: 1000, height: 700 } });
+ page.setDefaultTimeout(5000);
+ const errors = [];
+ page.on("pageerror", (e) => errors.push(e.message));
+ const fx = fixture(lang);
+ await page.route("**/*", fx.serve);
+ t.after(async () => {
+ if (process.env.ARTIFACT_DIR) {
+ await fs.mkdir(process.env.ARTIFACT_DIR, { recursive: true });
+ await page.screenshot({ path: path.join(process.env.ARTIFACT_DIR, `${engine}-${lang}-models.png`) });
+ }
+ await browser.close();
+ });
+ await page.goto("http://magpie.test/");
+ const entry = page.locator('.row.agent[data-id="codex"] .ag-models');
+ await entry.waitFor();
+ assert.equal((await entry.innerText()).trim(), w.entry);
+ // under the name, not beside it
+ const name = await page.locator('.row.agent[data-id="codex"] .who .name').boundingBox();
+ const eb = await entry.boundingBox();
+ assert(eb.y >= name.y + name.height - 2, "the line is under the name");
+
+ const scroll = () => page.evaluate(() => [scrollY, document.scrollingElement.scrollTop, $("#view-agents").scrollTop]);
+ const was = await scroll();
+ await entry.click();
+ const pop = page.locator(".am-pop:not(.leaving)");
+ await pop.waitFor();
+ assert.deepEqual(await scroll(), was, "opening it moves nothing");
+ assert.equal(await pop.locator(".am-t").innerText(), w.title);
+ // on the screen whole
+ const pb = await pop.boundingBox();
+ assert(pb.x >= 0 && pb.y >= 0 && pb.x + pb.width <= 1000 && pb.y + pb.height <= 700, JSON.stringify(pb));
+ assert(await page.evaluate(() => document.activeElement?.closest(".am-search") !== null), "the search has the keys");
+
+ // routing groups first; the long OpenRouter folded, OpenAI open
+ const groups = await pop.locator(".am-fold .gn").allInnerTexts();
+ assert.equal(groups.length, 3);
+ assert.equal(groups[1], "OpenAI");
+ assert.equal(groups[2], "OpenRouter");
+ assert.equal(await pop.locator(".am-g").nth(2).locator(".am-mr").count(), 0, "OpenRouter's 24 start folded");
+ assert.equal(await pop.locator(".am-g").nth(1).locator(".am-mr").count(), 6);
+ assert.equal(await pop.locator(".am-g").nth(2).locator(".c").innerText(), "1 / 24");
+
+ // the one Codex is set to stays
+ const cur = pop.locator(".am-mr", { hasText: "GPT-5.5" }).first();
+ assert.equal(await cur.locator(".am-tag").innerText(), w.current);
+ await cur.click({ force: true }); // aria-disabled: Playwright waits for it to be enabled
+ assert.equal(fx.posts.length, 0, "the model in use can't be taken out");
+ assert.equal(await cur.locator(".ck svg").count(), 1);
+
+ // a click takes one out at once, the count under the name with it
+ const mini = pop.locator(".am-mr", { hasText: "O4-MINI" });
+ await mini.click();
+ await page.waitForFunction(() => document.querySelector(".ag-models")?.innerText.includes("7"));
+ assert.equal((await entry.innerText()).trim(), w.after);
+ await page.waitForTimeout(150);
+ assert.equal(fx.posts.length, 1);
+ assert(fx.posts[0].includes("openai/o4-mini") && fx.posts[0].includes("openrouter/m5"), "the whole list goes");
+ assert.equal(await mini.locator(".ck svg").count(), 0);
+ assert(await mini.evaluate((e) => e.classList.contains("off")));
+ assert.equal(await pop.locator(".am-g").nth(1).locator(".c").innerText(), "5 / 6");
+
+ // a search opens a folded group to what it finds
+ const q = pop.locator(".am-search input");
+ await q.fill("router model 7");
+ assert.equal(await pop.locator(".am-mr").count(), 1);
+ await q.fill("");
+
+ // "Shown": only those shown, one turned off there stays till it changes
+ await pop.locator(".am-seg button", { hasText: w.shown }).click();
+ assert.equal(await pop.locator(".am-mr").count(), 1 + 5 + 0, "routing group, OpenAI's 5; OpenRouter folded");
+ await pop.locator(".am-mr", { hasText: "O4" }).first().click();
+ assert.equal(await pop.locator(".am-mr").count(), 6);
+
+ // the group's "show all" comes on hover
+ const gh = pop.locator(".am-gh").nth(2);
+ assert.equal(await gh.locator(".am-all").evaluate((e) => getComputedStyle(e).opacity), "0");
+ await gh.hover();
+ await page.waitForTimeout(250);
+ assert.equal(await gh.locator(".am-all").evaluate((e) => getComputedStyle(e).opacity), "1");
+ assert.equal(await gh.locator(".am-all").innerText(), w.showAll);
+
+ // back to all shown
+ await pop.locator(".am-reset").click();
+ await page.waitForTimeout(150);
+ assert.deepEqual(fx.posts.at(-1), []);
+ assert.equal((await entry.innerText()).trim(), w.all);
+ assert(await pop.locator(".am-reset").isDisabled());
+
+ // Esc closes it; so does a click elsewhere
+ await page.keyboard.press("Escape");
+ await pop.waitFor({ state: "detached" });
+ await entry.click();
+ await pop.waitFor();
+ await page.mouse.click(900, 650);
+ await pop.waitFor({ state: "detached" });
+ assert.deepEqual(errors, []);
+ });
+ }
+}
diff --git a/internal/provider/codex_models.go b/internal/provider/codex_models.go
index 6c0c43ef..8fd76c5f 100644
--- a/internal/provider/codex_models.go
+++ b/internal/provider/codex_models.go
@@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"io"
+ "maps"
"net/http"
"net/url"
"os"
@@ -19,6 +20,7 @@ import (
"time"
"github.com/yetone/magpie/internal/catalog"
+ "github.com/yetone/magpie/internal/codexcat"
"github.com/yetone/magpie/internal/proc"
)
@@ -276,6 +278,35 @@ func CodexListed() []catalog.Model {
})
}
+// CodexNativeHidden is the ChatGPT account's own model slugs the user took
+// out of Codex's list (HiddenModels): the backend lists them, and the
+// gateway drops them from its /models answer as it does the ones not picked.
+func CodexNativeHidden() map[string]bool {
+ off := HiddenModels("codex")
+ if len(off) == 0 {
+ return nil
+ }
+ out := map[string]bool{}
+ for _, e := range Catalog() {
+ if off[e.ID] && e.Group == "" && e.Provider.Account != nil && e.Provider.Account.Agent == "codex" {
+ out[e.Model] = true
+ }
+ }
+ return out
+}
+
+// CodexListTag names the list Codex is handed, for its ETag: magpie's models
+// and the account's own taken out of it, so either changing has Codex ask
+// for the list again.
+func CodexListTag() string {
+ ms := CodexListed()
+ off := slices.Sorted(maps.Keys(CodexNativeHidden()))
+ for _, slug := range off {
+ ms = append(ms, catalog.Model{ID: "-" + slug})
+ }
+ return codexcat.Tag(ms)
+}
+
// CodexNativePicked is the set of the ChatGPT account's own model slugs the
// user kept, and whether they narrowed that list at all. The backend lists
// every model the account can reach; when the user has picked among them on
diff --git a/internal/provider/hidden_models_test.go b/internal/provider/hidden_models_test.go
new file mode 100644
index 00000000..776fea68
--- /dev/null
+++ b/internal/provider/hidden_models_test.go
@@ -0,0 +1,86 @@
+package provider
+
+import (
+ "slices"
+ "testing"
+
+ "github.com/yetone/magpie/internal/catalog"
+ "github.com/yetone/magpie/internal/settings"
+)
+
+func ids(es []Entry) []string {
+ var out []string
+ for _, e := range es {
+ out = append(out, e.ID)
+ }
+ return out
+}
+
+// Models taken out of one agent's lists one by one: gone from its catalog,
+// every other agent's untouched, a new model shown, and the agents told.
+func TestHiddenModels(t *testing.T) {
+ isolate(t)
+ t.Setenv("HOME", t.TempDir())
+ t.Setenv("XDG_CONFIG_HOME", t.TempDir())
+ t.Setenv("XDG_CACHE_HOME", t.TempDir())
+ for _, id := range []string{"relay", "other"} {
+ if err := Save(Provider{ID: id, Name: "My " + id, Key: "k", Chat: "http://127.0.0.1:1/v1", Models: []string{"m1", "m2"}}); err != nil {
+ t.Fatal(err)
+ }
+ }
+ told := 0
+ was := catalog.Changed
+ catalog.Changed = func() { told++ }
+ t.Cleanup(func() { catalog.Changed = was })
+
+ all, _ := CatalogFor("codex")
+ if err := SetHiddenModels("Codex", []string{" relay/m2 ", "other/m1", "relay/m2", ""}); err != nil {
+ t.Fatal(err)
+ }
+ if got := settings.Load().HiddenModels["codex"]; !slices.Equal(got, []string{"other/m1", "relay/m2"}) {
+ t.Fatalf("saved %v", got)
+ }
+ shown, hidden := CatalogFor("codex")
+ if slices.Contains(ids(shown), "relay/m2") || slices.Contains(ids(shown), "other/m1") ||
+ len(shown) != len(all)-2 || !slices.Equal(slices.Sorted(slices.Values(ids(hidden))), []string{"other/m1", "relay/m2"}) {
+ t.Fatalf("shown %v hidden %v", ids(shown), ids(hidden))
+ }
+ // what it may pick among is still every one
+ if listed, _ := ListedFor("codex"); len(listed) != len(all) {
+ t.Fatalf("listed %v", ids(listed))
+ }
+ if other, _ := CatalogFor("claude"); len(other) != len(all) {
+ t.Fatalf("another agent's list narrowed: %v", ids(other))
+ }
+ if told != 1 {
+ t.Fatalf("agents told %d times", told)
+ }
+ // the same again changes nothing and tells no one
+ if err := SetHiddenModels("codex", []string{"relay/m2", "other/m1"}); err != nil || told != 1 {
+ t.Fatalf("%v told %d", err, told)
+ }
+ // a model new to a provider is shown
+ if err := Save(Provider{ID: "relay", Name: "My relay", Key: "k", Chat: "http://127.0.0.1:1/v1", Models: []string{"m1", "m2", "m3"}}); err != nil {
+ t.Fatal(err)
+ }
+ if shown, _ := CatalogFor("codex"); !slices.Contains(ids(shown), "relay/m3") {
+ t.Fatalf("new model not shown: %v", ids(shown))
+ }
+ // a renamed provider's hidden models follow it
+ if err := Rename("relay", "fast"); err != nil {
+ t.Fatal(err)
+ }
+ if got := settings.Load().HiddenModels["codex"]; !slices.Contains(got, "fast/m2") || slices.Contains(got, "relay/m2") {
+ t.Fatalf("after rename %v", got)
+ }
+ // none puts every one back
+ if err := SetHiddenModels("codex", nil); err != nil {
+ t.Fatal(err)
+ }
+ if _, ok := settings.Load().HiddenModels["codex"]; ok {
+ t.Fatal("empty list kept")
+ }
+ if _, hidden := CatalogFor("codex"); len(hidden) != 0 {
+ t.Fatalf("still hidden %v", ids(hidden))
+ }
+}
diff --git a/internal/provider/modelprefs.go b/internal/provider/modelprefs.go
index bd11ea5a..7f879ba3 100644
--- a/internal/provider/modelprefs.go
+++ b/internal/provider/modelprefs.go
@@ -252,7 +252,15 @@ func vendorSees(p *Provider, model string) (bool, bool) {
func renameModelPrefs(s *settings.Settings, from, to string) bool {
named := renameKeys(s.ModelNames, from, to)
imaged := renameKeys(s.ModelImages, from, to)
- return renameKeys(s.ModelEfforts, from, to) || named || imaged
+ hidden := false
+ for _, ids := range s.HiddenModels {
+ for i, id := range ids {
+ if rest, ok := strings.CutPrefix(id, from+"/"); ok {
+ ids[i], hidden = to+"/"+rest, true
+ }
+ }
+ }
+ return renameKeys(s.ModelEfforts, from, to) || named || imaged || hidden
}
func renameKeys[V any](m map[string]V, from, to string) bool {
diff --git a/internal/provider/visible.go b/internal/provider/visible.go
index 7d840153..ca0af7a0 100644
--- a/internal/provider/visible.go
+++ b/internal/provider/visible.go
@@ -1,9 +1,11 @@
package provider
import (
+ "errors"
"slices"
"strings"
+ "github.com/yetone/magpie/internal/catalog"
"github.com/yetone/magpie/internal/settings"
)
@@ -44,8 +46,24 @@ func Shows(names []string, e Entry) bool {
}
// CatalogFor is the catalog as agent is shown it, and what is kept from it
-// (none when its lists aren't narrowed).
+// (none when its lists aren't narrowed): its visibility's, less the models
+// taken out of its lists one by one (HiddenModels).
func CatalogFor(agent string) (shown, hidden []Entry) {
+ listed, hidden := ListedFor(agent)
+ off := HiddenModels(agent)
+ for _, e := range listed {
+ if off[e.ID] {
+ hidden = append(hidden, e)
+ } else {
+ shown = append(shown, e)
+ }
+ }
+ return shown, hidden
+}
+
+// ListedFor is the catalog agent's visibility gives it, the models it may
+// pick to show or not among, and what the visibility keeps from it.
+func ListedFor(agent string) (listed, kept []Entry) {
all := Catalog()
names, ok := VisibleTo(agent)
if !ok {
@@ -53,12 +71,56 @@ func CatalogFor(agent string) (shown, hidden []Entry) {
}
for _, e := range all {
if Shows(names, e) {
- shown = append(shown, e)
+ listed = append(listed, e)
} else {
- hidden = append(hidden, e)
+ kept = append(kept, e)
}
}
- return shown, hidden
+ return listed, kept
+}
+
+// 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)]
+ out := make(map[string]bool, len(ids))
+ for _, id := range ids {
+ out[id] = true
+ }
+ return out
+}
+
+// SetHiddenModels takes these entries out of agent's lists, and puts back
+// every other; none shows it every model its visibility gives it. The
+// agents that keep the models in files of their own are told.
+func SetHiddenModels(agent string, ids []string) error {
+ agent = strings.ToLower(strings.TrimSpace(agent))
+ if agent == "" {
+ return errors.New("no agent")
+ }
+ var keep []string
+ for _, id := range ids {
+ if id = strings.TrimSpace(id); id != "" && !slices.Contains(keep, id) {
+ keep = append(keep, id)
+ }
+ }
+ slices.Sort(keep)
+ s := settings.Load()
+ if slices.Equal(s.HiddenModels[agent], keep) {
+ return nil
+ }
+ if len(keep) == 0 {
+ delete(s.HiddenModels, agent)
+ } else {
+ if s.HiddenModels == nil {
+ s.HiddenModels = map[string][]string{}
+ }
+ s.HiddenModels[agent] = keep
+ }
+ if err := settings.Save(s); err != nil {
+ return err
+ }
+ catalog.Touched()
+ return nil
}
// Families are the families providers and groups are tagged with, sorted.
diff --git a/internal/settings/settings.go b/internal/settings/settings.go
index d3547bee..8dbed49c 100644
--- a/internal/settings/settings.go
+++ b/internal/settings/settings.go
@@ -118,6 +118,10 @@ type Settings struct {
// families (the tag a provider or group is given), provider ids and
// group ids its lists hold. An agent it doesn't name is shown them all.
Visible map[string][]string `json:"visible,omitempty"`
+ // HiddenModels are the catalog entries (their ids, "/"
+ // or a group's) taken out of an agent's lists one by one, by agent id,
+ // after Visible: a model not named here, a new one among them, is shown.
+ HiddenModels map[string][]string `json:"hiddenModels,omitempty"`
// ModelNames are the names the user gave models, by "/": agents, the gateway's model list and magpie itself
// show them for the vendor's (see provider.SetModelName).