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:
1 parent
bd456fa51e
commit
273be22cac
15 files changed
+776
-89
No files matched your search
+108
-58
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user