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:
1 parent
a525510a79
commit
62f9612ba6
10 files changed
+220
-14
No files matched your search
+3
-3
@@ -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
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, []);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user