From 4df1402d107cc91e1a1bb3db11cade95883dfa02 Mon Sep 17 00:00:00 2001 From: yetone Date: Sat, 10 Oct 2026 14:12:58 +0800 Subject: [PATCH] provider: Provider in model names Off names a remote magpie's models alone, not with the other magpie's provider or "routing group" after them (ARNO on Discord) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A remote magpie's list names each model by its label there (magpie_label), which carries that magpie's provider, or "· routing group", by that magpie's own setting. Here it was taken as the model's name, so turning Provider in model names Off on this computer still left "DeepSeek Chat · Relay A" and "Fast · routing group" in every agent's list. The list's display_name, the name there alone, is now kept beside it (catalog.Model.Plain, Entry.Plain). provider.Labels names an entry by it under Off, and under own for a name the user gave; two that would read the same keep their labels as before. On is unchanged ("DeepSeek Chat · Relay A · Remote magpie"). /v1/models' display_name is that name alone too, as it is for every other model, so a magpie chained after this one gets it. The provider editor's Names & levels line says the same ("plain" in the provider's model JSON). Reproduced in a sandbox: fake vendor on :3571, magpie A on :3572 with it and a group, magpie B on :3573 with remote-magpie → A and `model suffix off`: B's /v1/models said "DeepSeek Chat · Relay A", "Fast · routing group". With the fix: "DeepSeek Chat", "Fast"; on: "DeepSeek Chat · Relay A · Remote magpie". Run: - TestRemoteMagpieSuffixOff (internal/gateway, A's list bytes as served) fails on the old code (off: "DeepSeek Chat · Relay A", "Fast · routing group"), passes with the fix. - name-suffix-remote.test.cjs, Chromium and WebKit, en/zh/zh-TW/ja/de at 420px: 10/10; on the old app.js it fails ("… · Relay A"). Sibling name-suffix-*.test.cjs and names-fold: 86/86. - go test -tags nogui ./internal/{catalog,provider,gui,agent,gateway} under a temp HOME with pinned caches: ok. gofmt -l clean; go vet darwin/linux/windows on the touched packages: ok. Full suite before push. --- internal/catalog/catalog.go | 5 ++ internal/catalog/live.go | 7 +- internal/gateway/gateway.go | 2 +- internal/gateway/remote_magpie_suffix_test.go | 78 +++++++++++++++++ internal/gui/assets/app.js | 7 +- internal/gui/providers.go | 3 + .../gui/tests/name-suffix-remote.test.cjs | 83 +++++++++++++++++++ internal/provider/modelprefs.go | 13 ++- internal/provider/models.go | 13 ++- internal/provider/remote_magpie.go | 3 + 10 files changed, 203 insertions(+), 11 deletions(-) create mode 100644 internal/gateway/remote_magpie_suffix_test.go create mode 100644 internal/gui/tests/name-suffix-remote.test.cjs diff --git a/internal/catalog/catalog.go b/internal/catalog/catalog.go index 8f238bb0..1c355235 100644 --- a/internal/catalog/catalog.go +++ b/internal/catalog/catalog.go @@ -54,6 +54,11 @@ type Model struct { // window it is told when Context is longer. 0 is none set, for the // one for every model (settings.Compact). Compact int `json:",omitempty"` + // Plain, on a model of another magpie's list, is its name there alone + // (display_name), when Name is its label there, with that magpie's + // provider after it (magpie_label): the name Provider in model names + // Off has here (ARNO on Discord). + Plain string `json:",omitempty"` // Fast is set on a model Codex may ask for priority processing (its // Fast mode): one a ChatGPT account serves. Fast bool `json:",omitempty"` diff --git a/internal/catalog/live.go b/internal/catalog/live.go index 9a83d455..f4a08e9e 100644 --- a/internal/catalog/live.go +++ b/internal/catalog/live.go @@ -296,9 +296,12 @@ func fetchOne(ctx context.Context, url, key string, anthropic bool, headers map[ if !drawer && !films && !decision && headers["X-Magpie-Drawers"] == "" && !textModel(mdModel{ID: id}) { continue } - name := r.DisplayName + name, plain := r.DisplayName, "" if r.Label != "" { name = r.Label + if r.DisplayName != "" && r.DisplayName != r.Label { + plain = r.DisplayName + } } if name == "" { name = id @@ -311,7 +314,7 @@ func fetchOne(ctx context.Context, url, key string, anthropic bool, headers map[ if len(apis) == 0 { apis = targetAPIs(r.TypeTarget) } - m := Model{ID: id, Name: name, ImageInput: input, APIs: apis, Draws: drawer, Films: films, Decides: decision} + m := Model{ID: id, Name: name, Plain: plain, ImageInput: input, APIs: apis, Draws: drawer, Films: films, Decides: decision} if n, ok := r.ContextLength.(float64); ok && n > 0 { m.Context = int(n) } diff --git a/internal/gateway/gateway.go b/internal/gateway/gateway.go index 81fee3d6..5f76faa2 100644 --- a/internal/gateway/gateway.go +++ b/internal/gateway/gateway.go @@ -651,7 +651,7 @@ func modelObject(e provider.Entry) map[string]any { levels = append(levels, reasoningLevel{Effort: effort}) } m := map[string]any{"id": e.ID, "object": "model", "type": "model", "created": 0, "created_at": "2025-01-01T00:00:00Z", - "owned_by": e.Provider.ID, "display_name": e.Name, "reasoning": e.Reasoning || len(levels) > 0, "supported_reasoning_levels": levels} + "owned_by": e.Provider.ID, "display_name": e.PlainName(), "reasoning": e.Reasoning || len(levels) > 0, "supported_reasoning_levels": levels} // the window, as the names clients read it by: a group's context // (magpie group set … context=) included if e.Context > 0 { diff --git a/internal/gateway/remote_magpie_suffix_test.go b/internal/gateway/remote_magpie_suffix_test.go new file mode 100644 index 00000000..e9adfdb4 --- /dev/null +++ b/internal/gateway/remote_magpie_suffix_test.go @@ -0,0 +1,78 @@ +package gateway + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/yetone/magpie/internal/provider" +) + +// remoteList is the list another magpie (v0.1 on main, its own Provider in +// model names on) answered this one's remote-magpie with, as served on +// 127.0.0.1:3572 in a sandbox: its group and its relay's two models, each +// with display_name its name alone and magpie_label its name with its +// provider there after it. +const remoteList = `{"data":[{"context_length":202752,"context_window":202752,"created":0,"created_at":"2025-01-01T00:00:00Z","display_name":"Fast","id":"group/fast","magpie_label":"Fast · routing group","max_input_tokens":202752,"max_output_tokens":202752,"object":"model","owned_by":"relaya","reasoning":true,"supported_reasoning_levels":[],"type":"model"},{"context_length":128000,"context_window":128000,"created":0,"created_at":"2025-01-01T00:00:00Z","display_name":"DeepSeek Chat","id":"relaya/deepseek-chat","magpie_label":"DeepSeek Chat · Relay A","max_input_tokens":128000,"max_output_tokens":128000,"native_endpoints":["/v1/chat/completions"],"object":"model","owned_by":"relaya","reasoning":false,"supported_reasoning_levels":[],"type":"model"},{"context_length":202752,"context_window":202752,"created":0,"created_at":"2025-01-01T00:00:00Z","display_name":"GLM-5","id":"relaya/glm-5","magpie_label":"GLM-5 · Relay A","max_input_tokens":202752,"max_output_tokens":131072,"native_endpoints":["/v1/chat/completions"],"object":"model","owned_by":"relaya","reasoning":true,"supported_reasoning_levels":[],"type":"model"}],"first_id":"group/fast","has_more":false,"last_id":"relaya/glm-5","object":"list"}` + +// Provider in model names Off names a remote magpie's models alone here +// too (ARNO on Discord): its list's labels carry the other magpie's +// providers after them by that magpie's own setting, and they were kept +// whatever this one's said — "DeepSeek Chat · Relay A", "Fast · routing +// group" under Off. On, they keep that provider and this one's after it, +// as before; a name the user gave one here is theirs under own. +func TestRemoteMagpieSuffixOff(t *testing.T) { + fresh(t) + remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + io.WriteString(w, remoteList) + })) + t.Cleanup(remote.Close) + id, err := provider.Add(provider.Provider{ID: "remote-magpie", Name: "Remote magpie", Key: "magpie", Preset: provider.RemoteMagpiePreset, Chat: remote.URL}) + if err != nil { + t.Fatal(err) + } + p, _ := provider.Find(id) + if _, err := p.Fetch(context.Background()); err != nil { + t.Fatal(err) + } + if err := provider.SetModelName("remote-magpie/relaya/glm-5", "My GLM"); 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[strings.TrimPrefix(m.ID, "remote-magpie/")] = m.Name + "|" + m.Label + } + return got + } + for _, c := range []struct{ mode, group, ds, glm string }{ + {provider.SuffixOn, "Fast|Fast · routing group · Remote magpie", "DeepSeek Chat|DeepSeek Chat · Relay A · Remote magpie", "My GLM|My GLM · Remote magpie"}, + {provider.SuffixOwn, "Fast|Fast · routing group · Remote magpie", "DeepSeek Chat|DeepSeek Chat · Relay A · Remote magpie", "My GLM|My GLM"}, + {provider.SuffixOff, "Fast|Fast", "DeepSeek Chat|DeepSeek Chat", "My GLM|My GLM"}, + } { + if err := provider.SetSuffixMode(c.mode); err != nil { + t.Fatal(err) + } + got := list() + if got["group/fast"] != c.group || got["relaya/deepseek-chat"] != c.ds || got["relaya/glm-5"] != c.glm { + t.Errorf("%s: %v", c.mode, got) + } + } +} diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js index aa2dfdce..62606030 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -9620,7 +9620,12 @@ function renderModels(p) { const first = p.models.find((x) => x.id === ids[0]) || { id: ids[0], name: ids[0] }; const sayLabel = (mode) => { const name = prefs[first.id]?.name ?? (first.default ? first.name : ""); - sfxSaid.textContent = t("Agents’ lists show “{label}”", { label: suffixed(name || first.default || first.name || first.id, p.name || p.id, !!name, mode) }); + const base = name || first.default || first.name || first.id; + let label = suffixed(base, p.name || p.id, !!name, mode); + // a remote magpie's model, named there with its provider after it, + // is that name alone when none is put after it here (ARNO on Discord) + if (!name && first.plain && label === base) label = first.plain; + sfxSaid.textContent = t("Agents’ lists show “{label}”", { label }); }; sayLabel(); sfx.append(el("span", "", t("Provider in model names")), suffixSegs(sayLabel), sfxSaid); diff --git a/internal/gui/providers.go b/internal/gui/providers.go index b6c609d3..1e4f1c3b 100644 --- a/internal/gui/providers.go +++ b/internal/gui/providers.go @@ -28,6 +28,7 @@ type modelJSON struct { ID string `json:"id"` Name string `json:"name"` // the user's name for it, if they gave one Default string `json:"default,omitempty"` // its own name, when the user gave it another + Plain string `json:"plain,omitempty"` // a remote magpie's model's name there alone, when Name has its provider there after it (catalog.Model's) Kept []string `json:"kept,omitempty"` // the reasoning levels the user keeps of Efforts, when not all Efforts []string `json:"efforts,omitempty"` Given bool `json:"given,omitempty"` // its levels aren't known: Efforts are those it can be given, Kept those it was @@ -560,6 +561,8 @@ func providerInfo(p provider.Provider, agents []agentUse) providerJSON { if n, ok := names[m.ID]; ok { j.Default = cmp.Or(m.Name, m.ID) j.Name = n + } else { + j.Plain = m.Plain } if api, ok := p.ModelAPI(m.ID); ok { j.API = string(api) diff --git a/internal/gui/tests/name-suffix-remote.test.cjs b/internal/gui/tests/name-suffix-remote.test.cjs new file mode 100644 index 00000000..1bd93abf --- /dev/null +++ b/internal/gui/tests/name-suffix-remote.test.cjs @@ -0,0 +1,83 @@ +// Run with Node's test runner and Playwright on the module path; see README.md. +// ARNO on Discord (我在设置里关闭了"模型名称带供应商"但是接入的remote-magpie的 +// 模型名字还是带上了…): a remote magpie's models are named in its list with +// its own provider after them ("DeepSeek Chat · Relay A"), by that magpie's +// setting, and Off here kept it. Names & levels says what the agents' lists +// show under each choice: On, that label with this provider after it; Off, +// the model's name there alone. Every language. +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 serve(lang, posts) { + let cur = { lang, theme: "light", plainNames: false, plainOwnNames: false }; + const state = { agents: [{ id: "codex", name: "Codex", path: "/test/codex", fields: [] }], profiles: [], settings: { lang, theme: "light" } }; + const models = [ + { id: "relaya/deepseek-chat", name: "DeepSeek Chat · Relay A", plain: "DeepSeek Chat", on: true, efforts: [], images: false }, + ]; + const provider = { id: "remote-magpie", name: "Remote magpie", preset: "remote-magpie", icon: "generic", chat: "http://192.168.1.20:3425/v1", responses: "http://192.168.1.20:3425/v1", anthropic: "http://192.168.1.20:3425", + models, agents: [], key: { set: true, masked: "sk-…1234" }, ready: true }; + return async (r) => { + const req = r.request(), url = new URL(req.url()); + const json = (data) => r.fulfill({ json: data }); + if (url.pathname === "/boot.js") return r.fulfill({ contentType: "text/javascript", body: `window.bootPrefs = {lang:"${lang}",theme:"light",web:true};` }); + if (url.pathname === "/wails/runtime.js") return r.fulfill({ contentType: "text/javascript", body: "export const Window = {};" }); + if (url.pathname === "/api/state") return json(state); + if (url.pathname === "/api/settings/plain-names") { + const body = req.postDataJSON(); + posts.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/gateway/trace") { + if (url.searchParams.get("wait")) return new Promise(() => {}); + return json({ mine: true, now: new Date().toISOString(), seq: 1, totals: { requests: 0, rerouted: 0, errors: 0 }, routes: [] }); + } + if (url.pathname === "/api/gateway/history") return json({ cut: false, days: [], routes: [] }); + if (url.pathname === "/api/groups") return json({ models: [], pools: [], groups: [] }); + if (url.pathname === "/api/providers") return json({ providers: [provider], presets: [], excluded: [], gateway: { running: true, window: true, url: "http://127.0.0.1:3999" } }); + 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 r.fulfill({ body: await fs.readFile(file), contentType }); + }; +} + +async function says(loc, want) { + for (let i = 0; i < 100 && (await loc.textContent()) !== want; i++) await new Promise((r) => setTimeout(r, 20)); + assert.equal(await loc.textContent(), want); +} + +for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) { + for (const lang of ["en", "zh", "zh-TW", "ja", "de"]) { + test(`${engine} ${lang}: a remote magpie's model is named alone under Off`, async (t) => { + const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: process.env.PLAYWRIGHT_CHANNEL || "chromium" })); + t.after(() => browser.close()); + // narrow, as the panel is + const page = await (await browser.newContext({ viewport: { width: 420, height: 900 }, reducedMotion: "reduce" })).newPage(); + page.setDefaultTimeout(5000); + const errors = [], posts = []; + page.on("pageerror", (e) => errors.push(e.message)); + await page.route("**/*", serve(lang, posts)); + await page.goto("http://magpie.test/?view=providers"); + const tr = (k, vars = {}) => page.evaluate(([k, vars]) => t(k, vars), [k, vars]); + await page.locator(".row.provider").click(); + if (!(await page.locator(".mnames:not([hidden])").count())) await page.getByRole("button", { name: await tr("Names & levels"), exact: true }).click(); + const sfx = page.locator(".mnames:not([hidden]) .msuffix"); + await sfx.waitFor(); + assert.equal((await sfx.locator(".opt.on").textContent()).trim(), await tr("On")); + assert.equal(await sfx.locator(".hint").textContent(), await tr("Agents’ lists show “{label}”", { label: "DeepSeek Chat · Relay A · Remote magpie" })); + await sfx.locator(".opt", { hasText: await tr("Off") }).click(); + await page.locator(".mnames:not([hidden]) .msuffix .opt.on", { hasText: await tr("Off") }).waitFor(); + await says(sfx.locator(".hint"), await tr("Agents’ lists show “{label}”", { label: "DeepSeek Chat" })); + assert.deepEqual(posts, [{ mode: "off" }]); + assert.deepEqual(errors, []); + }); + } +} diff --git a/internal/provider/modelprefs.go b/internal/provider/modelprefs.go index cda9a61b..75ed7623 100644 --- a/internal/provider/modelprefs.go +++ b/internal/provider/modelprefs.go @@ -1,6 +1,7 @@ package provider import ( + "cmp" "errors" "fmt" "maps" @@ -636,19 +637,25 @@ func Labels(es []Entry) []string { same := map[string]int{} if plain || own { for _, e := range es { - same[strings.ToLower(e.Name)]++ + same[strings.ToLower(e.PlainName())]++ } } for i, e := range es { out[i] = e.Label() mine := own && (e.Default != "" || e.Named) - if (plain || mine) && e.Name != "" && same[strings.ToLower(e.Name)] == 1 { - out[i] = e.Name + if name := e.PlainName(); (plain || mine) && name != "" && same[strings.ToLower(name)] == 1 { + out[i] = name } } return out } +// PlainName is the entry's name alone: a remote magpie's model's without +// the provider its list put after it by that magpie's own setting, which +// this one's Provider in model names Off leaves out as well (ARNO on +// Discord: "· Relay A" and "· routing group" stayed under Off). +func (e Entry) PlainName() string { return cmp.Or(e.Plain, e.Name) } + // 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). diff --git a/internal/provider/models.go b/internal/provider/models.go index 0740783f..531f4b79 100644 --- a/internal/provider/models.go +++ b/internal/provider/models.go @@ -1183,10 +1183,13 @@ func (p Provider) Chosen(id string) bool { // Entry is one model as the agents see it. type Entry struct { - ID string `json:"id"` // what the agent sends magpie - Model string `json:"model"` // what magpie sends the vendor - Name string `json:"name"` // the user's name for it, when they gave one (SetModelName) - Default string `json:"default,omitempty"` // the model's own name, when the user gave it another + ID string `json:"id"` // what the agent sends magpie + Model string `json:"model"` // what magpie sends the vendor + Name string `json:"name"` // the user's name for it, when they gave one (SetModelName) + Default string `json:"default,omitempty"` // the model's own name, when the user gave it another + // Plain is a remote magpie's model's name there alone, when Name is + // its label there with that magpie's provider after it (catalog.Model's) + Plain string `json:"-"` Efforts []string `json:"efforts,omitempty"` Provider Provider `json:"-"` // a group's: its first member's Group string `json:"group,omitempty"` // set on a routing group (group.go) @@ -1298,6 +1301,8 @@ func entryFor(p Provider, m catalog.Model, s settings.Settings) Entry { Images: images, ImageInput: imageInput, Context: ctx, Output: output, Free: m.Free, Rate: m.Rate, RateWas: m.RateWas} if n, ok := modelNameIn(s.ModelNames, p.ID, m.ID); ok { e.Name, e.Default = n, name + } else { + e.Plain = m.Plain } // a model that thinks still does with the levels the user kept or // none at all; one its source says nothing of thinks as most of the diff --git a/internal/provider/remote_magpie.go b/internal/provider/remote_magpie.go index 655ff149..e5203131 100644 --- a/internal/provider/remote_magpie.go +++ b/internal/provider/remote_magpie.go @@ -26,6 +26,9 @@ import ( // Each model's label is the other magpie's name for it, with its provider // there after it ("Claude Sonnet 5 · RelayA"), as its own agents see it: // two of its providers' models of one name are told apart here too. +// Provider in model names Off here names each by its name there alone +// (display_name, catalog.Model's Plain), whatever that magpie's own +// setting put after it (ARNO on Discord). // Its video models come the same way (VideomakersHeader), marked "kind": // "video", and go on to its videos API (#545).