From 7c0b5441a8b0bd37210dfa0bc4a9d4a6e4fc9c19 Mon Sep 17 00:00:00 2001 From: yetone Date: Wed, 30 Sep 2026 16:26:34 +0800 Subject: [PATCH] =?UTF-8?q?routing:=20a=20group=20can=20name=20the=20reaso?= =?UTF-8?q?ning=20levels=20agents=20are=20offered,=20so=20one=20member=20w?= =?UTF-8?q?ith=20few=20doesn't=20take=20the=20rest=20from=20the=20others?= =?UTF-8?q?=20(#295)=20=E2=80=94=20a=20group=20offered=20only=20the=20leve?= =?UTF-8?q?ls=20every=20member=20has,=20so=20deepseek-flash=20(low/high/ma?= =?UTF-8?q?x)=20beside=20models=20with=20medium=20and=20xhigh=20left=20the?= =?UTF-8?q?=20group=20without=20them.=20Group.Levels=20(CLI=20`magpie=20gr?= =?UTF-8?q?oup=20set=20=20levels=3Dlow,medium,high,xhigh`,=20`levels?= =?UTF-8?q?=3D`=20clears;=20TUI=20`l`;=20GUI=20group=20editor's=20Levels:?= =?UTF-8?q?=20its=20models'=20shared=20/=20named)=20is=20offered=20instead?= =?UTF-8?q?=20in=20/v1/models,=20Codex's=20catalog,=20Pi=20and=20the=20res?= =?UTF-8?q?t;=20only=20levels=20magpie=20knows=20are=20taken,=20stored=20l?= =?UTF-8?q?owest=20first;=20a=20group=20in=20a=20group=20that=20names=20it?= =?UTF-8?q?s=20own=20offers=20them=20for=20its=20models;=20unset=20is=20as?= =?UTF-8?q?=20before.=20A=20member=20without=20the=20level=20asked=20is=20?= =?UTF-8?q?sent=20its=20nearest,=20as=20it=20already=20was.=20The=20GUI's?= =?UTF-8?q?=20group=20save=20also=20keeps=20a=20family=20set=20from=20the?= =?UTF-8?q?=20CLI.=20TestGroupLevelsOverride,=20TestGroupLevelsReachAgents?= =?UTF-8?q?,=20TestGroupLevelsNamed=20(passthrough=20and=20translated:=20m?= =?UTF-8?q?edium=E2=86=92high,=20xhigh=E2=86=92max=20on=20the=20low/high/m?= =?UTF-8?q?ax=20member,=20as=20asked=20on=20the=20one=20with=20all),=20Tes?= =?UTF-8?q?tGroupSetLevels=20and=20routing-levels.test.cjs=20(Chromium,=20?= =?UTF-8?q?WebKit,=20en/zh)=20fail=20without=20the=20change.=20go=20vet,?= =?UTF-8?q?=20go=20build=20-tags=20nogui=20(darwin,=20GOOS=3Dlinux),=20GOO?= =?UTF-8?q?S=3Dwindows=20go=20build,=20go=20test=20-tags=20nogui=20./...?= =?UTF-8?q?=20pass=20(TestUserPath,=20a=20known=20flake=20under=20load,=20?= =?UTF-8?q?passes=20alone);=20click-scroll,=20old-webkit,=20routing-effort?= =?UTF-8?q?(-row),=20routing-levels,=20settings-groups=20GUI=20tests=20pas?= =?UTF-8?q?s.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- groups_cli.go | 18 ++- groups_levels_cli_test.go | 28 +++++ internal/agent/grouplevels_test.go | 51 ++++++++ internal/gateway/grouplevels_test.go | 85 +++++++++++++ internal/gui/assets/i18n.js | 7 ++ internal/gui/assets/routing.css | 3 + internal/gui/assets/routing.js | 39 +++++- internal/gui/groups.go | 12 +- internal/gui/tests/README.md | 7 ++ internal/gui/tests/routing-levels.test.cjs | 139 +++++++++++++++++++++ internal/provider/group.go | 22 +++- internal/provider/groupeffort.go | 25 ++++ internal/provider/grouplevels_test.go | 72 +++++++++++ internal/provider/models.go | 3 + internal/tui/routing.go | 16 +++ internal/tui/tui.go | 2 +- internal/tui/tui_test.go | 7 ++ 17 files changed, 528 insertions(+), 8 deletions(-) create mode 100644 groups_levels_cli_test.go create mode 100644 internal/agent/grouplevels_test.go create mode 100644 internal/gateway/grouplevels_test.go create mode 100644 internal/gui/tests/routing-levels.test.cjs create mode 100644 internal/provider/grouplevels_test.go diff --git a/groups_cli.go b/groups_cli.go index abb1d5b4..d5c81bf9 100644 --- a/groups_cli.go +++ b/groups_cli.go @@ -23,8 +23,10 @@ const groupUsage = `usage: magpie group set k=v… change one: name, models (the whole list, in order), models+= (append), models-= (drop), routing, stays, context (how long a request agents are told it takes: 272k; empty is - its shortest model's), family (a tag: magpie visible shows agents - families, not each group), + its shortest model's), levels (the reasoning levels agents are offered: + levels=none,low,medium,high,xhigh,max; empty is those every model has — + a model without the one asked is sent its nearest), + family (a tag: magpie visible shows agents families, not each group), id (what agents pick it as: id=gpt-6-astra drops auto-; the groups it is in follow; an agent set to the old id needs setting again), effort=auto (the classifier picks each turn's reasoning; needs classifier=), @@ -323,6 +325,13 @@ func applyGroupPairs(g *provider.Group, pairs []string, resolve func(string) (st if strings.TrimSpace(v) != "" { g.Context, err = parseTokens(v) } + case "levels", "level": + // the reasoning levels agents are offered; empty is those every + // member has again + g.Levels = nil + if strings.TrimSpace(v) != "" { + g.Levels, err = provider.CleanLevels(splitList(v)) + } case "family", "tag": g.Family = strings.TrimSpace(v) case "effort", "reasoning": @@ -337,7 +346,7 @@ func applyGroupPairs(g *provider.Group, pairs []string, resolve func(string) (st case "classifier", "classify": g.Classifier = strings.TrimPrefix(strings.TrimSpace(v), "magpie/") default: - return fmt.Errorf("unknown field %q (fields: name, models, models+, models-, routing, stays, context, family, effort, classifier; magpie group help)", k) + return fmt.Errorf("unknown field %q (fields: name, models, models+, models-, routing, stays, context, levels, family, effort, classifier; magpie group help)", k) } if err != nil { return err @@ -747,6 +756,9 @@ func showGroup(g provider.Group) error { } kv(k, fmt.Sprintf("%d %s", i+1, ruleLine(r))) } + if len(g.Levels) > 0 { + kv("levels", strings.Join(g.Levels, ", ")+muted.Render(" offered to agents; a model without the one asked is sent its nearest")) + } if g.Effort == provider.EffortAuto { kv("effort", "auto"+muted.Render(" the classifier picks each turn's reasoning")) } diff --git a/groups_levels_cli_test.go b/groups_levels_cli_test.go new file mode 100644 index 00000000..be6cc55f --- /dev/null +++ b/groups_levels_cli_test.go @@ -0,0 +1,28 @@ +package main + +import ( + "fmt" + "testing" +) + +// levels= names the reasoning levels agents are offered for a group +// (#295): kept lowest first, only those magpie knows; empty is its +// members' shared levels again. +func TestGroupSetLevels(t *testing.T) { + groupsHome(t) + if _, err := addGroup("Mix", []string{"models=a/m,b/gpt-5.5", "levels=XHigh,medium low"}); err != nil { + t.Fatal(err) + } + if gs := storedGroups(t); len(gs) != 1 || fmt.Sprint(gs[0]["levels"]) != "[low medium xhigh]" { + t.Fatalf("stored: %v", gs) + } + if _, err := setGroup("mix", []string{"levels=low,turbo"}); err == nil { + t.Fatal("turbo taken") + } + if _, err := setGroup("mix", []string{"levels="}); err != nil { + t.Fatal(err) + } + if gs := storedGroups(t); gs[0]["levels"] != nil { + t.Fatalf("cleared: %v", gs) + } +} diff --git a/internal/agent/grouplevels_test.go b/internal/agent/grouplevels_test.go new file mode 100644 index 00000000..88a92642 --- /dev/null +++ b/internal/agent/grouplevels_test.go @@ -0,0 +1,51 @@ +package agent + +import ( + "encoding/json" + "path/filepath" + "slices" + "testing" + + "github.com/yetone/magpie/internal/catalog" + "github.com/yetone/magpie/internal/provider" +) + +// A routing group's levels as it names them (#295) are what agents are +// given for it: Codex's catalog, Pi's thinkingLevelMap — not only those +// its members share. +func TestGroupLevelsReachAgents(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) + t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache")) + if err := provider.Save(provider.Provider{ID: "a", Name: "A", Key: "k", Chat: "http://127.0.0.1:1/v1", Models: []string{"flash", "sol"}}); err != nil { + t.Fatal(err) + } + if err := provider.SetModelEfforts("a/flash", []string{"low", "high", "max"}); err != nil { + t.Fatal(err) + } + if err := provider.SetModelEfforts("a/sol", []string{"none", "low", "medium", "high", "xhigh", "max"}); err != nil { + t.Fatal(err) + } + all := []string{"none", "low", "medium", "high", "xhigh", "max"} + if err := provider.SaveGroup(provider.Group{Name: "mix", Members: []string{"a/sol", "a/flash"}, Levels: all}); err != nil { + t.Fatal(err) + } + i := slices.IndexFunc(magpieModels("codex"), func(m catalog.Model) bool { return m.ID == "group/mix" }) + if i < 0 || !slices.Equal(magpieModels("codex")[i].Efforts, all) { + t.Fatalf("codex: %+v", magpieModels("codex")) + } + pi, _ := json.Marshal(magpieProviderJSON("pi")) + type piModel struct { + ID string `json:"id"` + Levels map[string]any `json:"thinkingLevelMap"` + } + var cfg struct { + Models []piModel `json:"models"` + } + json.Unmarshal(pi, &cfg) + j := slices.IndexFunc(cfg.Models, func(m piModel) bool { return m.ID == "group/mix" }) + if j < 0 || cfg.Models[j].Levels["medium"] != "medium" || cfg.Models[j].Levels["xhigh"] != "xhigh" { + t.Fatalf("pi: %s", pi) + } +} diff --git a/internal/gateway/grouplevels_test.go b/internal/gateway/grouplevels_test.go new file mode 100644 index 00000000..655e414b --- /dev/null +++ b/internal/gateway/grouplevels_test.go @@ -0,0 +1,85 @@ +package gateway + +import ( + "encoding/json" + "net/http/httptest" + "slices" + "strings" + "testing" + + "github.com/yetone/magpie/internal/provider" +) + +// A group that names its own levels (#295) lists them on /v1/models, and a +// level its member at hand lacks is sent as the one it has nearest — asked +// in the vendor's own API (a Chat request to a Chat vendor, relayed as it +// is) or translated (a Responses request to it) — so offering more than +// every member has never sends a vendor a level it turns away. +func TestGroupLevelsNamed(t *testing.T) { + s, a, b := ruled(t) + if err := provider.SetModelEfforts("a/small", []string{"low", "high", "max"}); err != nil { // deepseek-flash + t.Fatal(err) + } + all := []string{"none", "low", "medium", "high", "xhigh", "max"} + if err := provider.SetModelEfforts("b/big", all); err != nil { // gpt-6-sol + t.Fatal(err) + } + g, _, _ := provider.FindGroup("group/r") + g.Levels = all + if err := provider.SaveGroup(g); err != nil { + t.Fatal(err) + } + rec := httptest.NewRecorder() + s.Handler().ServeHTTP(rec, httptest.NewRequest("GET", "/v1/models", nil)) + var list struct { + Data []struct { + ID string `json:"id"` + Levels []struct { + Effort string `json:"effort"` + } `json:"supported_reasoning_levels"` + } `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &list); err != nil { + t.Fatalf("%v: %s", err, rec.Body) + } + var listed []string + for _, m := range list.Data { + if m.ID == "group/r" { + for _, l := range m.Levels { + listed = append(listed, l.Effort) + } + } + } + if !slices.Equal(listed, all) { + t.Fatalf("group/r lists %v, want %v", listed, all) + } + + responses := func(effort string) map[string]any { + t.Helper() + rec := httptest.NewRecorder() + s.Handler().ServeHTTP(rec, httptest.NewRequest("POST", "/v1/responses", strings.NewReader(`{"model":"group/r","input":"hi","reasoning":{"effort":"`+effort+`"}}`))) + if rec.Code != 200 { + t.Fatalf("%d %s", rec.Code, rec.Body) + } + return sentBody(t, a) + } + for asked, want := range map[string]string{"none": "low", "low": "low", "medium": "high", "high": "high", "xhigh": "max", "max": "max"} { + out, r := postOK(t, s, "", chat("hi", nil, 0, `,"reasoning_effort":"`+asked+`"`)) + if sent := sentBody(t, a); !strings.Contains(out, "from ka") || sent["reasoning_effort"] != want || r.Tries[0].Effort != want { + t.Errorf("chat asked %s: sent %v, traced %+v", asked, sent["reasoning_effort"], r.Tries) + } + if sent := responses(asked); sent["model"] != "small" || sent["reasoning_effort"] != want { + t.Errorf("responses asked %s: sent %v", asked, sent["reasoning_effort"]) + } + } + // the member that has them all is sent each as asked + a.mu.Lock() + a.fail = 500 + a.mu.Unlock() + for _, asked := range []string{"medium", "xhigh"} { + postOK(t, s, "", chat("hi", nil, 0, `,"reasoning_effort":"`+asked+`"`)) + if sent := sentBody(t, b); sent["reasoning_effort"] != asked { + t.Errorf("big asked %s: sent %v", asked, sent["reasoning_effort"]) + } + } +} diff --git a/internal/gui/assets/i18n.js b/internal/gui/assets/i18n.js index a9ea3529..30f2110e 100644 --- a/internal/gui/assets/i18n.js +++ b/internal/gui/assets/i18n.js @@ -1156,6 +1156,13 @@ const I18N = { "As a turn begins, this model is asked how hard the turn is — once; a small, fast one without reasoning is best. If it fails or can't say, the turn reasons as the agent asked. Its calls show in the usage as magpie’s own.": "每轮开始时问这个模型一次这一轮有多难;最好用小而快、不推理的模型。它失败或答不上来时,这一轮按 agent 自己要求的强度推理。它的调用在用量里记为 magpie 自己的。", "As a turn begins, this model is asked which of the intents the message is and how hard the turn is, each once; a small, fast one without reasoning is best. If it can't say, no intent matches and the turn reasons as the agent asked. Its calls show in the usage as magpie’s own.": "每轮开始时分别问这个模型一次:这条消息属于哪个意图、这一轮有多难;最好用小而快、不推理的模型。答不上来时不匹配任何意图,并按 agent 自己要求的强度推理。它的调用在用量里记为 magpie 自己的。", "Each request reasons as much as the agent asked.": "每个请求按 agent 自己要求的强度推理。", + "Levels": "推理档位", + "Its models' shared": "成员共有", + "Named": "自定", + "Agents are offered the levels every model has: {levels}.": "对 Agent 提供所有模型都有的档位:{levels}。", + "Agents are offered the levels every model has.": "对 Agent 提供所有模型都有的档位。", + "Agents are offered these. A model without the level asked is sent the one it has nearest.": "对 Agent 提供这些档位。模型没有所选档位时,按它最接近的档位发送。", + "Pick a level to offer, or leave them to its models": "请至少选一个档位,或改回成员共有", "As a turn begins, Jev is asked once which of the intents the message is, and how hard the turn is when it picks the effort. An intent it isn't sure of matches no rule. Its calls show in the usage as magpie’s own.": "每一轮开始时,问 Jev 一次:这条消息属于哪个意图;如果由它选推理强度,也一并问这一轮有多难。它没把握的意图不命中任何规则。它的调用在用量里显示为 magpie 自己的调用。", "It was told turn {prev} reasoned at {level}, which a message that only carries on from it needs too.": "同时告诉了它第 {prev} 轮按 {level} 推理,只是接着上一轮说的消息也需要这么多。", "With two models or more, a rule can send some turns to one of them first.": "组里有两个及以上模型时,可以用规则把某些轮次先发给其中一个。", diff --git a/internal/gui/assets/routing.css b/internal/gui/assets/routing.css index bf39682d..703b810c 100644 --- a/internal/gui/assets/routing.css +++ b/internal/gui/assets/routing.css @@ -307,6 +307,9 @@ .rt-classifier[hidden] { display: none; } .rt-classifier .w { color: var(--muted); font-size: 12px; } .rt-classifier .hint { flex-basis: 100%; } +/* the levels a group names for agents (#295): one toggle each */ +.rt-levels { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 6px; margin-top: 6px; } +.rt-levels[hidden] { display: none; } /* the field is the pill: the editor's boxed input (its border and focus ring) stays off it */ .rt-cond input, .editor .rt-cond input[type="text"], .editor .rt-cond input:focus { box-shadow: none; border-radius: 0; } .editor .rt-cond input:focus-visible { box-shadow: none; border-radius: 0; } diff --git a/internal/gui/assets/routing.js b/internal/gui/assets/routing.js index 712becc4..6d7a2db8 100644 --- a/internal/gui/assets/routing.js +++ b/internal/gui/assets/routing.js @@ -1806,7 +1806,7 @@ if (!g.ready) tags.append(el("span", "tag bad", t("no member ready"))); const edit = el("button", "text", t("Edit")); edit.onclick = (e) => { e.stopPropagation(); open(); }; - const open = () => { gEdit = { id: g.id, draft: { name: g.name, members: [...g.members], routing: g.routing || "", affinity: g.affinity || "", classifier: g.classifier || "", effort: g.effort || "", rules: (g.rules || []).map((r) => ({ ...r, intent: r.intent || "", agents: [...(r.agents || [])] })) } }; renderGroups(); }; + const open = () => { gEdit = { id: g.id, draft: { name: g.name, members: [...g.members], routing: g.routing || "", affinity: g.affinity || "", classifier: g.classifier || "", effort: g.effort || "", levels: [...(g.levels || [])], rules: (g.rules || []).map((r) => ({ ...r, intent: r.intent || "", agents: [...(r.agents || [])] })) } }; renderGroups(); }; row.onclick = open; row.append(ics, main, tags, edit); return row; @@ -2075,6 +2075,40 @@ cw.append(cls); ed.append(clabel, cw); drawRules(); + // the levels agents are offered: those every model has, or ones the + // group names (#295) — a model without the one asked is sent its + // nearest, so one with few needn't take the rest from the others + if (!d.levels) d.levels = []; + let own = d.levels.length > 0; + const shared = g && d.members.join() === g.members.join() ? g.shared || [] : null; // as saved + const lHint = el("div", "hint"); + const chips = el("div", "rt-levels"); + const drawLevels = () => { + chips.hidden = !own; + lHint.textContent = own + ? t("Agents are offered these. A model without the level asked is sent the one it has nearest.") + : shared?.length ? t("Agents are offered the levels every model has: {levels}.", { levels: shared.join(", ") }) + : t("Agents are offered the levels every model has."); + }; + for (const v of LEVELS) { + const c = el("button", "rt-cond" + (d.levels.includes(v) ? " on" : ""), v); + c.onclick = () => { + d.levels = d.levels.includes(v) ? d.levels.filter((x) => x !== v) : LEVELS.filter((x) => x === v || d.levels.includes(x)); + c.classList.toggle("on", d.levels.includes(v)); + }; + chips.append(c); + } + const lw = el("div"); + lw.append(segs([["", t("Its models' shared")], ["own", t("Named")]], own ? "own" : "", (v) => { + own = v === "own"; + if (own && !d.levels.length) { + d.levels = LEVELS.filter((x) => (shared?.length ? shared : ["low", "medium", "high"]).includes(x)); + for (const c of chips.children) c.classList.toggle("on", d.levels.includes(c.textContent)); + } + drawLevels(); + }), chips, lHint); + drawLevels(); + ed.append(el("label", "", t("Levels")), lw); const bar = el("div", "bar"); if (g) { @@ -2093,8 +2127,9 @@ if (bare >= 0) return status(t("Rule {n} needs a condition", { n: bare + 1 }), "warn"); if (d.rules.some((r) => r.intent) && !d.classifier) return status(t("Choose the model that tells which intent a message is"), "warn"); if (d.effort === "auto" && !d.classifier) return status(t("Choose the model that rates how hard a turn is"), "warn"); + if (own && !d.levels.length) return status(t("Pick a level to offer, or leave them to its models"), "warn"); saveBtn.classList.add("busy"); - groupAction("save", { id: idOf(), from: g?.id, name: d.name.trim() || idOf(), members: d.members, routing: d.routing, affinity: d.affinity, rules: d.rules, effort: d.effort, classifier: d.rules.some((r) => r.intent) || d.effort === "auto" ? d.classifier : "", context: g?.context || 0 }, t(g ? "{name} saved" : "{name} added", { name: d.name.trim() || idOf() })); + groupAction("save", { id: idOf(), from: g?.id, name: d.name.trim() || idOf(), members: d.members, routing: d.routing, affinity: d.affinity, rules: d.rules, effort: d.effort, classifier: d.rules.some((r) => r.intent) || d.effort === "auto" ? d.classifier : "", context: g?.context || 0, levels: own ? d.levels : [], family: g?.family || "" }, t(g ? "{name} saved" : "{name} added", { name: d.name.trim() || idOf() })); }; saveBtn.onclick = save; bar.append(cancel, saveBtn); diff --git a/internal/gui/groups.go b/internal/gui/groups.go index 16472b05..506f7f98 100644 --- a/internal/gui/groups.go +++ b/internal/gui/groups.go @@ -29,6 +29,10 @@ type groupJSON struct { // Holds: the groups in it, at any depth — none of which can have it in // turn Holds []string `json:"holds"` + // Offers: the reasoning levels agents are offered for it; Shared: those + // its members have in common, which it offers unless it names its own + Offers []string `json:"offers"` + Shared []string `json:"shared"` } type memberJSON struct { @@ -130,7 +134,13 @@ func groupsState() groupsJSON { } } for _, g := range provider.Groups() { - gj := groupJSON{Group: g, Info: []memberJSON{}, Holds: []string{}} + gj := groupJSON{Group: g, Info: []memberJSON{}, Holds: []string{}, Offers: []string{}, Shared: []string{}} + for _, e := range served { + if e.ID == provider.GroupPrefix+g.ID { + gj.Offers, gj.Shared = append(gj.Offers, e.Efforts...), append(gj.Shared, e.Shared...) + break + } + } if _, ms, ok := provider.FindGroup(provider.GroupPrefix + g.ID); ok { for _, m := range ms { for _, v := range m.Groups() { diff --git a/internal/gui/tests/README.md b/internal/gui/tests/README.md index 50fb0f22..4198b7b6 100644 --- a/internal/gui/tests/README.md +++ b/internal/gui/tests/README.md @@ -62,6 +62,13 @@ under way, one with two tries, in Chromium and WebKit, English and Chinese, at text is drawn over another (#273: in two columns of ~470px it sat on the time taken). +`routing-levels.test.cjs` edits a group's reasoning levels in Chromium and +WebKit, English and Chinese (#295: a member with low/high/max took medium and +xhigh from the rest). "Its models' shared" names those levels; "Named" shows a +toggle per level, starting from the shared ones, and toggling them moves +nothing; saved, they go lowest first and the group's family stays. A group with +its own opens on them, none picked is refused, and back to shared saves none. + `routing-served.test.cjs` lists a request whose vendor's reply names another model than the one asked for (gpt-6-sol served as gpt-6-luna), one answered under the model's dated name and ones naming none: only the first is marked diff --git a/internal/gui/tests/routing-levels.test.cjs b/internal/gui/tests/routing-levels.test.cjs new file mode 100644 index 00000000..abbdec27 --- /dev/null +++ b/internal/gui/tests/routing-levels.test.cjs @@ -0,0 +1,139 @@ +// Run with Node's test runner and Playwright on the module path; see README.md. +// A routing group may name the reasoning levels agents are offered for it +// (#295: a cheap member with low/high/max took medium and xhigh from the +// strong ones beside it). The group editor's Levels reads "its models' +// shared" with those levels in words; "Named" shows a toggle per level, +// starting from the shared ones, each toggled with nothing moved; saved, +// they go as levels, lowest first, and the group's family stays; a group +// with its own opens on them, and back to shared saves none. None picked +// is refused. In English and Chinese. +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 all = ["none", "low", "medium", "high", "xhigh", "max"]; +const models = [ + { id: "a/sol", name: "sol", providerName: "A", icon: "generic", efforts: all }, + { id: "b/flash", name: "flash", providerName: "B", icon: "generic", efforts: ["low", "high", "max"] }, +]; +const group = (id, name, levels) => ({ id, name, members: ["a/sol", "b/flash"], routing: "order", family: "relay", ready: true, levels, + memberInfo: [{ id: "a/sol", ready: true }, { id: "b/flash", ready: true }], offers: levels?.length ? levels : ["low", "high", "max"], shared: ["low", "high", "max"] }); +const groups = () => ({ models, groups: [group("mix", "Mix"), group("own", "Own", ["medium", "xhigh"])], pools: [] }); + +const words = { + en: { edit: "Edit", save: "Save", levels: "Levels", shared: "Its models' shared", named: "Named", + sharedHint: "Agents are offered the levels every model has: low, high, max.", namedHint: "Agents are offered these. A model without the level asked is sent the one it has nearest.", + none: "Pick a level to offer, or leave them to its models" }, + zh: { edit: "编辑", save: "保存", levels: "推理档位", shared: "成员共有", named: "自定", + sharedHint: "对 Agent 提供所有模型都有的档位:low, high, max。", namedHint: "对 Agent 提供这些档位。模型没有所选档位时,按它最接近的档位发送。", + none: "请至少选一个档位,或改回成员共有" }, +}; + +function serve(lang, posts) { + const state = { agents: [{ id: "codex", name: "Codex", path: "/test/codex", 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/gateway/trace") { + if (url.searchParams.get("wait")) return new Promise(() => {}); // nothing more comes + return json({ mine: true, now: new Date().toISOString(), seq: 1, totals: { requests: 0, rerouted: 0, errors: 0 }, routes: [] }); + } + if (url.pathname === "/api/gateway/history") return json({ cut: false, days: [], routes: [] }); + if (url.pathname === "/api/groups") return json(groups()); + if (url.pathname.startsWith("/api/groups/")) { + posts.push({ path: url.pathname, body: JSON.parse(r.request().postData() || "{}") }); + return json(groups()); + } + 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"]) { + const w = words[lang]; + test(`${engine} ${lang}: a group names the reasoning levels agents are offered`, async (t) => { + const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" })); + t.after(() => browser.close()); + const page = await (await browser.newContext({ viewport: { width: 1100, height: 1400 } })).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=routing"); + await page.locator(".rt-group").first().waitFor(); + const ed = page.locator(".rt-gedit"); + // the editor's Levels row: the field after its label + const row = () => ed.locator("label", { hasText: new RegExp(`^${w.levels}$`) }).locator("xpath=following-sibling::div[1]"); + const chips = () => row().locator(".rt-levels .rt-cond"); + const lit = () => row().locator(".rt-levels .rt-cond.on").allTextContents(); + const save = async () => { + // (the view wheeled down to it, as the reader would — the page + // keeps still for any other scroll — and clicked where it shows) + const b = ed.locator("button.primary", { hasText: w.save }); + await page.mouse.move(550, 600); + for (let i = 0; i < 10 && (await b.boundingBox()).y > 1400 - 160; i++) { + await page.mouse.wheel(0, 300); + await page.waitForTimeout(150); + } + const bb = await b.boundingBox(); + await page.mouse.click(bb.x + bb.width / 2, bb.y + bb.height / 2); + }; + + // shared: the levels in words, no toggles + await page.locator(".rt-group", { hasText: "Mix" }).locator("button", { hasText: w.edit }).click(); + await row().waitFor(); + assert.equal(await row().locator(".segs .opt.on").textContent(), w.shared); + assert.equal(await row().locator(".hint").textContent(), w.sharedHint); + assert.equal(await row().locator(".rt-levels").isHidden(), true); + + // named: one toggle per level, the shared ones on + await row().locator(".segs .opt", { hasText: w.named }).click(); + assert.equal(await row().locator(".hint").textContent(), w.namedHint); + assert.deepEqual(await chips().allTextContents(), ["none", "minimal", "low", "medium", "high", "xhigh", "max"]); + assert.deepEqual(await lit(), ["low", "high", "max"]); + const at = () => page.evaluate(() => [...document.querySelectorAll("*")].filter((e) => e.scrollTop).map((e) => [e.id || e.className, e.scrollTop]).join(";") + "|" + Math.round(document.querySelector(".rt-gedit").getBoundingClientRect().top)); + await row().evaluate((x) => x.scrollIntoView({ block: "center" })); // by the test, as the reader would + await page.waitForTimeout(200); + const before = await at(); + for (const v of ["xhigh", "medium", "none"]) await chips().filter({ hasText: new RegExp(`^${v}$`) }).click(); + await chips().filter({ hasText: /^none$/ }).click(); // off again + assert.equal(await at(), before, "the clicks moved nothing"); + assert.deepEqual(await lit(), ["low", "medium", "high", "xhigh", "max"]); + await save(); + await page.waitForFunction(() => !document.querySelector(".rt-gedit")); + let sent = posts.find((p) => p.path === "/api/groups/save"); + assert.deepEqual(sent.body.levels, ["low", "medium", "high", "xhigh", "max"]); + assert.equal(sent.body.family, "relay", "the family stays"); + + // a group with its own opens on them; none picked is refused; back + // to shared saves none + posts.length = 0; + await page.locator(".rt-group", { hasText: "Own" }).locator("button", { hasText: w.edit }).click(); + await row().waitFor(); + assert.equal(await row().locator(".segs .opt.on").textContent(), w.named); + assert.deepEqual(await lit(), ["medium", "xhigh"]); + await chips().filter({ hasText: /^medium$/ }).click(); + await chips().filter({ hasText: /^xhigh$/ }).click(); + await save(); + await page.waitForFunction((s) => document.documentElement.innerText.includes(s), w.none); + assert.equal(posts.length, 0, "nothing saved with no level"); + await row().locator(".segs .opt", { hasText: w.shared }).click(); + assert.equal(await row().locator(".rt-levels").isHidden(), true); + await save(); + await page.waitForFunction(() => !document.querySelector(".rt-gedit")); + sent = posts.find((p) => p.path === "/api/groups/save"); + assert.deepEqual(sent.body.levels, []); + assert.deepEqual(errors, []); + }); + } +} diff --git a/internal/provider/group.go b/internal/provider/group.go index 2b05b726..1d88a5a4 100644 --- a/internal/provider/group.go +++ b/internal/provider/group.go @@ -72,6 +72,13 @@ type Group struct { // Context is how long a request the user says the group takes, in // tokens: agents are told it rather than its shortest member's. Context int `json:"context,omitempty"` + // Levels are the reasoning levels agents are offered for the group, + // lowest first, when the user names them (#295): rather than those + // every member has, so that a member with few doesn't take the rest + // from the others. A member without the level a request asks for is + // sent the one it has nearest, as ever. Empty offers the members' + // shared levels. + Levels []string `json:"levels,omitempty"` // Family is a tag the group goes by in which agents are shown it // (settings' Visible), with its id. Family string `json:"family,omitempty"` @@ -311,6 +318,8 @@ func membersIn(entries []Entry, all []Group, g Group) []Member { // member has — but for those fixed at an effort of their own, which take // whatever the agent asks. With every member fixed, the group offers the // levels they are fixed at, so that an agent still asks it to reason. +// A group that names its own levels (Group.Levels) offers those, and so +// does a group in it for its models. func groupEntries(entries []Entry) []Entry { var out []Entry all := groupsIn(entries) @@ -354,7 +363,11 @@ func groupEntries(entries []Entry) []Entry { } else { e.ImageInput = sharedImageInput(e.ImageInput, imageInput) } - if m.Effort != "" { + // a group in the group that names its own levels offers them for + // its models (the outermost that does) + if i := slices.IndexFunc(m.Via, func(v Group) bool { return len(v.Levels) > 0 }); i >= 0 { + efforts = m.Via[i].Levels + } else if m.Effort != "" { if !slices.Contains(fixed, m.Effort) { fixed = append(fixed, m.Effort) } @@ -370,6 +383,10 @@ func groupEntries(entries []Entry) []Entry { if !levelled { e.Efforts = fixedLevels(fixed) } + e.Shared = e.Efforts + if len(g.Levels) > 0 { + e.Efforts = slices.Clone(g.Levels) + } if ultra && slices.Contains(e.Efforts, "max") && !slices.Contains(e.Efforts, "ultra") { e.Efforts = append(e.Efforts, "ultra") } @@ -434,6 +451,9 @@ func SaveGroup(g Group) error { return err } g.Rules = rules + if g.Levels, err = CleanLevels(g.Levels); err != nil { + return err + } g.Classifier = strings.TrimPrefix(strings.TrimSpace(g.Classifier), "magpie/") g.Effort = strings.ToLower(strings.TrimSpace(g.Effort)) intents := slices.ContainsFunc(g.Rules, func(r Rule) bool { return r.Intent != "" }) diff --git a/internal/provider/groupeffort.go b/internal/provider/groupeffort.go index c511abcc..417510cb 100644 --- a/internal/provider/groupeffort.go +++ b/internal/provider/groupeffort.go @@ -20,6 +20,7 @@ package provider // provider has. import ( + "fmt" "slices" "strings" @@ -104,3 +105,27 @@ func fixedLevels(fixed []string) []string { } return out } + +// CleanLevels are the reasoning levels a group names for itself +// (Group.Levels) as they are kept: lowercase, each once, lowest first. +// Only the levels magpie knows are taken. +func CleanLevels(in []string) ([]string, error) { + var want []string + for _, l := range in { + l = strings.ToLower(strings.TrimSpace(l)) + if l == "" { + continue + } + if !slices.Contains(Levels, l) { + return nil, fmt.Errorf("%q is not a reasoning level magpie knows (they are %s)", l, strings.Join(Levels, ", ")) + } + want = append(want, l) + } + var out []string + for _, l := range Levels { + if slices.Contains(want, l) { + out = append(out, l) + } + } + return out, nil +} diff --git a/internal/provider/grouplevels_test.go b/internal/provider/grouplevels_test.go new file mode 100644 index 00000000..7dfc0d86 --- /dev/null +++ b/internal/provider/grouplevels_test.go @@ -0,0 +1,72 @@ +package provider + +import ( + "slices" + "strings" + "testing" +) + +// A group that names its reasoning levels (#295) offers those, whatever +// its members have in common; one that doesn't offers what they share, +// as before. A group in a group offers its own for its models. Only the +// levels magpie knows are taken, kept lowest first. +func TestGroupLevelsOverride(t *testing.T) { + effortHome(t) + if err := SetModelEfforts("a/m", []string{"low", "high", "max"}); err != nil { // deepseek-flash + t.Fatal(err) + } + if err := SetModelEfforts("a/n", []string{"none", "low", "medium", "high", "xhigh", "max"}); err != nil { // gpt-6-sol + t.Fatal(err) + } + entry := func(id string) Entry { + t.Helper() + for _, e := range Catalog() { + if e.ID == GroupPrefix+id { + return e + } + } + t.Fatalf("%s not listed", id) + return Entry{} + } + if err := SaveGroup(Group{Name: "mix", Members: []string{"a/n", "a/m"}}); err != nil { + t.Fatal(err) + } + if e := entry("mix"); !slices.Equal(e.Efforts, []string{"low", "high", "max"}) || !slices.Equal(e.Shared, e.Efforts) { + t.Fatalf("unset: levels %v, shared %v", e.Efforts, e.Shared) + } + g, _, _ := FindGroup("group/mix") + g.Levels = []string{"MAX", " xhigh", "medium", "low", "high", "none", "medium"} + if err := SaveGroup(g); err != nil { + t.Fatal(err) + } + all := []string{"none", "low", "medium", "high", "xhigh", "max"} + if g, _, _ := FindGroup("group/mix"); !slices.Equal(g.Levels, all) { + t.Fatalf("kept %v", g.Levels) + } + if e := entry("mix"); !slices.Equal(e.Efforts, all) || !slices.Equal(e.Shared, []string{"low", "high", "max"}) { + t.Fatalf("named: levels %v, shared %v", e.Efforts, e.Shared) + } + // a group with mix in it offers mix's levels for mix's models + if err := SaveGroup(Group{Name: "outer", Members: []string{"group/mix", "a/n"}}); err != nil { + t.Fatal(err) + } + if e := entry("outer"); !slices.Equal(e.Efforts, all) { + t.Fatalf("outer: levels %v", e.Efforts) + } + // a level magpie doesn't know is refused, the group left as it was + g.Levels = []string{"low", "turbo"} + if err := SaveGroup(g); err == nil || !strings.Contains(err.Error(), "turbo") { + t.Fatalf("turbo: %v", err) + } + // cleared, it offers what its members share again + g.Levels = nil + if err := SaveGroup(g); err != nil { + t.Fatal(err) + } + if e := entry("mix"); !slices.Equal(e.Efforts, []string{"low", "high", "max"}) { + t.Fatalf("cleared: levels %v", e.Efforts) + } + if e := entry("outer"); !slices.Equal(e.Efforts, []string{"low", "high", "max"}) { + t.Fatalf("outer, cleared: levels %v", e.Efforts) + } +} diff --git a/internal/provider/models.go b/internal/provider/models.go index 577017be..de14758b 100644 --- a/internal/provider/models.go +++ b/internal/provider/models.go @@ -616,6 +616,9 @@ type Entry struct { Family string `json:"family,omitempty"` // Free is set on a model its subscription serves at no cost to it. Free bool `json:"free,omitempty"` + // Shared are a group's levels its members have in common: its Efforts, + // unless the group names its own (Group.Levels). + Shared []string `json:"-"` } // Catalog lists the routing groups, then every exposed model of every ready diff --git a/internal/tui/routing.go b/internal/tui/routing.go index 53601565..c11b83c5 100644 --- a/internal/tui/routing.go +++ b/internal/tui/routing.go @@ -402,6 +402,19 @@ func (m model) updateGroup(msg tea.KeyMsg) (tea.Model, tea.Cmd) { }, g.Name+" context "+dash(v)) }}) m.back = modeGroup + case "l": + in := newInput("e.g. low,medium,high,xhigh · empty for those its models share") + in.SetValue(strings.Join(g.Levels, ",")) + m.openAsk(ask{crumbs: []string{"routing", g.Name, "levels"}, input: in, empty: true, + hint: "the reasoning levels agents are offered: " + strings.Join(provider.Levels, ", ") + "; a model without the one asked is sent its nearest", + onEnter: func(v string) tea.Cmd { + return saveGroup(g.ID, func(g *provider.Group) error { + var err error + g.Levels, err = provider.CleanLevels(strings.FieldsFunc(v, func(r rune) bool { return r == ',' || r == ' ' })) + return err + }, g.Name+" levels "+dash(v)) + }}) + m.back = modeGroup case "R": in := newInput("the group's new id") in.SetValue(g.ID) @@ -528,6 +541,9 @@ func (m model) viewGroup() string { if g.Context > 0 { head = append(head, "context "+fmtTokens(g.Context)) } + if len(g.Levels) > 0 { + head = append(head, "levels "+strings.Join(g.Levels, "/")) + } if g.Family != "" { head = append(head, "family "+g.Family) } diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 0f28989a..14d556aa 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -701,7 +701,7 @@ func (m model) View() string { body = m.viewGroup() // two lines: there is more to do to a group than one holds footer = hints("↑↓", "model / rule", "J K", "move", "a", "add model", "n", "new rule", "↵", "edit rule", "d", "take out", "esc", "back") + "\n" + - pad + hints("c", "classifier", "o", "routing", "s", "stays", "x", "context", "f", "family", "R", "rename") + pad + hints("c", "classifier", "o", "routing", "s", "stays", "x", "context", "l", "levels", "f", "family", "R", "rename") case modeName: body = m.viewName() footer = hints("↵", "save", "esc", "cancel") diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index ea6e2f0c..1bdeebe0 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -180,6 +180,13 @@ func TestRoutingPage(t *testing.T) { if g.Classifier != "b/gpt-5.5" || g.Routing != provider.Ordered || g.Affinity != provider.AffinitySession || g.Context != 300000 { t.Fatalf("group %+v", g) } + // the levels agents are offered (#295) + m = press(t, m, "l") + m = typeIn(m, "xhigh,medium") + m = press(t, m, "enter") + if g := group(t, "opus"); !slices.Equal(g.Levels, []string{"medium", "xhigh"}) { + t.Fatalf("levels %v", g.Levels) + } // d twice removes the rule picked; a model taken out takes its rules m = press(t, m, "d")