providers: a model a vendor names its own way is merged with the same model from other providers — by its id as Volcengine Ark spells it, or by the model the user says it is the same as in Names & levels (kyzhouxu, #583: 火山引擎获取到的默认 ds 4.1 flash 模型的名称跟其他供应商的名称不一样,没有自动合并到同一个模型分组) — the groups magpie finds merged a model by its id lowercased, its vendor prefix and an eight-digit date taken off and a version's dot made a dash, so Ark's deepseek-v4-1-flash-260910 (a six-digit YYMMDD snapshot) was a group of its own beside ali-tp's deepseek-v4.1-flash. sameModel now also takes off Ark's YYMMDD (a year from 23 to 39, a month and a day that are one), Vertex's "@YYYYMMDD", reads Fireworks' "p" between digits (deepseek-v4p1) as the dot and "_" as "-"; -flash, -thinking, ":batch", a four-digit release (qwen's -2507, kimi-k2-0905) and a number that is no date (-128000, -261301) stay apart. For an id no rule matches (an Ark endpoint id, a relay's own name), each model in a provider's Names & levels now has a "Same as" (等同于) box, its placeholder the name it is merged by now: the model it is the same as, as any vendor spells it, staged in draft.modelPrefs and made with the provider's Save like the other prefs (Cancel drops it, Restore default empties it, nothing sent or redrawn or scrolled before the Save). It is kept in settings' ModelSameAs by "<provider>/<model>" (renamed with the provider like every Model* map), set by provider.SetModelSame (its own id however spelt keeps nothing; a model the provider doesn't serve, or a name of no model, is refused) and taken by autoGroups, AutoStandIn and the agents' reseat (AutoGroupOf); the agents are told once. TestSameModelVendorSpellings (Ark, Fireworks, Vertex, underscores, and pairs that must stay apart) and TestModelSameAsMergesFoundGroup (c/ep-sol-x joins auto-sol once said, GroupFor("sol") finds it, back out when cleared, refusals) are new; TestProviderSaveModelPrefs covers a Save's same; provider-same-as.test.cjs (WebKit, en and zh: placeholder, staging, unsaved, Restore default, Cancel, one Save carrying exactly the changes, no scroll, Chinese for each string) fails without the change and passes with it; provider-levels-save and provider-detect pass. go vet ./internal/..., go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui -count=1 ./... under a temp HOME pass.

This commit is contained in:
yetone committed 2026-10-02 23:39:40 +08:00
1 parent 52e7e4fc8d
commit f65d02f080
15 files changed
+408 -27

No files matched your search

+1 -1
View File
@@ -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
}
}
+3
View File
@@ -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; }
+22 -2
View File
@@ -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);
+3 -1
View File
@@ -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": "未保存",
+5
View File
@@ -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
}
@@ -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)
+1
View File
@@ -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",
+1 -1
View File
@@ -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, []);
@@ -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, []);
});
}
}
+66 -16
View File
@@ -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/<id>"): 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/<id>".
@@ -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 "<provider>/<model>"),
// 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/<id>") 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 {
+1 -1
View File
@@ -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)
}
+60 -3
View File
@@ -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]
+131
View File
@@ -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")
}
}
+2 -2
View File
@@ -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 {
+6
View File
@@ -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 "<provider id>/<model id>": 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"`