From 4547dafe957641bd9e1cb3628264f49df17d2987 Mon Sep 17 00:00:00 2001 From: yetone Date: Thu, 1 Oct 2026 22:56:52 +0800 Subject: [PATCH] =?UTF-8?q?models:=20Provider=20in=20model=20names=20has?= =?UTF-8?q?=20a=20third=20way,=20a=20name=20the=20user=20gave=20used=20jus?= =?UTF-8?q?t=20as=20they=20wrote=20it=20(#92=20marsxxl:=20v0.1.591=20?= =?UTF-8?q?=E7=9A=84=20Codex=20=E4=B8=8B=E6=8B=89=E9=87=8C=E8=87=AA?= =?UTF-8?q?=E5=AE=9A=E4=B9=89=E5=90=8D=E7=A7=B0=E5=8F=88=E5=B8=A6=E4=B8=8A?= =?UTF-8?q?=E4=BA=86=20=C2=B7=20WorkBuddy=20/=20=C2=B7=20Claude=20Code)=20?= =?UTF-8?q?=E2=80=94=20559ce021=20(#203)=20had=20a=20custom=20name=20keep?= =?UTF-8?q?=20"=20=C2=B7=20"=20after=20it=20like=20any=20other,?= =?UTF-8?q?=20so=20"Opus=205.5"=20read=20"Opus=205.5=20=C2=B7=20Claude=20C?= =?UTF-8?q?ode"=20in=20every=20list=20Labels=20feeds=20(Codex's=20model=5F?= =?UTF-8?q?catalog=5Fjson=20and=20the=20gateway's=20Codex=20/models,=20Pi'?= =?UTF-8?q?s,=20OpenCode's,=20ZCode's,=20OMP's,=20Muse's),=20while=20#92?= =?UTF-8?q?=20had=20been=20promised=20the=20name=20as=20written.=20Both=20?= =?UTF-8?q?are=20kept:=20the=20setting=20is=20now=20Off=20/=20Not=20on=20n?= =?UTF-8?q?ames=20I=20set=20(=E8=87=AA=E5=AE=9A=E4=B9=89=E5=90=8D=E7=A7=B0?= =?UTF-8?q?=E4=B8=8D=E5=B8=A6=E4=BE=9B=E5=BA=94=E5=95=86)=20/=20On.=20On?= =?UTF-8?q?=20stays=20the=20default=20and=20keeps=20#203's=20behaviour,=20?= =?UTF-8?q?so=20nobody's=20list=20changes=20unasked;=20own=20leaves=20a=20?= =?UTF-8?q?name=20the=20user=20gave=20as=20it=20is=20and=20a=20vendor's=20?= =?UTF-8?q?name=20with=20its=20provider's;=20off=20is=20#335's=20plain=20n?= =?UTF-8?q?ames.=20Stored=20as=20plainOwnNames=20beside=20plainNames,=20so?= =?UTF-8?q?=20a=20settings.json=20of=20before=20reads=20as=20it=20did=20(p?= =?UTF-8?q?lainNames:=20true=20is=20off).=20POST=20/api/settings/plain-nam?= =?UTF-8?q?es=20takes=20mode=20on|own|off=20(a=20body=20of=20on=20alone=20?= =?UTF-8?q?still=20works),=20magpie=20model=20suffix=20on|own|off,=20a=20S?= =?UTF-8?q?ettings=20save=20keeps=20it,=20and=20/v1/models'=20magpie=5Flab?= =?UTF-8?q?el=20now=20follows=20the=20setting=20through=20Labels=20as=20th?= =?UTF-8?q?e=20agents'=20lists=20do.=20The=20row's=20hint=20is=20one=20lin?= =?UTF-8?q?e=20that=20fits=20(en,=20zh),=20and=20its=20zh=20name=20says=20?= =?UTF-8?q?=E4=BE=9B=E5=BA=94=E5=95=86=20as=20the=20rest=20of=20the=20app.?= =?UTF-8?q?=20Prompts=20and=20headers=20are=20unchanged.=20TestSuffixMode,?= =?UTF-8?q?=20TestSuffixModesReachAgentFiles=20(Codex=20model=5Fcatalog=5F?= =?UTF-8?q?json=20and=20Pi=20models.json),=20TestModelsListSuffixModes=20(?= =?UTF-8?q?/v1/models=20magpie=5Flabel)=20and=20model-suffix-own.test.cjs?= =?UTF-8?q?=20(Chromium=20and=20WebKit,=20en=20and=20zh)=20fail=20without?= =?UTF-8?q?=20the=20change;=20TestModelName=20and=20TestModelNameAndLevels?= =?UTF-8?q?ReachAgentFiles=20keep=20#203's=20assertions.=20go=20vet,=20go?= =?UTF-8?q?=20build=20-tags=20nogui=20(darwin,=20GOOS=3Dlinux),=20GOOS=3Dw?= =?UTF-8?q?indows=20go=20build,=20go=20test=20-tags=20nogui=20./...=20pass?= =?UTF-8?q?=20(TestConfiguredModelsAdvertiseImageInput=20load-flaky,=20pas?= =?UTF-8?q?ses=20alone);=20plain-names,=20click-scroll,=20old-webkit=20pas?= =?UTF-8?q?s.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/agent/suffix_mode_test.go | 82 +++++++++++++ internal/gateway/custom_name_label_test.go | 69 +++++++++++ internal/gateway/gateway.go | 10 +- internal/gateway/muse.go | 2 +- internal/gui/api.go | 15 ++- internal/gui/assets/app.js | 10 +- internal/gui/assets/i18n.js | 5 +- internal/gui/assets/index.html | 2 +- internal/gui/settings_keep_test.go | 1 + internal/gui/tests/README.md | 10 +- internal/gui/tests/model-suffix-own.test.cjs | 116 +++++++++++++++++++ internal/gui/tests/plain-names.test.cjs | 20 ++-- internal/provider/modelprefs.go | 61 ++++++++-- internal/provider/suffix_mode_test.go | 85 ++++++++++++++ internal/settings/settings.go | 5 + model_cli.go | 37 +++--- model_suffix_cli_test.go | 15 ++- 17 files changed, 496 insertions(+), 49 deletions(-) create mode 100644 internal/agent/suffix_mode_test.go create mode 100644 internal/gateway/custom_name_label_test.go create mode 100644 internal/gui/tests/model-suffix-own.test.cjs create mode 100644 internal/provider/suffix_mode_test.go diff --git a/internal/agent/suffix_mode_test.go b/internal/agent/suffix_mode_test.go new file mode 100644 index 00000000..61d7f3c7 --- /dev/null +++ b/internal/agent/suffix_mode_test.go @@ -0,0 +1,82 @@ +package agent + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/yetone/magpie/internal/catalog" + "github.com/yetone/magpie/internal/provider" +) + +// The lists magpie writes into Codex's model_catalog_json and Pi's +// models.json name models by the Provider in model names setting, and are +// written again as it changes: on, as by default, a renamed model too has +// its provider's after it (#203); own, the name the user gave is just as +// they wrote it while a vendor's keeps its provider's (#92); off, none has +// it (#335). +func TestSuffixModesReachAgentFiles(t *testing.T) { + home := syncHome(t) + os.WriteFile(catalog.CachePath(), []byte(`{"zai":{"models":{"glm-4.6":{"id":"glm-4.6","name":"GLM-4.6"},"glm-5":{"id":"glm-5","name":"GLM-5"}}}}`), 0o644) + catalog.Reset() + if err := provider.Save(provider.Provider{ID: "relay", Name: "Relay", Catalog: "zai", Key: "k", Chat: "http://127.0.0.1:1/v1", Models: []string{"glm-4.6", "glm-5"}}); err != nil { + t.Fatal(err) + } + piModels := filepath.Join(home, ".pi", "agent", "models.json") + writeFile(t, piModels, `{"providers":{"magpie":{"name":"magpie","models":[]}}}`) + codexDir := filepath.Join(home, ".codex") + codexCat := filepath.Join(codexDir, "magpie-models.json") + writeFile(t, filepath.Join(codexDir, "config.toml"), "model = \"relay/glm-4.6\"\nmodel_provider = \"magpie\"\nmodel_catalog_json = \""+codexCat+"\"\n") + writeFile(t, codexCat, `{"models":[]}`) + catalog.Changed = SyncCatalog + t.Cleanup(func() { catalog.Changed = nil }) + if err := provider.SetModelName("relay/glm-4.6", "GLM 5.3"); err != nil { + t.Fatal(err) + } + + names := func() (pi, codex map[string]string) { + var p struct { + Providers map[string]struct { + Models []map[string]any `json:"models"` + } `json:"providers"` + } + if err := json.Unmarshal([]byte(readFile(piModels)), &p); err != nil { + t.Fatal(err) + } + pi = map[string]string{} + for _, m := range p.Providers["magpie"].Models { + pi[m["id"].(string)], _ = m["name"].(string) + } + var cx struct { + Models []struct { + Slug string `json:"slug"` + Name string `json:"display_name"` + } `json:"models"` + } + if err := json.Unmarshal([]byte(readFile(codexCat)), &cx); err != nil { + t.Fatal(err) + } + codex = map[string]string{} + for _, m := range cx.Models { + codex[m.Slug] = m.Name + } + return pi, codex + } + for _, c := range []struct{ mode, named, own string }{ + {provider.SuffixOn, "GLM 5.3 · Relay", "GLM-5 · Relay"}, + {provider.SuffixOwn, "GLM 5.3", "GLM-5 · Relay"}, + {provider.SuffixOff, "GLM 5.3", "GLM-5"}, + {provider.SuffixOn, "GLM 5.3 · Relay", "GLM-5 · Relay"}, + } { + if err := provider.SetSuffixMode(c.mode); err != nil { + t.Fatal(err) + } + pi, codex := names() + for what, got := range map[string]map[string]string{"pi": pi, "codex": codex} { + if got["relay/glm-4.6"] != c.named || got["relay/glm-5"] != c.own { + t.Fatalf("%s, %s: %v", c.mode, what, got) + } + } + } +} diff --git a/internal/gateway/custom_name_label_test.go b/internal/gateway/custom_name_label_test.go new file mode 100644 index 00000000..3db51609 --- /dev/null +++ b/internal/gateway/custom_name_label_test.go @@ -0,0 +1,69 @@ +package gateway + +import ( + "encoding/json" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/yetone/magpie/internal/catalog" + "github.com/yetone/magpie/internal/provider" +) + +// /v1/models' magpie_label names a model as the agents' lists do, by the +// Provider in model names setting: on, as by default, a name the user gave +// a model has its provider's after it like any other (#203); own, that +// name is just as they wrote it and the vendor's keep theirs (#92: "Opus +// 5.5", not "Opus 5.5 · Claude Code"); off, none has it (#335). +// display_name is the name alone throughout. +func TestModelsListSuffixModes(t *testing.T) { + fresh(t) + os.MkdirAll(filepath.Dir(catalog.CachePath()), 0o755) + data := `{"a":{"models":{"sol":{"id":"sol","name":"Sol"}}},"b":{"models":{"luna":{"id":"luna","name":"Luna"}}}}` + if err := os.WriteFile(catalog.CachePath(), []byte(data), 0o644); err != nil { + t.Fatal(err) + } + catalog.Reset() + t.Cleanup(catalog.Reset) + for id, m := range map[string]string{"a": "sol", "b": "luna"} { + if err := provider.Save(provider.Provider{ID: id, Name: "Vendor " + strings.ToUpper(id), Catalog: id, Key: "k", Chat: "http://127.0.0.1:1/v1", Models: []string{m}}); err != nil { + t.Fatal(err) + } + } + if err := provider.SetModelName("a/sol", "Sol 5"); err != nil { + t.Fatal(err) + } + list := func() map[string]string { + rec := httptest.NewRecorder() + New().Handler().ServeHTTP(rec, httptest.NewRequest("GET", "/v1/models", nil)) + var out struct { + Data []struct { + ID string `json:"id"` + Name string `json:"display_name"` + Label string `json:"magpie_label"` + } `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { + t.Fatal(err) + } + got := map[string]string{} + for _, m := range out.Data { + got[m.ID] = m.Name + "|" + m.Label + } + return got + } + for _, c := range []struct{ mode, sol, luna string }{ + {provider.SuffixOn, "Sol 5|Sol 5 · Vendor A", "Luna|Luna · Vendor B"}, + {provider.SuffixOwn, "Sol 5|Sol 5", "Luna|Luna · Vendor B"}, + {provider.SuffixOff, "Sol 5|Sol 5", "Luna|Luna"}, + } { + if err := provider.SetSuffixMode(c.mode); err != nil { + t.Fatal(err) + } + if got := list(); got["a/sol"] != c.sol || got["b/luna"] != c.luna { + t.Fatalf("%s: %v", c.mode, got) + } + } +} diff --git a/internal/gateway/gateway.go b/internal/gateway/gateway.go index 474b925b..2cb29e52 100644 --- a/internal/gateway/gateway.go +++ b/internal/gateway/gateway.go @@ -504,12 +504,14 @@ func (s *Server) models(w http.ResponseWriter, r *http.Request) { if agentOf(r) == "claude-desktop" { data = desktopModels(shown) } else { - for _, e := range shown { + labels := provider.Labels(shown) + for i, e := range shown { m := modelObject(e) - // for another magpie: its name with its provider here after - // it, so two providers' models of one name are told apart + // for another magpie: its name as the agents' lists here call + // it, with its provider's after it unless the user wants it + // plain, so two providers' models of one name are told apart // there as they are here - m["magpie_label"] = e.Label() + m["magpie_label"] = labels[i] data = append(data, m) } } diff --git a/internal/gateway/muse.go b/internal/gateway/muse.go index 73ea252a..69e47a7e 100644 --- a/internal/gateway/muse.go +++ b/internal/gateway/muse.go @@ -34,7 +34,7 @@ func (s *Server) museModels(w http.ResponseWriter, r *http.Request) { "attachment": e.Images, "reasoning": len(e.Efforts) > 0, "temperature": false, "tool_call": true, "modalities": map[string]any{"input": input, "output": []string{"text"}}, "options": map[string]any{"include": []string{}}, "variants": map[string]any{}, - "description": e.Label() + " via magpie"} + "description": labels[i] + " via magpie"} // the window and the most a reply may hold: Muse hides a model // whose limit lacks either, and without a limit asks for replies // of 128K tokens, more than most models give; one magpie doesn't diff --git a/internal/gui/api.go b/internal/gui/api.go index 410e863a..b1be1a04 100644 --- a/internal/gui/api.go +++ b/internal/gui/api.go @@ -591,7 +591,7 @@ func Handler(w Windows, gw *gateway.Server) http.Handler { // used or left is the Usage page's toggle as much as Settings', set on its own in.QuotaLeft = cur.QuotaLeft // how agents' lists name models, set on its own for the agents to be told - in.PlainNames = cur.PlainNames + in.PlainNames, in.PlainOwnNames = cur.PlainNames, cur.PlainOwnNames // which Codex accounts spend a reset by themselves, set on the Usage card in.CodexAutoReset = cur.CodexAutoReset // and the text size, which the keyboard changes too (text-size below) @@ -681,12 +681,21 @@ func Handler(w Windows, gw *gateway.Server) http.Handler { // whether the agents' lists name a model with its provider's after it or // alone (#335): their files are written again, and Codex asks again mux.HandleFunc("POST /api/settings/plain-names", func(rw http.ResponseWriter, r *http.Request) { - var in struct{ On bool } + // Mode is on, own (#92: not on the names the user gave) or off; a + // body of On alone is the two-way switch's, On meaning plain + var in struct { + On bool + Mode string + } if err := json.NewDecoder(r.Body).Decode(&in); err != nil { fail(rw, err) return } - if err := provider.SetPlainNames(in.On); err != nil { + set := func() error { return provider.SetPlainNames(in.On) } + if in.Mode != "" { + set = func() error { return provider.SetSuffixMode(in.Mode) } + } + if err := set(); err != nil { fail(rw, err) return } diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js index cd004a7e..61526196 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -10613,10 +10613,12 @@ function renderTrayUsage(s, keep) { $("#unitsSegs").replaceChildren(segs([[false, t("万 / 亿")], [true, t("K / M / B")]], !!s.westernUnits, (v) => savePrefs({ ...keep, westernUnits: v }))); renderAlerts(s, keep); - // the agents' lists name a model with its provider's after it, or alone - // (#335): set on its own, so the agents are told - $("#plainNamesSegs").replaceChildren(segs([["off", t("Off")], ["on", t("On")]], s.plainNames ? "off" : "on", (v) => - writingPrefs(api("settings/plain-names", { on: v === "off" })).then((ns) => { prefs = ns; renderSettings(); }).catch((e) => { status(t(e.message), "err"); renderSettings(); }))); + // the agents' lists name a model with its provider's after it, all but + // the names the user gave (#92), or none (#335): set on its own, so the + // agents are told + const suffix = s.plainNames ? "off" : s.plainOwnNames ? "own" : "on"; + $("#plainNamesSegs").replaceChildren(segs([["off", t("Off")], ["own", t("Not on names I set")], ["on", t("On")]], suffix, (v) => + writingPrefs(api("settings/plain-names", { mode: v })).then((ns) => { prefs = ns; renderSettings(); }).catch((e) => { status(t(e.message), "err"); renderSettings(); }))); const rate = s.fx?.rate; const currencySub = $("#currencySub"); currencySub.textContent = t("What a cost — the Usage page's, the tray panel's, the TUI's and the CLI's — is shown as; a vendor's own balance, already in its own currency, is never converted"); diff --git a/internal/gui/assets/i18n.js b/internal/gui/assets/i18n.js index 1d42f8dc..49233a73 100644 --- a/internal/gui/assets/i18n.js +++ b/internal/gui/assets/i18n.js @@ -204,8 +204,9 @@ const I18N = { "Notifications can't be shown on this system": "此系统无法显示通知", "Share used": "已用比例", "Amount": "金额", - "Provider in model names": "模型名带提供商", - "Agents’ model lists name each model with its provider or “routing group” after it. Off, the name alone, unless two would read the same": "写给 agent 的模型列表里,每个模型名后面带上提供商或“routing group”。关闭后只用模型名,除非有两个会重名", + "Provider in model names": "模型名带供应商", + "Agents’ lists put each model’s provider after its name, or not after names you set": "写给 agent 的模型列表在模型名后带上供应商,或自定义的名称不带", + "Not on names I set": "自定义名称不带供应商", "$ USD": "$ 美元", "¥ CNY": "¥ 人民币", "What a cost — the Usage page's, the tray panel's, the TUI's and the CLI's — is shown as; a vendor's own balance, already in its own currency, is never converted": "费用——用量页、托盘面板、TUI 和命令行里的——按此显示;厂商自己给出的余额本就是它自己的货币,不会被换算", diff --git a/internal/gui/assets/index.html b/internal/gui/assets/index.html index aff1c116..a0dde92c 100644 --- a/internal/gui/assets/index.html +++ b/internal/gui/assets/index.html @@ -266,7 +266,7 @@
-
Provider in model names
Agents’ model lists name each model with its provider or “routing group” after it. Off, the name alone, unless two would read the same
+
Provider in model names
Agents’ lists put each model’s provider after its name, or not after names you set
diff --git a/internal/gui/settings_keep_test.go b/internal/gui/settings_keep_test.go index 6bbd5659..c1ac4ead 100644 --- a/internal/gui/settings_keep_test.go +++ b/internal/gui/settings_keep_test.go @@ -84,6 +84,7 @@ func TestSettingsSaveKeepsWhatItDoesNotSend(t *testing.T) { RequestArchive: true, QuotaLeft: true, PlainNames: true, + PlainOwnNames: true, CodexAutoReset: []string{"me@example.com"}, TextSize: 125, UpdateSkip: "0.1.500", diff --git a/internal/gui/tests/README.md b/internal/gui/tests/README.md index 61074f14..8da04441 100644 --- a/internal/gui/tests/README.md +++ b/internal/gui/tests/README.md @@ -245,10 +245,16 @@ Routing tab alike; the other keeps "agent · model → provider". English and Chinese, Chromium and WebKit (#337). `plain-names.test.cjs` checks the Settings page's "Provider in model names" -row (#335): on by default, Off posts `settings/plain-names` with `on: true` +row (#335): on by default, Off posts `settings/plain-names` with `mode: "off"` on its own and lights Off, On posts it back, and neither click scrolls the page. English and Chinese, Chromium and WebKit. +`model-suffix-own.test.cjs` checks that row's third way (#92): "Not on names +I set" (自定义名称不带供应商) between Off and On, lit by `plainOwnNames`; On and +then it post `settings/plain-names` with `mode: "on"` and `mode: "own"`, +neither click scrolls the page, the hint isn't cut short and the three fit +the row at 900px and 560px. English and Chinese, Chromium and WebKit. + `routing-steady.test.cjs` scrolls the Routing page down to its routing groups, opens one in its editor and types into its name, then streams six requests in from the trace (new agents and accounts on the stage, failures and retries in @@ -983,7 +989,7 @@ reads shows none of it. English and Chinese, Chromium and WebKit, with With Node.js and Playwright available: ```sh -node --test internal/gui/tests/menu-scroll.test.cjs internal/gui/tests/click-scroll.test.cjs internal/gui/tests/panel-fold.test.cjs internal/gui/tests/rollup-short.test.cjs internal/gui/tests/panel-routing.test.cjs internal/gui/tests/gateway-fold.test.cjs internal/gui/tests/routing-kind.test.cjs internal/gui/tests/balance-fix.test.cjs internal/gui/tests/cli-update.test.cjs internal/gui/tests/login-import.test.cjs internal/gui/tests/agy-launch.test.cjs internal/gui/tests/currency.test.cjs internal/gui/tests/usage-ledger.test.cjs internal/gui/tests/update-check.test.cjs internal/gui/tests/omarchy.test.cjs internal/gui/tests/brand.test.cjs internal/gui/tests/library-loading.test.cjs internal/gui/tests/provider-typed.test.cjs node --test internal/gui/tests/signin-callback.test.cjs internal/gui/tests/routing-steady.test.cjs internal/gui/tests/old-webkit.test.cjs internal/gui/tests/session-terminal.test.cjs internal/gui/tests/text-size.test.cjs internal/gui/tests/zcode-site.test.cjs internal/gui/tests/rail-tip.test.cjs internal/gui/tests/sync-update.test.cjs internal/gui/tests/plugin-signin.test.cjs internal/gui/tests/plugin-market.test.cjs internal/gui/tests/s3-sync.test.cjs internal/gui/tests/routing-wb-refused.test.cjs internal/gui/tests/routing-blocked.test.cjs internal/gui/tests/routing-zcode-blocked.test.cjs internal/gui/tests/routing-shape.test.cjs internal/gui/tests/market-have.test.cjs internal/gui/tests/sessions-today.test.cjs internal/gui/tests/routing-side-calls.test.cjs internal/gui/tests/import-all-skills.test.cjs internal/gui/tests/routing-flood.test.cjs internal/gui/tests/routing-busy.test.cjs internal/gui/tests/own-skill.test.cjs internal/gui/tests/routing-manual.test.cjs internal/gui/tests/tray-usages.test.cjs internal/gui/tests/provider-remove.test.cjs internal/gui/tests/signin-link.test.cjs internal/gui/tests/tray-inuse.test.cjs internal/gui/tests/codex-auto-reset.test.cjs internal/gui/tests/routing-auto-reset.test.cjs internal/gui/tests/claude-usage-asked.test.cjs internal/gui/tests/claude-risk.test.cjs internal/gui/tests/library-toggle-status.test.cjs internal/gui/tests/library-all.test.cjs internal/gui/tests/routing-reroute-title.test.cjs internal/gui/tests/plain-names.test.cjs internal/gui/tests/panel-effort.test.cjs internal/gui/tests/agent-models-all-hidden.test.cjs internal/gui/tests/omp-roles.test.cjs internal/gui/tests/claude-effort.test.cjs internal/gui/tests/remote-magpie.test.cjs internal/gui/tests/qoder-cn-tile.test.cjs internal/gui/tests/provider-levels-scroll.test.cjs internal/gui/tests/signin-paste-codex.test.cjs internal/gui/tests/signin-paste-key.test.cjs internal/gui/tests/usage-alert.test.cjs internal/gui/tests/quota-families.test.cjs internal/gui/tests/provider-search.test.cjs internal/gui/tests/webdav-settings-refresh.test.cjs internal/gui/tests/lan-docker.test.cjs internal/gui/tests/plugin-key-hint.test.cjs internal/gui/tests/update-hide.test.cjs internal/gui/tests/tray-logos.test.cjs internal/gui/tests/routing-proxy.test.cjs internal/gui/tests/sse-body.test.cjs internal/gui/tests/remote-group-served.test.cjs internal/gui/tests/plugin-move-overflow.test.cjs internal/gui/tests/routing-time.test.cjs internal/gui/tests/unlisted-models.test.cjs internal/gui/tests/plugin-updates.test.cjs internal/gui/tests/request-archive.test.cjs internal/gui/tests/group-member-fast.test.cjs internal/gui/tests/providers-off-fold.test.cjs internal/gui/tests/account-return.test.cjs internal/gui/tests/usage-ledger-detail.test.cjs internal/gui/tests/usage-ledger-chart.test.cjs internal/gui/tests/usage-chart-axis.test.cjs internal/gui/tests/panel-usage.test.cjs internal/gui/tests/usage-refresh.test.cjs internal/gui/tests/usage-ledger-content.test.cjs internal/gui/tests/claude-direct.test.cjs internal/gui/tests/signin-again.test.cjs internal/gui/tests/sessions-calendar-fill.test.cjs internal/gui/tests/editor-short-window.test.cjs internal/gui/tests/group-from-model-add.test.cjs internal/gui/tests/sync-other-kind.test.cjs internal/gui/tests/web-page.test.cjs internal/gui/tests/balance-parts.test.cjs internal/gui/tests/search-api.test.cjs internal/gui/tests/provider-file-error.test.cjs internal/gui/tests/sessions-manage.test.cjs internal/gui/tests/number-units.test.cjs internal/gui/tests/sessions-bar-names.test.cjs +node --test internal/gui/tests/menu-scroll.test.cjs internal/gui/tests/click-scroll.test.cjs internal/gui/tests/panel-fold.test.cjs internal/gui/tests/rollup-short.test.cjs internal/gui/tests/panel-routing.test.cjs internal/gui/tests/gateway-fold.test.cjs internal/gui/tests/routing-kind.test.cjs internal/gui/tests/balance-fix.test.cjs internal/gui/tests/cli-update.test.cjs internal/gui/tests/login-import.test.cjs internal/gui/tests/agy-launch.test.cjs internal/gui/tests/currency.test.cjs internal/gui/tests/usage-ledger.test.cjs internal/gui/tests/update-check.test.cjs internal/gui/tests/omarchy.test.cjs internal/gui/tests/brand.test.cjs internal/gui/tests/library-loading.test.cjs internal/gui/tests/provider-typed.test.cjs node --test internal/gui/tests/signin-callback.test.cjs internal/gui/tests/routing-steady.test.cjs internal/gui/tests/old-webkit.test.cjs internal/gui/tests/session-terminal.test.cjs internal/gui/tests/text-size.test.cjs internal/gui/tests/zcode-site.test.cjs internal/gui/tests/rail-tip.test.cjs internal/gui/tests/sync-update.test.cjs internal/gui/tests/plugin-signin.test.cjs internal/gui/tests/plugin-market.test.cjs internal/gui/tests/s3-sync.test.cjs internal/gui/tests/routing-wb-refused.test.cjs internal/gui/tests/routing-blocked.test.cjs internal/gui/tests/routing-zcode-blocked.test.cjs internal/gui/tests/routing-shape.test.cjs internal/gui/tests/market-have.test.cjs internal/gui/tests/sessions-today.test.cjs internal/gui/tests/routing-side-calls.test.cjs internal/gui/tests/import-all-skills.test.cjs internal/gui/tests/routing-flood.test.cjs internal/gui/tests/routing-busy.test.cjs internal/gui/tests/own-skill.test.cjs internal/gui/tests/routing-manual.test.cjs internal/gui/tests/tray-usages.test.cjs internal/gui/tests/provider-remove.test.cjs internal/gui/tests/signin-link.test.cjs internal/gui/tests/tray-inuse.test.cjs internal/gui/tests/codex-auto-reset.test.cjs internal/gui/tests/routing-auto-reset.test.cjs internal/gui/tests/claude-usage-asked.test.cjs internal/gui/tests/claude-risk.test.cjs internal/gui/tests/library-toggle-status.test.cjs internal/gui/tests/library-all.test.cjs internal/gui/tests/routing-reroute-title.test.cjs internal/gui/tests/plain-names.test.cjs internal/gui/tests/panel-effort.test.cjs internal/gui/tests/agent-models-all-hidden.test.cjs internal/gui/tests/omp-roles.test.cjs internal/gui/tests/claude-effort.test.cjs internal/gui/tests/remote-magpie.test.cjs internal/gui/tests/qoder-cn-tile.test.cjs internal/gui/tests/provider-levels-scroll.test.cjs internal/gui/tests/signin-paste-codex.test.cjs internal/gui/tests/signin-paste-key.test.cjs internal/gui/tests/usage-alert.test.cjs internal/gui/tests/quota-families.test.cjs internal/gui/tests/provider-search.test.cjs internal/gui/tests/webdav-settings-refresh.test.cjs internal/gui/tests/lan-docker.test.cjs internal/gui/tests/plugin-key-hint.test.cjs internal/gui/tests/update-hide.test.cjs internal/gui/tests/tray-logos.test.cjs internal/gui/tests/routing-proxy.test.cjs internal/gui/tests/sse-body.test.cjs internal/gui/tests/remote-group-served.test.cjs internal/gui/tests/plugin-move-overflow.test.cjs internal/gui/tests/routing-time.test.cjs internal/gui/tests/unlisted-models.test.cjs internal/gui/tests/plugin-updates.test.cjs internal/gui/tests/request-archive.test.cjs internal/gui/tests/group-member-fast.test.cjs internal/gui/tests/providers-off-fold.test.cjs internal/gui/tests/account-return.test.cjs internal/gui/tests/usage-ledger-detail.test.cjs internal/gui/tests/usage-ledger-chart.test.cjs internal/gui/tests/usage-chart-axis.test.cjs internal/gui/tests/panel-usage.test.cjs internal/gui/tests/usage-refresh.test.cjs internal/gui/tests/usage-ledger-content.test.cjs internal/gui/tests/claude-direct.test.cjs internal/gui/tests/signin-again.test.cjs internal/gui/tests/sessions-calendar-fill.test.cjs internal/gui/tests/editor-short-window.test.cjs internal/gui/tests/group-from-model-add.test.cjs internal/gui/tests/sync-other-kind.test.cjs internal/gui/tests/web-page.test.cjs internal/gui/tests/balance-parts.test.cjs internal/gui/tests/search-api.test.cjs internal/gui/tests/provider-file-error.test.cjs internal/gui/tests/sessions-manage.test.cjs internal/gui/tests/number-units.test.cjs internal/gui/tests/sessions-bar-names.test.cjs internal/gui/tests/model-suffix-own.test.cjs ``` If Playwright is installed outside the repository, set `NODE_PATH` to the diff --git a/internal/gui/tests/model-suffix-own.test.cjs b/internal/gui/tests/model-suffix-own.test.cjs new file mode 100644 index 00000000..3fcf8ad9 --- /dev/null +++ b/internal/gui/tests/model-suffix-own.test.cjs @@ -0,0 +1,116 @@ +// Run with Node's test runner and Playwright on the module path; see README.md. +// The "Provider in model names" setting's third way (#92: 自定义名称后面又带上了 +// · 供应商): "Not on names I set" between Off and On, lit when settings say +// plainOwnNames; On and then it post settings/plain-names with mode on and +// own on their own, neither click scrolls the Settings page, and the three +// fit the row, wide and narrow. English and Chinese, Chromium and WebKit; no +// backend, the API is faked here. +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"); + +function settingsPayload(over) { + return { + theme: "light", lang: "en", tray: "panel", quotaLeft: false, currency: "usd", + dock: false, dockWindow: false, proxy: "", redact: false, redactPersonal: false, redactWords: [], + codexWarmup: "", claudeWarmup: "", codexWarmAt: "", claudeWarmAt: "", workbuddyCheckin: false, noStats: false, + trayUsage: "", trayUsageEvery: 3, vision: "", imageGen: "", + version: "0.1.400", dir: "~/.config/magpie", gateway: "http://127.0.0.1:3425", + proxyNow: "none", proxySource: "none", login: false, + visionModels: [], imageGenModels: [], workbuddyCheckins: [], lanURLs: [], + fx: { rate: 7.2, at: new Date().toISOString(), stale: false }, + ...over, + }; +} + +function server(lang, posted) { + let cur = settingsPayload({ lang, plainOwnNames: true }); + return async (route) => { + const req = route.request(), 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:false};` }); + if (url.pathname === "/wails/runtime.js") return route.fulfill({ contentType: "text/javascript", body: "export const Window = {};" }); + if (url.pathname === "/api/state") return json({ agents: [], profiles: [], settings: { lang, theme: "light" }, fx: cur.fx }); + if (url.pathname === "/api/settings/plain-names") { + const body = req.postDataJSON(); + posted.push(body); + cur = { ...cur, plainNames: body.mode === "off", plainOwnNames: body.mode === "own" }; + return json(cur); + } + if (url.pathname === "/api/settings") return json(cur); + if (url.pathname === "/api/plugins") return json({ plugins: [] }); + if (url.pathname === "/api/usage/quotas") return json([]); + if (url.pathname === "/api/groups") return json({ groups: [], models: [] }); + 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 }); + }; +} + +const want = { + en: { labels: ["Off", "Not on names I set", "On"], sub: /^Agents’ lists put each model’s provider after its name, or not after names you set$/ }, + zh: { labels: ["关闭", "自定义名称不带供应商", "开启"], sub: /^写给 agent 的模型列表在模型名后带上供应商,或自定义的名称不带$/ }, +}; +const view = (page) => page.locator("#view-settings").evaluate((v) => v.scrollTop); + +for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) { + for (const lang of ["en", "zh"]) { + test(`${engine} ${lang}: a name the user gave can go without its provider`, async (t) => { + const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" })); + const context = await browser.newContext({ viewport: { width: 900, height: 480 }, reducedMotion: "reduce" }); + const page = await context.newPage(); + page.setDefaultTimeout(5000); + const errors = [], posted = []; + page.on("pageerror", (e) => errors.push(e.message)); + await page.route("**/*", server(lang, posted)); + 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}-model-suffix-own.png`) }); + } + await browser.close(); + }); + await page.goto("http://magpie.test/?view=settings"); + const segs = page.locator("#plainNamesSegs .opt"); + await segs.first().waitFor(); + const row = page.locator(".row.pref", { has: page.locator("#plainNamesSegs") }); + assert.match(await row.locator(".sub").textContent(), want[lang].sub); + assert.equal(await row.locator(".sub").evaluate((e) => e.scrollWidth <= e.clientWidth), true, "the hint is cut short"); + assert.deepEqual((await segs.allTextContents()).map((s) => s.trim()), want[lang].labels); + const own = segs.nth(1); + assert.equal(await own.evaluate((b) => b.classList.contains("on")), true, "plainOwnNames lights the middle one"); + + // the three stay inside the row, wide and narrow + for (const width of [900, 560]) { + await page.setViewportSize({ width, height: 480 }); + const r = await row.boundingBox(), s = await page.locator("#plainNamesSegs .segs").boundingBox(); + assert(s.x >= r.x - 0.5 && s.x + s.width <= r.x + r.width + 0.5, `${width}px: the options overflow the row`); + assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true, `${width}px: the page scrolls sideways`); + } + await page.setViewportSize({ width: 900, height: 480 }); + + // scrolled by a wheel till the row is mid-view, On and then the + // middle one move nothing and are posted on their own + const box = await page.locator("#view-settings").boundingBox(); + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + const top = () => page.locator("#plainNamesSegs").evaluate((e) => e.getBoundingClientRect().top); + for (let i = 0; i < 40 && (await top()) > 240; i++) { await page.mouse.wheel(0, 100); await page.waitForTimeout(30); } + const before = await view(page); + assert(before > 0, "the settings list must scroll to the row"); + await segs.nth(2).click(); + await page.locator("#plainNamesSegs .opt.on", { hasText: want[lang].labels[2] }).waitFor(); + assert.deepEqual(posted, [{ mode: "on" }]); + await page.locator("#plainNamesSegs .opt", { hasText: want[lang].labels[1] }).click(); + await page.locator("#plainNamesSegs .opt.on", { hasText: want[lang].labels[1] }).waitFor(); + await page.waitForTimeout(400); + assert.deepEqual(posted, [{ mode: "on" }, { mode: "own" }]); + assert.equal(await view(page), before, "a click scrolled the page"); + assert.deepEqual(errors, []); + }); + } +} diff --git a/internal/gui/tests/plain-names.test.cjs b/internal/gui/tests/plain-names.test.cjs index 1a35a39a..7e54b9b9 100644 --- a/internal/gui/tests/plain-names.test.cjs +++ b/internal/gui/tests/plain-names.test.cjs @@ -2,8 +2,8 @@ // The "Provider in model names" setting (#335: 希望 Codex 模型列表里的显示名可以 // 不带 · routing group / · 提供商 后缀): on by default; Off posts // settings/plain-names on its own (so the agents' lists are written again) -// and lights Off, On posts it back, and neither click scrolls the Settings -// page. English and Chinese, Chromium and WebKit; no backend, the API is faked here. +// with mode off and lights Off, On posts it back, and neither click scrolls +// the Settings page. English and Chinese, Chromium and WebKit; no backend, the API is faked here. const assert = require("node:assert/strict"); const fs = require("node:fs/promises"); const path = require("node:path"); @@ -37,7 +37,7 @@ function server(lang, posted) { if (url.pathname === "/api/settings/plain-names") { const body = req.postDataJSON(); posted.push(["plain-names", body]); - cur = { ...cur, plainNames: body.on }; + cur = { ...cur, plainNames: body.mode === "off", plainOwnNames: body.mode === "own" }; return json(cur); } if (url.pathname === "/api/settings") { @@ -59,8 +59,8 @@ function server(lang, posted) { } const want = { - en: { name: "Provider in model names", sub: /routing group/, off: "Off", on: "On" }, - zh: { name: "模型名带提供商", sub: /除非有两个会重名/, off: "关闭", on: "开启" }, + en: { name: "Provider in model names", sub: /provider after its name/, off: "Off", own: "Not on names I set", on: "On" }, + zh: { name: "模型名带供应商", sub: /自定义的名称不带/, off: "关闭", own: "自定义名称不带供应商", on: "开启" }, }; const view = (page) => page.locator("#view-settings").evaluate((v) => v.scrollTop); @@ -87,8 +87,8 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", const row = page.locator(".row.pref", { has: page.locator("#plainNamesSegs") }); assert.equal((await row.locator(".name").textContent()).trim(), want[lang].name); assert.match(await row.locator(".sub").textContent(), want[lang].sub); - assert.deepEqual((await segs.allTextContents()).map((s) => s.trim()), [want[lang].off, want[lang].on]); - assert.equal(await segs.nth(1).evaluate((b) => b.classList.contains("on")), true, "on by default"); + assert.deepEqual((await segs.allTextContents()).map((s) => s.trim()), [want[lang].off, want[lang].own, want[lang].on]); + assert.equal(await segs.nth(2).evaluate((b) => b.classList.contains("on")), true, "on by default"); // scrolled by a wheel till the row is mid-view (a real wheel, so the // reader's-scroll guard lets it stick), Off moves nothing and is @@ -103,12 +103,12 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", await page.locator("#plainNamesSegs .opt.on", { hasText: want[lang].off }).waitFor(); await page.waitForTimeout(400); assert.equal(await view(page), before, "the click scrolled the page"); - assert.deepEqual(posted, [["plain-names", { on: true }]]); + assert.deepEqual(posted, [["plain-names", { mode: "off" }]]); // back on - await page.locator("#plainNamesSegs .opt", { hasText: want[lang].on }).click(); + await segs.nth(2).click(); await page.locator("#plainNamesSegs .opt.on", { hasText: want[lang].on }).waitFor(); - assert.deepEqual(posted.at(-1), ["plain-names", { on: false }]); + assert.deepEqual(posted.at(-1), ["plain-names", { mode: "on" }]); assert.equal(await view(page), before, "the click scrolled the page"); assert.deepEqual(errors, []); }); diff --git a/internal/provider/modelprefs.go b/internal/provider/modelprefs.go index 30b5d140..791b39c6 100644 --- a/internal/provider/modelprefs.go +++ b/internal/provider/modelprefs.go @@ -414,10 +414,14 @@ func (e Entry) Label() string { // when the user wants names plain (settings' PlainNames, #335), by the // name alone — but for two or more the list would call the same, as a // routing group found for a model is called with that model left in the -// list, which keep their provider's after it to tell them apart. +// list, which keep their provider's after it to tell them apart. With +// their own names plain (PlainOwnNames, #92), a name the user gave a model +// is that name just as they wrote it, and the vendor's keep Label's. func Labels(es []Entry) []string { out := make([]string, len(es)) - plain := settings.Load().PlainNames + s := settings.Load() + plain := s.PlainNames + own := !plain && s.PlainOwnNames same := map[string]int{} if plain { for _, e := range es { @@ -426,21 +430,53 @@ func Labels(es []Entry) []string { } for i, e := range es { out[i] = e.Label() - if plain && e.Name != "" && same[strings.ToLower(e.Name)] == 1 { + if plain && e.Name != "" && same[strings.ToLower(e.Name)] == 1 || own && e.Default != "" && e.Name != "" { out[i] = e.Name } } return out } -// SetPlainNames has the agents' model lists name models by their names -// alone (see Labels), or with their providers' again, and the agents told. -func SetPlainNames(on bool) error { +// The ways the agents' lists name models (SuffixMode): every name with its +// provider's after it, as by default; all but the names the user gave +// models (#92); or none (#335). +const ( + SuffixOn = "on" + SuffixOwn = "own" + SuffixOff = "off" +) + +// SuffixMode is how the agents' lists name models: SuffixOn, SuffixOwn or +// SuffixOff. +func SuffixMode() string { s := settings.Load() - if s.PlainNames == on { + switch { + case s.PlainNames: + return SuffixOff + case s.PlainOwnNames: + return SuffixOwn + } + return SuffixOn +} + +// SetSuffixMode has the agents' model lists name models as mode says (see +// Labels), and the agents told when it changes. +func SetSuffixMode(mode string) error { + plain, own := false, false + switch mode { + case SuffixOn: + case SuffixOwn: + own = true + case SuffixOff: + plain = true + default: + return fmt.Errorf("provider in model names: on, own or off, not %q", mode) + } + s := settings.Load() + if s.PlainNames == plain && s.PlainOwnNames == own { return nil } - s.PlainNames = on + s.PlainNames, s.PlainOwnNames = plain, own if err := settings.Save(s); err != nil { return err } @@ -448,6 +484,15 @@ func SetPlainNames(on bool) error { return nil } +// SetPlainNames has the agents' model lists name models by their names +// alone (see Labels), or with their providers' again, and the agents told. +func SetPlainNames(on bool) error { + if on { + return SetSuffixMode(SuffixOff) + } + return SetSuffixMode(SuffixOn) +} + // ModelNames are the names the user gave the provider's models, by model id. func (p Provider) ModelNames() map[string]string { out := map[string]string{} diff --git a/internal/provider/suffix_mode_test.go b/internal/provider/suffix_mode_test.go new file mode 100644 index 00000000..fc59d311 --- /dev/null +++ b/internal/provider/suffix_mode_test.go @@ -0,0 +1,85 @@ +package provider + +import ( + "slices" + "testing" + + "github.com/yetone/magpie/internal/catalog" + "github.com/yetone/magpie/internal/settings" +) + +// Provider in model names has three ways (#92): on, as by default and as a +// settings.json from before has it, every name with its provider's after +// it, one the user gave too (#203); own, a name the user gave just as they +// wrote it, the vendor's with their provider's; off, none (#335), as an +// older plainNames: true still reads. Each change tells the agents; one to +// the way already set doesn't, and a way of no such name is refused. +func TestSuffixMode(t *testing.T) { + prefsHome(t) + touched := 0 + catalog.Changed = func() { touched++ } + t.Cleanup(func() { catalog.Changed = nil }) + if err := SetModelName("a/sol", "My Sol"); err != nil { + t.Fatal(err) + } + touched = 0 + codex := func() []string { + var out []string + for _, m := range CodexListed() { + out = append(out, m.ID+"="+m.Name) + } + slices.Sort(out) + return out + } + + if SuffixMode() != SuffixOn { + t.Fatalf("default %q", SuffixMode()) + } + if got := codex(); !slices.Equal(got, []string{"a/sol=My Sol · A", "b/sol=Sol · B", "group/auto-sol=Sol · routing group"}) { + t.Fatalf("on: %q", got) + } + + if err := SetSuffixMode(SuffixOwn); err != nil { + t.Fatal(err) + } + if s := settings.Load(); SuffixMode() != SuffixOwn || s.PlainNames || !s.PlainOwnNames || touched != 1 { + t.Fatalf("own: %q %+v, told %d", SuffixMode(), s, touched) + } + if got := codex(); !slices.Equal(got, []string{"a/sol=My Sol", "b/sol=Sol · B", "group/auto-sol=Sol · routing group"}) { + t.Fatalf("own: %q", got) + } + if err := SetSuffixMode(SuffixOwn); err != nil || touched != 1 { + t.Fatal(err, touched) + } + + if err := SetSuffixMode(SuffixOff); err != nil { + t.Fatal(err) + } + if s := settings.Load(); SuffixMode() != SuffixOff || !s.PlainNames || s.PlainOwnNames || touched != 2 { + t.Fatalf("off: %q %+v, told %d", SuffixMode(), s, touched) + } + // b's sol and the group found for it would both read "Sol": they keep theirs + if got := codex(); !slices.Equal(got, []string{"a/sol=My Sol", "b/sol=Sol · B", "group/auto-sol=Sol · routing group"}) { + t.Fatalf("off: %q", got) + } + + // the two-way switch of before: off is plain, on is on again + if err := SetPlainNames(false); err != nil { + t.Fatal(err) + } + if SuffixMode() != SuffixOn || touched != 3 { + t.Fatalf("plain names off: %q, told %d", SuffixMode(), touched) + } + // a settings.json of before, plainNames: true, reads as off + s := settings.Load() + s.PlainNames = true + if err := settings.Save(s); err != nil { + t.Fatal(err) + } + if SuffixMode() != SuffixOff { + t.Fatalf("plainNames: true reads %q", SuffixMode()) + } + if err := SetSuffixMode("sometimes"); err == nil { + t.Fatal("took sometimes") + } +} diff --git a/internal/settings/settings.go b/internal/settings/settings.go index f30dc0c8..faaf5d2f 100644 --- a/internal/settings/settings.go +++ b/internal/settings/settings.go @@ -150,6 +150,11 @@ type Settings struct { // (#335) — but for two in one list that would read the same, which keep // it (see provider.Labels). PlainNames bool `json:"plainNames,omitempty"` + // PlainOwnNames, with PlainNames off, has those lists name a model the + // user gave a name of their own by that name alone, just as they wrote + // it (#92: "Opus 5.5", not "Opus 5.5 · Claude Code"), the vendor's names + // keeping their provider's after them (see provider.Labels). + PlainOwnNames bool `json:"plainOwnNames,omitempty"` // TextSize is how large the window's and the tray panel's pages are // drawn, in percent (one of TextSizes): the webviews' own zoom, as a // browser's, so the text and everything around it grow together. diff --git a/model_cli.go b/model_cli.go index 15fcef0a..bdedb58b 100644 --- a/model_cli.go +++ b/model_cli.go @@ -49,9 +49,11 @@ const modelUsage = `usage: and says when there was no name of its own to take away magpie model wires the names your vendors are asked for models by magpie model names the models you named or narrowed - magpie model suffix [on|off] whether the agents' lists name each model with its provider + magpie model suffix [on|own|off] whether the agents' lists name each model with its provider (or "routing group") after it: on, as by default, "Sol · OpenAI"; - off, "Sol" alone — but two a list would name the same keep it + own, a name you gave a model just as you wrote it, "Opus 5.5", + the others as on; off, "Sol" alone — but two a list would name + the same keep it Each is looked for in this order: this model, then /*, then the provider's own list, then models.dev. --reset removes only the first, and says so when a /* value @@ -474,30 +476,39 @@ func modelPrices() error { } // modelSuffix says whether the agents' lists name models with their -// providers' after them, or sets it (#335). +// providers' after them, all but the names the user gave (#92), or none +// (#335), or sets it. func modelSuffix(args []string) error { if len(args) == 0 { - if settings.Load().PlainNames { - fmt.Println("off", muted.Render("· agents' lists name a model alone, \"Sol\" · magpie model suffix on")) - } else { - fmt.Println("on", muted.Render("· agents' lists name a model with its provider, \"Sol · OpenAI\" · magpie model suffix off")) + switch provider.SuffixMode() { + case provider.SuffixOff: + fmt.Println("off", muted.Render("· agents' lists name a model alone, \"Sol\" · magpie model suffix on|own")) + case provider.SuffixOwn: + fmt.Println("own", muted.Render("· a name you gave a model just as you wrote it, \"Opus 5.5\"; the others \"Sol · OpenAI\" · magpie model suffix on|off")) + default: + fmt.Println("on", muted.Render("· agents' lists name a model with its provider, \"Sol · OpenAI\" · magpie model suffix own|off")) } return nil } - var plain bool + mode := provider.SuffixOn switch strings.ToLower(args[0]) { case "on", "yes", "true": + case "own", "mine": + mode = provider.SuffixOwn case "off", "no", "false": - plain = true + mode = provider.SuffixOff default: - return fmt.Errorf("magpie model suffix on|off, not %q", args[0]) + return fmt.Errorf("magpie model suffix on|own|off, not %q", args[0]) } - if err := provider.SetPlainNames(plain); err != nil { + if err := provider.SetSuffixMode(mode); err != nil { return err } - if plain { + switch mode { + case provider.SuffixOff: fmt.Println(green.Render("✓"), "agents' lists name each model alone", muted.Render("· two that would read the same keep their provider's")) - } else { + case provider.SuffixOwn: + fmt.Println(green.Render("✓"), "agents' lists name a model you named just as you wrote it", muted.Render("· the others with their provider")) + default: fmt.Println(green.Render("✓"), "agents' lists name each model with its provider again") } return nil diff --git a/model_suffix_cli_test.go b/model_suffix_cli_test.go index da2ace42..dce80e88 100644 --- a/model_suffix_cli_test.go +++ b/model_suffix_cli_test.go @@ -4,11 +4,13 @@ import ( "testing" "github.com/yetone/magpie/internal/catalog" + "github.com/yetone/magpie/internal/provider" "github.com/yetone/magpie/internal/settings" ) // magpie model suffix off has the agents' lists name models alone, on -// names them with their providers again (#335); anything else is refused. +// names them with their providers again (#335), own all but the names the +// user gave (#92); anything else is refused. func TestModelSuffixCmd(t *testing.T) { groupsHome(t) catalog.Changed = nil // no agent's files are rewritten here @@ -24,6 +26,17 @@ func TestModelSuffixCmd(t *testing.T) { if err := modelCmd([]string{"suffix", "on"}); err != nil || settings.Load().PlainNames { t.Fatal(err, settings.Load().PlainNames) } + // own: a name the user gave just as they wrote it, the others with + // their provider's (#92) + if err := modelCmd([]string{"suffix", "own"}); err != nil || provider.SuffixMode() != provider.SuffixOwn { + t.Fatal(err, provider.SuffixMode()) + } + if err := modelCmd([]string{"suffix"}); err != nil { + t.Fatal(err) + } + if err := modelCmd([]string{"suffix", "on"}); err != nil || provider.SuffixMode() != provider.SuffixOn { + t.Fatal(err, provider.SuffixMode()) + } if err := modelCmd([]string{"suffix", "maybe"}); err == nil { t.Fatal("took maybe") }