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).