diff --git a/internal/agent/reseat.go b/internal/agent/reseat.go index c52ce902..43c9b8c9 100644 --- a/internal/agent/reseat.go +++ b/internal/agent/reseat.go @@ -142,7 +142,7 @@ func sameModel(opts []Option, ref string) Option { if o.Ref == "" || strings.HasPrefix(o.Ref, provider.GroupPrefix) || !served(o.Ref) { continue } - if _, m, _ := strings.Cut(o.Ref, "/"); provider.AutoGroupID(m) == gid { + if pid, m, _ := strings.Cut(o.Ref, "/"); provider.AutoGroupOf(pid, m) == gid { return o } } diff --git a/internal/gui/assets/app.css b/internal/gui/assets/app.css index 6cfd358e..055194cb 100644 --- a/internal/gui/assets/app.css +++ b/internal/gui/assets/app.css @@ -2019,6 +2019,9 @@ body.window .ag-sk-icon { width: 22px; height: 22px; border-radius: 6px; } .mname .mwho input { flex: 1; min-width: 0; max-width: 220px; height: 25px; } .mname .mwho code { font-size: 11px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; } .mname .mapi { flex: none; height: 24px; font-size: 11px; } +.mname .msame { display: flex; align-items: center; gap: 6px; flex: 0 1 230px; min-width: 0; font-size: 11.5px; color: var(--muted); } +.mname .msame span { flex: none; } +.mname .msame input { flex: 1; min-width: 0; height: 25px; font-size: 11.5px; } .mname .mlevels { display: flex; flex-wrap: wrap; gap: 2px 10px; font-size: 11.5px; } .editor .mname label.tick { gap: 4px; } .mname .text { flex: none; padding: 2px 7px; font-size: 11.5px; } diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js index 5b69f2fa..0384c983 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -6243,6 +6243,24 @@ function renderModels(p) { const who = el("div", "mwho"); who.append(name, el("code", "", m.id)); row.append(who); + // the model of other providers' it is, for the routing groups magpie + // finds, when its id is one no rule matches up with theirs + // (Volcengine's deepseek-v4-1-flash-260910 and others' + // deepseek-v4.1-flash, #583); empty merges it by its own id, which + // the box shows as it is merged + const sameNow = () => prefs[id]?.same ?? m.same ?? ""; + const sameBox = el("label", "msame"); + const same = input(sameNow(), m.merge || m.id); + same.title = t("The model other providers serve that {id} is the same as: the routing groups magpie finds put them together. Empty: by its own id", { id: m.id }); + same.spellcheck = false; + same.onchange = () => { + const v = same.value.trim(); + if (v === (m.same || "")) delete pref().same; else pref().same = v; + drawReset(); + }; + same.onkeydown = (e) => { e.stopPropagation(); if (e.key === "Enter") same.blur(); else if (e.key === "Escape") { same.value = sameNow(); same.blur(); } }; + sameBox.append(el("span", "", t("Same as")), same); + row.append(sameBox); const [img, imgCb] = tick(t("Accepts images"), imagesNow()); img.title = t("Whether agents are told {id} can see images", { id: m.id }); imgCb.onchange = () => { @@ -6297,7 +6315,7 @@ function renderModels(p) { const unsaved = el("span", "hint munsaved", t("unsaved")); unsaved.title = t("Made when the provider is saved; Cancel drops it"); const reset = el("button", "text action", t("Restore default")); - reset.title = t("Its own name, every reasoning level it has, whether it sees images, and the API it is asked on"); + reset.title = t("Its own name, every reasoning level it has, whether it sees images, the API it is asked on, and the model it is the same as"); reset.onclick = () => { prefs[id] = {}; if (m.default) prefs[id].name = ""; @@ -6307,6 +6325,8 @@ function renderModels(p) { imgCb.checked = imagesNow(); for (const [l, cb] of boxes) cb.checked = keptNow().includes(l); if (m.api) prefs[id].api = ""; + if (m.same) prefs[id].same = ""; + same.value = sameNow(); if (apiSeg) { for (const b of apiSeg.querySelectorAll(".opt")) b.classList.toggle("on", b.dataset.api === apiNow()); slide(apiSeg, "api"); } drawReset(); }; @@ -6316,7 +6336,7 @@ function renderModels(p) { unsaved.hidden = !prefs[id]; // staged back to its own already, there is nothing to restore const images = prefs[id]?.ownImages ? false : prefs[id]?.images !== undefined ? prefs[id].images !== !!m.ownImages : !!m.imageSet; - const custom = nameNow() !== "" || (prefs[id]?.efforts ? prefs[id].efforts.length > 0 : !!m.kept?.length) || images || apiNow() !== ""; + const custom = nameNow() !== "" || (prefs[id]?.efforts ? prefs[id].efforts.length > 0 : !!m.kept?.length) || images || apiNow() !== "" || sameNow() !== ""; reset.hidden = !custom; }; row.append(unsaved, reset); diff --git a/internal/gui/assets/i18n.js b/internal/gui/assets/i18n.js index e84b75cb..6f410e54 100644 --- a/internal/gui/assets/i18n.js +++ b/internal/gui/assets/i18n.js @@ -903,7 +903,9 @@ const I18N = { "Keep at least one level": "至少保留一个档位", "{id}: {levels}": "{id}:{levels}", "Restore default": "恢复默认", - "Its own name, every reasoning level it has, whether it sees images, and the API it is asked on": "恢复默认名称、全部推理档位、是否支持图片,以及请求所用的协议", + "Its own name, every reasoning level it has, whether it sees images, the API it is asked on, and the model it is the same as": "恢复默认名称、全部推理档位、是否支持图片、请求所用的协议,以及它等同的模型", + "Same as": "等同于", + "The model other providers serve that {id} is the same as: the routing groups magpie finds put them together. Empty: by its own id": "{id} 与其他供应商的哪个模型是同一个:magpie 自动发现的路由分组会把它们合并在一起。留空则按它自己的 ID 合并", "The API {id} is asked on. Auto: as the vendor's list says, else each URL the provider has; pick one when the vendor serves it on that one only": "{id} 请求所用的协议。自动:按供应商模型列表所说,否则依次用供应商已填的各个地址;供应商只在某个协议上提供它时,选那一个", "{id} is as its provider has it again": "{id} 已恢复为供应商的默认设置", "unsaved": "未保存", diff --git a/internal/gui/providers.go b/internal/gui/providers.go index 62fe4b50..0816f07d 100644 --- a/internal/gui/providers.go +++ b/internal/gui/providers.go @@ -40,6 +40,8 @@ type modelJSON struct { Rate float64 `json:"rate,omitempty"` // the credits a request costs the subscription, as a multiple RateWas float64 `json:"rateWas,omitempty"` // the rate before a discount running now API string `json:"api,omitempty"` // the one API the user said it is asked on + Same string `json:"same,omitempty"` // the model the user said it is the same as, for the groups magpie finds (#583) + Merge string `json:"merge,omitempty"` // what those groups merge it by when the user says nothing } type providerJSON struct { @@ -376,6 +378,7 @@ func providerInfo(p provider.Provider, agents []agentUse) providerJSON { } seen := map[string]bool{} names, kept := p.ModelNames(), p.ModelEfforts() + sames := settings.Load().ModelSameAs // a list fetched before magpie kept each model's most: the one Codex // CLI keeps says it var most []catalog.Model @@ -401,6 +404,8 @@ func providerInfo(p provider.Provider, agents []agentUse) providerJSON { if api, ok := p.ModelAPI(m.ID); ok { j.API = string(api) } + j.Same = sames[p.ID+"/"+m.ID] + j.Merge = provider.MergeName(m.ID) if len(j.Efforts) == 0 { j.Efforts, j.Given = provider.Levels, true } diff --git a/internal/gui/providers_modelprefs_test.go b/internal/gui/providers_modelprefs_test.go index 3dcadac1..017f0660 100644 --- a/internal/gui/providers_modelprefs_test.go +++ b/internal/gui/providers_modelprefs_test.go @@ -38,6 +38,13 @@ func TestProviderSaveModelPrefs(t *testing.T) { if s.ModelNames["relay/sol"] != "My Sol" || !slices.Equal(s.ModelEfforts["relay/sol"], []string{"low", "high"}) || !s.ModelImages["relay/sol"] { t.Fatalf("names %v, efforts %v, images %v", s.ModelNames, s.ModelEfforts, s.ModelImages) } + // the model it is the same as, for the groups magpie finds (#583) + if w := post(`{"id":"relay","from":"relay","name":"Relay","chat":"http://127.0.0.1:1/v1","models":["sol"],"modelPrefs":{"sol":{"same":"deepseek-v4.1-flash"}}}`); w.Code != 200 { + t.Fatalf("%d %s", w.Code, w.Body) + } + if got := settings.Load().ModelSameAs["relay/sol"]; got != "deepseek-v4.1-flash" { + t.Fatalf("same as %q", got) + } // one it refuses fails the Save, saying why if w := post(`{"id":"relay","from":"relay","name":"Relay","chat":"http://127.0.0.1:1/v1","models":["sol"],"modelPrefs":{"sol":{"efforts":["loud"]}}}`); w.Code == 200 || !strings.Contains(w.Body.String(), "loud") { t.Fatalf("%d %s", w.Code, w.Body) diff --git a/internal/gui/settings_keep_test.go b/internal/gui/settings_keep_test.go index b6dac650..00b0a700 100644 --- a/internal/gui/settings_keep_test.go +++ b/internal/gui/settings_keep_test.go @@ -78,6 +78,7 @@ func TestSettingsSaveKeepsWhatItDoesNotSend(t *testing.T) { ModelPrices: map[string]settings.ModelPrice{"p/m": {Input: &one, Output: &two}}, ModelWires: map[string]string{"p/m": "vendor-c/m"}, ModelAPIs: map[string]string{"p/m": "anthropic"}, + ModelSameAs: map[string]string{"p/m": "deepseek-v4.1-flash"}, RedactRules: []redact.Rule{{Kind: "prefix", Prefix: "oc_sk_"}}, LAN: true, LANKey: "sk-lan", diff --git a/internal/gui/tests/provider-detect.test.cjs b/internal/gui/tests/provider-detect.test.cjs index 26028181..745382b9 100644 --- a/internal/gui/tests/provider-detect.test.cjs +++ b/internal/gui/tests/provider-detect.test.cjs @@ -150,7 +150,7 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "Taken · save to keep", "Ask {model} on {api} only", "Staged in Names & levels and made with the Save; Auto there gives it back", "{model} is asked on {api} once saved", "no URL to ask", "no model to try: type one the vendor serves", "no key is on for this endpoint", "The API {id} is asked on. Auto: as the vendor's list says, else each URL the provider has; pick one when the vendor serves it on that one only", - "Its own name, every reasoning level it has, whether it sees images, and the API it is asked on", + "Its own name, every reasoning level it has, whether it sees images, the API it is asked on, and the model it is the same as", ].filter((k) => !I18N.zh[k])); assert.deepEqual(missing, [], "every string has its Chinese"); assert.deepEqual(errors, []); diff --git a/internal/gui/tests/provider-same-as.test.cjs b/internal/gui/tests/provider-same-as.test.cjs new file mode 100644 index 00000000..7ad2bf81 --- /dev/null +++ b/internal/gui/tests/provider-same-as.test.cjs @@ -0,0 +1,99 @@ +// A model a vendor names its own way (Volcengine Ark's +// deepseek-v4-1-flash-260910) can be said, in the provider editor's Names & +// levels, to be the same as another vendor's model, so the routing groups +// magpie finds merge them (kyzhouxu, #583). It is staged and made with the +// editor's Save, as the rest of Names & levels is; Cancel drops it. +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"); +const levels = ["none", "low", "medium", "high"]; + +function serve(lang, posts) { + const state = { agents: [], profiles: [], settings: { lang, theme: "light" } }; + const models = [ + { id: "deepseek-v4-1-flash-260910", name: "deepseek-v4-1-flash-260910", on: true, efforts: levels, images: false, merge: "deepseek-v4-1-flash" }, + { id: "ep-2026-sol", name: "ep-2026-sol", on: true, efforts: levels, images: false, merge: "ep-2026-sol", same: "sol" }, + ]; + const provider = { id: "relay", name: "Relay", icon: "generic", chat: "https://relay.example/v1", responses: "", anthropic: "", models, agents: [], key: { set: true, masked: "sk-…1234" }, ready: true }; + const providers = { providers: [provider], presets: [], excluded: [], gateway: { running: true, window: true } }; + return async (route) => { + const url = new URL(route.request().url()); + const json = (data) => route.fulfill({ json: data }); + if (url.pathname === "/boot.js") return route.fulfill({ contentType: "text/javascript", body: `window.bootPrefs = {lang:${JSON.stringify(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/providers") return json(providers); + if (url.pathname === "/api/groups") return json({ groups: [] }); + if (url.pathname.startsWith("/api/provider/")) { + posts.push({ path: url.pathname, body: route.request().postDataJSON() }); + return json(providers); + } + if (url.pathname.startsWith("/api/")) return json({}); + const file = path.join(assets, url.pathname === "/" ? "index.html" : url.pathname); + await route.fulfill({ body: await fs.readFile(file), contentType: { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".png": "image/png" }[path.extname(file)] }); + }; +} + +for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) { + for (const lang of ["en", "zh"]) { + test(`${engine} ${lang}: the model a model is the same as is staged and made with the provider's Save`, async (t) => { + const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: process.env.PLAYWRIGHT_CHANNEL || "chromium" })); + t.after(() => browser.close()); + const page = await (await browser.newContext({ viewport: { width: 900, height: 720 }, 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 zh = lang === "zh"; + const L = { names: zh ? "名称与推理档位" : "Names & levels", same: zh ? "等同于" : "Same as", unsaved: zh ? "未保存" : "unsaved", save: zh ? "保存" : "Save", cancel: zh ? "取消" : "Cancel", reset: zh ? "恢复默认" : "Restore default" }; + const open = async () => { + await page.locator(".row.provider").click(); + if (!(await page.locator(".mnames:not([hidden])").count())) await page.getByRole("button", { name: L.names, exact: true }).click(); + }; + const row = (id) => page.locator(".mname", { has: page.locator("code", { hasText: id }) }); + const box = (id) => row(id).getByRole("textbox", { name: L.same, exact: true }); + await open(); + // empty, it says what the found groups merge it by now; one given shows + assert.equal(await box("deepseek-v4-1-flash-260910").inputValue(), ""); + assert.equal(await box("deepseek-v4-1-flash-260910").getAttribute("placeholder"), "deepseek-v4-1-flash"); + assert.equal(await box("ep-2026-sol").inputValue(), "sol"); + const list = await page.locator(".mnames:not([hidden])").elementHandle(); + const y = await page.evaluate(() => scrollY); + await box("deepseek-v4-1-flash-260910").fill("deepseek-v4.1-flash"); + await box("deepseek-v4-1-flash-260910").press("Enter"); + await row("ep-2026-sol").getByRole("button", { name: L.reset }).click(); + await page.waitForTimeout(200); + assert.deepEqual(posts, [], "nothing is sent before the Save"); + assert(await list.evaluate((e) => e.isConnected), "the list is not redrawn"); + assert.equal(await page.evaluate(() => scrollY), y, "nothing scrolls the page"); + assert.equal(await box("ep-2026-sol").inputValue(), "", "Restore default empties it"); + for (const id of ["deepseek-v4-1-flash-260910", "ep-2026-sol"]) assert(await row(id).getByText(L.unsaved, { exact: true }).isVisible(), id + " says it is unsaved"); + // Cancel drops them + await page.getByRole("button", { name: L.cancel, exact: true }).click(); + await open(); + assert.equal(await box("deepseek-v4-1-flash-260910").inputValue(), ""); + assert.equal(await box("ep-2026-sol").inputValue(), "sol"); + assert.deepEqual(posts, []); + // given again and saved: one Save carries them + await box("deepseek-v4-1-flash-260910").fill("deepseek-v4.1-flash"); + await box("deepseek-v4-1-flash-260910").press("Enter"); + await box("ep-2026-sol").fill(""); + await box("ep-2026-sol").press("Enter"); + await page.getByRole("button", { name: L.save, exact: true }).click(); + await page.waitForFunction(() => !document.querySelector(".mnames")); + assert.deepEqual(posts.map((p) => p.path), ["/api/provider/save"]); + assert.deepEqual(posts[0].body.modelPrefs, { "deepseek-v4-1-flash-260910": { same: "deepseek-v4.1-flash" }, "ep-2026-sol": { same: "" } }); + const missing = await page.evaluate(() => ["Same as", + "The model other providers serve that {id} is the same as: the routing groups magpie finds put them together. Empty: by its own id", + "Its own name, every reasoning level it has, whether it sees images, the API it is asked on, and the model it is the same as", + ].filter((k) => !I18N.zh[k])); + assert.deepEqual(missing, [], "every string has its Chinese"); + assert.deepEqual(errors, []); + }); + } +} diff --git a/internal/provider/group.go b/internal/provider/group.go index 84373e4a..63366696 100644 --- a/internal/provider/group.go +++ b/internal/provider/group.go @@ -10,7 +10,9 @@ package provider // its own: a model more than one provider serves under the same name is a // group of those, derived each time and never stored until the user // changes one. Models of different names are only ever grouped by the -// user: nothing here guesses which models are alike. +// user — in a group of their own, or by saying in a provider's Names & +// levels that one is the same as another (settings' ModelSameAs): nothing +// here guesses which models are alike beyond how vendors spell one id. // // A group's member may be another group ("group/"): to the group it is // one member, which a rule can put first like a model; its models are its @@ -23,6 +25,8 @@ import ( "fmt" "slices" "strings" + + "github.com/yetone/magpie/internal/settings" ) // GroupPrefix starts a group's id in the catalog: "group/". @@ -198,7 +202,7 @@ func groupsIn(entries []Entry) []Group { if f.NoAutoGroups { return out } - for _, g := range autoGroups(entries) { + for _, g := range autoGroups(entries, settings.Load().ModelSameAs) { if slices.ContainsFunc(out, func(o Group) bool { return o.ID == g.ID }) { continue // the user changed it: theirs now } @@ -261,6 +265,27 @@ func SetAutoGroups(on bool) error { // vendor spells it: "auto-claude-opus-5-5" for claude-opus-5.5. func AutoGroupID(model string) string { return "auto-" + Slug(sameModel(model)) } +// AutoGroupOf is the id of the group magpie finds for a provider's model: +// AutoGroupID of the model the user said it is the same as (settings' +// ModelSameAs), else of its own id. +func AutoGroupOf(pid, model string) string { + return "auto-" + Slug(mergeKey(pid, model, settings.Load().ModelSameAs)) +} + +// MergeName is the name the groups magpie finds merge a model by, when +// the user said nothing of it: its id as vendors agree on it (sameModel). +func MergeName(model string) string { return sameModel(model) } + +// mergeKey is what the groups magpie finds merge pid's model by: the +// model the user said it is the same as (same, by "/"), +// spelt as vendors agree on it, else its own id so spelt. +func mergeKey(pid, model string, same map[string]string) string { + if v := same[pid+"/"+model]; v != "" { + return sameModel(v) + } + return sameModel(model) +} + // AutoStandIn is the model a request for a group magpie found goes to while // such groups are off (SetAutoGroups): its model, from the first provider // that serves it, as "provider/model". An agent set to the group, or a @@ -276,7 +301,7 @@ func AutoStandIn(id string) (string, bool) { return "", false } for _, e := range entries { - if AutoGroupID(e.Model) == gid { + if AutoGroupOf(e.Provider.ID, e.Model) == gid { return e.ID, true } } @@ -284,13 +309,14 @@ func AutoStandIn(id string) (string, bool) { } // autoGroups are the models more than one ready provider serves under the -// same name — however each vendor spells it (see sameModel) — in the order -// the providers were added. -func autoGroups(entries []Entry) []Group { +// same name — however each vendor spells it (see sameModel), or as the +// user said one is the same as another (same: settings' ModelSameAs) — in +// the order the providers were added. +func autoGroups(entries []Entry, same map[string]string) []Group { var order []string by := map[string][]Entry{} for _, e := range entries { - k := sameModel(e.Model) + k := mergeKey(e.Provider.ID, e.Model, same) if !slices.ContainsFunc(by[k], func(o Entry) bool { return o.Provider.ID == e.Provider.ID }) { if by[k] == nil { order = append(order, k) @@ -324,28 +350,52 @@ func autoGroups(entries []Entry) []Group { } // sameModel is a model's name as vendors agree on it: lowercase, without -// the vendor's own prefix ("anthropic/claude-sonnet-5" is claude-sonnet-5), -// with a version's dot as Anthropic writes it ("claude-opus-5.5" is -// claude-opus-5-5) and without the snapshot date some add -// ("claude-opus-5-5-20260801"). A variant after ":" (":batch") stays apart. +// the vendor's own prefix ("anthropic/claude-sonnet-5" is claude-sonnet-5, +// "accounts/fireworks/models/…" too), with a version's dot as Anthropic +// writes it ("claude-opus-5.5" is claude-opus-5-5, and so is Fireworks' +// "p" for the dot, "deepseek-v4p1"), "_" as "-", and without the snapshot +// date some add: "claude-opus-5-5-20260801", Vertex's "…@20260801", and +// Volcengine Ark's six digits ("deepseek-v4-1-flash-260910" is +// deepseek-v4-1-flash, #583). Anything else stays: a variant after ":" +// (":batch"), -flash, -thinking, and a four-digit release (qwen's -2507, +// kimi-k2-0905) that is a model of its own. func sameModel(id string) string { - k := strings.ToLower(id) + k := strings.ReplaceAll(strings.ToLower(strings.TrimSpace(id)), "_", "-") if i := strings.LastIndex(k, "/"); i >= 0 { k = k[i+1:] } b := []byte(k) for i := 1; i+1 < len(b); i++ { - if b[i] == '.' && isDigit(b[i-1]) && isDigit(b[i+1]) { + if (b[i] == '.' || b[i] == 'p') && isDigit(b[i-1]) && isDigit(b[i+1]) { b[i] = '-' } } k = string(b) - if i := strings.LastIndex(k, "-"); i > 0 && len(k)-i-1 == 8 && strings.HasPrefix(k[i+1:], "20") && strings.Trim(k[i+1:], "0123456789") == "" { + if i := strings.LastIndexAny(k, "-@"); i > 0 && snapshotDate(k[i+1:]) { k = k[:i] } return k } +// snapshotDate is whether s is the date a vendor dates a snapshot of a +// model by: YYYYMMDD from 2000 on, or Ark's YYMMDD from 2023 on, with a +// month and a day that are one. +func snapshotDate(s string) bool { + if strings.Trim(s, "0123456789") != "" { + return false + } + switch { + case len(s) == 8 && strings.HasPrefix(s, "20"): + return true + case len(s) == 6: + yy, mm, dd := atoi2(s[0:2]), atoi2(s[2:4]), atoi2(s[4:6]) + return yy >= 23 && yy <= 39 && mm >= 1 && mm <= 12 && dd >= 1 && dd <= 31 + } + return false +} + +func atoi2(s string) int { return int(s[0]-'0')*10 + int(s[1]-'0') } + func isDigit(c byte) bool { return c >= '0' && c <= '9' } // FindGroup looks a group up by its catalog id ("group/") and resolves @@ -765,7 +815,7 @@ func DeleteGroup(id string) error { } return false }) - if slices.ContainsFunc(autoGroups(providerEntries()), func(g Group) bool { return g.ID == id }) { + if slices.ContainsFunc(autoGroups(providerEntries(), settings.Load().ModelSameAs), func(g Group) bool { return g.ID == id }) { f.Groups = append(f.Groups, Group{ID: id, Hidden: true}) found = true } @@ -824,7 +874,7 @@ func RenameGroup(from, to string) error { slices.ContainsFunc(f.Groups, func(o Group) bool { return o.ID == to }) { return fmt.Errorf("there is a group %q already", to) } - found := slices.ContainsFunc(autoGroups(providerEntries()), func(o Group) bool { return o.ID == from }) + found := slices.ContainsFunc(autoGroups(providerEntries(), settings.Load().ModelSameAs), func(o Group) bool { return o.ID == from }) g.ID, g.Auto, g.Hidden = to, false, false f.Groups = slices.DeleteFunc(f.Groups, func(o Group) bool { return o.ID == from }) if found { diff --git a/internal/provider/group_test.go b/internal/provider/group_test.go index 88d110ae..98f53514 100644 --- a/internal/provider/group_test.go +++ b/internal/provider/group_test.go @@ -25,7 +25,7 @@ func TestAutoGroupsSameModel(t *testing.T) { e("openrouter", "anthropic/claude-opus-5.5", "anthropic/claude-opus-5.5"), e("copilot", "claude-opus-5.5", "claude-opus-5.5"), e("claude", "claude-opus-5-5", "Claude Opus 5.5"), - }) + }, nil) if len(gs) != 1 || gs[0].ID != "auto-claude-opus-5-5" || len(gs[0].Members) != 3 || gs[0].Name != "Claude Opus 5.5" { t.Fatalf("groups: %+v", gs) } diff --git a/internal/provider/modelprefs.go b/internal/provider/modelprefs.go index 53e99137..eacc56c4 100644 --- a/internal/provider/modelprefs.go +++ b/internal/provider/modelprefs.go @@ -426,18 +426,21 @@ func (p Provider) ModelAPI(model string) (Protocol, bool) { // ModelPref is what the provider editor's Names & levels changed of one // model, sent with its Save: each part left nil is as it was. Name "" gives // the model its own name back, Efforts [] all its levels, OwnImages the -// vendor's answer for whether it sees images, and API "" every API the -// provider has for it. +// vendor's answer for whether it sees images, API "" every API the +// provider has for it, and Same "" its own id to merge it with other +// vendors' by (see SetModelSame). type ModelPref struct { Name *string `json:"name,omitempty"` Efforts *[]string `json:"efforts,omitempty"` Images *bool `json:"images,omitempty"` OwnImages bool `json:"ownImages,omitempty"` API *string `json:"api,omitempty"` + Same *string `json:"same,omitempty"` } // SetModelPrefs makes the changes to a provider's models, by model id, as -// SetModelName, SetModelEfforts, SetModelImage and SetModelAPI do, and tells the agents +// SetModelName, SetModelEfforts, SetModelImage, SetModelAPI and +// SetModelSame do, and tells the agents // once, after them all, rather than once a change. It stops at the first // that fails, telling the agents of those made before it. func SetModelPrefs(pid string, prefs map[string]ModelPref) error { @@ -473,12 +476,66 @@ func SetModelPrefs(pid string, prefs map[string]ModelPref) error { return err } } + if m.Same != nil { + if err := set(setModelSame(ref, *m.Same)); err != nil { + return err + } + } } return nil }() return touchedIf(changed, err) } +// SetModelSame says which model a provider's model, spelt "provider/model", +// is the same as, for one a vendor names its own way (Volcengine Ark's +// dated ids, kyzhouxu on #583): the routing groups magpie finds merge it +// with that model from every other provider (autoGroups). The name is a +// model's id as any vendor spells it ("deepseek-v4.1-flash", or with a +// vendor's prefix); "" — or a name that is the model's own however spelt — +// merges it by its own id again. The agents are told, the groups they are +// shown having changed. +func SetModelSame(ref, same string) error { + return touchedIf(setModelSame(ref, same)) +} + +func setModelSame(ref, same string) (bool, error) { + p, model, err := splitRef(ref) + if err != nil { + return false, err + } + same = strings.TrimSpace(same) + if len([]rune(same)) > 80 { + return false, errors.New("the model a model is the same as is at most 80 characters") + } + if same != "" && Slug(sameModel(same)) == "" { + return false, fmt.Errorf("%q names no model: give a model's id, as deepseek-v4.1-flash", same) + } + if sameModel(same) == sameModel(model) { + same = "" // its own id: merged by it already + } + if same != "" && !p.serves(model) { + return false, fmt.Errorf("%s has no model %s (magpie provider %s lists them)", p.ID, model, p.ID) + } + s := settings.Load() + key := p.ID + "/" + model + if s.ModelSameAs[key] == same { + return false, nil + } + if same == "" { + delete(s.ModelSameAs, key) + } else { + if s.ModelSameAs == nil { + s.ModelSameAs = map[string]string{} + } + s.ModelSameAs[key] = same + } + if err := settings.Save(s); err != nil { + return false, err + } + return true, nil +} + // ImageOverride is the user's answer for whether pid's model takes images. func ImageOverride(pid, model string) (bool, bool) { v, ok := settings.Load().ModelImages[pid+"/"+model] diff --git a/internal/provider/modelsame_test.go b/internal/provider/modelsame_test.go new file mode 100644 index 00000000..2710ec30 --- /dev/null +++ b/internal/provider/modelsame_test.go @@ -0,0 +1,131 @@ +package provider + +import ( + "slices" + "strings" + "testing" + + "github.com/yetone/magpie/internal/catalog" + "github.com/yetone/magpie/internal/settings" +) + +// The groups magpie finds merge a model however a vendor spells its id: +// Volcengine Ark's dated deepseek-v4-1-flash-260910 is ali's +// deepseek-v4.1-flash (kyzhouxu, #583). Models that differ stay apart. +func TestSameModelVendorSpellings(t *testing.T) { + for in, want := range map[string]string{ + // Ark's YYMMDD snapshot + "deepseek-v4-1-flash-260910": "deepseek-v4-1-flash", + "deepseek-v4.1-flash": "deepseek-v4-1-flash", + "DeepSeek-V4.1-Flash": "deepseek-v4-1-flash", + "deepseek-ai/DeepSeek-V4.1-Flash": "deepseek-v4-1-flash", + "doubao-seed-1-6-250615": "doubao-seed-1-6", + "doubao-seed-1.6-thinking-250715": "doubao-seed-1-6-thinking", + // Fireworks: its path, and "p" for the dot + "accounts/fireworks/models/deepseek-v4p1-flash": "deepseek-v4-1-flash", + "accounts/fireworks/models/qwen2p5-72b": "qwen2-5-72b", + // "_" for "-", and Vertex's "@" date + "deepseek_v4_1_flash": "deepseek-v4-1-flash", + "claude-opus-5-5@20260801": "claude-opus-5-5", + // not a date: kept + "deepseek-v4-1-flash-128000": "deepseek-v4-1-flash-128000", + "m-221301": "m-221301", + "m-261301": "m-261301", + "m-260100": "m-260100", + "qwen3-235b-a22b-2507": "qwen3-235b-a22b-2507", + "kimi-k2-0905": "kimi-k2-0905", + "gpt-4o": "gpt-4o", + "o3-pro": "o3-pro", + "llama-3p": "llama-3p", + } { + if got := sameModel(in); got != want { + t.Errorf("sameModel(%q) = %q, want %q", in, got, want) + } + } + // genuinely different models: never one group + for _, pair := range [][2]string{ + {"deepseek-v4-1-flash-260910", "deepseek-v4.1"}, + {"deepseek-v4-1-260910", "deepseek-v4.1-flash"}, + {"deepseek-v4.1-thinking", "deepseek-v4.1"}, + {"qwen3-235b-a22b-2507", "qwen3-235b-a22b"}, + {"claude-opus-5.5:batch", "claude-opus-5.5"}, + {"deepseek-v4.1-flash", "deepseek-v4.11-flash"}, + } { + if sameModel(pair[0]) == sameModel(pair[1]) { + t.Errorf("%s and %s merged as %s", pair[0], pair[1], sameModel(pair[0])) + } + } + e := func(p, m string) Entry { return Entry{ID: p + "/" + m, Model: m, Name: m, Provider: Provider{ID: p}} } + gs := autoGroups([]Entry{e("volcengine", "deepseek-v4-1-flash-260910"), e("ali-tp", "deepseek-v4.1-flash")}, nil) + if len(gs) != 1 || gs[0].ID != "auto-deepseek-v4-1-flash" || !slices.Equal(gs[0].Members, []string{"volcengine/deepseek-v4-1-flash-260910", "ali-tp/deepseek-v4.1-flash"}) { + t.Fatalf("groups: %+v", gs) + } +} + +// A model whose id no rule matches up with another vendor's is merged +// with it once the user says, in the provider's Names & levels, which +// model it is the same as (#583): saved with the provider's Save, kept in +// settings' ModelSameAs, and taken by the groups magpie finds. +func TestModelSameAsMergesFoundGroup(t *testing.T) { + prefsHome(t) + touched := 0 + catalog.Changed = func() { touched++ } + t.Cleanup(func() { catalog.Changed = nil }) + if err := Save(Provider{ID: "c", Name: "C", Key: "k", Chat: "http://127.0.0.1:1/v1", Models: []string{"ep-sol-x"}}); err != nil { + t.Fatal(err) + } + members := func(gid string) []string { + _, ms, ok := FindGroup(GroupPrefix + gid) + if !ok { + return nil + } + var out []string + for _, m := range ms { + out = append(out, m.ID) + } + return out + } + if got := members("auto-sol"); !slices.Equal(got, []string{"a/sol", "b/sol"}) { + t.Fatalf("before: %v", got) + } + touched = 0 + same := "Vendor/SOL" + if err := SetModelPrefs("c", map[string]ModelPref{"ep-sol-x": {Same: &same}}); err != nil { + t.Fatal(err) + } + if touched != 1 || settings.Load().ModelSameAs["c/ep-sol-x"] != "Vendor/SOL" { + t.Fatalf("told %d, saved %v", touched, settings.Load().ModelSameAs) + } + if got := members("auto-sol"); !slices.Equal(got, []string{"a/sol", "b/sol", "c/ep-sol-x"}) { + t.Fatalf("after: %v", got) + } + if AutoGroupOf("c", "ep-sol-x") != "auto-sol" || AutoGroupID("ep-sol-x") != "auto-ep-sol-x" { + t.Fatalf("AutoGroupOf %s", AutoGroupOf("c", "ep-sol-x")) + } + if g, ok := GroupFor("sol"); !ok || g != GroupPrefix+"auto-sol" { + t.Fatalf("GroupFor: %s %v", g, ok) + } + // the same again: nothing to tell + if err := SetModelPrefs("c", map[string]ModelPref{"ep-sol-x": {Same: &same}}); err != nil || touched != 1 { + t.Fatalf("%v, told %d", err, touched) + } + // its own id however spelt is no mapping: nothing kept + own := "c/EP_Sol-X" + if err := SetModelPrefs("c", map[string]ModelPref{"ep-sol-x": {Same: &own}}); err != nil { + t.Fatal(err) + } + if _, ok := settings.Load().ModelSameAs["c/ep-sol-x"]; ok || touched != 2 { + t.Fatalf("kept %v, told %d", settings.Load().ModelSameAs, touched) + } + if got := members("auto-sol"); !slices.Equal(got, []string{"a/sol", "b/sol"}) { + t.Fatalf("restored: %v", got) + } + // a model the provider doesn't serve, or a name of no model, is refused + if err := SetModelPrefs("c", map[string]ModelPref{"nope": {Same: &same}}); err == nil || !strings.Contains(err.Error(), "nope") { + t.Fatalf("got %v", err) + } + junk := "--/" + if err := SetModelPrefs("c", map[string]ModelPref{"ep-sol-x": {Same: &junk}}); err == nil { + t.Fatal("a name of no model was taken") + } +} diff --git a/internal/provider/provider_order_test.go b/internal/provider/provider_order_test.go index e248b0c7..fafb383a 100644 --- a/internal/provider/provider_order_test.go +++ b/internal/provider/provider_order_test.go @@ -38,11 +38,11 @@ func TestProviderOrder(t *testing.T) { if p, _, ok := Resolve("m"); !ok || p.ID != "c" { t.Fatalf("m resolves to %q, not c", p.ID) } - g := slices.IndexFunc(autoGroups(providerEntries()), func(g Group) bool { return g.ID == AutoGroupID("m") }) + g := slices.IndexFunc(autoGroups(providerEntries(), nil), func(g Group) bool { return g.ID == AutoGroupID("m") }) if g < 0 { t.Fatal("no group found of m") } - if ms := autoGroups(providerEntries())[g].Members; !slices.Equal(ms, []string{"c/m", "a/m", "b/m"}) { + if ms := autoGroups(providerEntries(), nil)[g].Members; !slices.Equal(ms, []string{"c/m", "a/m", "b/m"}) { t.Fatalf("group of m tries %v", ms) } if err := Save(Provider{ID: "d", Name: "d", Key: "sk-d", Chat: "https://d.example.com/v1", Models: []string{"m"}}); err != nil { diff --git a/internal/settings/settings.go b/internal/settings/settings.go index 8c29d35d..c69cd016 100644 --- a/internal/settings/settings.go +++ b/internal/settings/settings.go @@ -251,6 +251,12 @@ type Settings struct { // (01huadalang on Discord). Absent leaves it to the vendor's list and // to each URL the provider has (see provider.SetModelAPI). ModelAPIs map[string]string `json:"modelAPIs,omitempty"` + // ModelSameAs is the model another vendor sells under another name + // that a model is, by "/": the routing groups + // magpie finds (provider's autoGroups) merge it with that one rather + // than by its own id, for an id no rule of magpie's matches up + // (kyzhouxu, #583). Absent leaves it to its id. + ModelSameAs map[string]string `json:"modelSameAs,omitempty"` // The main window's size when it was last resized, width and height, // so it opens at it again after a restart. Window []int `json:"window,omitempty"`