agents: each agent's own model list — under an agent's name on the Agents page, "Showing 5 / 32 models" (or "All 41 models") opens a list to take models out of that agent's picker one by one and put them back (@ChongkaiX on X: 在 magpie 里选哪些模型出现在 Codex 的模型列表里) — until now an agent's list could only be narrowed by whole providers or families (Visible). settings.HiddenModels holds, by agent, the catalog entries taken out, after Visible: a deny list, so a new model is shown. provider.CatalogFor drops them, so the gateway's /models, the agents' files and pickers and Codex's catalog all follow in one place, and catalog.Touched rewrites the agents' files; Codex's own ChatGPT models are dropped from the backend's list too (CodexNativeHidden), and the list's ETag and models_cache tag carry them (CodexListTag) so Codex asks again. The model an agent is set to is marked 在用 and never taken out, on the server as well. The list groups routing groups first, then providers with their logos and on/total, a long group folded; search, 全部/已显示, a group's 全部显示/全部隐藏 on hover, 恢复全部显示; a click on a row applies at once, Esc or a click elsewhere closes it, and nothing scrolls. A renamed provider's hidden models follow it. Tested with agent-models.test.cjs in Chromium and WebKit, English and Chinese; TestHiddenModels (provider), TestCodexModelListHidesOnAgentsPage (gateway) and TestAgentModelsAPI (gui) in sandbox HOMEs. click-scroll, panel-fold, old-webkit, model-pick, menu-scroll, add-button pass; GOOS=linux go vet, linux and windows builds, go test -tags nogui ./... pass. Not tried in the real Wails window or against a real Codex.

This commit is contained in:
yetone committed 2026-09-30 12:44:59 +08:00
1 parent 98d1674a52
commit 3df75514b9
17 files changed
+975 -10

No files matched your search

+4
View File
@@ -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
+1 -1
View File
@@ -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:
+14 -2
View File
@@ -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()))
}
}
+54
View File
@@ -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.
+136
View File
@@ -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)})
})
}
+77
View File
@@ -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)
}
}
+7
View File
@@ -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()
+57
View File
@@ -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; }
+239 -2
View File
@@ -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 = '<svg viewBox="0 0 16 16" width="12" height="12"><circle cx="7" cy="7" r="4.5" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="m10.5 10.5 3 3" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>';
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"));
+14
View File
@@ -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} 个已隐藏)",
+10
View File
@@ -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.
+166
View File
@@ -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, []);
});
}
}
+31
View File
@@ -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
+86
View File
@@ -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))
}
}
+9 -1
View File
@@ -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 {
+66 -4
View File
@@ -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.
+4
View File
@@ -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, "<provider>/<model>"
// 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 "<provider
// id>/<model id>": agents, the gateway's model list and magpie itself
// show them for the vendor's (see provider.SetModelName).