routing: a group can be routed by hand, its model picked on its card (#317, @azir12345: pick the channel as CC Switch does) — a group's routing may now be Manual: every request goes to the one member picked (pick in the group's JSON; the first when none or one no longer in it), over that member's own accounts or keys, and the others and the rules wait (kept, not dropped) until another routing is chosen; none takes over when it fails. On Routing, a manual group's card lists its models as pills, the picked one marked by a dot (role radio), and clicking another saves the group with that pick without opening its editor or moving the page; its rules tag says they wait. The editor offers Manual beside the other routings. magpie group pick <id> <model> (or group set <id> pick=<model>, a bare model id when it names one member) picks from the CLI; magpie groups and group show mark the pick, the TUI shows "manual → model". A renamed provider or model renames the pick. TestManualGroup, TestGroupPick and routing-manual.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; click-scroll, old-webkit, routing-levels, routing-steady, panel-routing, settings-groups pass.

This commit is contained in:
yetone committed 2026-09-30 19:42:44 +08:00
1 parent d08018e433
commit 52a0d799d6
14 files changed
+500 -26

No files matched your search

+72 -7
View File
@@ -30,7 +30,10 @@ const groupUsage = `usage:
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=),
effort=agent (the agent's again), classifier=<provider/model>|group/<id>
effort=agent (the agent's again), classifier=<provider/model>|group/<id>,
pick=<model> (routing=manual, every request to that one of its models)
magpie group pick <id> <model> route the group manually, every request to that one of its models
(as clicking it on the group's card in the Routing view does)
magpie group rm <id> remove a group (one magpie found is hidden instead)
magpie group restore <id> bring back a group magpie found that you removed
magpie group rule add|rm|mv <id> … rules: which model a turn goes to first, by its length, an image,
@@ -51,6 +54,8 @@ const groupUsage = `usage:
order the first model until it can't answer, then the next
rotate each conversation's next turn goes to the next member's account or key
usage the account or key with the most of its allowance left first
manual only the model you pick (pick=, or click it on the group's card): the others and
the rules wait until you pick another or route it otherwise; no failover to them
stays auto (default) with the account or key that answered, while its cache is worth keeping
session for the whole session
turn within a turn only; routing decides afresh when you speak again
@@ -68,6 +73,7 @@ const groupUsage = `usage:
magpie group set opus-anywhere effort=auto classifier=typesafe/jev-latest
magpie group add Fast models=codex/gpt-5.6-luna:low,deepseek/deepseek-v4-flash,glm/glm-5.3-flash:high
magpie group set fast models+=gcloud/gemini-3.8-flash:medium
magpie group pick opus-anywhere copilot/claude-opus-5.5
magpie claude group/opus-anywhere`
// routingNames: each routing's value in the file, what the CLI calls it,
@@ -80,6 +86,7 @@ var routingNames = []struct {
{provider.Ordered, "order", []string{"ordered", "in-order"}},
{provider.Rotate, "rotate", []string{"in-turn", "round-robin"}},
{provider.LeastUsed, "usage", []string{"least-used"}},
{provider.Manual, "manual", []string{"pick", "picked", "pinned"}},
}
var staysNames = []struct {
@@ -345,8 +352,13 @@ func applyGroupPairs(g *provider.Group, pairs []string, resolve func(string) (st
}
case "classifier", "classify":
g.Classifier = strings.TrimPrefix(strings.TrimSpace(v), "magpie/")
case "pick", "use":
// the one member a manual group sends to (#317): routing is
// manual then
g.Pick, err = pickMember(g.Members, v)
g.Routing = provider.Manual
default:
return fmt.Errorf("unknown field %q (fields: name, models, models+, models-, routing, stays, context, levels, family, effort, classifier; magpie group help)", k)
return fmt.Errorf("unknown field %q (fields: name, models, models+, models-, routing, pick, stays, context, levels, family, effort, classifier; magpie group help)", k)
}
if err != nil {
return err
@@ -355,6 +367,28 @@ func applyGroupPairs(g *provider.Group, pairs []string, resolve func(string) (st
return nil
}
// pickMember is the group's member the user named: its id, or a bare
// model id one member has.
func pickMember(members []string, v string) (string, error) {
v = strings.TrimPrefix(strings.TrimSpace(v), "magpie/")
if i := slices.IndexFunc(members, func(x string) bool { return strings.EqualFold(x, v) }); i >= 0 {
return members[i], nil
}
var hits []string
for _, x := range members {
if _, bare, _ := strings.Cut(x, "/"); strings.EqualFold(bare, v) {
hits = append(hits, x)
}
}
switch len(hits) {
case 1:
return hits[0], nil
case 0:
return "", fmt.Errorf("%s is not in the group (its models: %s)", v, strings.Join(members, ", "))
}
return "", fmt.Errorf("%s is %d of the group's models; name one: %s", v, len(hits), strings.Join(hits, ", "))
}
// findGroup looks a group up by its id, its catalog id or its name.
func findGroup(ref string) (provider.Group, error) {
all := provider.Groups()
@@ -448,6 +482,16 @@ func groupCmd(args []string) error {
fmt.Println(green.Render("✓"), "removed", bold.Render(g.Name))
}
return nil
case "pick", "use":
if len(rest) != 2 {
return fmt.Errorf("magpie group pick <id> <model>")
}
g, err := setGroup(rest[0], []string{"pick=" + rest[1]})
if err != nil {
return err
}
fmt.Println(green.Render("✓"), bold.Render(g.Name), "sends every request to", bold.Render(g.Picked()), muted.Render("· routing manual"))
return showGroup(g)
case "rule", "rules":
return ruleCmd(rest)
case "show":
@@ -684,11 +728,21 @@ func groups() error {
}
var ms []string
for _, id := range g.Members {
if model, _ := provider.MemberEffort(id); names[model].ID != "" {
model, _ := provider.MemberEffort(id)
served := names[model].ID != ""
picked := g.Routing == provider.Manual && id == g.Picked()
if served && (picked || g.Routing != provider.Manual) {
ready = true
ms = append(ms, id)
} else {
}
switch {
case !served:
ms = append(ms, faint.Render(id+" (not served)"))
case picked:
ms = append(ms, green.Render("● "+id))
case g.Routing == provider.Manual:
ms = append(ms, faint.Render(id))
default:
ms = append(ms, id)
}
}
r.members = strings.Join(ms, sep)
@@ -733,7 +787,11 @@ func showGroup(g provider.Group) error {
head += amber.Render(" removed") + muted.Render(" · magpie group restore "+g.ID)
}
fmt.Println(" ", head)
kv("routing", routingName(g.Routing))
if g.Routing == provider.Manual {
kv("routing", "manual"+muted.Render(" every request to "+g.Picked()+"; magpie group pick "+g.ID+" <model> picks another"))
} else {
kv("routing", routingName(g.Routing))
}
kv("stays", staysName(g.Affinity))
names := catalogByID()
for i, id := range g.Members {
@@ -742,6 +800,9 @@ func showGroup(g provider.Group) error {
k = "models"
}
line := fmt.Sprintf("%d %s", i+1, id)
if g.Routing == provider.Manual && id == g.Picked() {
line = green.Render(fmt.Sprintf("%d %s ●", i+1, id))
}
if l, ok := memberLabel(id, names); ok {
line += muted.Render(" " + l)
} else {
@@ -754,7 +815,11 @@ func showGroup(g provider.Group) error {
if i == 0 {
k = "rules"
}
kv(k, fmt.Sprintf("%d %s", i+1, ruleLine(r)))
line := fmt.Sprintf("%d %s", i+1, ruleLine(r))
if g.Routing == provider.Manual {
line = faint.Render(line + " (waits: routing is manual)")
}
kv(k, line)
}
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"))
+53
View File
@@ -0,0 +1,53 @@
package main
import (
"testing"
"github.com/yetone/magpie/internal/provider"
)
// magpie group pick / pick= route a group manually (#317): every request
// to the model picked, stored with the group, the others left aside.
func TestGroupPick(t *testing.T) {
groupsHome(t)
if v, err := parseRouting("manual"); err != nil || v != provider.Manual {
t.Fatalf("routing manual: %q %v", v, err)
}
if _, err := addGroup("Two", []string{"models=a/m,b/vendor/m"}); err != nil {
t.Fatal(err)
}
g, err := setGroup("two", []string{"pick=vendor/m"}) // a bare model id one member has
if err != nil {
t.Fatal(err)
}
if g.Routing != provider.Manual || g.Pick != "b/vendor/m" || g.Picked() != "b/vendor/m" {
t.Fatalf("picked: %+v", g)
}
if gs := storedGroups(t); len(gs) != 1 || gs[0]["routing"] != "manual" || gs[0]["pick"] != "b/vendor/m" {
t.Fatalf("stored: %v", gs)
}
if _, ms, ok := provider.FindGroup("group/two"); !ok || len(ms) != 1 || ms[0].Provider.ID != "b" {
t.Fatalf("members routed to: %+v", ms)
}
if err := groupCmd([]string{"group", "pick", "two", "a/m"}); err != nil {
t.Fatal(err)
}
if _, ms, _ := provider.FindGroup("group/two"); len(ms) != 1 || ms[0].Provider.ID != "a" {
t.Fatalf("picked a/m: %+v", ms)
}
if _, err := setGroup("two", []string{"pick=only-a"}); err == nil {
t.Fatal("picked a model not in the group")
}
// routed otherwise, every member again; the pick kept for next time
g, err = setGroup("two", []string{"routing=smart"})
if err != nil {
t.Fatal(err)
}
if _, ms, _ := provider.FindGroup("group/two"); g.Pick != "a/m" || len(ms) != 2 {
t.Fatalf("smart again: %+v %d", g, len(ms))
}
// a model taken out of the group is no longer its pick
if g, err = setGroup("two", []string{"routing=manual", "models-=a/m"}); err != nil || g.Picked() != "b/vendor/m" {
t.Fatalf("pick taken out: %+v %v", g, err)
}
}
+5 -1
View File
@@ -310,7 +310,11 @@ func planLevel(g provider.Group, ms []provider.Member, depth int, from provider.
}
all = append(all, cs...)
}
cs, wg := weigh(provider.Provider{ID: provider.GroupPrefix + g.ID, Routing: g.Routing}, all, "", from)
routing := g.Routing
if routing == provider.Manual {
routing = "" // the member picked, its keys or accounts weighed smartly
}
cs, wg := weigh(provider.Provider{ID: provider.GroupPrefix + g.ID, Routing: routing}, all, "", from)
for i, c := range cs {
m := of[c.seat()]
w := weighed(c, m.Provider, wg, false, from)
+1
View File
@@ -709,6 +709,7 @@ func (s *Server) serve(w http.ResponseWriter, r *http.Request, from provider.Pro
// a routing group's rules pick the member that goes first, looked at
// before any image is taken out of the request: one may be for images
g, ms, isGroup := provider.FindGroup(asked)
g = g.Live() // a manual group's rules wait
var hit *RuleHit
var ruled []provider.Member
var ruleAt, words string
+50
View File
@@ -0,0 +1,50 @@
package gateway
import (
"strings"
"testing"
"github.com/yetone/magpie/internal/provider"
)
// A manual group (#317) sends every request to the member the user picked,
// never to the others — not when a rule matches, not when the pick fails —
// and picking another moves the next request there.
func TestManualGroup(t *testing.T) {
fresh(t)
a, b := &keyed{}, &keyed{fail: map[string]int{"kb": 500}}
serveOn(t, "a", "ka", []string{"m"}, a)
serveOn(t, "b", "kb", []string{"n"}, b)
g := provider.Group{Name: "Mine", Members: []string{"a/m", "b/n"}, Routing: provider.Manual, Pick: "b/n",
Rules: []provider.Rule{{Use: "a/m", Tokens: 1}}}
if err := provider.SaveGroup(g); err != nil {
t.Fatal(err)
}
s := New()
const req = `{"model":"group/mine","messages":[{"role":"user","content":"hi"}]}`
code, body := postAs(t, s, "", req)
if code == 200 || len(a.tried) != 0 || len(b.tried) == 0 || strings.Trim(strings.Join(b.tried, ","), "kb,") != "" {
t.Fatalf("picked b/n: %d %s, a tried %v, b tried %v", code, body, a.tried, b.tried)
}
r := s.trace.routes[len(s.trace.routes)-1]
if r.Group == nil || r.Group.Routing != provider.Manual || len(r.Order) != 1 || r.Order[0].Provider != "b" {
t.Fatalf("trace: %+v", r)
}
got, _, _ := provider.FindGroup("group/mine")
got.Pick = "a/m"
if err := provider.SaveGroup(got); err != nil {
t.Fatal(err)
}
b.tried = nil
code, body = postAs(t, s, "", req)
if code != 200 || !strings.Contains(body, "from ka") || len(b.tried) != 0 {
t.Fatalf("picked a/m: %d %s, b tried %v", code, body, b.tried)
}
// the rules wait while it is manual, but are kept
for _, x := range provider.Groups() {
if x.ID == "mine" && len(x.Rules) != 1 {
t.Fatalf("rules lost: %+v", x)
}
}
}
+9
View File
@@ -1112,6 +1112,15 @@ const I18N = {
"{name} is back": "{name} 已恢复",
"found by magpie": "magpie 自动发现",
"no member ready": "没有可用成员",
// a manual group (#317)
"Manual": "手动",
"Manual: every request goes to the model you pick on the group's card, over its own accounts or keys; the others, and the rules, wait until you pick another — none takes over when it fails.": "手动:所有请求都发给你在路由组卡片上点选的模型(在它自己的账号或 Key 之间路由);其他模型和规则暂不生效,直到你改选别的模型——它失败时也不会切到其他模型。",
"Manual: every request goes to the model picked on the group's card, over its own accounts or keys.": "手动:所有请求都发给在路由组卡片上点选的模型,在它自己的账号或 Key 之间路由。",
"The rules wait while you pick the model by hand.":"手动选择模型时,规则暂不生效。",
"Model every request goes to": "所有请求发往的模型",
"Every request goes to {name}": "所有请求都发给 {name}",
"Send every request to {name}": "把所有请求改发给 {name}",
"{name}: every request to {model}": "{name}:所有请求改发给 {model}",
"Edit": "编辑",
"found by magpie — saving a change makes it yours": "magpie 自动发现的——保存修改后就归你管理",
"e.g. Opus anywhere": "例如:Opus 多家备份",
+13
View File
@@ -242,6 +242,19 @@
.rt-group .tags { display: flex; gap: 4px; flex: none; }
.rt-group .tag { font-size: 11px; line-height: 18px; padding: 0 8px; border-radius: 999px; box-shadow: inset 0 0 0 1px var(--line); color: var(--muted); white-space: nowrap; }
.rt-group .tag.bad { color: var(--red); }
.rt-group .tag.idle { opacity: .55; }
/* a manual group's members on its card: the one picked, marked by a dot */
.rt-picks { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 5px; }
.rt-pick { display: inline-flex; align-items: center; gap: 5px; max-width: 100%; min-width: 0; height: 22px; padding: 0 8px 0 6px; border: 1px solid var(--line); border-radius: 999px; background: transparent; color: var(--muted); font: inherit; font-size: 11.5px; cursor: pointer; transition: background .12s, color .12s, border-color .12s; }
.rt-pick:hover { color: var(--fg); border-color: var(--muted); background: var(--card); }
.rt-pick .dot { width: 7px; height: 7px; flex: none; border-radius: 50%; box-shadow: inset 0 0 0 1.5px var(--faint); }
.rt-pick .ic, .rt-pick .ic-stack { width: 13px; height: 13px; flex: none; }
.rt-pick .n { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rt-pick small { color: var(--faint); font-size: 10.5px; white-space: nowrap; }
.rt-pick.on { color: var(--fg); border-color: var(--accent); background: color-mix(in srgb, var(--accent) 10%, transparent); cursor: default; }
.rt-pick.on .dot { background: var(--accent); box-shadow: none; }
.rt-pick.off { opacity: .55; }
.rt-pick:focus-visible { outline: none; box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 30%, transparent); }
.rt-gnone { padding: 14px; font-size: 12px; line-height: 1.5; }
.rt-ghidden { display: flex; flex-wrap: wrap; align-items: center; gap: 2px; padding: 6px 12px 6px 14px; font-size: 11.5px; color: var(--faint); }
.rt-ghidden > span { margin-right: 4px; }
+48 -8
View File
@@ -153,6 +153,7 @@
order: ["In order", "In order: the first answers everything until it can't; then the next."],
rotate: ["In turn", "In turn: each conversation's next turn goes to the account after the one that answered its last, and a new conversation starts one further along; the requests within a turn stay put, keeping the prompt cache."],
usage: ["Least used", "Least used first: the account with the most of its allowance left goes first; a key by the tokens magpie sent it lately."],
manual: ["Manual", "Manual: every request goes to the model picked on the group's card, over its own accounts or keys."],
};
const GROUP_ORDER = "In order: member by member, the first model the group names until it can't answer, each over its own accounts or keys as its provider routes them.";
const KEYS_SMART = "Smart: keys that suit the request go first — one made for the model's own API — then in their order. One resting after a failure goes last.";
@@ -1787,6 +1788,9 @@
// so the list sits right under it
more.append(gsec);
const ROUTE_OPTS = [["", "Smart"], ["order", "In order"], ["rotate", "In turn"], ["usage", "Least used"]];
// a group may also be routed by hand: every request to the member the
// user picks on its card (provider.Manual, #317) — a provider's keys can't
const GROUP_ROUTE_OPTS = [...ROUTE_OPTS, ["manual", "Manual"]];
const AFF_OPTS = [["", "Auto"], ["session", "Session"], ["turn", "Within a turn"], ["off", "Off"]];
const AFF_HINT = {
"": "A conversation stays with the account or key that answered it while what the vendor cached of it is worth keeping — within a turn always, across turns while it's fresh.",
@@ -1799,6 +1803,7 @@
order: "In order: the first model until it can't answer, then the next — each over its own accounts or keys as its provider routes them.",
rotate: "In turn: each conversation's next turn goes to the next member's account or key, spreading the load.",
usage: "Least used first: the account or key with the most of its allowance left goes first.",
manual: "Manual: every request goes to the model you pick on the group's card, over its own accounts or keys; the others, and the rules, wait until you pick another — none takes over when it fails.",
};
const EFFORTS = ["low", "medium", "high", "xhigh", "max"]; // provider.Efforts
// what a rule matches, in words
@@ -1887,26 +1892,59 @@
const nm = el("div", "nm");
nm.append(el("b", "", g.name), el("code", "mdl", "group/" + g.id));
if (g.auto) nm.append(el("small", "auto", t("found by magpie")));
const manual = g.routing === "manual";
const sep = g.routing === "order" ? " → " : " · ";
const mem = el("div", "mem", g.members.map((id) => memberLabel(g, id)).join(sep));
const mem = manual ? pickRow(g) : el("div", "mem", g.members.map((id) => memberLabel(g, id)).join(sep));
main.append(nm, mem);
const m = ROUTE_OPTS.find(([id]) => id === (g.routing || "")) || ROUTE_OPTS[0];
const m = GROUP_ROUTE_OPTS.find(([id]) => id === (g.routing || "")) || ROUTE_OPTS[0];
const tags = el("span", "tags");
tags.append(el("span", "tag", t(m[1])));
if (g.affinity) tags.append(el("span", "tag", t(AFF_OPTS.find(([id]) => id === g.affinity)?.[1] || "")));
if (g.rules?.length) {
const r = el("span", "tag", t(g.rules.length === 1 ? "1 rule" : "{n} rules", { n: g.rules.length }));
r.title = g.rules.map((x, i) => `${i + 1}. ${ruleText(x)} → ${memberLabel(g, x.use)}`).join("\n");
const r = el("span", "tag" + (manual ? " idle" : ""), t(g.rules.length === 1 ? "1 rule" : "{n} rules", { n: g.rules.length }));
r.title = (manual ? t("The rules wait while you pick the model by hand.") + "\n" : "") + g.rules.map((x, i) => `${i + 1}. ${ruleText(x)} → ${memberLabel(g, x.use)}`).join("\n");
tags.append(r);
}
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 || "", levels: [...(g.levels || [])], 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 || "", pick: g.pick || "", 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;
}
// pickRow: a manual group's members on its card, the one every request
// goes to marked; clicking another sends them there from the next
// request on (#317), as CC Switch switches a provider
function pickRow(g) {
const picked = g.picked || (g.members.includes(g.pick) ? g.pick : g.members[0]);
const box = el("div", "rt-picks");
box.setAttribute("role", "radiogroup");
box.setAttribute("aria-label", t("Model every request goes to"));
for (const id of g.members) {
const info = g.memberInfo?.find((x) => x.id === id);
const on = id === picked;
const b = el("button", "rt-pick" + (on ? " on" : "") + (info && !info.ready ? " off" : ""));
b.type = "button";
b.setAttribute("role", "radio");
b.setAttribute("aria-checked", String(on));
b.dataset.member = id;
b.append(el("span", "dot"), memberIcon(id), el("span", "n", memberName(id)));
const note = memberNote(id);
if (note) b.append(el("small", "", note));
b.title = on ? t("Every request goes to {name}", { name: memberLabel(g, id) })
: info && !info.ready ? t("No provider serves {id} now; it is skipped", { id })
: t("Send every request to {name}", { name: memberLabel(g, id) });
b.onclick = (e) => {
e.stopPropagation(); // the card opens the editor; this picks
if (on) return;
groupAction("save", { id: g.id, name: g.name, members: g.members, routing: "manual", pick: id, affinity: g.affinity || "", rules: g.rules || [], effort: g.effort || "", classifier: g.classifier || "", context: g.context || 0, levels: g.levels || [], family: g.family || "" },
t("{name}: every request to {model}", { name: g.name, model: memberName(id) }));
};
box.append(b);
}
return box;
}
function groupEditor(g) {
const d = gEdit.draft;
const ed = el("div", "editor rt-gedit");
@@ -2014,7 +2052,7 @@
const rHint = el("div", "hint", t(GROUP_HINT[d.routing] || GROUP_HINT[""]));
const rw = el("div");
rw.append(segs(ROUTE_OPTS.map(([id, n]) => [id, t(n)]), d.routing, (v) => { d.routing = v; rHint.textContent = t(GROUP_HINT[v] || GROUP_HINT[""]); }), rHint);
rw.append(segs(GROUP_ROUTE_OPTS.map(([id, n]) => [id, t(n)]), d.routing, (v) => { d.routing = v; rHint.textContent = t(GROUP_HINT[v] || GROUP_HINT[""]); drawRules(); }), rHint);
ed.append(el("label", "", t("Routing")), rw);
const aHint = el("div", "hint", t(AFF_HINT[d.affinity] || AFF_HINT[""]));
const aw = el("div");
@@ -2030,7 +2068,9 @@
const drawRules = () => {
rlist.replaceChildren();
rAdd.hidden = d.members.length < 2;
rHint2.textContent = t(d.members.length < 2 ? "With two models or more, a rule can send some turns to one of them first." : "Checked top first when you send a message: the first that matches sends that turn to its model first; the rest stay behind it if it fails. A turn under way is never moved.");
rHint2.textContent = t(d.members.length < 2 ? "With two models or more, a rule can send some turns to one of them first."
: d.routing === "manual" ? "The rules wait while you pick the model by hand."
: "Checked top first when you send a message: the first that matches sends that turn to its model first; the rest stay behind it if it fails. A turn under way is never moved.");
d.rules.forEach((r, i) => {
const row = el("div", "rt-rule");
const when = el("div", "when");
@@ -2225,7 +2265,7 @@
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, levels: own ? d.levels : [], family: g?.family || "" }, 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, pick: d.pick || "", 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);
+8
View File
@@ -33,6 +33,8 @@ type groupJSON struct {
// its members have in common, which it offers unless it names its own
Offers []string `json:"offers"`
Shared []string `json:"shared"`
// Picked: the member a manual group sends every request to
Picked string `json:"picked,omitempty"`
}
type memberJSON struct {
@@ -185,6 +187,12 @@ func groupsState() groupsJSON {
}
gj.Info = append(gj.Info, m)
}
if g.Routing == provider.Manual {
// agents can pick it while the member picked can answer
gj.Picked = g.Picked()
i := slices.IndexFunc(gj.Info, func(m memberJSON) bool { return m.ID == gj.Picked })
gj.Ready = i >= 0 && gj.Info[i].Ready
}
out.Groups = append(out.Groups, gj)
}
for _, p := range provider.All() {
+9 -1
View File
@@ -93,6 +93,14 @@ 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-manual.test.cjs` routes a group by hand in Chromium and WebKit,
English and Chinese (#317: pick the model, as CC Switch picks a provider). A
manual group's card lists its models, the one every request goes to marked, and
its rules tag says they wait; the reader wheels down to it and clicks another,
which saves the group with that pick and its other fields as they were, the
editor staying shut and nothing on the page moving, and clicking the one picked
does nothing. The editor offers Manual with its hint and keeps the pick on save.
`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
@@ -368,7 +376,7 @@ that draws the latest numbers. The checks run in English and Chinese.
With Node.js and Playwright available:
```sh
node --test internal/gui/tests/menu-scroll.test.cjs internal/gui/tests/click-scroll.test.cjs internal/gui/tests/panel-fold.test.cjs internal/gui/tests/panel-routing.test.cjs internal/gui/tests/gateway-fold.test.cjs internal/gui/tests/routing-kind.test.cjs internal/gui/tests/balance-fix.test.cjs internal/gui/tests/cli-update.test.cjs internal/gui/tests/login-import.test.cjs internal/gui/tests/agy-launch.test.cjs internal/gui/tests/currency.test.cjs internal/gui/tests/usage-ledger.test.cjs internal/gui/tests/update-check.test.cjs internal/gui/tests/omarchy.test.cjs internal/gui/tests/brand.test.cjs node --test internal/gui/tests/signin-callback.test.cjs internal/gui/tests/routing-steady.test.cjs internal/gui/tests/old-webkit.test.cjs internal/gui/tests/session-terminal.test.cjs internal/gui/tests/text-size.test.cjs internal/gui/tests/zcode-site.test.cjs internal/gui/tests/rail-tip.test.cjs internal/gui/tests/sync-update.test.cjs internal/gui/tests/plugin-signin.test.cjs internal/gui/tests/plugin-market.test.cjs internal/gui/tests/s3-sync.test.cjs internal/gui/tests/routing-wb-refused.test.cjs internal/gui/tests/market-have.test.cjs internal/gui/tests/sessions-today.test.cjs internal/gui/tests/routing-side-calls.test.cjs internal/gui/tests/import-all-skills.test.cjs internal/gui/tests/routing-flood.test.cjs internal/gui/tests/routing-busy.test.cjs internal/gui/tests/own-skill.test.cjs
node --test internal/gui/tests/menu-scroll.test.cjs internal/gui/tests/click-scroll.test.cjs internal/gui/tests/panel-fold.test.cjs internal/gui/tests/panel-routing.test.cjs internal/gui/tests/gateway-fold.test.cjs internal/gui/tests/routing-kind.test.cjs internal/gui/tests/balance-fix.test.cjs internal/gui/tests/cli-update.test.cjs internal/gui/tests/login-import.test.cjs internal/gui/tests/agy-launch.test.cjs internal/gui/tests/currency.test.cjs internal/gui/tests/usage-ledger.test.cjs internal/gui/tests/update-check.test.cjs internal/gui/tests/omarchy.test.cjs internal/gui/tests/brand.test.cjs node --test internal/gui/tests/signin-callback.test.cjs internal/gui/tests/routing-steady.test.cjs internal/gui/tests/old-webkit.test.cjs internal/gui/tests/session-terminal.test.cjs internal/gui/tests/text-size.test.cjs internal/gui/tests/zcode-site.test.cjs internal/gui/tests/rail-tip.test.cjs internal/gui/tests/sync-update.test.cjs internal/gui/tests/plugin-signin.test.cjs internal/gui/tests/plugin-market.test.cjs internal/gui/tests/s3-sync.test.cjs internal/gui/tests/routing-wb-refused.test.cjs internal/gui/tests/market-have.test.cjs internal/gui/tests/sessions-today.test.cjs internal/gui/tests/routing-side-calls.test.cjs internal/gui/tests/import-all-skills.test.cjs internal/gui/tests/routing-flood.test.cjs internal/gui/tests/routing-busy.test.cjs internal/gui/tests/own-skill.test.cjs internal/gui/tests/routing-manual.test.cjs
```
If Playwright is installed outside the repository, set `NODE_PATH` to the
+155
View File
@@ -0,0 +1,155 @@
// Run with Node's test runner and Playwright on the module path; see README.md.
// A routing group may be routed by hand (#317: pick the channel, as CC
// Switch does, rather than only by rules). The group editor offers Manual
// beside the other routings, with its hint, and saving keeps the pick; a
// manual group's card lists its models, the one every request goes to
// marked, and clicking another saves the group with that pick — the card's
// editor stays shut and nothing on the page moves. The rules tag says they
// wait. 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 models = [
{ id: "a/sol", name: "sol", providerName: "A", icon: "generic" },
{ id: "b/flash", name: "flash", providerName: "B", icon: "generic" },
{ id: "c/luna", name: "luna", providerName: "C", icon: "generic" },
];
const info = [{ id: "a/sol", ready: true }, { id: "b/flash", ready: true }, { id: "c/luna", ready: true }];
const base = { members: ["a/sol", "b/flash", "c/luna"], ready: true, memberInfo: info, offers: [], shared: [] };
const groups = (pick) => ({ models, pools: [], groups: [
// filler above, so that the reader scrolls down to the group
...Array.from({ length: 14 }, (_, i) => ({ ...base, id: "g" + i, name: "Filler " + i, routing: "" })),
{ ...base, id: "hand", name: "Hand", routing: "manual", pick, picked: pick, affinity: "session", family: "relay",
rules: [{ use: "c/luna", tokens: 100000 }] },
{ ...base, id: "auto", name: "Auto", routing: "order" },
] });
const words = {
en: { edit: "Edit", save: "Save", routing: "Routing", manual: "Manual", smart: "Smart",
hint: "Manual: every request goes to the model you pick on the group's card, over its own accounts or keys; the others, and the rules, wait until you pick another — none takes over when it fails.",
rulesWait: "The rules wait while you pick the model by hand.", group: "Model every request goes to",
saved: "Hand: every request to luna" },
zh: { edit: "编辑", save: "保存", routing: "路由", manual: "手动", smart: "智能",
hint: "手动:所有请求都发给你在路由组卡片上点选的模型(在它自己的账号或 Key 之间路由);其他模型和规则暂不生效,直到你改选别的模型——它失败时也不会切到其他模型。",
rulesWait: "手动选择模型时,规则暂不生效。", group: "所有请求发往的模型",
saved: "Hand:所有请求改发给 luna" },
};
function serve(lang, posts) {
const state = { agents: [{ id: "codex", name: "Codex", path: "/test/codex", fields: [] }], profiles: [], settings: { lang, theme: "light" } };
let pick = "b/flash";
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(() => {}); // 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(pick));
if (url.pathname.startsWith("/api/groups/")) {
const body = JSON.parse(r.request().postData() || "{}");
posts.push({ path: url.pathname, body });
if (body.id === "hand" && body.pick) pick = body.pick;
return json(groups(pick));
}
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 manual group's model is picked on its card`, 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: 700 } })).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");
const card = page.locator(".rt-group", { hasText: "Hand" });
await card.waitFor();
// the card: every model, the picked one marked; the rules wait
const picks = card.locator(`.rt-picks[role="radiogroup"][aria-label="${w.group}"] .rt-pick`);
assert.deepEqual(await picks.locator(".n").allTextContents(), ["sol", "flash", "luna"]);
assert.deepEqual(await card.locator(".rt-pick.on .n").allTextContents(), ["flash"]);
assert.equal(await card.locator('.rt-pick[aria-checked="true"]').getAttribute("data-member"), "b/flash");
assert.equal(await card.locator(".tag").first().textContent(), w.manual);
assert.match(await card.locator(".tag.idle").getAttribute("title"), new RegExp("^" + w.rulesWait));
// a group routed otherwise lists its models as before
assert.equal(await page.locator(".rt-group", { hasText: "Auto" }).locator(".rt-picks").count(), 0);
// the reader scrolls the card to the middle, then picks luna: saved
// with the group's other fields as they were, nothing moves, the
// editor stays shut
const view = page.locator("#view-routing");
const dy = await card.evaluate((g) => { const v = document.querySelector("#view-routing");
return Math.round(g.getBoundingClientRect().top - v.getBoundingClientRect().top - v.clientHeight / 2); });
await page.mouse.move(550, 400);
await page.mouse.wheel(0, dy);
await page.waitForTimeout(400);
const at = () => view.evaluate((v) => v.scrollTop + "|" + Math.round(document.querySelector(".rt-picks").getBoundingClientRect().top));
const before = await at();
assert.ok(parseInt(before) > 200, `the view must be scrolled down to the group (${before})`);
await picks.filter({ hasText: "luna" }).click();
await page.waitForFunction(() => document.querySelector(".rt-pick.on .n")?.textContent === "luna");
assert.equal(await at(), before, "the pick moved nothing");
assert.equal(await page.locator(".rt-gedit").count(), 0, "the editor stays shut");
assert.equal(posts.length, 1);
const sent = posts[0];
assert.equal(sent.path, "/api/groups/save");
assert.deepEqual({ id: sent.body.id, routing: sent.body.routing, pick: sent.body.pick, members: sent.body.members, affinity: sent.body.affinity, family: sent.body.family, rules: sent.body.rules },
{ id: "hand", routing: "manual", pick: "c/luna", members: ["a/sol", "b/flash", "c/luna"], affinity: "session", family: "relay", rules: [{ use: "c/luna", tokens: 100000 }] });
await page.waitForFunction((s) => document.documentElement.innerText.includes(s), w.saved);
// the one picked already does nothing
await picks.filter({ hasText: "luna" }).click();
await page.waitForTimeout(200);
assert.equal(posts.length, 1);
// the editor: Manual among the routings, its hint, the pick kept on save
await card.locator("button", { hasText: w.edit }).click();
const ed = page.locator(".rt-gedit");
const row = ed.locator("label", { hasText: new RegExp(`^${w.routing}$`) }).locator("xpath=following-sibling::div[1]");
assert.equal(await row.locator(".segs .opt.on").textContent(), w.manual);
// the reader wheels the editor's routing into the middle (code that
// scrolls is put back: only the reader moves the view)
const wheelTo = async (x) => {
const d = await x.evaluate((e) => { const v = document.querySelector("#view-routing");
return Math.round(e.getBoundingClientRect().top - v.getBoundingClientRect().top - v.clientHeight / 2); });
await page.mouse.move(550, 400);
await page.mouse.wheel(0, d);
await page.waitForTimeout(400);
};
await wheelTo(row);
assert.equal(await row.locator(".hint").textContent(), w.hint);
await row.locator(".segs .opt", { hasText: w.smart }).click();
assert.notEqual(await row.locator(".hint").textContent(), w.hint);
await row.locator(".segs .opt", { hasText: w.manual }).click();
assert.equal(await row.locator(".hint").textContent(), w.hint);
assert.ok((await ed.innerText()).includes(w.rulesWait));
const b = ed.locator("button.primary", { hasText: w.save });
await wheelTo(b);
await b.click();
await page.waitForFunction(() => !document.querySelector(".rt-gedit"));
const saved = posts.at(-1);
assert.equal(saved.body.routing, "manual");
assert.equal(saved.body.pick, "c/luna");
assert.deepEqual(errors, []);
});
}
}
+63 -6
View File
@@ -48,13 +48,57 @@ const EffortAuto = "auto"
// begins: a rule to put a member first, or the turn's effort.
func (g Group) Ruled() bool { return len(g.Rules) > 0 || g.Effort == EffortAuto }
// Manual is a group's routing when the user picks which member it uses,
// as CC Switch has one provider on at a time (#317): every request goes to
// the member picked (Group.Pick), over its own keys or accounts; the
// others, and the rules, wait until another is picked or the group is
// routed otherwise. The pick is changed on the group's card, in a click.
const Manual = "manual"
// Picked is the member a manual group sends to: the one the user picked
// while it is in the group, else its first.
func (g Group) Picked() string {
if slices.Contains(g.Members, g.Pick) {
return g.Pick
}
if len(g.Members) > 0 {
return g.Members[0]
}
return ""
}
// routes are the members requests to the group may go to now: a manual
// group's pick alone, else every one.
func (g Group) routes() []string {
if g.Routing == Manual {
if p := g.Picked(); p != "" {
return []string{p}
}
return nil
}
return g.Members
}
// Live is the group as the gateway routes it: a manual group's rules
// wait (the member picked is all it sends to), though they are kept.
func (g Group) Live() Group {
if g.Routing == Manual {
g.Rules = nil
}
return g
}
// Group is a routing group.
type Group struct {
ID string `json:"id"`
Name string `json:"name"`
Members []string `json:"members"` // "provider/model[:effort]" or "group/<id>", in order (see MemberEffort)
Routing string `json:"routing,omitempty"` // as Provider.Routing, over all the members' keys and accounts
Routing string `json:"routing,omitempty"` // as Provider.Routing, over all the members' keys and accounts; or Manual
Affinity string `json:"affinity,omitempty"` // as Provider.Affinity
// Pick is the member a Manual group sends every request to, as the
// user picked it on the group's card; "" is its first. It is kept
// while the group routes otherwise, for when it is manual again.
Pick string `json:"pick,omitempty"`
// Rules send the requests they match to one member first, in order:
// the first that matches decides (see Rule).
Rules []Rule `json:"rules,omitempty"`
@@ -282,20 +326,21 @@ func groupOf(all []Group, id string) (Group, bool) {
// membersIn are a group's models, ready now, in its order: a group in it
// gives its own there, as deep as they go. A model met again is left
// where it was first — the same model at another effort is another
// member — and a group that would be in itself is cut there.
// member — and a group that would be in itself is cut there. A manual
// group has only the member picked (see Manual), however deep.
func membersIn(entries []Entry, all []Group, g Group) []Member {
var out []Member
seen := map[string]bool{}
var walk func(g Group, path []string, via []Group, in []string)
walk = func(g Group, path []string, via []Group, in []string) {
for _, id := range g.Members {
for _, id := range g.routes() {
at := append(slices.Clone(path), id)
if gid, ok := strings.CutPrefix(id, GroupPrefix); ok {
sub, ok := groupOf(all, gid)
if !ok || slices.Contains(in, gid) || len(via) >= maxNest {
continue // gone, a loop, or deeper than anyone nests
}
walk(sub, at, append(slices.Clone(via), sub), append(slices.Clone(in), gid))
walk(sub, at, append(slices.Clone(via), sub.Live()), append(slices.Clone(in), gid))
continue
}
model, effort := memberEffortIn(entries, id)
@@ -393,7 +438,7 @@ func groupEntries(entries []Entry) []Entry {
if e.ImageInput != nil && !*e.ImageInput {
e.Images = false
}
ruledEntry(&e, g, ms, entries)
ruledEntry(&e, g.Live(), ms, entries)
if g.Context > 0 {
e.Context = g.Context
}
@@ -440,12 +485,21 @@ func SaveGroup(g Group) error {
return fmt.Errorf("%s decides a group's model and effort; it holds no conversation, so it can only be the group's classifier", m)
}
}
if g.Routing != Ordered && g.Routing != Rotate && g.Routing != LeastUsed {
if g.Routing != Ordered && g.Routing != Rotate && g.Routing != LeastUsed && g.Routing != Manual {
g.Routing = ""
}
if !slices.Contains(Affinities, g.Affinity) {
g.Affinity = ""
}
if g.Pick = strings.TrimPrefix(strings.TrimSpace(g.Pick), "magpie/"); g.Pick != "" {
g.Pick = cleanMember(entries, g.Pick)
}
if !slices.Contains(g.Members, g.Pick) {
g.Pick = "" // taken out of the group: its first, when manual
}
if g.Routing == Manual && g.Pick == "" {
g.Pick = g.Members[0]
}
rules, err := cleanRules(g.Rules, g.Members)
if err != nil {
return err
@@ -661,6 +715,9 @@ func RenameGroup(from, to string) error {
f.Groups[i].Rules[j].Use = now
}
}
if f.Groups[i].Pick == old {
f.Groups[i].Pick = now
}
if f.Groups[i].Classifier == old {
f.Groups[i].Classifier = now
}
+1
View File
@@ -78,6 +78,7 @@ func Rename(from, to string) error {
g.Rules[k].Use = renamedRef(r.Use, from, to)
}
g.Classifier = renamedRef(g.Classifier, from, to)
g.Pick = renamedRef(g.Pick, from, to)
}
// the vendor's list last fetched goes with it
os.Rename(catalog.LivePath(from), catalog.LivePath(to))
+13 -3
View File
@@ -34,7 +34,7 @@ func (m *model) reloadGroups() {
m.grow = clamp(m.grow, len(m.groups))
}
var routings = []string{"", provider.Ordered, provider.Rotate, provider.LeastUsed}
var routings = []string{"", provider.Ordered, provider.Rotate, provider.LeastUsed, provider.Manual}
func routingName(v string) string {
switch v {
@@ -44,10 +44,20 @@ func routingName(v string) string {
return "rotate"
case provider.LeastUsed:
return "least used"
case provider.Manual:
return "manual"
}
return "smart"
}
// groupRouting is how a group routes: a manual one names its pick.
func groupRouting(g provider.Group) string {
if g.Routing == provider.Manual {
return "manual → " + g.Picked()
}
return routingName(g.Routing)
}
func staysName(v string) string {
switch v {
case provider.AffinitySession:
@@ -509,7 +519,7 @@ func (m model) viewGroups() string {
b.WriteString(line + "\n")
continue
}
notes := []string{fmt.Sprintf("%d model%s", len(g.Members), plural(len(g.Members))), routingName(g.Routing)}
notes := []string{fmt.Sprintf("%d model%s", len(g.Members), plural(len(g.Members))), groupRouting(g)}
if g.Affinity != "" {
notes = append(notes, staysName(g.Affinity))
}
@@ -537,7 +547,7 @@ func (m model) viewGroup() string {
var b strings.Builder
b.WriteString(m.header("routing", g.Name))
b.WriteString("\n\n")
head := []string{provider.GroupPrefix + g.ID, routingName(g.Routing), staysName(g.Affinity)}
head := []string{provider.GroupPrefix + g.ID, groupRouting(g), staysName(g.Affinity)}
if g.Context > 0 {
head = append(head, "context "+fmtTokens(g.Context))
}