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:
9 files changed
+275
-87
No files matched your search
@@ -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
@@ -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")) {
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user