groups: a routing group's own id keeps a model's dots (#968) — a group named GPT 6.1 Sol was group/gpt-6-1-sol, so a harness pricing or tuning by the model's name didn't know it (dbydd). provider.GroupSlug is Slug with the dots kept (a run of them one, none at the ends); SaveGroup, RenameGroup, the backup's import and export, the GUI's save and rename, the CLI's group add and edit, and the Routing editor (groupSlug) derive and check a group's id by it. The ids magpie finds stay Slug's (auto-gpt-6-1-sol), so no id saved before changes, and an agent sending the bare name gpt-6.1-sol reaches a group of that id first (GroupFor). groupid_test (ids, save, rename, bad ids, found ids unchanged) and gateway's TestDottedGroupID (group/gpt-6.1-sol and the bare name served, falling through as the group's order says; -race -count=5) pass and fail without the fix; group-id-dots (hint and Add's body, groupSlug cases) passes in Chromium and WebKit, en and zh, and fails without it. The other group-* GUI tests, agent-model-groups and i18n-keys pass, except group-arrange, whose Chromium cases fail the same on origin/main (mouse.move: Invalid parameters at line 165). go test . provider/gui/gateway/agent/tui, vet, linux/windows builds pass.

This commit is contained in:
yetone committed 2026-10-06 12:32:16 +08:00
1 parent a525510a79
commit 62f9612ba6
10 files changed
+220 -14

No files matched your search

+3 -3
View File
@@ -507,7 +507,7 @@ func findGroup(ref string) (provider.Group, error) {
// newGroupID is the id a new group gets, as the Routing view makes it:
// its name's, else "group", numbered past one taken.
func newGroupID(name string) string {
base := provider.Slug(name)
base := provider.GroupSlug(name)
if base == "" {
base = "group"
}
@@ -671,8 +671,8 @@ func setGroup(ref string, pairs []string) (provider.Group, error) {
}
pruneRules(&g)
to := strings.ToLower(strings.TrimSpace(g.ID))
if to != from && (to == "" || to != provider.Slug(to)) {
return g, fmt.Errorf("a group's id must be lowercase letters, digits and dashes, not %q", g.ID)
if to != from && (to == "" || to != provider.GroupSlug(to)) {
return g, fmt.Errorf("a group's id must be lowercase letters, digits, dots and dashes, not %q", g.ID)
}
g.ID = from
if err := provider.SaveGroup(g); err != nil { // one magpie found is the user's now
+39
View File
@@ -0,0 +1,39 @@
package gateway
import (
"strings"
"testing"
"time"
"github.com/yetone/magpie/internal/provider"
)
// A routing group whose id keeps a model's dots (#968) is served by it, as
// group/<id> and as the bare name, and the call is recorded under it.
func TestDottedGroupID(t *testing.T) {
fresh(t)
a, b := &keyed{}, &keyed{fail: map[string]int{"kb": 429}}
serveOn(t, "a", "ka", []string{"gpt-6.1-sol"}, a)
serveOn(t, "b", "kb", []string{"gpt-6.1-sol"}, b)
if err := provider.SaveGroup(provider.Group{Name: "GPT 6.1 Sol", Members: []string{"b/gpt-6.1-sol", "a/gpt-6.1-sol"}, Routing: provider.Ordered}); err != nil {
t.Fatal(err)
}
s := New()
for _, id := range []string{"group/gpt-6.1-sol", "gpt-6.1-sol"} {
a.tried, b.tried = nil, nil
restingUntil.Lock()
restingUntil.m = map[string]time.Time{}
restingUntil.Unlock()
sticks.Lock()
sticks.m = map[string]stick{}
sticks.Unlock()
code, body := postAs(t, s, "", `{"model":"`+id+`","messages":[{"role":"user","content":"hi"}]}`)
if code != 200 || !strings.Contains(body, "from ka") || strings.Join(b.tried, ",") != "kb" {
t.Fatalf("%s: %d %s, b tried %v", id, code, body, b.tried)
}
r := s.trace.routes[len(s.trace.routes)-1]
if r.Group == nil || r.Group.ID != "gpt-6.1-sol" || s.Recent()[0].Model != id {
t.Fatalf("%s: route %+v call %+v", id, r.Group, s.Recent()[0])
}
}
}
+3
View File
@@ -11803,6 +11803,9 @@ function editorError(msg, kind = "err") {
}
function slug(s) { return s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""); }
// groupSlug: a routing group's id as provider.GroupSlug derives it, the
// dots kept (gpt-6.1-sol, #968).
function groupSlug(s) { return s.toLowerCase().replace(/[^a-z0-9.]+/g, "-").replace(/\.{2,}/g, ".").replace(/^[-.]+|[-.]+$/g, ""); }
function hostOf(u) { try { return new URL(u.includes("://") ? u : "https://" + u).host; } catch { return ""; } }
// the sheet opens below the list: it unrolls on the rows' spring and the
+2 -2
View File
@@ -3040,8 +3040,8 @@
}
const idIn = g ? keys(input(d.id, g.id)) : null;
const idOf = () => {
if (g) return slug(d.id) || g.id;
let id = slug(d.name) || "group", n = 1;
if (g) return groupSlug(d.id) || g.id;
let id = groupSlug(d.name) || "group", n = 1;
const base = id;
while (groups.groups.some((x) => x.id === id)) id = `${base}-${++n}`;
return id;
+2 -2
View File
@@ -303,8 +303,8 @@ func groupRoutes(mux *http.ServeMux) {
err = provider.SaveGroup(in)
break
}
if to == "" || to != provider.Slug(to) {
err = fmt.Errorf("a group's id must be lowercase letters, digits and dashes, not %q", in.ID)
if to == "" || to != provider.GroupSlug(to) {
err = fmt.Errorf("a group's id must be lowercase letters, digits, dots and dashes, not %q", in.ID)
break
}
in.ID = body.From
+5
View File
@@ -384,6 +384,11 @@ and the status line says which agent was moved to the model from one provider;
clicked on, the found group is back. Neither click moves the page, the switch
has no left-border accent, and every string has its Chinese.
`group-id-dots.test.cjs` checks that a routing group's id keeps the dots of a
model's name (#968): a new group named "GPT 6.1 Sol" is said and sent as
`group/gpt-6.1-sol`, and `groupSlug` folds a run of dots and trims them at the
ends as `provider.GroupSlug` does, in English and Chinese, Chromium and WebKit.
`group-new-head.test.cjs` checks that a routing group of any models can be
made from the top of the Routing page, in Chromium and WebKit, English and
Chinese (mintonight, #944: the groups' New group sat below the requests, so
+87
View File
@@ -0,0 +1,87 @@
// Run with Node's test runner and Playwright on the module path; see README.md.
// A routing group's id keeps the dots of a model's name (dbydd, #968): a
// new group named "GPT 6.1 Sol" is group/gpt-6.1-sol, not gpt-6-1-sol, as
// the editor says and as Add sends it, so a harness that prices or tunes
// by the model's name reads it. groupSlug folds a run of dots and trims
// them at the ends, as provider.GroupSlug. In English and Chinese,
// Chromium and WebKit.
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 models = [
{ id: "a/gpt-6.1-sol", name: "gpt-6.1-sol", providerName: "A", icon: "generic" },
{ id: "b/gpt-6.1-sol", name: "gpt-6.1-sol", providerName: "B", icon: "generic" },
];
const found = { id: "auto-gpt-6-1-sol", name: "GPT-6.1 Sol", members: ["a/gpt-6.1-sol", "b/gpt-6.1-sol"], auto: true, ready: true,
memberInfo: ["a/gpt-6.1-sol", "b/gpt-6.1-sol"].map((id) => ({ id, ready: true })) };
function serve(lang, saved) {
const state = { agents: [{ id: "claude", name: "Claude Code", path: "/test/claude", fields: [] }], profiles: [], settings: { lang, theme: "light" } };
return async (r) => {
const url = new URL(r.request().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/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: 0, 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/save") {
const body = r.request().postDataJSON();
saved.push(body);
return json({ models, pools: [], deciders: [], found: true, groups: [found, { id: body.id, name: body.name, members: body.members, ready: true }] });
}
if (url.pathname === "/api/groups") return json({ models, pools: [], deciders: [], found: true, groups: [found] });
if (url.pathname === "/api/providers") return json({ providers: [], 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 });
};
}
for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) {
for (const lang of ["en", "zh"]) {
test(`${engine} ${lang}: a group's id keeps a model's dots`, async (t) => {
const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" }));
const page = await (await browser.newContext({ viewport: { width: 1000, height: 700 }, reducedMotion: "reduce" })).newPage();
t.after(() => browser.close());
page.setDefaultTimeout(5000);
const errors = [], saved = [];
page.on("pageerror", (e) => errors.push(e.message));
await page.route("**/*", serve(lang, saved));
await page.goto("http://magpie.test/?view=routing");
await page.locator(".rt-group", { hasText: "GPT-6.1 Sol" }).waitFor({ state: "attached" });
assert.deepEqual(await page.evaluate(() => ["GPT 6.1 Sol", "gpt-6.1-sol", "a..b", ".x.", "-.a.-", "claude/opus 5", "..."].map(groupSlug)),
["gpt-6.1-sol", "gpt-6.1-sol", "a.b", "x", "a", "claude-opus-5", ""]);
await page.evaluate(() => { window.newGroupWith("a/gpt-6.1-sol", "GPT 6.1 Sol"); });
const ed = page.locator(".rt-gsec .rt-gedit");
await ed.waitFor();
await page.waitForFunction(() => document.querySelector(".rt-gedit input")?.value === "GPT 6.1 Sol");
assert.match(await ed.locator(".hint").first().textContent(), /group\/gpt-6\.1-sol(?![\w.-])/);
// Add is below the fold: the wheel brings it up, as a reader scrolls
const add = ed.locator("button.primary");
await page.mouse.move(500, 300);
for (let i = 0; i < 20; i++) {
const b = await add.boundingBox();
if (b.y >= 60 && b.y + b.height < 640) break;
await page.mouse.wheel(0, b.y < 60 ? -300 : 300);
await page.waitForTimeout(100);
}
await add.click();
for (let i = 0; i < 40 && !saved.length; i++) await page.waitForTimeout(50);
assert.equal(saved.length, 1, "Add sent the group");
assert.equal(saved[0].id, "gpt-6.1-sol");
assert.deepEqual(errors, []);
});
}
}
+2 -2
View File
@@ -72,7 +72,7 @@ func RestoreGroups(gs []Group) error {
return err
}
for _, g := range gs {
if g.ID == "" || g.ID != Slug(g.ID) {
if g.ID == "" || g.ID != GroupSlug(g.ID) {
continue
}
g.Auto = false
@@ -112,6 +112,6 @@ func Mirror(ps []Provider, gs []Group) error {
out = append(out, p)
}
f.Providers = out
f.Groups = slices.DeleteFunc(slices.Clone(gs), func(g Group) bool { return g.ID == "" || g.ID != Slug(g.ID) })
f.Groups = slices.DeleteFunc(slices.Clone(gs), func(g Group) bool { return g.ID == "" || g.ID != GroupSlug(g.ID) })
return store(f)
}
+20 -5
View File
@@ -23,6 +23,7 @@ package provider
import (
"errors"
"fmt"
"regexp"
"slices"
"strings"
@@ -307,6 +308,20 @@ 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)) }
var groupDots = regexp.MustCompile(`\.{2,}`)
// GroupSlug derives a group's own id from a name as Slug does, the dots
// kept: "GPT 6.1 Sol" → "gpt-6.1-sol", so an agent sending group/<id> sends
// the model's name as harnesses that price or tune by it read it (#968). A
// run of dots is one, and none starts or ends it. The ids magpie finds
// (auto-…) stay Slug's, so no id saved before changes.
func GroupSlug(name string) string {
s := strings.Join(strings.FieldsFunc(strings.ToLower(name), func(r rune) bool {
return (r < 'a' || r > 'z') && (r < '0' || r > '9') && r != '.'
}), "-")
return strings.Trim(groupDots.ReplaceAllString(s, "."), "-.")
}
// 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.
@@ -703,10 +718,10 @@ func SaveGroup(g Group) error {
g.ID = strings.ToLower(strings.TrimSpace(g.ID))
g.Name = strings.TrimSpace(g.Name)
if g.ID == "" {
g.ID = Slug(g.Name)
g.ID = GroupSlug(g.Name)
}
if g.ID == "" || g.ID != Slug(g.ID) {
return fmt.Errorf("a group's id must be lowercase letters, digits and dashes, not %q", g.ID)
if g.ID == "" || g.ID != GroupSlug(g.ID) {
return fmt.Errorf("a group's id must be lowercase letters, digits, dots and dashes, not %q", g.ID)
}
if g.Name == "" {
g.Name = g.ID
@@ -1093,8 +1108,8 @@ func ShowGroup(id string) error {
func RenameGroup(from, to string) error {
from = strings.ToLower(strings.TrimSpace(from))
to = strings.ToLower(strings.TrimSpace(to))
if to == "" || to != Slug(to) {
return fmt.Errorf("a group's id must be lowercase letters, digits and dashes, not %q", to)
if to == "" || to != GroupSlug(to) {
return fmt.Errorf("a group's id must be lowercase letters, digits, dots and dashes, not %q", to)
}
if to == from {
return nil
+57
View File
@@ -0,0 +1,57 @@
package provider
import (
"testing"
)
// A group's own id keeps the dots of a model's name (#968): saved from its
// name or given, renamed to one, and an agent sending the bare name reaches
// it. The ids magpie finds keep Slug's dashes, so none saved before change.
func TestGroupIDKeepsDots(t *testing.T) {
for in, want := range map[string]string{
"gpt-6.1-sol": "gpt-6.1-sol",
"GPT 6.1 Sol": "gpt-6.1-sol",
"a..b": "a.b",
".x.": "x",
"-.a.-": "a",
"claude/opus 5": "claude-opus-5",
"...": "",
} {
if got := GroupSlug(in); got != want {
t.Errorf("GroupSlug(%q) = %q, want %q", in, got, want)
}
}
t.Setenv("HOME", t.TempDir())
t.Setenv("USERPROFILE", t.TempDir())
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
if err := Save(Provider{ID: "a", Name: "a", Key: "k", Chat: "http://127.0.0.1:1/v1", Models: []string{"gpt-6.1-sol", "x"}}); err != nil {
t.Fatal(err)
}
if err := SaveGroup(Group{Name: "GPT 6.1 Sol", Members: []string{"a/gpt-6.1-sol"}}); err != nil {
t.Fatal(err)
}
if _, _, ok := FindGroup("group/gpt-6.1-sol"); !ok {
t.Fatal("no group/gpt-6.1-sol")
}
if g, ok := GroupFor("gpt-6.1-sol"); !ok || g != "group/gpt-6.1-sol" {
t.Fatalf("GroupFor(gpt-6.1-sol) = %q %v", g, ok)
}
if err := SaveGroup(Group{ID: "x..y", Members: []string{"a/x"}}); err == nil {
t.Fatal("saved x..y")
}
if err := SaveGroup(Group{ID: "x.y", Members: []string{"a/x"}}); err != nil {
t.Fatal(err)
}
if err := RenameGroup("x.y", "x.z"); err != nil {
t.Fatal(err)
}
if err := RenameGroup("x.z", ".x"); err == nil {
t.Fatal("renamed to .x")
}
if _, _, ok := FindGroup("group/x.z"); !ok {
t.Fatal("no group/x.z")
}
if id := AutoGroupID("gpt-6.1-sol"); id != "auto-gpt-6-1-sol" {
t.Fatalf("a found group's id changed: %s", id)
}
}