diff --git a/internal/codexcat/codexcat.go b/internal/codexcat/codexcat.go index 576ff67c..f91ca9d6 100644 --- a/internal/codexcat/codexcat.go +++ b/internal/codexcat/codexcat.go @@ -49,67 +49,69 @@ func Catalog(ms []catalog.Model) []byte { return b } +// level, tier and model are an entry of models.json as Entries writes it. +type level struct { + Effort string `json:"effort"` + Description string `json:"description"` +} +type tier struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` +} +type model struct { + Slug string `json:"slug"` + DisplayName string `json:"display_name"` + Description string `json:"description"` + Instructions string `json:"base_instructions"` + DefaultEffort *string `json:"default_reasoning_level"` + Efforts []level `json:"supported_reasoning_levels"` + Shell string `json:"shell_type"` + Visibility string `json:"visibility"` + InAPI bool `json:"supported_in_api"` + Priority int `json:"priority"` + Verbosity bool `json:"support_verbosity"` + DefVerbosity *string `json:"default_verbosity"` + ApplyPatch string `json:"apply_patch_tool_type"` + Truncation struct { + Mode string `json:"mode"` + Limit int `json:"limit"` + } `json:"truncation_policy"` + Tools []string `json:"experimental_supported_tools"` + Modalities []string `json:"input_modalities"` + Context *int `json:"context_window,omitempty"` + // the model's whole window when Context is the working one + // (settings.Working): Codex's model_context_window may raise it + // that far, as it does OpenAI's own models' + MaxContext *int `json:"max_context_window,omitempty"` + Tiers []tier `json:"service_tiers"` + // Without the search, Codex puts every MCP tool's schema (a + // ChatGPT sign-in's apps' among them) in every request, 190K + // tokens before the first word (#258); with it, they are named in + // tool_search's description and handed over when searched for, + // as Codex does for its own models. Magpie serves the search to + // any model as a function (gateway/toolsearch.go). Code mode and + // Responses Lite stay off: the one has the model write JavaScript + // against Codex's tools, the other moves the tools and + // instructions into the input, neither for a model not trained on + // them. + SearchTool bool `json:"supports_search_tool"` + // Required from Codex 0.147 (#298: without it the whole catalog + // fails to load); later Codex ask for parallel calls whatever it + // says, so it says what they do. + Parallel bool `json:"supports_parallel_tool_calls"` + // "v1" only with settings.CodexAgentsV1, on an OpenAI model's + // entry (see V1); "v2" on a model offering Ultra that no ChatGPT + // account answers for (catalog.Model.AgentsV2), as Codex's own + // entry for it says: Ultra hands work to Codex's agents in V2 + // alone, and a magpie-served lead writes their tasks as text. + MultiAgent string `json:"multi_agent_version,omitempty"` +} + // Entries renders models as models.json entries, ranked after the first // `after`. Only fields Codex requires or that change behaviour are set; the // rest take Codex's defaults. func Entries(ms []catalog.Model, after int) []any { - type level struct { - Effort string `json:"effort"` - Description string `json:"description"` - } - type tier struct { - ID string `json:"id"` - Name string `json:"name"` - Description string `json:"description"` - } - type model struct { - Slug string `json:"slug"` - DisplayName string `json:"display_name"` - Description string `json:"description"` - Instructions string `json:"base_instructions"` - DefaultEffort *string `json:"default_reasoning_level"` - Efforts []level `json:"supported_reasoning_levels"` - Shell string `json:"shell_type"` - Visibility string `json:"visibility"` - InAPI bool `json:"supported_in_api"` - Priority int `json:"priority"` - Verbosity bool `json:"support_verbosity"` - DefVerbosity *string `json:"default_verbosity"` - ApplyPatch string `json:"apply_patch_tool_type"` - Truncation struct { - Mode string `json:"mode"` - Limit int `json:"limit"` - } `json:"truncation_policy"` - Tools []string `json:"experimental_supported_tools"` - Modalities []string `json:"input_modalities"` - Context *int `json:"context_window,omitempty"` - // the model's whole window when Context is the working one - // (settings.Working): Codex's model_context_window may raise it - // that far, as it does OpenAI's own models' - MaxContext *int `json:"max_context_window,omitempty"` - Tiers []tier `json:"service_tiers"` - // Without the search, Codex puts every MCP tool's schema (a - // ChatGPT sign-in's apps' among them) in every request, 190K - // tokens before the first word (#258); with it, they are named in - // tool_search's description and handed over when searched for, - // as Codex does for its own models. Magpie serves the search to - // any model as a function (gateway/toolsearch.go). Code mode and - // Responses Lite stay off: the one has the model write JavaScript - // against Codex's tools, the other moves the tools and - // instructions into the input, neither for a model not trained on - // them. - SearchTool bool `json:"supports_search_tool"` - // Required from Codex 0.147 (#298: without it the whole catalog - // fails to load); later Codex ask for parallel calls whatever it - // says, so it says what they do. - Parallel bool `json:"supports_parallel_tool_calls"` - // "v1" only with settings.CodexAgentsV1, on an OpenAI model's - // entry (see V1); "v2" on a model offering Ultra that no ChatGPT - // account answers for (catalog.Model.AgentsV2), as Codex's own - // entry for it says: Ultra hands work to Codex's agents in V2 - // alone, and a magpie-served lead writes their tasks as text. - MultiAgent string `json:"multi_agent_version,omitempty"` - } own := CacheEntries() v1 := V1() work := settings.Load() @@ -165,11 +167,59 @@ func Entries(ms []catalog.Model, after int) []any { d := DefaultEffort(m.Efforts) e.DefaultEffort = &d } - entries = append(entries, e) + entries = append(entries, &e) } return entries } +// Order ranks entries — Codex's own, as the backend gives them, and +// magpie's from Entries — in the order the user put them in (#855): Codex +// lists its models by priority, lowest first. The ones at names go first, +// by their place there; the others after them, as they stood. The slice is +// put in that order too. +func Order(entries []any, at map[string]int) { + type ranked struct { + i, by, was int + } + rs := make([]ranked, len(entries)) + for i, e := range entries { + slug, was := "", i + switch o := e.(type) { + case map[string]any: + slug, _ = o["slug"].(string) + switch p := o["priority"].(type) { + case float64: + was = int(p) + case int: + was = p + } + case *model: + slug, was = o.Slug, o.Priority + } + by := len(at) + if n, ok := at[slug]; ok { + by = n + } + rs[i] = ranked{i, by, was} + } + slices.SortStableFunc(rs, func(a, b ranked) int { + if a.by != b.by { + return a.by - b.by + } + return a.was - b.was + }) + in := slices.Clone(entries) + for n, r := range rs { + switch o := in[r.i].(type) { + case map[string]any: + o["priority"] = n + 1 + case *model: + o.Priority = n + 1 + } + entries[n] = in[r.i] + } +} + // CacheEntries is Codex's own models, as models_cache.json describes them // for the ChatGPT account it last asked with, by slug. func CacheEntries() map[string]map[string]any { diff --git a/internal/gateway/codex_backend.go b/internal/gateway/codex_backend.go index a8127d58..964bc7de 100644 --- a/internal/gateway/codex_backend.go +++ b/internal/gateway/codex_backend.go @@ -791,7 +791,11 @@ func (s *Server) codexModels(w http.ResponseWriter, r *http.Request) { ms := provider.CodexListed() // the list is the backend's and magpie's, and so is its ETag w.Header().Set("ETag", codexcat.WithTag(etag, provider.CodexListTag())) - writeJSON(w, 200, map[string]any{"models": append(own, codexcat.Entries(ms, len(own)+100)...)}) + all := append(own, codexcat.Entries(ms, len(own)+100)...) + if at, ok := provider.CodexOrder(); ok { + codexcat.Order(all, at) + } + writeJSON(w, 200, map[string]any{"models": all}) } // modelsEtag is the X-Models-Etag of a backend reply as Codex should read diff --git a/internal/gateway/codex_model_order_test.go b/internal/gateway/codex_model_order_test.go new file mode 100644 index 00000000..3aeecf9a --- /dev/null +++ b/internal/gateway/codex_model_order_test.go @@ -0,0 +1,104 @@ +package gateway + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "slices" + "testing" + + "github.com/yetone/magpie/internal/provider" +) + +// Codex's /model lists the models in the order dragged on the Agents page +// (M3chD09, #855): the account's own and magpie's in one order, a model +// it doesn't name after them as it stood, and the ETag changes with the +// order so Codex asks for the list again. Without one, the backend's come +// first, as before. +func TestCodexModelListInUsersOrder(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) + } + if err := provider.Save(provider.Provider{ID: "relay", Name: "Relay", Key: "k", Anthropic: "http://127.0.0.1:1", + Models: []string{"claude-opus-5.5", "claude-sonnet-4-5"}}); 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 }() + // the slugs as Codex lists them, by priority, and the list's ETag + 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"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + slices.SortStableFunc(got.Models, func(a, b map[string]any) int { + return int(a["priority"].(float64) - b["priority"].(float64)) + }) + var out []string + for _, m := range got.Models { + out = append(out, m["slug"].(string)) + } + return out, rec.Header().Get("ETag") + } + before, tag := list() + if want := []string{"gpt-5.5", "gpt-5-codex", "relay/claude-opus-5.5", "relay/claude-sonnet-4-5"}; !slices.Equal(before, want) { + t.Fatalf("default order %v, want %v", before, want) + } + var own string + for _, e := range provider.Catalog() { + if provider.CodexOwn(e) && e.Model == "gpt-5-codex" { + own = e.ID + } + } + if own == "" { + t.Fatal("no catalog entry for the account's gpt-5-codex") + } + // magpie's sonnet first, then the account's gpt-5-codex; the rest after + if err := provider.SetModelOrder("codex", []string{"relay/claude-sonnet-4-5", own}); err != nil { + t.Fatal(err) + } + after, tag2 := list() + if want := []string{"relay/claude-sonnet-4-5", "gpt-5-codex", "gpt-5.5", "relay/claude-opus-5.5"}; !slices.Equal(after, want) { + t.Errorf("ordered %v, want %v", after, want) + } + if tag2 == tag { + t.Errorf("the ETag %q didn't change with the order", tag) + } + // the catalog magpie writes for Codex follows it too + shown, _ := provider.CatalogFor("codex") + var ids []string + for _, e := range shown { + ids = append(ids, e.ID) + } + if len(ids) < 2 || ids[0] != "relay/claude-sonnet-4-5" || ids[1] != own { + t.Errorf("CatalogFor(codex) %v", ids) + } + // another agent's list keeps magpie's own order + other, _ := provider.CatalogFor("opencode") + for i, e := range other { + if e.ID == "relay/claude-sonnet-4-5" && i == 0 { + t.Errorf("opencode's list took Codex's order: %v", other) + } + } + // none puts the backend's first again + if err := provider.SetModelOrder("codex", nil); err != nil { + t.Fatal(err) + } + if back, _ := list(); !slices.Equal(back, before) { + t.Errorf("after the order was taken away %v, want %v", back, before) + } +} diff --git a/internal/gui/agent_model_order_test.go b/internal/gui/agent_model_order_test.go new file mode 100644 index 00000000..db7b4446 --- /dev/null +++ b/internal/gui/agent_model_order_test.go @@ -0,0 +1,109 @@ +package gui + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/yetone/magpie/internal/provider" +) + +// Codex's model list goes in the order dragged on the Agents page (#855): +// the list says it can be ordered, an order comes back in it and stays in +// the settings, the hidden kept as they were; none puts magpie's back, and +// another agent's list can't be ordered. +func TestAgentModelOrderAPI(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", 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) + 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) + } + if err := provider.SetHiddenModels("codex", []string{"relay/m2"}); err != nil { + t.Fatal(err) + } + mux := http.NewServeMux() + agentModelsAPI(mux) + type reply struct { + Models []agentModelJSON + Orderable bool + Ordered bool + } + call := func(method, agent, body string) (int, reply) { + t.Helper() + w := httptest.NewRecorder() + mux.ServeHTTP(w, httptest.NewRequest(method, "/api/agent-models/"+agent, strings.NewReader(body))) + var out reply + json.Unmarshal(w.Body.Bytes(), &out) + return w.Code, out + } + ids := func(ms []agentModelJSON) (out []string) { + for _, m := range ms { + if strings.HasPrefix(m.ID, "relay/") { + out = append(out, m.ID) + } + } + return out + } + _, got := call("GET", "codex", "") + if !got.Orderable || got.Ordered { + t.Fatalf("codex: orderable %v ordered %v", got.Orderable, got.Ordered) + } + if want := []string{"relay/m1", "relay/m2", "relay/m3"}; !slices.Equal(ids(got.Models), want) { + t.Fatalf("%v, want %v", ids(got.Models), want) + } + code, got := call("POST", "codex", `{"order":["relay/m3","relay/m1"]}`) + if code != 200 || !got.Ordered { + t.Fatalf("%d %+v", code, got) + } + if want := []string{"relay/m3", "relay/m1", "relay/m2"}; !slices.Equal(ids(got.Models), want) { + t.Errorf("ordered %v, want %v", ids(got.Models), want) + } + if !slices.Equal(provider.ModelOrder("codex"), []string{"relay/m3", "relay/m1"}) { + t.Errorf("saved %v", provider.ModelOrder("codex")) + } + if !provider.HiddenModels("codex")["relay/m2"] { + t.Error("ordering showed the hidden m2") + } + if _, got = call("GET", "codex", ""); !got.Ordered || ids(got.Models)[0] != "relay/m3" { + t.Errorf("read back %+v", got) + } + // a model added later goes after those ordered + 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", "m4"}}); err != nil { + t.Fatal(err) + } + if _, got = call("GET", "codex", ""); !slices.Equal(ids(got.Models), []string{"relay/m3", "relay/m1", "relay/m2", "relay/m4"}) { + t.Errorf("with a new model %v", ids(got.Models)) + } + // hiding keeps the order + call("POST", "codex", `{"hidden":["relay/m1"]}`) + if !slices.Equal(provider.ModelOrder("codex"), []string{"relay/m3", "relay/m1"}) { + t.Errorf("hiding lost the order: %v", provider.ModelOrder("codex")) + } + // none puts magpie's back + if code, got = call("POST", "codex", `{"order":[]}`); code != 200 || got.Ordered || ids(got.Models)[0] != "relay/m1" { + t.Errorf("%d %+v", code, got) + } + if len(provider.ModelOrder("codex")) != 0 { + t.Errorf("still saved %v", provider.ModelOrder("codex")) + } + // only Codex's + if _, got = call("GET", "opencode", ""); got.Orderable { + t.Error("opencode's list says it can be ordered") + } + if code, _ = call("POST", "opencode", `{"order":["relay/m3"]}`); code == 200 { + t.Error("opencode's list took an order") + } + if len(provider.ModelOrder("opencode")) != 0 { + t.Errorf("opencode's order saved: %v", provider.ModelOrder("opencode")) + } +} diff --git a/internal/gui/agent_models.go b/internal/gui/agent_models.go index 35124738..73612c05 100644 --- a/internal/gui/agent_models.go +++ b/internal/gui/agent_models.go @@ -3,6 +3,7 @@ package gui import ( "cmp" "encoding/json" + "errors" "net/http" "strings" @@ -54,6 +55,9 @@ type agentModelJSON struct { Hidden bool `json:"hidden,omitempty"` // InUse: the agent is set to it, so it can't be taken out InUse bool `json:"inUse,omitempty"` + // Own: one of Codex's own, a ChatGPT account's, which Codex lists + // ahead of magpie's until the user puts them in an order (#855) + Own bool `json:"own,omitempty"` } // takesCatalog reports whether an agent picks among magpie's catalog: some @@ -154,7 +158,7 @@ func agentModelList(a *agent.Agent) []agentModelJSON { 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)} + Hidden: off[e.ID], InUse: usedBy(a, vals, e), Own: a.ID == "codex" && provider.CodexOwn(e)} if m.Name == "" { m.Name = e.Model } @@ -172,6 +176,11 @@ func agentModelList(a *agent.Agent) []agentModelJSON { return out } +// orderable reports whether the agent's model list is put in the order the +// user drags it into: Codex's, whose /model lists the models by the +// priority magpie gives them (#855). +func orderable(id string) bool { return id == "codex" } + 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")) @@ -179,12 +188,18 @@ func agentModelsAPI(mux *http.ServeMux) { fail(rw, err) return } - writeJSON(rw, map[string]any{"models": agentModelList(a)}) + writeJSON(rw, map[string]any{"models": agentModelList(a), "orderable": orderable(a.ID), "ordered": len(provider.ModelOrder(a.ID)) > 0}) }) // 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 + // + // order, instead, is the order the agent's list puts them in (#855): + // the ones named first, as named; none puts back magpie's own mux.HandleFunc("POST /api/agent-models/{id}", func(rw http.ResponseWriter, r *http.Request) { - var in struct{ Hidden []string } + var in struct { + Hidden []string + Order *[]string + } if err := json.NewDecoder(r.Body).Decode(&in); err != nil { fail(rw, err) return @@ -194,6 +209,18 @@ func agentModelsAPI(mux *http.ServeMux) { fail(rw, err) return } + if in.Order != nil { + if !orderable(a.ID) { + fail(rw, errors.New(a.Name+"'s model list can't be put in an order")) + return + } + if err := provider.SetModelOrder(a.ID, *in.Order); err != nil { + fail(rw, err) + return + } + writeJSON(rw, map[string]any{"models": agentModelList(a), "ordered": len(provider.ModelOrder(a.ID)) > 0}) + return + } used := map[string]bool{} for _, m := range agentModelList(a) { used[m.ID] = m.InUse diff --git a/internal/gui/api.go b/internal/gui/api.go index fdeb6188..206c5e44 100644 --- a/internal/gui/api.go +++ b/internal/gui/api.go @@ -840,10 +840,10 @@ func Handler(w Windows, gw *gateway.Server) http.Handler { // its own. The per-model maps are carried whole rather than named one // by one, so a map added later is not silently dropped here. // - // HiddenModels is the other way round — keyed by agent, not by - // "/" — so it is not one of them, and belongs to the - // Agents page. - in.Visible, in.HiddenModels = cur.Visible, cur.HiddenModels + // HiddenModels and OrderedModels are the other way round — keyed by + // agent, not by "/" — so they are not among them, + // and belong to the Agents page. + in.Visible, in.HiddenModels, in.OrderedModels = cur.Visible, cur.HiddenModels, cur.OrderedModels settings.CarryPerModel(&in, &cur) in.LAN, in.LANKey = cur.LAN, cur.LANKey in.LANKeyID = cur.LANKeyID diff --git a/internal/gui/assets/app.css b/internal/gui/assets/app.css index a6065a13..464216bc 100644 --- a/internal/gui/assets/app.css +++ b/internal/gui/assets/app.css @@ -2777,6 +2777,17 @@ body.window .row.agent .who.with-models { flex-wrap: wrap; row-gap: 0; } .am-reset:hover:not(:disabled) { color: var(--accent); } .am-reset:disabled { color: var(--faint); cursor: default; } .am-dot { color: var(--faint); } +/* Codex's list in the order it shows it (#855): the whole list, dragged by + any row; its grip shows only on the row under the pointer */ +.pop.am-pop.ordering .am-search, .pop.am-pop.ordering .am-rail { display: none; } +.am-order { padding-top: 4px; } +.am-rows.am-order .am-mr { padding-left: 14px; cursor: grab; user-select: none; -webkit-user-select: none; } +.am-or .grip { flex: none; width: 6px; height: 11px; margin-right: -4px; background: radial-gradient(circle, var(--muted) .9px, transparent 1.2px) 0 0 / 3px 3.75px; opacity: 0; transition: opacity .15s; } +.am-or:is(:hover, :focus-visible, .dragging) .grip { opacity: 1; } +@media (hover: none) { .am-or .grip { opacity: 1; } } +.am-or .ix { flex: none; min-width: 16px; font-size: 11.5px; color: var(--faint); font-variant-numeric: tabular-nums; text-align: right; } +.am-order.sorting .am-or { transition: transform .22s cubic-bezier(.22, 1, .36, 1); cursor: grabbing; } +.am-order .am-or.dragging { position: relative; z-index: 2; transition: none; background: var(--pop-bg); box-shadow: 0 6px 18px rgba(20, 22, 30, .12), 0 0 0 1px var(--line); } /* ---- an agent's switch onto magpie ---------------------------------------- */ /* the rows drawn again under the line said beneath each name had WebKit's diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js index a0a4ce53..aaa8dfbf 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -2160,8 +2160,9 @@ async function openAgentModels(a, anchor, ev) { }, }; document.addEventListener("mousedown", loading.away, true); - let models; - try { models = (await api("agent-models/" + encodeURIComponent(a.id))).models; } + // an agent whose list goes in the order dragged here (Codex's, #855) + let models, orderable = false, ordered = false; + try { ({ models, orderable, ordered } = await api("agent-models/" + encodeURIComponent(a.id))); } catch (e) { if (agentModelsLoading === loading) { loading.drop(); status(e.message, "err"); } return; @@ -2185,9 +2186,10 @@ async function openAgentModels(a, anchor, ev) { 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"; + const segAll = el("button", "on", t("All")), segOn = el("button", "", t("Shown")), segOrder = el("button", "", t("Order")); + segAll.type = segOn.type = segOrder.type = "button"; seg.append(segAll, segOn); + if (orderable) seg.append(segOrder); tools.append(search, seg); const list = el("div", "am-list"); const foot = el("div", "am-foot"); @@ -2195,16 +2197,23 @@ async function openAgentModels(a, anchor, ev) { const hideAll = el("button", "am-reset am-hide", t("Hide all")); const reset = el("button", "am-reset", t("Show all")); hideAll.type = reset.type = "button"; - foot.append(el("span", "", t("New models are shown")), el("span", "sp"), hideAll, el("span", "am-dot", "·"), reset); + const unorder = el("button", "am-reset", t("Default order")); + unorder.type = "button"; + const footNote = el("span", "", t("New models are shown")); + foot.append(footNote, el("span", "sp"), hideAll, el("span", "am-dot", "·"), reset); // 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 regroup = () => { + groups.length = 0; + 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)); + }; + regroup(); const shut = new Set(groups.filter((g) => groups.length > 1 && g.models.length > 8 && !g.models.some((m) => m.inUse)).map((g) => g.name)); // with a few providers, a rail of them down the left: one picked shows // its models alone, so a long list is one click away rather than a @@ -2243,6 +2252,8 @@ async function openAgentModels(a, anchor, ev) { }; // under "Shown", one just turned off stays until the view changes let onlyShown = false, kept = new Set(); + // under "Order", the models shown, as the agent lists them + let ordering = false; const me = agentModels = { a, anchor, box, saving: Promise.resolve(), changed: false }; const save = () => { @@ -2271,7 +2282,65 @@ async function openAgentModels(a, anchor, ev) { me.saving = me.saving.then(() => api("agent-models/" + encodeURIComponent(a.id), { hidden })) .catch((e) => status(e.message, "err")); }; + // the shown models as the agent lists them: as dragged, or, before any + // drag, Codex's own (a ChatGPT account's) ahead of magpie's, as its + // /model has them + const inOrder = () => { + const shown = models.filter((m) => !m.hidden); + return ordered ? shown : [...shown.filter((m) => m.own), ...shown.filter((m) => !m.own)]; + }; + const saveOrder = (ids) => { + me.changed = true; + me.saving = me.saving.then(() => api("agent-models/" + encodeURIComponent(a.id), { order: ids })) + .catch((e) => status(e.message, "err")); + return me.saving; + }; + const moveTo = (id, to) => { + const shown = inOrder(); + const from = shown.findIndex((m) => m.id === id); + to = Math.max(0, Math.min(shown.length - 1, to)); + if (from < 0 || to === from) return false; + shown.splice(to, 0, ...shown.splice(from, 1)); + // the hidden after them, as they were; the groups keep the new order + models.splice(0, models.length, ...shown, ...models.filter((m) => m.hidden)); + for (const g of groups) g.models.sort((x, y) => models.indexOf(x) - models.indexOf(y)); + ordered = true; + saveOrder(shown.map((m) => m.id)); + return true; + }; + const drawOrder = () => { + const top = list.scrollTop; + list.replaceChildren(); + const body = el("div", "am-rows am-order"); + body.setAttribute("role", "list"); + const shown = inOrder(); + shown.forEach((m, i) => { + const r = el("div", "am-mr am-or"); + r.tabIndex = 0; + r.dataset.id = m.id; + r.setAttribute("role", "listitem"); + r.title = t("Drag to move · Alt+↑/↓ from the keyboard"); + const lg = m.group === ROUTING_GROUPS ? svg(FAN, 14, 1.5) : icon(m.logo || m.icon || "generic"); + lg.classList.add("lg"); + r.append(el("span", "grip"), el("span", "ix", String(i + 1)), lg, el("span", "n", m.name), + el("span", "x", m.group === ROUTING_GROUPS ? t(m.group) : m.group)); + r.onkeydown = (e) => { + if (!e.altKey || (e.key !== "ArrowUp" && e.key !== "ArrowDown")) return; + e.preventDefault(); + if (!moveTo(m.id, i + (e.key === "ArrowUp" ? -1 : 1))) return; + draw(); + list.querySelector(`.am-or[data-id="${CSS.escape(m.id)}"]`)?.focus({ preventScroll: true }); + }; + r.onpointerdown = (e) => dragRows(e, r, r, body, [...body.children], (to) => { moveTo(m.id, to); draw(); }); + body.append(r); + }); + list.append(body); + if (!shown.length) list.append(el("div", "am-none", t("No matches."))); + list.scrollTop = top; + unorder.disabled = !ordered; + }; const draw = () => { + if (ordering) return drawOrder(); const top = list.scrollTop; list.replaceChildren(); const words = q.value.trim().toLowerCase(); @@ -2351,16 +2420,37 @@ async function openAgentModels(a, anchor, ev) { hideAll.disabled = !models.some((m) => !m.hidden && !m.inUse); }; q.oninput = () => { list.scrollTop = 0; draw(); }; - const view = (shown) => { + const view = (shown, order = false) => { onlyShown = shown; + ordering = order; kept = new Set(); - segAll.classList.toggle("on", !shown); - segOn.classList.toggle("on", shown); + segAll.classList.toggle("on", !shown && !order); + segOn.classList.toggle("on", shown && !order); + segOrder.classList.toggle("on", order); + // ordering is of the whole list: no search, no provider picked, and the + // foot puts magpie's own order back rather than showing or hiding + box.classList.toggle("ordering", order); + footNote.textContent = order ? t("Drag to put them in the order {agent} lists them; new models go last", { agent: a.name }) : t("New models are shown"); + if (order) foot.replaceChildren(footNote, el("span", "sp"), unorder); + else foot.replaceChildren(footNote, el("span", "sp"), hideAll, el("span", "am-dot", "·"), reset); list.scrollTop = 0; draw(); }; segAll.onclick = () => view(false); segOn.onclick = () => view(true); + segOrder.onclick = () => view(false, true); + unorder.onclick = async () => { + unorder.disabled = true; + const r = await saveOrder([]); + ordered = false; + // magpie's own order, as the list comes back + if (r?.models) { + const was = new Map(models.map((m) => [m.id, m])); + models.splice(0, models.length, ...r.models.map((m) => was.get(m.id) || m)); + regroup(); + } + draw(); + }; reset.onclick = () => { for (const m of models) m.hidden = false; save(); diff --git a/internal/gui/assets/i18n.js b/internal/gui/assets/i18n.js index 59c72e21..2993a7b1 100644 --- a/internal/gui/assets/i18n.js +++ b/internal/gui/assets/i18n.js @@ -129,6 +129,10 @@ const I18N = { "Current": "在用", "{agent} is set to it, so it stays": "{agent} 正在使用它,不能隐藏", "New models are shown": "新模型默认显示", + "Order": "排序", + "Default order": "恢复默认顺序", + "Drag to move · Alt+↑/↓ from the keyboard": "拖动调整位置 · 键盘用 Alt+↑/↓", + "Drag to put them in the order {agent} lists them; new models go last": "拖动排列 {agent} 列表中的顺序;新模型排在最后", "{n} hidden": "已隐藏 {n} 个", "{n} hidden agents": "已隐藏的 Agent({n} 个)", "Show {n} more ({h} hidden)": "显示其余 {n} 个(含 {h} 个已隐藏)", @@ -3250,6 +3254,10 @@ const I18N = { "Current": "使用中", "{agent} is set to it, so it stays": "{agent} で使用中のため非表示にできません", "New models are shown": "新しいモデルは表示されます", + "Order": "並び順", + "Default order": "既定の順に戻す", + "Drag to move · Alt+↑/↓ from the keyboard": "ドラッグで移動 · キーボードは Alt+↑/↓", + "Drag to put them in the order {agent} lists them; new models go last": "ドラッグで {agent} の一覧の並び順を変更。新しいモデルは末尾に追加されます", "{n} hidden": "{n} 件非表示", "{n} hidden agents": "非表示のエージェント({n})", "Show {n} more ({h} hidden)": "さらに {n} 件表示(うち {h} 件は非表示)", @@ -6352,6 +6360,10 @@ const I18N = { "Current": "Aktuell", "{agent} is set to it, so it stays": "{agent} verwendet es, daher bleibt es", "New models are shown": "Neue Modelle werden angezeigt", + "Order": "Reihenfolge", + "Default order": "Standardreihenfolge", + "Drag to move · Alt+↑/↓ from the keyboard": "Ziehen zum Verschieben · Alt+↑/↓ per Tastatur", + "Drag to put them in the order {agent} lists them; new models go last": "Ziehen, um die Reihenfolge in {agent} festzulegen; neue Modelle kommen ans Ende", "{n} hidden": "{n} ausgeblendet", "{n} hidden agents": "{n} ausgeblendete Agenten", "Show {n} more ({h} hidden)": "{n} weitere anzeigen ({h} ausgeblendet)", diff --git a/internal/gui/settings_keep_test.go b/internal/gui/settings_keep_test.go index 8d3d9c5f..8184feed 100644 --- a/internal/gui/settings_keep_test.go +++ b/internal/gui/settings_keep_test.go @@ -112,6 +112,7 @@ func TestSettingsSaveKeepsWhatItDoesNotSend(t *testing.T) { AgentsShown: []string{"pi"}, Visible: map[string][]string{"claude": {"anthropic"}}, HiddenModels: map[string][]string{"claude": {"p/m"}, "codex": {"p/n", "group/g"}}, + OrderedModels: map[string][]string{"codex": {"group/g", "p/n"}}, ModelNames: map[string]string{"p/m": "Mine"}, ModelEfforts: map[string][]string{"p/m": {"low"}}, ModelImages: map[string]bool{"p/m": true}, diff --git a/internal/gui/tests/codex-model-order.test.cjs b/internal/gui/tests/codex-model-order.test.cjs new file mode 100644 index 00000000..e011b200 --- /dev/null +++ b/internal/gui/tests/codex-model-order.test.cjs @@ -0,0 +1,165 @@ +// Run with Node's test runner and Playwright on the module path; see README.md. +// Codex's /model in the user's order (M3chD09, #855): its model list's +// "Order" lists the models shown as Codex does, the account's own first, +// and a drag or Alt+↑/↓ puts one elsewhere, saved at once; "Default order" +// puts magpie's back. The hidden stay out, and nothing scrolls the page. +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"); +// magpie's first, as the catalog has them; Codex's own after +const models = () => [ + { id: "relay/claude-opus-5.5", name: "claude-opus-5.5", group: "Relay", icon: "anthropic" }, + { id: "relay/claude-sonnet-4-5", name: "claude-sonnet-4-5", group: "Relay", icon: "anthropic" }, + { id: "relay/old", name: "old", group: "Relay", icon: "anthropic", hidden: true }, + { id: "codex-acc/gpt-5.5", name: "gpt-5.5", group: "ChatGPT", icon: "openai", own: true, inUse: true }, + { id: "codex-acc/gpt-5-codex", name: "gpt-5-codex", group: "ChatGPT", icon: "openai", own: true }, +]; + +function fixture(lang, orderable) { + const first = models(); + let list = models(); + const posts = []; + const state = () => ({ + agents: [{ + id: "codex", name: "Codex", path: "/test/config.toml", icon: "codex-color", wired: true, + fields: [{ key: "model", label: "model", value: "gpt-5.5", options: [{ value: "gpt-5.5", label: "gpt-5.5", ref: "codex-acc/gpt-5.5" }] }], + models: { shown: 4, listed: 5, by: [{ name: "Relay", icon: "anthropic", n: 2 }, { name: "ChatGPT", icon: "openai", n: 2 }] }, + }], + 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 { order } = req.postDataJSON(); + posts.push(order); + const at = (m) => order.indexOf(m.id) < 0 ? order.length : order.indexOf(m.id); + list = order.length ? [...first].sort((x, y) => at(x) - at(y)) : first; + return json({ models: list, ordered: order.length > 0 }); + } + return json({ models: list, orderable, ordered: false }); + } + 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: "1 hidden", order: "Order", unorder: "Default order", note: "Drag to put them in the order Codex lists them; new models go last" }, + zh: { entry: "已隐藏 1 个", order: "排序", unorder: "恢复默认顺序", note: "拖动排列 Codex 列表中的顺序;新模型排在最后" }, +}; + +for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) { + for (const lang of ["en", "zh"]) { + test(`${engine} ${lang}: Codex's models in the user's order`, 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, true); + 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}-codex-order.png`) }); + } + await browser.close(); + }); + await page.goto("http://magpie.test/"); + const row = page.locator('.row.agent[data-id="codex"]'); + await row.locator(".ag-link").click(); + const entry = row.locator(".ag-exp .ag-chips .ag-quiet"); + await entry.waitFor(); + await page.waitForFunction(() => document.getAnimations().length === 0); + 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(); + + const seg = pop.locator(".am-seg button", { hasText: w.order }); + assert.equal(await seg.count(), 1, "Codex's list has an Order"); + await seg.click(); + assert(await seg.evaluate((e) => e.classList.contains("on"))); + assert.deepEqual(await scroll(), was, "nothing scrolls"); + // the shown as Codex lists them: its own first, the hidden left out + const names = async () => (await pop.locator(".am-or .n").allInnerTexts()).map((s) => s.trim()); + assert.deepEqual(await names(), ["gpt-5.5", "gpt-5-codex", "claude-opus-5.5", "claude-sonnet-4-5"]); + assert.deepEqual(await pop.locator(".am-or .ix").allInnerTexts(), ["1", "2", "3", "4"]); + assert(!(await pop.locator(".am-search").isVisible()), "no search while ordering"); + assert(!(await pop.locator(".am-rail").isVisible()), "no providers' rail while ordering"); + assert.equal(await pop.locator(".am-foot").innerText().then((s) => s.includes(w.note)), true); + const unorder = pop.locator(".am-foot .am-reset", { hasText: w.unorder }); + assert(await unorder.isDisabled(), "nothing to put back yet"); + // the grip shows on hover, not before + const grip = pop.locator(".am-or").nth(2).locator(".grip"); + assert.equal(await grip.evaluate((e) => getComputedStyle(e).opacity), "0"); + + // Alt+↓ moves one down, saved at once, and keeps the keys + await pop.locator(".am-or").first().focus(); + await page.keyboard.press("Alt+ArrowDown"); + await page.waitForTimeout(150); + assert.deepEqual(fx.posts.at(-1), ["codex-acc/gpt-5-codex", "codex-acc/gpt-5.5", "relay/claude-opus-5.5", "relay/claude-sonnet-4-5"]); + assert.deepEqual(await names(), ["gpt-5-codex", "gpt-5.5", "claude-opus-5.5", "claude-sonnet-4-5"]); + assert.equal(await page.evaluate(() => document.activeElement?.dataset.id), "codex-acc/gpt-5.5"); + assert(!(await unorder.isDisabled())); + + // a drag puts the last first + const rows = pop.locator(".am-or"); + const last = await rows.nth(3).boundingBox(), top = await rows.nth(0).boundingBox(); + await page.mouse.move(last.x + 20, last.y + last.height / 2); + await page.mouse.down(); + for (let i = 1; i <= 12; i++) await page.mouse.move(last.x + 20, last.y + last.height / 2 - (last.y - top.y + 10) * i / 12); + await page.mouse.up(); + await page.waitForTimeout(250); + assert.deepEqual(fx.posts.at(-1), ["relay/claude-sonnet-4-5", "codex-acc/gpt-5-codex", "codex-acc/gpt-5.5", "relay/claude-opus-5.5"]); + assert.deepEqual(await names(), ["claude-sonnet-4-5", "gpt-5-codex", "gpt-5.5", "claude-opus-5.5"]); + assert.deepEqual(await scroll(), was, "nothing scrolls"); + + // All shows every one again, the hidden among them, in that order + await pop.locator(".am-seg button").first().click(); + assert(await pop.locator(".am-search").isVisible()); + await seg.click(); + assert.deepEqual(await names(), ["claude-sonnet-4-5", "gpt-5-codex", "gpt-5.5", "claude-opus-5.5"]); + + // Default order puts magpie's back + await unorder.click(); + await page.waitForTimeout(150); + assert.deepEqual(fx.posts.at(-1), []); + assert.deepEqual(await names(), ["gpt-5.5", "gpt-5-codex", "claude-opus-5.5", "claude-sonnet-4-5"]); + assert(await unorder.isDisabled()); + assert.deepEqual(await scroll(), was, "nothing scrolls"); + assert.deepEqual(errors, []); + }); + } + test(`${engine}: a list that can't be ordered has no Order`, async (t) => { + const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" })); + const page = await browser.newPage({ viewport: { width: 1000, height: 700 } }); + page.setDefaultTimeout(5000); + t.after(() => browser.close()); + await page.route("**/*", fixture("en", false).serve); + await page.goto("http://magpie.test/"); + const row = page.locator('.row.agent[data-id="codex"]'); + await row.locator(".ag-link").click(); + await row.locator(".ag-exp .ag-chips .ag-quiet").click(); + const pop = page.locator(".am-pop:not(.leaving)"); + await pop.waitFor(); + assert.equal(await pop.locator(".am-seg button").count(), 2); + }); +} diff --git a/internal/provider/codex_models.go b/internal/provider/codex_models.go index 805eef88..c4203b21 100644 --- a/internal/provider/codex_models.go +++ b/internal/provider/codex_models.go @@ -371,15 +371,46 @@ func CodexNativeHidden() map[string]bool { } out := map[string]bool{} for _, e := range Catalog() { - if off[e.ID] && e.Group == "" && e.Provider.Account != nil && e.Provider.Account.Agent == "codex" { + if off[e.ID] && CodexOwn(e) { out[e.Model] = true } } return out } +// CodexOrder is where each model of Codex's list goes when the user put +// them in an order of their own on the Agents page (#855), by the slug +// Codex knows it by — a ChatGPT account's own by its bare one, as the +// backend lists it — and whether they did. A model it doesn't name keeps +// its place after them. +func CodexOrder() (map[string]int, bool) { + order := ModelOrder("codex") + if len(order) == 0 { + return nil, false + } + at := make(map[string]int, len(order)) + for i, id := range order { + at[id] = i + } + out := map[string]int{} + for _, e := range Catalog() { + i, ok := at[e.ID] + if !ok { + continue + } + slug := e.ID + if CodexOwn(e) { + slug = e.Model + } + if cur, seen := out[slug]; !seen || i < cur { + out[slug] = i + } + } + return out, true +} + // CodexListTag names the list Codex is handed, for its ETag: magpie's models, -// the account's own taken out of it, the windows set on them, and whether its OpenAI models say +// the account's own taken out of it, the order they are in, the windows set on them, and whether its OpenAI models say // multi-agent V1 (settings.CodexAgentsV1), so any of them changing has // Codex ask for the list again. func CodexListTag() string { @@ -388,6 +419,10 @@ func CodexListTag() string { for _, slug := range off { ms = append(ms, catalog.Model{ID: "-" + slug}) } + // and the order the user put them in, the account's own among them + for _, id := range ModelOrder("codex") { + ms = append(ms, catalog.Model{ID: "^" + id}) + } ms = append(ms, codexWindowsTag()...) return codexcat.PolicyTag(codexcat.Tag(ms)) } @@ -465,7 +500,7 @@ func codexListed(shown []Entry, members func(id string) []Member) []catalog.Mode labels := Labels(shown) seen := described() for i, e := range shown { - if e.Group == "" && e.Provider.Account != nil && e.Provider.Account.Agent == "codex" { + if CodexOwn(e) { continue } m := catalog.Model{ID: e.ID, Name: labels[i], Efforts: e.Efforts, Images: e.Images || seen, Context: e.Context, AgentsV2: e.AgentsV2} diff --git a/internal/provider/modelprefs.go b/internal/provider/modelprefs.go index 93e6859c..3a61ea2b 100644 --- a/internal/provider/modelprefs.go +++ b/internal/provider/modelprefs.go @@ -577,11 +577,14 @@ func renameModelPrefs(s *settings.Settings, from, to string) bool { // the models a user has hidden from a picker are keyed by provider as // well, and are not one of the per-model preference maps: they say // which models are shown, not what a model is called or costs + // (and so is the order they are listed in) 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 + for _, m := range []map[string][]string{s.HiddenModels, s.OrderedModels} { + for _, ids := range m { + for i, id := range ids { + if rest, ok := strings.CutPrefix(id, from+"/"); ok { + ids[i], hidden = to+"/"+rest, true + } } } } diff --git a/internal/provider/visible.go b/internal/provider/visible.go index cc99f87f..aaef2a98 100644 --- a/internal/provider/visible.go +++ b/internal/provider/visible.go @@ -77,7 +77,7 @@ func ListedFor(agent string) (listed, kept []Entry) { all := Catalog() names, ok := VisibleTo(agent) if !ok { - return all, nil + return InOrder(agent, all), nil } for _, e := range all { if Shows(names, e) { @@ -86,7 +86,7 @@ func ListedFor(agent string) (listed, kept []Entry) { kept = append(kept, e) } } - return listed, kept + return InOrder(agent, listed), kept } // HiddenModels are the ids of the entries taken out of agent's lists. @@ -150,3 +150,75 @@ func Families() []string { slices.Sort(out) return out } + +// ModelOrder is the order the user put agent's models in (OrderedModels), +// by entry id; none when they never did. +func ModelOrder(agent string) []string { + return slices.Clone(heldSettings().OrderedModels[strings.ToLower(agent)]) +} + +// SetModelOrder lists agent's models in this order, by entry id: the ones +// it names first, as it names them, then every other as before; none puts +// them back in magpie's own order. Only agent's lists follow it. +func SetModelOrder(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) + } + } + s := settings.Load() + if slices.Equal(s.OrderedModels[agent], keep) { + return nil + } + if len(keep) == 0 { + delete(s.OrderedModels, agent) + } else { + if s.OrderedModels == nil { + s.OrderedModels = map[string][]string{} + } + s.OrderedModels[agent] = keep + } + if err := settings.Save(s); err != nil { + return err + } + catalog.Touched() + return nil +} + +// InOrder is es in agent's order: the entries ModelOrder names first, as it +// names them, then the rest as they come — a model added since goes after. +// Codex's own, a ChatGPT account's models, lead the rest, as Codex's list +// has them before magpie's. +func InOrder(agent string, es []Entry) []Entry { + order := ModelOrder(agent) + if len(order) == 0 { + return es + } + at := make(map[string]int, len(order)) + for i, id := range order { + at[id] = i + } + out := slices.Clone(es) + rank := func(e Entry) int { + if i, ok := at[e.ID]; ok { + return i + } + if strings.EqualFold(agent, "codex") && !CodexOwn(e) { + return len(order) + 1 + } + return len(order) + } + slices.SortStableFunc(out, func(a, b Entry) int { return rank(a) - rank(b) }) + return out +} + +// CodexOwn reports whether e is one of a ChatGPT account's own models, +// which the ChatGPT backend lists to Codex by its bare slug (e.Model). +func CodexOwn(e Entry) bool { + return e.Group == "" && e.Provider.Account != nil && e.Provider.Account.Agent == "codex" +} diff --git a/internal/settings/settings.go b/internal/settings/settings.go index 2635879f..fc55dca3 100644 --- a/internal/settings/settings.go +++ b/internal/settings/settings.go @@ -271,6 +271,10 @@ type Settings struct { // 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"` + // OrderedModels is the order an agent's lists put its models in, by + // agent id and then entry id, as the user dragged them on the Agents + // page (Codex's, #855): the ones named first, any other after them. + OrderedModels map[string][]string `json:"orderedModels,omitempty"` // The three maps below, and every one added beside them, are the // per-model ones: a field named Model* whose type is a map[string]X,