routing: a group can name the reasoning levels agents are offered, so one member with few doesn't take the rest from the others (#295) — a group offered only the levels every member has, so deepseek-flash (low/high/max) beside models with medium and xhigh left the group without them. Group.Levels (CLI magpie group set <id> levels=low,medium,high,xhigh, levels= clears; TUI l; GUI group editor's Levels: its models' shared / named) is offered instead in /v1/models, Codex's catalog, Pi and the rest; only levels magpie knows are taken, stored lowest first; a group in a group that names its own offers them for its models; unset is as before. A member without the level asked is sent its nearest, as it already was. The GUI's group save also keeps a family set from the CLI. TestGroupLevelsOverride, TestGroupLevelsReachAgents, TestGroupLevelsNamed (passthrough and translated: medium→high, xhigh→max on the low/high/max member, as asked on the one with all), TestGroupSetLevels and routing-levels.test.cjs (Chromium, WebKit, en/zh) fail without the change. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass (TestUserPath, a known flake under load, passes alone); click-scroll, old-webkit, routing-effort(-row), routing-levels, settings-groups GUI tests pass.

This commit is contained in:
yetone committed 2026-09-30 16:26:37 +08:00
1 parent 7f007660d6
commit 7c0b5441a8
17 files changed
+528 -8

No files matched your search

+15 -3
View File
@@ -23,8 +23,10 @@ const groupUsage = `usage:
magpie group set <id> k=v… change one: name, models (the whole list, in order),
models+=<m> (append), models-=<m> (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"))
}
+28
View File
@@ -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)
}
}
+51
View File
@@ -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)
}
}
+85
View File
@@ -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"])
}
}
}
+7
View File
@@ -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.": "组里有两个及以上模型时,可以用规则把某些轮次先发给其中一个。",
+3
View File
@@ -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; }
+37 -2
View File
@@ -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);
+11 -1
View File
@@ -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() {
+7
View File
@@ -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
+139
View File
@@ -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, []);
});
}
}
+21 -1
View File
@@ -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 != "" })
+25
View File
@@ -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
}
+72
View File
@@ -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)
}
}
+3
View File
@@ -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
+16
View File
@@ -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)
}
+1 -1
View File
@@ -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")
+7
View File
@@ -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")