groups, routing: a found group the user changed drops a removed provider's members, and a switched-off provider's members are said as switched off (Discord)

A user on Discord: in magpie's found (auto-…) routing groups, a provider
removed or switched off stayed in the group and had to be taken out by
hand; one switched on again should be back by itself.

A found group magpie derives already followed its providers (built from
the providers that are on). The gap was a found group the user had changed
(any edit on its card stores it under its auto-… id with its members):
its members stayed as they were.

Now:
- provider: groupsIn reads a stored auto-… group without the members of
  providers magpie no longer has (withoutRemoved). This is read-time only,
  never written back, so a provider back under its id (an account shown
  again) is back in the group. A manual group's pick and a model a rule
  sends to are kept, since taking them out would change where requests go
  without asking, or stop the group from saving. If no member would be
  left, all of them stay. The user's own groups (other ids) keep their
  members as they were.
- A switched-off provider's members stay in any group. The gateway
  already skips them (membersIn), and they are routed to again once the
  provider is on.
- gui: groupsState marks such a member providerOff, with its provider's
  name, icon and model. The Routing page says it as "<provider> · <model>
  (switched off)" on the card. A manual group's pick and the editor's row
  say why: "{name} is switched off: the group skips it until it is on
  again" (new string in zh, zh-TW, ja, de). Before, it was a bare id.

Run:
- go test ./internal/provider TestFoundGroupFollowsItsProviders. It fails
  on the old group.go: "c removed, auto-x's members: [a/x b/x c/x]".
- go test ./internal/gui TestGroupMemberOfASwitchedOffProvider.
- New internal/gui/tests/routing-provider-off.test.cjs in Chromium and
  WebKit, en/zh/zh-TW/ja/de, at 1100 and 420: 10/10 each. On the previous
  assets it fails: the card said "B Cloud · sol" with no "(switched off)".
- The full ./internal/provider and ./internal/gui go test suites, in a
  sandbox HOME: ok.
- ./internal/gateway -run 'TestGroup|TestAuto|TestRouting': ok.
- gofmt -l clean. go vet -tags nogui on darwin, linux and windows: ok.
- All routing-*, panel-routing, click-scroll, gui-ja, gui-de, gui-zh-tw
  and i18n-keys GUI tests: 265/265 in Chromium and 265/265 in WebKit.

Not tried with real providers in the live app.
This commit is contained in:
yetone committed 2026-10-09 11:10:12 +08:00
1 parent 55544f09f6
commit e4a90aa62f
8 files changed
+345 -5

No files matched your search

+1 -1
View File
@@ -26,7 +26,7 @@ keys and accounts per request is in [Gateway routing and fallback](gateway-routi
| Saved accounts | `logins.json` remembers every Codex and Claude Code account seen signed in. A switch moves the credentials into the agent's own store and saves the ones it replaces. A saved account that is on (`SetLoginOn`) stands behind the agent's own. Its tokens stay in `logins.json`. | [`logins.go`](../../internal/provider/logins.go), [`logins_on.go`](../../internal/provider/logins_on.go) |
| Sign-in in magpie | `StartSignIn` runs the vendor's own browser OAuth with its client and takes the tokens at a callback on this machine. A browser that can't reach that callback (magpie on a server or in Docker) ends on a page that won't load; its address, pasted (`SubmitSignInCallback`), goes through the same callback handler (`pastedCallback`). A plugin's browser sign-in (`auto`) whose page names a port on this machine to come back to takes its pasted address too: magpie opens it there on 127.0.0.1, for the plugin's own listener (`pluginCallback`, `loopbackPorts`); Command Code's page posts its key from script, so it offers the API key way instead. A Claude account is signed in by Claude Code itself. Signing in brings back an account removed from magpie (`ShowAccount`). A sign-in started in magpie (`StartSignIn`, `StartPluginSignIn`) reads as done (`SignInStatus`, `WaitSignIn`) only after `finish` has run `ShowAccount` for its account, so the window, which opens the account as soon as it sees done, finds it listed. If providers.json can't be written then, the account stays hidden and the sign-in still ends done. Signing in to a plugin's provider from the command line (`pluginLogin`, with a key or the browser) brings it back too. It also takes off the mark a sign-in its vendor turned away left on the account (`Lapsed`) and shows again the agent's own account removed in magpie (`clearPluginLapse`). The window's plugin sign-in does the same with the browser (`pluginDone`, then `finish`) or with a key (`PluginAPIKey`); a key and the command line share these steps in `PluginSignedIn`. | [`signin.go`](../../internal/provider/signin.go), [`claude_signin.go`](../../internal/provider/claude_signin.go), [`pluginsignin.go`](../../internal/provider/pluginsignin.go), [`pluginsignin_paste.go`](../../internal/provider/pluginsignin_paste.go), [`plugins_cli.go`](../../plugins_cli.go) |
| Used-up switch | When the account Codex or Claude Code is signed in to has used its allowance, the agent is signed in to the next account that is on and has room (#208). | [`codex_switch.go`](../../internal/provider/codex_switch.go) |
| Routing groups | User-made groups from the Routing view, and auto-found groups of the same model across providers (derived, never stored). Members are `provider/model[:effort]`. A group can nest another (`group/<id>`); `SaveGroup` refuses loops. `Match` takes a glob or `re:` pattern (#766). | [`group.go`](../../internal/provider/group.go) |
| Routing groups | User-made groups from the Routing view, and auto-found groups of the same model across providers (derived, never stored). Members are `provider/model[:effort]`. A group can nest another (`group/<id>`); `SaveGroup` refuses loops. `Match` takes a glob or `re:` pattern (#766). A found group follows its providers: one switched off or removed leaves it, and one on again is back. A found group the user changed (an `auto-…` id stored) is read without the members of providers magpie no longer has (`withoutRemoved`, not written back, so a provider back under its id is back in it), except a manual group's pick, a rule's model, or all of them when none would be left. A switched-off provider's members stay in it, skipped by `membersIn`, and the Routing page says them as switched off (`memberJSON.providerOff`). The user's own groups keep their members. | [`group.go`](../../internal/provider/group.go) |
| Allowance | `Quotas` asks every vendor at once: subscription windows (`SubscriptionUsage`), key-bought plans (`PlanQuotas`) and key balances (`KeyBalances`). The Usage page, `magpie quota --json` and `GET /v1/magpie/quotas` read it. A single card can be read again on its own (#840). A remote magpie's cards come from what it has kept (`CachedCards`, `GET /v1/magpie/quotas/cards`) and are never read there for this computer except by a card's refresh (`POST /v1/magpie/quotas/refresh`, throttled per card). What it has kept is the Usage page's cards with the readings made behind the requests since laid over them, a later one over an earlier by `readSeq` (`cards_kept.go`): each account's `LoginUsage` reading (the used-up switch, the Codex warm-up, routing's `Allowances`), and each key's `KeyAllowance` windows on its plan or balance card. An account or key read that way and never on the page gets a card from that reading, and after a restart, before either is read again, the cards come from `quotas.json`, dated (`AsOf`). A magpie serving only other magpies has nobody on its page (#1313). Cursor, Grok and a Kiro key keep the page's cards only. | [`cards_kept.go`](../../internal/provider/cards_kept.go), [`quotas.go`](../../internal/provider/quotas.go), [`subscription_usage.go`](../../internal/provider/subscription_usage.go), [`planquota.go`](../../internal/provider/planquota.go), [`balance.go`](../../internal/provider/balance.go), [`quota_refresh.go`](../../internal/provider/quota_refresh.go), [`remote_quotas.go`](../../internal/provider/remote_quotas.go) |
| A remote magpie's prices | A local magpie asks a remote magpie's `/v1/models` with `X-Magpie-Prices` (`PricesHeader`, sent by `listHeaders`). The remote answers each model's `magpie_price`: what its own Usage page counts the model at (`EntryPriceIn`: the price its user set, else its provider's list price at its price rate; a routing group only when every member costs the same). It tells it only to a request from its own computer or one let in with a gateway key (`local` or `sharedWith`); one let in without a key (`MAGPIE_ADDR` open, not shared) and a list that didn't ask get no prices. The local keeps the price with the fetched list (`catalog.Model.Price`; a value that isn't a price, or has a negative part, is dropped, the model kept) and uses it as that provider's list price (`ListPrice` → `remotePrice`). A price the local user set (`provider/model`, `provider/*`, `*/model`) still comes first, and the local provider's own price rate still applies on top. A remote from before this sends no field and the models stay priced as before; an older local sends no header and is sent nothing new. The model editor says the greyed price is the other magpie's (`remoteList`). | [`remote_magpie.go`](../../internal/provider/remote_magpie.go), [`models.go`](../../internal/provider/models.go), [`gateway.go`](../../internal/gateway/gateway.go) (`models`), [`live.go`](../../internal/catalog/live.go) |
| A key's own windows | A sub2api key given a spending limit per 5 hours, day or 7 days answers its `/v1/usage` with `rate_limits` and no `remaining` (`readSub2APIKeyLimits`). With the Balance URL set to it, the key's card shows them as the windows *5 hours*, *1 day*, *7 days*, in USD, beside a balance field the user wrote; *Check balance* and `magpie provider show` give what its fullest window has left (`Balance`). `KeyAllowance` gives routing the same windows: never waited for, read in the background at most once a minute, a failed read keeping the last, and seeded from the card's last reading (`quotas.json`) after a restart. A key bought as a plan (Kimi Code, GLM Coding Plan and Z.ai, MiniMax Coding Plan, OpenCode Go, Command Code: `planQuotaSourceOf`) gives routing its card's windows the same way (`planKeyWindows`, Zhipu's team quota behind a key with none of its own); the Usage page's read of its card is taken at once (`noteKeyAllowance`), and a key there that turns out pay-as-you-go, with no windows, is asked again only every 30 minutes (`keyNoWindowsAge`). StepFun's Step Plan is read through a sign-in, not the key, and isn't one of them. | [`sub2api_usage.go`](../../internal/provider/sub2api_usage.go), [`key_allowance.go`](../../internal/provider/key_allowance.go) |
+4
View File
@@ -1064,6 +1064,7 @@ const I18N = {
"Compact at: {v} is not a number of tokens like 272k": "压缩阈值:{v} 不是 272k 这样的 token 数",
"add a model: filter, or type provider/model…": "添加模型:筛选,或输入 provider/model…",
"No provider serves {id} now; it is skipped": "现在没有供应商提供 {id},会跳过它",
"{name} is switched off: the group skips it until it is on again": "{name} 已关闭:开启前这个组会跳过它",
"Up": "上移",
"Down": "下移",
"switched off": "已关闭",
@@ -4861,6 +4862,7 @@ const I18N = {
"Compact at: {v} is not a number of tokens like 272k": "壓縮閾值:{v} 不是 272k 這樣的 token 數",
"add a model: filter, or type provider/model…": "新增模型:篩選,或輸入 provider/model…",
"No provider serves {id} now; it is skipped": "現在沒有供應商提供 {id},會跳過它",
"{name} is switched off: the group skips it until it is on again": "{name} 已關閉:開啟前這個組會跳過它",
"Up": "上移",
"Down": "下移",
"switched off": "已關閉",
@@ -8634,6 +8636,7 @@ const I18N = {
"Compact at: {v} is not a number of tokens like 272k": "圧縮しきい値:{v} は 272k のようなトークン数ではありません",
"add a model: filter, or type provider/model…": "モデルを追加:絞り込み、または provider/model を入力…",
"No provider serves {id} now; it is skipped": "現在 {id} を提供しているプロバイダはないため、スキップされます",
"{name} is switched off: the group skips it until it is on again": "{name} はオフです。オンに戻すまでグループはスキップします",
"Up": "上へ",
"Down": "下へ",
"switched off": "オフ",
@@ -12426,6 +12429,7 @@ const I18N = {
"Compact at: {v} is not a number of tokens like 272k": "Komprimieren ab: {v} ist keine Token-Zahl wie 272k",
"add a model: filter, or type provider/model…": "Modell hinzufügen: filtern oder provider/model eingeben…",
"No provider serves {id} now; it is skipped": "Derzeit stellt kein Anbieter {id} bereit; es wird übersprungen",
"{name} is switched off: the group skips it until it is on again": "{name} ist ausgeschaltet: Die Gruppe überspringt es, bis es wieder eingeschaltet ist",
"Up": "Nach oben",
"Down": "Nach unten",
"switched off": "ausgeschaltet",
+12 -4
View File
@@ -3010,9 +3010,14 @@
return true;
};
const groupIcons = (g) => [...new Map((g.memberInfo || []).filter((i) => i.icon).map((i) => [i.provider || i.icon, i.icon])).values()];
const memberIcon = (id) => { const s = subOf(id); return s ? stackIcon(groupIcons(s)) : icon(modelOf(id)?.icon || "generic"); };
const memberName = (id) => { const s = subOf(id), m = modelOf(id); return s ? s.name : m ? m.name || m.id : id; };
const memberNote = (id) => subOf(id) ? t("routing group") : [modelOf(id)?.providerName, fixedOf(id) && fixedWords(fixedOf(id))].filter(Boolean).join(" · ");
// offOf: a member whose provider is switched off (groupsState's
// providerOff), said by its provider's name and model rather than a bare
// id: the group skips it until the provider is on again
const offOf = (id) => { for (const g of groups?.groups || []) { const i = g.memberInfo?.find((x) => x.id === id && x.providerOff); if (i) return i; } };
const offWords = (i) => t("{name} is switched off: the group skips it until it is on again", { name: i.name });
const memberIcon = (id) => { const s = subOf(id); return s ? stackIcon(groupIcons(s)) : icon(modelOf(id)?.icon || offOf(id)?.icon || "generic"); };
const memberName = (id) => { const s = subOf(id), m = modelOf(id), o = !m && offOf(id); return s ? s.name : m ? m.name || m.id : o ? o.model : id; };
const memberNote = (id) => { const o = !subOf(id) && !modelOf(id) && offOf(id); return subOf(id) ? t("routing group") : [modelOf(id)?.providerName || o && `${o.name} · ${t("switched off")}`, fixedOf(id) && fixedWords(fixedOf(id))].filter(Boolean).join(" · "); };
// a member the group sends in its vendor's fast mode (Group.Fast)
const fastIn = (g, id) => !!g?.fast?.includes(id) && !subOf(id);
function memberLabel(g, id) {
@@ -3020,6 +3025,7 @@
if (s) return `${t("routing group")} · ${s.name}`;
const at = (f ? ` · ${fixedWords(f)}` : "") + (fastIn(g, id) ? ` · ${t("fast")}` : "");
if (m) return `${m.providerName} · ${m.name || m.id}${at}`;
if (i?.providerOff) return `${i.name} · ${i.model}${at} (${t("switched off")})`;
return i?.name ? `${i.name} · ${i.model}${at}` : id;
}
function renderGroups() {
@@ -3395,6 +3401,7 @@
const note = [memberNote(id), fastIn(g, id) && t("fast")].filter(Boolean).join(" · ");
if (note) b.append(el("small", "", note));
b.title = on ? t("Every request goes to {name}", { name: memberLabel(g, id) })
: info?.providerOff ? offWords(info)
: 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) => {
@@ -3536,6 +3543,7 @@
const n = el("span", "n");
n.append(el("span", "", memberName(id)));
if (m || s) n.append(el("small", "", subOf(id) ? memberNote(id) : m.providerName));
else if (info?.providerOff) n.append(el("small", "", `${info.name} · ${t("switched off")}`));
const matched = d.matched.includes(id);
if (matched) n.append(el("small", "", t("by pattern")));
// switched off, it keeps its place and its rules but is sent
@@ -3615,7 +3623,7 @@
row.append(hx);
}
if (s) row.title = s.members.map((x) => memberLabel(s, x)).join(s.routing === "order" ? " → " : " · ");
if (!m && !s) { row.classList.add("off"); row.title = t("No provider serves {id} now; it is skipped", { id }); }
if (!m && !s) { row.classList.add("off"); row.title = info?.providerOff ? offWords(info) : t("No provider serves {id} now; it is skipped", { id }); }
if (matched) {
// a pattern's: it follows the catalog, so it is switched off
// rather than taken out — the pattern would find it again
+73
View File
@@ -0,0 +1,73 @@
package gui
import (
"encoding/json"
"os"
"strings"
"testing"
"github.com/yetone/magpie/internal/provider"
)
// A group's member whose provider is switched off is said as that on the
// Routing page, with its provider's name and model, rather than as a bare
// id nothing serves: the group skips it until the provider is on again.
// One whose provider is gone says nothing of a provider.
func TestGroupMemberOfASwitchedOffProvider(t *testing.T) {
t.Setenv("HOME", t.TempDir())
t.Setenv("USERPROFILE", os.Getenv("HOME"))
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
t.Setenv("XDG_CACHE_HOME", t.TempDir())
for _, id := range []string{"a", "b", "c"} {
if err := provider.Save(provider.Provider{ID: id, Name: strings.ToUpper(id) + " Cloud", Key: "k", Chat: "http://127.0.0.1:1/v1", Models: []string{"x"}}); err != nil {
t.Fatal(err)
}
}
if err := provider.SaveGroup(provider.Group{ID: "mine", Name: "Mine", Members: []string{"a/x", "b/x", "c/x"}}); err != nil {
t.Fatal(err)
}
if err := provider.SetOff("b", true); err != nil {
t.Fatal(err)
}
if err := provider.Delete("c"); err != nil {
t.Fatal(err)
}
var mine *groupJSON
st := groupsState()
for i := range st.Groups {
if st.Groups[i].ID == "mine" {
mine = &st.Groups[i]
}
}
if mine == nil {
t.Fatal("no group mine")
}
info := map[string]memberJSON{}
for _, m := range mine.Info {
info[m.ID] = m
}
if m := info["a/x"]; !m.Ready || m.ProviderOff {
t.Fatalf("a/x: %+v", m)
}
if m := info["b/x"]; m.Ready || !m.ProviderOff || m.Name != "B Cloud" || m.Model != "x" || m.Provider != "b" {
t.Fatalf("b/x, its provider off: %+v", m)
}
if m := info["c/x"]; m.Ready || m.ProviderOff || m.Name != "" {
t.Fatalf("c/x, its provider gone: %+v", m)
}
b, _ := json.Marshal(info["b/x"])
if !strings.Contains(string(b), `"providerOff":true`) {
t.Fatalf("on the wire: %s", b)
}
// on again, it is a member like any other
if err := provider.SetOff("b", false); err != nil {
t.Fatal(err)
}
for _, g := range groupsState().Groups {
for _, m := range g.Info {
if g.ID == "mine" && m.ID == "b/x" && (!m.Ready || m.ProviderOff) {
t.Fatalf("b on again: %+v", m)
}
}
}
}
+7
View File
@@ -55,6 +55,9 @@ type memberJSON struct {
Model string `json:"model,omitempty"` // what the vendor is asked for
On int `json:"on"` // its keys or accounts on
Group bool `json:"group,omitempty"` // a routing group in the group; Name is its
// ProviderOff: its provider is switched off, so the group skips it
// until it is on again; Provider, Name, Icon and Model are still said
ProviderOff bool `json:"providerOff,omitempty"`
// Of is the model's id without the effort the member is fixed at, and
// Effort that effort ("provider/model:low"); "" for one without
Of string `json:"of,omitempty"`
@@ -236,6 +239,10 @@ func groupsState() groupsJSON {
}
}
gj.Ready = true
} else if pid, model, ok := strings.Cut(of, "/"); ok {
if p, err := provider.Find(pid); err == nil && !p.On() {
m.ProviderOff, m.Provider, m.Name, m.Icon, m.Model = true, p.ID, p.Name, p.Icon, model
}
}
gj.Info = append(gj.Info, m)
}
@@ -0,0 +1,98 @@
// Run with Node's test runner and Playwright on the module path; see README.md.
// A group's member whose provider is switched off (groupsState's
// providerOff) is said as that, by its provider's name and model, not as a
// bare id nothing serves: on the group's card, on a manual group's pick
// and in the editor, where its row is greyed and says why — the group skips
// it until the provider is on again (Discord: a found group's members of
// a provider switched off or removed stayed, to be taken out by hand).
// One whose provider is gone still says no provider serves it. Every
// language, 1100 and 420 wide; the API is faked here.
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 Cloud", icon: "generic" }];
const info = [
{ id: "a/sol", ready: true, provider: "a", name: "A Cloud", model: "sol", on: 1 },
{ id: "b/sol", ready: false, providerOff: true, provider: "b", name: "B Cloud", icon: "generic", model: "sol", on: 0 },
{ id: "c/sol", ready: false, on: 0 },
];
const base = { members: ["a/sol", "b/sol", "c/sol"], ready: true, memberInfo: info, offers: [], shared: [] };
const groups = { models, pools: [], groups: [
{ ...base, id: "auto-sol", name: "Sol", routing: "order" },
{ ...base, id: "hand", name: "Hand", routing: "manual", pick: "a/sol", picked: "a/sol" },
] };
function serve(lang) {
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({ agents: [{ id: "codex", name: "Codex", path: "/test/codex", fields: [] }], profiles: [], settings: { lang, theme: "light" } });
if (url.pathname === "/api/plugins") return json({ plugins: [] });
if (url.pathname === "/api/gateway/trace") {
if (url.searchParams.get("wait")) return new Promise(() => {});
return json({ mine: true, now: new Date().toISOString(), seq: 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 === "/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", "zh-TW", "ja", "de"]) {
for (const width of [1100, 420]) {
test(`${engine} ${lang} ${width}: a member whose provider is switched off is said as that`, 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, height: 900 } })).newPage();
page.setDefaultTimeout(5000);
const errors = [];
page.on("pageerror", (e) => errors.push(e.message));
await page.route("**/*", serve(lang));
await page.goto("http://magpie.test/?view=routing");
const say = (key, vars = {}) => page.evaluate(([lang, key, vars]) => {
const s = (lang !== "en" && I18N[lang]?.[key]) || key;
return s.replace(/\{(\w+)\}/g, (_, k) => vars[k] ?? `{${k}}`);
}, [lang, key, vars]);
const off = await say("switched off"), why = await say("{name} is switched off: the group skips it until it is on again", { name: "B Cloud" });
assert.notEqual(why, "", "the string");
if (lang !== "en") assert.ok(!why.startsWith("{name} is switched off"), `${lang} has it in its own words`);
const card = page.locator(".rt-group", { hasText: "Sol" }).first();
await card.waitFor();
assert.equal(await card.locator(".mem").first().textContent(), `A Cloud · sol → B Cloud · sol (${off}) → c/sol`);
// a manual group's pick says why it is skipped
const hand = page.locator(".rt-group", { hasText: "Hand" });
const pick = hand.locator('.rt-pick[data-member="b/sol"]');
assert.equal(await pick.locator(".n").textContent(), "sol");
assert.equal(await pick.locator("small").textContent(), `B Cloud · ${off}`);
assert.equal(await pick.getAttribute("title"), why);
assert.equal(await pick.evaluate((b) => b.classList.contains("off")), true);
assert.equal(await hand.locator('.rt-pick[data-member="c/sol"]').getAttribute("title"), await say("No provider serves {id} now; it is skipped", { id: "c/sol" }));
// the editor: its row muted, saying why
// the page puts back a scroll the reader didn't make, so at 420 the
// button is clicked where it is
await card.locator("button", { hasText: await say("Edit") }).dispatchEvent("click");
const ed = page.locator(".rt-gedit");
const row = ed.locator(".fbrow", { has: page.locator(".n small", { hasText: `B Cloud · ${off}` }) });
assert.equal(await row.count(), 1);
assert.equal(await row.getAttribute("title"), why);
assert.equal(await row.evaluate((r) => r.classList.contains("off")), true);
assert.equal(await row.locator(".n > span").first().textContent(), "sol");
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth), true, "nothing spills");
assert.deepEqual(errors, []);
});
}
}
}
+44
View File
@@ -242,11 +242,21 @@ func groupsIn(entries []Entry) []Group {
f := heldOf("file", load)
var out []Group
hidden := map[string]bool{}
var have map[string]bool // the providers magpie has, switched off or not
for _, g := range f.Groups {
if g.Hidden {
hidden[g.ID] = true
continue
}
if strings.HasPrefix(g.ID, "auto-") {
if have == nil {
have = map[string]bool{}
for _, p := range All() {
have[p.ID] = true
}
}
g = withoutRemoved(g, have)
}
out = append(out, withMatches(entries, g))
}
if f.NoAutoGroups {
@@ -262,6 +272,40 @@ func groupsIn(entries []Entry) []Group {
return orderedGroups(out, f.GroupOrder)
}
// withoutRemoved is a found group the user changed (an "auto-…" id kept
// in providers.json) without the members of providers magpie no longer
// has (have): a provider removed leaves the group as it leaves one magpie
// finds, where they had stayed for the user to take out by hand (Discord).
// It is how the group is read, not stored, so a provider back under its
// id (an account shown again) is back in it. A member the user picked a
// manual group's requests to, or a rule sends to, stays: taken out, the
// group would send elsewhere unasked, or not save. So does every member
// when none would be left. A provider switched off keeps its members: the
// gateway skips them (membersIn) and they are sent to again once it is on.
func withoutRemoved(g Group, have map[string]bool) Group {
gone := func(m string) bool {
if strings.HasPrefix(m, GroupPrefix) || IsPattern(m) {
return false
}
pid, _, ok := strings.Cut(m, "/")
if !ok || have[pid] || (g.Routing == Manual && m == g.Picked()) {
return false
}
return !slices.ContainsFunc(g.Rules, func(r Rule) bool { return r.Use == m })
}
if !slices.ContainsFunc(g.Members, gone) {
return g
}
kept := slices.DeleteFunc(slices.Clone(g.Members), gone)
if len(kept) == 0 && len(g.Match) == 0 {
return g
}
g.Members = kept
g.Off = slices.DeleteFunc(slices.Clone(g.Off), gone)
g.Fast = slices.DeleteFunc(slices.Clone(g.Fast), gone)
return g
}
// AutoGroupsOn reports whether magpie finds groups on its own: a model
// more than one provider serves is a group of them (autoGroups). It is on
// until the user turns it off.
+106
View File
@@ -0,0 +1,106 @@
package provider
import (
"slices"
"testing"
)
// A provider removed leaves the groups magpie found, the ones the user
// changed too, rather than staying in them to be taken out by hand
// (Discord); one switched off is skipped and back once it is on again.
// The user's own groups keep their members as they were, and a found
// group's manual pick stays even when its provider is gone.
func TestFoundGroupFollowsItsProviders(t *testing.T) {
t.Setenv("HOME", t.TempDir())
t.Setenv("USERPROFILE", t.TempDir())
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
add := func(id string) {
t.Helper()
if err := Save(Provider{ID: id, Name: id, Key: "k", Chat: "http://127.0.0.1:1/v1", Models: []string{"x", "y"}}); err != nil {
t.Fatal(err)
}
}
for _, id := range []string{"a", "b", "c"} {
add(id)
}
members := func(id string) []string {
t.Helper()
g, ok := groupOf(Groups(), id)
if !ok {
t.Fatalf("no group %s", id)
}
return g.Members
}
routed := func(id string) (out []string) {
_, ms, _ := FindGroup(GroupPrefix + id)
for _, m := range ms {
out = append(out, m.Provider.ID+"/"+m.Model)
}
return out
}
all := []string{"a/x", "b/x", "c/x"}
if got := members("auto-x"); !slices.Equal(got, all) {
t.Fatalf("found: %v", got)
}
// changed, it is the user's, stored with its members
if err := SaveGroup(Group{ID: "auto-x", Name: "X", Members: all, Routing: Ordered}); err != nil {
t.Fatal(err)
}
if err := SaveGroup(Group{ID: "mine", Name: "Mine", Members: []string{"a/y", "c/y"}}); err != nil {
t.Fatal(err)
}
// switched off: kept in it, skipped, and sent to again once on
if err := SetOff("c", true); err != nil {
t.Fatal(err)
}
if got := members("auto-x"); !slices.Equal(got, all) {
t.Fatalf("c off, auto-x's members: %v", got)
}
if got := routed("auto-x"); !slices.Equal(got, []string{"a/x", "b/x"}) {
t.Fatalf("c off, auto-x routes to %v", got)
}
if err := SetOff("c", false); err != nil {
t.Fatal(err)
}
if got := routed("auto-x"); !slices.Equal(got, all) {
t.Fatalf("c on again, auto-x routes to %v", got)
}
// removed: out of the found group, not of the user's own
if err := Delete("c"); err != nil {
t.Fatal(err)
}
if got := members("auto-x"); !slices.Equal(got, []string{"a/x", "b/x"}) {
t.Fatalf("c removed, auto-x's members: %v", got)
}
if got := members("mine"); !slices.Equal(got, []string{"a/y", "c/y"}) {
t.Fatalf("c removed, the user's own group changed: %v", got)
}
// read so, not written: a provider back under its id is back in it
add("c")
if got := members("auto-x"); !slices.Equal(got, all) {
t.Fatalf("c back, auto-x's members: %v", got)
}
// a manual group's pick stays, gone or not: the group would send
// elsewhere unasked
if err := SaveGroup(Group{ID: "auto-x", Name: "X", Members: all, Routing: Manual, Pick: "c/x"}); err != nil {
t.Fatal(err)
}
if err := Delete("c"); err != nil {
t.Fatal(err)
}
g, _ := groupOf(Groups(), "auto-x")
if !slices.Equal(g.Members, all) || g.Picked() != "c/x" {
t.Fatalf("c removed, the manual pick: %v %s", g.Members, g.Picked())
}
// and a save of what was read keeps it as the user has it
if err := SaveGroup(Group{ID: "auto-x", Name: "X", Members: []string{"a/x", "b/x", "c/x"}, Routing: Ordered, Off: []string{"c/x"}}); err != nil {
t.Fatal(err)
}
if g, _ := groupOf(Groups(), "auto-x"); !slices.Equal(g.Members, []string{"a/x", "b/x"}) || len(g.Off) != 0 {
t.Fatalf("ordered again, c's member is still read: %v off %v", g.Members, g.Off)
}
}