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)
}
}