providers: Test models sends each model agents see a tiny request of its own, on the endpoint it's served on and with a key that sees it, and marks its chip with a dot — green with how long it took, red with what the vendor said — so a relay whose other models answer can't hide one that doesn't (magpie provider test <id> <model>… does it in the terminal). Usage has Hide emails too, one setting with Routing's, to screenshot the subscription cards

This commit is contained in:
yetone committed 2026-09-27 12:26:00 +08:00
1 parent c3052fe43c
commit 08309e8c04
9 files changed
+275 -87

No files matched your search

+8 -1
View File
@@ -985,8 +985,15 @@ body.window .ag-sk-icon { width: 22px; height: 22px; border-radius: 6px; }
.mchip.on { color: var(--accent); background: var(--accent-soft); border-color: color-mix(in srgb, var(--accent) 45%, transparent); font-weight: 500; }
.mchip.on::before { content: "✓"; font-size: 10px; }
.mchip.own.on::before { content: "+"; }
/* how a model answered Test models: a dot after its name */
.mchip .tdot { width: 6px; height: 6px; border-radius: 50%; flex: none; background: var(--faint); }
.mchip .tdot.ok { background: var(--green); }
.mchip .tdot.bad { background: var(--red); }
.mchip .tdot.wait { animation: tdot 1s ease-in-out infinite alternate; }
@keyframes tdot { to { opacity: .25; } }
.mchips .hint, .mfoot .hint { font-size: 11px; color: var(--faint); align-self: center; line-height: 27px; }
.mfoot { display: flex; align-items: center; gap: 8px; min-height: 27px; }
.mfoot { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 8px; min-height: 27px; }
.mfoot .hint { white-space: nowrap; }
.mfoot > input { height: 27px; }
.mfoot .text.action { flex: none; }
.mfoot input { max-width: 200px; height: 27px !important; }
+114 -1
View File
@@ -2774,7 +2774,19 @@ function renderEndpoints(p, src) {
return eps;
}
// modelTests: what each provider's models answered Test models, by id; a
// model still being asked is null
const modelTests = {};
function renderModels(p) {
// a chip's dot: how its model answered, when it has been asked
const tested = (c, id) => {
const got = modelTests[p.id];
if (!got || !(id in got)) return;
const x = got[id];
c.append(el("span", "tdot " + (!x ? "wait" : x.ok ? "ok" : "bad")));
c.title = !x ? t("Testing…") : x.ok ? t("Answered in {ms} ms", { ms: x.ms }) : (x.status ? x.status + " · " : "") + x.error;
};
const box = el("div", "models");
const chips = el("div", "mchips");
const names = el("div", "mnames");
@@ -2790,6 +2802,7 @@ function renderModels(p) {
c.append(el("span", "", m.name && m.name !== m.id ? m.name : m.id));
if (m.default) c.title = `${m.id} · ${m.default}`;
else if (m.name && m.name !== m.id) c.title = m.id;
tested(c, m.id);
c.onclick = () => { draft.chosen = on ? draft.chosen.filter((x) => x !== m.id) : [...draft.chosen, m.id]; draw(); };
chips.append(c);
if (++shown >= 80 && !f) { chips.append(el("span", "hint", t("… {n} more, filter to find them", { n: p.models.length - shown }))); break; }
@@ -2799,6 +2812,7 @@ function renderModels(p) {
const c = el("button", "mchip on own");
c.append(el("span", "", id));
c.title = t("Added by hand");
tested(c, id);
c.onclick = () => { draft.chosen = draft.chosen.filter((x) => x !== id); draw(); };
chips.append(c);
}
@@ -2888,10 +2902,32 @@ function renderModels(p) {
renderProviders();
} catch (e) { status(e.message, "err"); refresh.classList.remove("busy"); }
};
// each model the agents see gets a tiny request of its own: a vendor
// that answers can still have a model that doesn't
const testAll = el("button", "text action", t("Test models"));
testAll.title = t("Send a tiny request to each model agents see, to find the ones that don't answer");
testAll.onclick = async () => {
const ids = draft.chosen.length ? draft.chosen : p.models.filter((m) => m.on).map((m) => m.id);
if (!ids.length) { status(t("Pick a model first."), "err"); return; }
testAll.classList.add("busy");
const got = modelTests[p.id] = {};
for (const id of ids) got[id] = null;
draw();
try {
const r = await api("provider/test", { id: p.id, test: ids });
r.results.forEach((x, i) => { got[ids[i]] = x; });
const bad = r.results.filter((x) => !x.ok).length;
status(bad ? t("{n} of {all} models didn't answer", { n: bad, all: ids.length }) : t("All {n} models answered", { n: ids.length }), bad ? "err" : "ok");
} catch (e) { delete modelTests[p.id]; status(e.message, "err"); }
testAll.classList.remove("busy");
draw();
};
const rename = el("button", "text action" + (naming === p.id ? " on" : ""), t("Names & levels"));
rename.title = t("Rename the models agents see, or offer fewer of their reasoning levels");
rename.onclick = () => { naming = naming === p.id ? null : p.id; rename.classList.toggle("on", naming === p.id); drawNames(); };
foot.append(add, refresh, rename);
foot.append(add, refresh);
if (!p.decide && !p.account) foot.append(testAll);
foot.append(rename);
if (p.fetched) foot.append(el("span", "hint", t("vendor list · {when}", { when: p.fetched })));
// a signed-in account's list, until the vendor gives one, is magpie's own
else if (p.models.length) foot.append(el("span", "hint", t(p.account ? "magpie's list · Refresh asks the vendor" : p.decide ? "Jev's names · Refresh asks the vendor" : "from models.dev · Refresh asks the vendor")));
@@ -4460,6 +4496,83 @@ setInterval(async () => {
}, 15000);
window.addEventListener("focus", load);
setInterval(renderUpdateBadge, 15 * 60 * 1000); // a window left open still hears of a new version
// ---------- hiding emails, for a screenshot to share ----------
// Routing and Usage each have a Hide emails button, one setting for both.
// Each email address on the page — an account's, in a row, a sentence,
// a tooltip — is swapped for blurred stand-in letters while it's on, as the
// page redraws too; the address itself is kept aside to put back.
(() => {
const EMAIL = /[\w.+-]+@[\w-]+(?:\.[\w-]+)+/g, IS_EMAIL = new RegExp(EMAIL.source);
// stand-in letters of the address's shape, the same each time it's drawn:
// blurred, they read as a name without being one
const dots = (s) => { let h = 7; return s.replace(/[^@.]/g, (c) => (h = (h * 31 + c.charCodeAt(0)) >>> 0, "aeiounrstlcmdh"[h % 14])); };
// what a page redraws is masked before it's painted; masking isn't
// itself watched, so it can't set itself off again
const OBS = { subtree: true, childList: true, characterData: true, attributes: true, attributeFilter: ["title"] };
let masked = false;
try { masked = localStorage.getItem("magpie.maskEmails") === "1"; } catch {}
const pages = [["#view-routing", "#rtMask"], ["#view-usage", "#usageMask"]].map(([v, b]) => {
const view = $(v), btn = $(b);
function mask() {
const walk = document.createTreeWalker(view, NodeFilter.SHOW_TEXT), found = [];
for (let n; (n = walk.nextNode());) if (n.data.includes("@") && IS_EMAIL.test(n.data) && !n.parentElement?.closest(".pii")) found.push(n);
for (const n of found) {
const bits = [];
let last = 0;
for (const m of n.data.matchAll(EMAIL)) {
if (m.index > last) bits.push(n.data.slice(last, m.index));
const s = el("span", "pii", dots(m[0]));
s.dataset.raw = m[0];
bits.push(s);
last = m.index + m[0].length;
}
if (!last) continue;
if (last < n.data.length) bits.push(n.data.slice(last));
// one piece still, where the text was: in a flex row each would
// otherwise stand as an item of its own
if (bits.length > 1) { const run = el("span", "pii-run"); run.append(...bits); n.replaceWith(run); }
else n.replaceWith(...bits);
}
for (const e of view.querySelectorAll("[title]")) {
if (!e.title.includes("@") || !IS_EMAIL.test(e.title)) continue;
e.dataset.piiTitle = e.title;
e.title = e.title.replace(EMAIL, (m) => m.replace(/[^@.]/g, "•")); // a tooltip can't blur
}
}
function unmask() {
for (const s of view.querySelectorAll(".pii")) s.replaceWith(s.dataset.raw);
for (const r of view.querySelectorAll(".pii-run")) r.replaceWith(r.textContent);
view.normalize();
for (const e of view.querySelectorAll("[data-pii-title]")) { e.title = e.dataset.piiTitle; delete e.dataset.piiTitle; }
}
const watch = new MutationObserver(() => {
if (!masked) return;
watch.disconnect();
mask();
watch.observe(view, OBS);
});
btn.onclick = () => {
setMasked(!masked);
// pixelated in when asked for, not again each time the page redraws
view.classList.add("masking");
clearTimeout(btn._t);
btn._t = setTimeout(() => view.classList.remove("masking"), 450);
};
return (on) => {
btn.setAttribute("aria-pressed", String(on));
view.classList.toggle("masked", on);
if (on) { mask(); watch.observe(view, OBS); }
else { watch.disconnect(); unmask(); }
};
});
function setMasked(on) {
masked = on;
try { localStorage.setItem("magpie.maskEmails", on ? "1" : "0"); } catch {}
for (const set of pages) set(on);
}
setMasked(masked);
})();
// Opened on a magpie://import link: fetch what it describes (once — the
// id is spent) and ask before adding it.
if (mode === "window" && params.get("import")) {
+6
View File
@@ -380,6 +380,12 @@ const I18N = {
"magpie's list · Refresh asks the vendor": "magpie 内置列表 · 刷新可向供应商获取",
"Names & levels": "名称与推理档位",
"Rename the models agents see, or offer fewer of their reasoning levels": "重命名 Agent 看到的模型,或只提供部分推理档位",
"Test models": "测试模型",
"Send a tiny request to each model agents see, to find the ones that don't answer": "给 Agent 能看到的每个模型发一个极小的请求,找出不通的模型",
"{n} of {all} models didn't answer": "{all} 个模型中有 {n} 个没有响应",
"All {n} models answered": "{n} 个模型全部可用",
"Testing…": "测试中…",
"Answered in {ms} ms": "{ms} 毫秒内响应",
"Pick a model first.": "请先选择模型。",
"The name agents and magpie show for {id}; empty for its own": "Agent 和 magpie 为 {id} 显示的名称;留空则用默认名称",
"{id} is called {name}": "{id} 已改名为 {name}",
+1
View File
@@ -90,6 +90,7 @@
<div class="row-head quota-head" id="quotaHead" hidden>
<span class="label" data-t>Allowances</span>
<span class="grow"></span>
<button class="text rt-mask" id="usageMask" aria-pressed="false" data-tt="Mask the email addresses on this page, for a screenshot to share"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9.9 4.2A10.4 10.4 0 0 1 12 4c6.5 0 10 8 10 8a17.6 17.6 0 0 1-2.2 3.2M6.6 6.6C3.9 8.4 2 12 2 12s3.5 8 10 8a9.7 9.7 0 0 0 5.4-1.6M9.9 9.9a3 3 0 0 0 4.2 4.2M2 2l20 20"/></svg><span data-t>Hide emails</span></button>
<div class="segs" id="quotaMode" hidden></div>
</div>
<section class="subscription-usage" id="subscriptionUsage"></section>
-68
View File
@@ -1645,74 +1645,6 @@
window.addEventListener("focus", () => { if (shown()) loadGroups(); });
loadGroups();
// ---------- hiding emails, for a screenshot to share ----------
// Each email address on the page — an account's, in a row, a sentence,
// a tooltip — is swapped for blurred stand-in letters while it's on, as the
// page redraws too; the address itself is kept aside to put back.
const view = $("#view-routing"), maskBtn = $("#rtMask");
const EMAIL = /[\w.+-]+@[\w-]+(?:\.[\w-]+)+/g, IS_EMAIL = new RegExp(EMAIL.source);
// stand-in letters of the address's shape, the same each time it's drawn:
// blurred, they read as a name without being one
const dots = (s) => { let h = 7; return s.replace(/[^@.]/g, (c) => (h = (h * 31 + c.charCodeAt(0)) >>> 0, "aeiounrstlcmdh"[h % 14])); };
let masked = false;
try { masked = localStorage.getItem("magpie.maskEmails") === "1"; } catch {}
function mask() {
const walk = document.createTreeWalker(view, NodeFilter.SHOW_TEXT), found = [];
for (let n; (n = walk.nextNode());) if (n.data.includes("@") && IS_EMAIL.test(n.data) && !n.parentElement?.closest(".pii")) found.push(n);
for (const n of found) {
const bits = [];
let last = 0;
for (const m of n.data.matchAll(EMAIL)) {
if (m.index > last) bits.push(n.data.slice(last, m.index));
const s = el("span", "pii", dots(m[0]));
s.dataset.raw = m[0];
bits.push(s);
last = m.index + m[0].length;
}
if (!last) continue;
if (last < n.data.length) bits.push(n.data.slice(last));
// one piece still, where the text was: in a flex row each would
// otherwise stand as an item of its own
if (bits.length > 1) { const run = el("span", "pii-run"); run.append(...bits); n.replaceWith(run); }
else n.replaceWith(...bits);
}
for (const e of view.querySelectorAll("[title]")) {
if (!e.title.includes("@") || !IS_EMAIL.test(e.title)) continue;
e.dataset.piiTitle = e.title;
e.title = e.title.replace(EMAIL, (m) => m.replace(/[^@.]/g, "•")); // a tooltip can't blur
}
}
function unmask() {
for (const s of view.querySelectorAll(".pii")) s.replaceWith(s.dataset.raw);
for (const r of view.querySelectorAll(".pii-run")) r.replaceWith(r.textContent);
view.normalize();
for (const e of view.querySelectorAll("[data-pii-title]")) { e.title = e.dataset.piiTitle; delete e.dataset.piiTitle; }
}
// what the page redraws is masked before it's painted; masking isn't
// itself watched, so it can't set itself off again
const OBS = { subtree: true, childList: true, characterData: true, attributes: true, attributeFilter: ["title"] };
const watch = new MutationObserver(() => {
if (!masked) return;
watch.disconnect();
mask();
watch.observe(view, OBS);
});
function setMasked(on) {
masked = on;
try { localStorage.setItem("magpie.maskEmails", on ? "1" : "0"); } catch {}
maskBtn.setAttribute("aria-pressed", String(on));
view.classList.toggle("masked", on);
if (on) { mask(); watch.observe(view, OBS); }
else { watch.disconnect(); unmask(); }
}
maskBtn.onclick = () => {
setMasked(!masked);
// pixelated in when asked for, not again each time the page redraws
view.classList.add("masking");
clearTimeout(maskBtn._t);
maskBtn._t = setTimeout(() => view.classList.remove("masking"), 450);
};
setMasked(masked);
new ResizeObserver(() => layout()).observe(stage);
words();
requestAnimationFrame(frame);
+9
View File
@@ -382,6 +382,9 @@ func providerRoutes(mux *http.ServeMux, w Windows) {
// Efforts, for efforts: the reasoning levels it offers, none
// for all it has
Efforts []string `json:"efforts"`
// Test, for test: models to send a request each, in place of
// one per endpoint
Test []string `json:"test"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
fail(rw, err)
@@ -511,6 +514,12 @@ func providerRoutes(mux *http.ServeMux, w Windows) {
fail(rw, err)
return
}
if len(req.Test) > 0 {
ctx, cancel := context.WithTimeout(r.Context(), 90*time.Second)
defer cancel()
writeJSON(rw, map[string]any{"results": p.TestModels(ctx, req.Test)})
return
}
ctx, cancel := context.WithTimeout(r.Context(), 25*time.Second)
defer cancel()
writeJSON(rw, struct {
+76 -12
View File
@@ -9,6 +9,7 @@ import (
"net/http"
"slices"
"strings"
"sync"
"time"
"github.com/yetone/magpie/internal/catalog"
@@ -40,23 +41,86 @@ func (p Provider) Test(ctx context.Context) []Result {
out = append(out, Result{Protocol: proto, Model: model, Error: "no key is on for this endpoint"})
continue
}
var url, body string
switch proto {
case Chat:
url = q.Chat + "/chat/completions"
body = fmt.Sprintf(`{"model":%q,"messages":[{"role":"user","content":"hi"}],"max_tokens":16}`, model)
case Responses:
url = q.Responses + "/responses"
body = fmt.Sprintf(`{"model":%q,"input":"hi","max_output_tokens":16}`, model)
case Anthropic:
url = q.Anthropic + "/v1/messages"
body = fmt.Sprintf(`{"model":%q,"max_tokens":16,"messages":[{"role":"user","content":"hi"}]}`, model)
}
url, body := tiny(q, proto, model)
out = append(out, probe(ctx, q, proto, url, q.Prepare([]byte(body)), model))
}
return out
}
// tiny is the smallest request for model on proto's endpoint.
func tiny(q Provider, proto Protocol, model string) (url, body string) {
switch proto {
case Chat:
return q.Chat + "/chat/completions", fmt.Sprintf(`{"model":%q,"messages":[{"role":"user","content":"hi"}],"max_tokens":16}`, model)
case Responses:
return q.Responses + "/responses", fmt.Sprintf(`{"model":%q,"input":"hi","max_output_tokens":16}`, model)
case Anthropic:
return q.Anthropic + "/v1/messages", fmt.Sprintf(`{"model":%q,"max_tokens":16,"messages":[{"role":"user","content":"hi"}]}`, model)
}
return "", ""
}
// TestModels sends each of models the smallest request, a few at a time,
// on the endpoint it's served on and with a key that sees it: whether each
// answers, not only whether the vendor does. Results are in models' order.
func (p Provider) TestModels(ctx context.Context, models []string) []Result {
out := make([]Result, len(models))
sem := make(chan struct{}, 4)
var wg sync.WaitGroup
for i, model := range models {
wg.Add(1)
go func() {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
out[i] = p.testOne(ctx, model)
}()
}
wg.Wait()
return out
}
func (p Provider) testOne(ctx context.Context, model string) Result {
var protos []Protocol
for _, pr := range p.Speaks() {
if pr == Chat || pr == Responses || pr == Anthropic {
protos = append(protos, pr)
}
}
if len(protos) == 0 {
return Result{Model: model, Error: "this provider can't be sent a test request"}
}
// the endpoint the vendor's list says serves it, else Anthropic's for a
// Claude model, else the one it prefers
proto := protos[0]
if apis := p.APIs(model); apis != nil {
if i := slices.IndexFunc(protos, func(pr Protocol) bool { return slices.Contains(apis, pr) }); i >= 0 {
proto = protos[i]
}
} else if isClaude(model) && slices.Contains(protos, Anthropic) {
proto = Anthropic
}
// a key made for that endpoint, else one made for any, that sees it
q, ok := p, true
if keys := p.KeysOn(); len(keys) > 0 {
ok = false
pick:
for _, want := range []Protocol{proto, ""} {
for _, k := range keys {
if k.Protocol == want && p.Serves(k, model) {
q, ok = p.WithKey(k), true
break pick
}
}
}
}
if !ok {
return Result{Protocol: proto, Model: model, Error: "no key that's on sees this model"}
}
url, body := tiny(q, proto, model)
return probe(ctx, q, proto, url, q.Prepare([]byte(body)), model)
}
// keyFor is p using the first key on that works with proto: one made for
// it, else one made for any. A provider without keys is left as it is.
func (p Provider) keyFor(proto Protocol) (Provider, bool) {
+48
View File
@@ -6,6 +6,7 @@ import (
"net/http"
"net/http/httptest"
"path/filepath"
"sync"
"testing"
)
@@ -58,3 +59,50 @@ func TestTestUsesEachEndpointsKey(t *testing.T) {
t.Error("a model no list has is anyone's")
}
}
// Each model is asked on its own, with a key that sees it: one the vendor
// doesn't serve fails while the others answer.
func TestTestModels(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"))
var mu sync.Mutex
seen := map[string]string{} // model → path and key it came with
srv := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
if r.Header.Get("Authorization") == "Bearer sk-anthropic" {
_, _ = rw.Write([]byte(`{"data":[{"id":"claude-opus-5"}]}`))
} else {
_, _ = rw.Write([]byte(`{"data":[{"id":"gpt-6-sol"},{"id":"gone"}]}`))
}
return
}
var in struct{ Model string }
_ = json.NewDecoder(r.Body).Decode(&in)
mu.Lock()
seen[in.Model] = r.URL.Path + " " + r.Header.Get("Authorization")
mu.Unlock()
if in.Model == "gone" {
rw.WriteHeader(http.StatusNotFound)
_, _ = rw.Write([]byte(`{"error":{"message":"no channel for model gone"}}`))
return
}
_, _ = rw.Write([]byte(`{}`))
}))
defer srv.Close()
p := Provider{ID: "relay", Name: "Relay", Chat: srv.URL + "/v1", Anthropic: srv.URL,
Key: "sk-chat", KeyProtocol: Chat,
Keys: []KeyAccount{{Key: "sk-anthropic", Protocol: Anthropic}}}
p.Fetch(context.Background())
rs := p.TestModels(context.Background(), []string{"gpt-6-sol", "claude-opus-5", "gone"})
if len(rs) != 3 || !rs[0].OK || !rs[1].OK || rs[2].OK || rs[2].Status != 404 || rs[2].Error != "no channel for model gone" {
t.Fatalf("results %+v", rs)
}
if rs[0].Model != "gpt-6-sol" || rs[1].Protocol != Anthropic || rs[2].Model != "gone" {
t.Fatalf("order %+v", rs)
}
if seen["gpt-6-sol"] != "/v1/chat/completions Bearer sk-chat" || seen["claude-opus-5"] != "/v1/messages Bearer sk-anthropic" {
t.Fatalf("seen %v", seen)
}
}
+13 -5
View File
@@ -32,7 +32,7 @@ const providerUsage = `usage:
magpie provider fallback <id> <provider/model>… where requests go when it's out of quota or down (none clears)
magpie provider models <id> [ids…] fetch the vendor's model list, or choose which models to expose
magpie provider listed <id> yes|no no: its models serve only through routing groups, not in the list
magpie provider test <id> send a tiny request through each endpoint
magpie provider test <id> [model…] send a tiny request through each endpoint, or to each model
magpie provider rm <id> remove a provider
e.g. magpie provider add "My Relay" url=https://relay.example.com/v1 key=sk-…
@@ -345,17 +345,22 @@ func providerCmd(args []string) error {
fmt.Println(green.Render("✓"), "removed", p.Name)
return nil
case "test":
if len(rest) != 1 {
return fmt.Errorf("magpie provider test <id>")
// with models named, a request to each of them; else one per endpoint
if len(rest) < 1 {
return fmt.Errorf("magpie provider test <id> [model…]")
}
p, err := provider.Find(rest[0])
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
res, wait := p.Test, 30*time.Second
if models := rest[1:]; len(models) > 0 {
res, wait = func(ctx context.Context) []provider.Result { return p.TestModels(ctx, models) }, 90*time.Second
}
ctx, cancel := context.WithTimeout(context.Background(), wait)
defer cancel()
ok := true
for _, r := range p.Test(ctx) {
for _, r := range res(ctx) {
if r.OK {
fmt.Printf(" %s %-10s %s\n", green.Render("✓"), r.Protocol, muted.Render(fmt.Sprintf("%d ms · %s", r.Millis, r.Model)))
} else {
@@ -364,6 +369,9 @@ func providerCmd(args []string) error {
if r.Status != 0 {
msg = fmt.Sprintf("%d · %s", r.Status, r.Error)
}
if len(rest) > 1 {
msg = r.Model + " · " + msg
}
fmt.Printf(" %s %-10s %s\n", amber.Render("✗"), r.Protocol, msg)
}
}