diff --git a/internal/gui/assets/app.css b/internal/gui/assets/app.css index 97c960c7..da91067b 100644 --- a/internal/gui/assets/app.css +++ b/internal/gui/assets/app.css @@ -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; } diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js index 365342fc..451afba8 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -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")) { diff --git a/internal/gui/assets/i18n.js b/internal/gui/assets/i18n.js index 5c93beb7..7b1c5d43 100644 --- a/internal/gui/assets/i18n.js +++ b/internal/gui/assets/i18n.js @@ -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}", diff --git a/internal/gui/assets/index.html b/internal/gui/assets/index.html index 7741f3c6..46cc955a 100644 --- a/internal/gui/assets/index.html +++ b/internal/gui/assets/index.html @@ -90,6 +90,7 @@
diff --git a/internal/gui/assets/routing.js b/internal/gui/assets/routing.js index 70516127..89477960 100644 --- a/internal/gui/assets/routing.js +++ b/internal/gui/assets/routing.js @@ -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); diff --git a/internal/gui/providers.go b/internal/gui/providers.go index d83064d1..6e048ace 100644 --- a/internal/gui/providers.go +++ b/internal/gui/providers.go @@ -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 { diff --git a/internal/provider/test.go b/internal/provider/test.go index 25c3ee89..5c841c53 100644 --- a/internal/provider/test.go +++ b/internal/provider/test.go @@ -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) { diff --git a/internal/provider/test_test.go b/internal/provider/test_test.go index b8a27f0a..dfb8c5f3 100644 --- a/internal/provider/test_test.go +++ b/internal/provider/test_test.go @@ -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) + } +} diff --git a/providers_cli.go b/providers_cli.go index b55289c8..b55c5baf 100644 --- a/providers_cli.go +++ b/providers_cli.go @@ -32,7 +32,7 @@ const providerUsage = `usage: magpie provider fallback … where requests go when it's out of quota or down (none clears) magpie provider models [ids…] fetch the vendor's model list, or choose which models to expose magpie provider listed yes|no no: its models serve only through routing groups, not in the list - magpie provider test send a tiny request through each endpoint + magpie provider test [model…] send a tiny request through each endpoint, or to each model magpie provider rm 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 ") + // with models named, a request to each of them; else one per endpoint + if len(rest) < 1 { + return fmt.Errorf("magpie provider test [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) } }