agents: Codex's /model lists its models in the order dragged on the Agents page (M3chD09, #855) — Codex lists the models by the priority magpie gives them: a ChatGPT account's own first, as the backend has them, then magpie's in the catalog's order, with no way to change it. Codex's model list (Pick on its row) now has a third view, Order: the models shown as Codex lists them, numbered, each dragged elsewhere (its grip shows on hover) or moved with Alt+↑/↓; it is saved at once (settings orderedModels, by agent and entry id), and the account's own models and magpie's go in one order. A model not in the order (one added later) goes after those that are, as it stood; the hidden stay hidden and out of the order view, hiding keeps the order, and a provider renamed renames its ids in the order too. Default order puts magpie's back. Codex gets it at once: signed in to ChatGPT, the gateway's /models gives the backend's and magpie's models priorities 1..n in that order and the list's ETag carries the order so Codex asks again; not signed in, the catalog file magpie writes for Codex (ListedFor → InOrder) is rewritten in it. Only Codex's list can be ordered; base_instructions are untouched. zh, ja, de strings. TestCodexModelListInUsersOrder (gateway: default order, an order across the account's and magpie's, the ETag changing, CatalogFor, opencode's untouched, none restoring it) and TestAgentModelOrderAPI (gui: orderable, an order read back, a new model last, hidden kept, hiding keeping it, reset, opencode refused) fail on the old code (no SetModelOrder/ModelOrder); codex-model-order.test.cjs (WebKit, en and zh: Order lists the own first and leaves the hidden out, Alt+↓ and a drag post the order, Default order posts none, nothing scrolls; a list that can't be ordered has no Order) fails on the old code (no Order). agent-models, agent-models-all-hidden, agent-models-loading, agent-model-groups, click-scroll, gui-ja and gui-de pass on WebKit. go vet, builds (darwin, GOOS=linux nogui, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass.

This commit is contained in:
yetone committed 2026-10-05 11:52:34 +08:00
1 parent bd456fa51e
commit 273be22cac
15 files changed
+776 -89

No files matched your search

+108 -58
View File
@@ -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 {
+5 -1
View File
@@ -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
+104
View File
@@ -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)
}
}
+109
View File
@@ -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"))
}
}
+30 -3
View File
@@ -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
+4 -4
View File
@@ -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
// "<provider>/<model>" — 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 "<provider>/<model>" — 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
+11
View File
@@ -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
+104 -14
View File
@@ -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();
+12
View File
@@ -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)",
+1
View File
@@ -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},
@@ -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);
});
}
+38 -3
View File
@@ -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}
+7 -4
View File
@@ -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
}
}
}
}
+74 -2
View File
@@ -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"
}
+4
View File
@@ -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,