providers: each model's credit rate shows by it in the provider's model list and an agent's model picker, Qoder's 0.5× and WorkBuddy's 0.03×, a discount's price before it struck through (01huadalang on Discord: qcoder 或者 workbuddy 能不能实时抓取或者按一段时间抓取能展示列表模型的积分消耗倍率,这样不用打开软件查看,能选择到便宜的模型) — magpie read each model's price only to tell free from not: Qoder's price_factor (06584e15) and WorkBuddy's "credits" ("x0.03", wbFreeCredits), so the lists said FREE or nothing, and picking a cheap model meant opening Qoder or WorkBuddy. catalog.Model now keeps the price, Rate (the credits a request costs, as a multiple; 0 is not listed, or Free) and RateWas (the price before a discount running now), and carries both through provider.Entry, agent.Option and the providers page's models. Qoder (and Qoder CN): Rate is the price_factor; RateWas an active promotion's before_promotion_price_factor, else an original_price_factor above the price (Qwen3.8-Flash: free, 0.1× struck through), and a promotion's 0 that isn't free is its price before times its discount_factor. WorkBuddy and WorkBuddy AI (the CodeBuddy plan; both builds read the same /v3/config): Rate is the credits (wbCredits; x0.00 stays FREE, an unreadable or infinite rate is none). A plugin's model gives it as it gives free: rate and rateWas on the model its provider.models hook lists, a number (0.5) or as the vendor writes it ("x0.03", "0.5×"), which host.js reads (rateOf) into plugin.Model.Rate/RateWas and pluginCatalog keeps; the moved Qoder and WorkBuddy users get it once their plugins send it. The badge is small and grey (the context badge's colours, no uppercase), beside FREE where that shows, its title saying what a request costs (每次请求消耗 0.5× 积分;折扣前 / 限时免费,原价). Rates are as of the provider's last list: a built-in's is fetched at start-up when it has none, on Refresh and when its key changes, a plugin's when its plugins are asked again (start-up, a sign-in, a plugin change); nothing asks on a timer. Kiro, Cursor, ZCode and Devin list no per-model rate magpie reads, so they show none. TestParseModelsRate (qoder) and TestWBCreditsRate (provider) are new and don't build without the change; TestFakePlugin now checks a plugin's rate given as a number and as "x0.03" (0 for both with host.js not reading them). model-rate.test.cjs (WebKit, en and zh: the chips' badges and titles, struck-through prices, grey not green, no stripe, a click picks and scrolls nothing, the picker's badges) fails without the change and passes with it; click-scroll, models-free-only, model-pick and favorite-star pass in WebKit. No request was made to Qoder, WorkBuddy or any vendor. go vet ./internal/..., go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui -count=1 ./... under a temp HOME, go test ./internal/gui pass.
This commit is contained in:
1 parent
4007beb9c0
commit
33acb6bca0
19 files changed
+348
-26
No files matched your search
@@ -32,6 +32,11 @@ type Option struct {
|
||||
GroupIcon string `json:"groupIcon,omitempty"`
|
||||
Ref string `json:"ref,omitempty"` // the catalog model, the same in every agent
|
||||
Free bool `json:"free,omitempty"` // costs its subscription nothing
|
||||
// Rate and RateWas are the credits a request costs its subscription,
|
||||
// as a multiple, and before a discount running now (Qoder's 0.5×,
|
||||
// WorkBuddy's x0.03), when its vendor lists them
|
||||
Rate float64 `json:"rate,omitempty"`
|
||||
RateWas float64 `json:"rateWas,omitempty"`
|
||||
// Context is the tokens the model takes, when known; the picker marks
|
||||
// the large ones
|
||||
Context int `json:"context,omitempty"`
|
||||
|
||||
@@ -47,7 +47,7 @@ func viaMagpie(agent, prefix string) []Option {
|
||||
note = a.User + " · via magpie"
|
||||
}
|
||||
out = append(out, Option{Value: prefix + e.ID, Label: e.Name, Note: note,
|
||||
Icon: e.Provider.Icon, Group: e.Provider.Name, Ref: e.ID, Free: e.Free, Context: e.Context, own: own[e.Provider.ID]})
|
||||
Icon: e.Provider.Icon, Group: e.Provider.Name, Ref: e.ID, Free: e.Free, Rate: e.Rate, RateWas: e.RateWas, Context: e.Context, own: own[e.Provider.ID]})
|
||||
}
|
||||
return append(groups, out...)
|
||||
}
|
||||
|
||||
@@ -62,6 +62,13 @@ type Model struct {
|
||||
// Free is set on a model a subscription serves at no cost to its
|
||||
// allowance: WorkBuddy's "credits": "x0.00".
|
||||
Free bool `json:",omitempty"`
|
||||
// Rate is what a request costs of a subscription's credits, as a
|
||||
// multiple, when its vendor lists it: Qoder's price_factor (0.5),
|
||||
// WorkBuddy's "credits": "x0.03". 0 is not listed, or Free.
|
||||
Rate float64 `json:",omitempty"`
|
||||
// RateWas is the rate before a discount running now, when the vendor
|
||||
// says it: Qoder's Qwen3.8-Flash at 0× with 0.1× struck through.
|
||||
RateWas float64 `json:",omitempty"`
|
||||
// Reasoning is set on a model that thinks, whether or not it takes
|
||||
// levels: mimo-v2.6-flash thinks with a switch alone (#402).
|
||||
Reasoning bool `json:",omitempty"`
|
||||
|
||||
@@ -1320,7 +1320,7 @@ body.window .foot { padding-left: 18px; }
|
||||
.pop li .ic { width: 16px; height: 16px; }
|
||||
.pop li .option-words { display: flex; align-items: center; min-width: 0; flex: 1 1 auto; gap: 9px; }
|
||||
.pop li .v { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; font-variant-numeric: tabular-nums; }
|
||||
.pop li .badge.free, .pop li .badge.ctx { flex: none; margin-left: -4px; padding: 0 4px; font-size: 9px; line-height: 14px; align-self: center; }
|
||||
.pop li .badge.free, .pop li .badge.ctx, .pop li .badge.rate { flex: none; margin-left: -4px; padding: 0 4px; font-size: 9px; line-height: 14px; align-self: center; }
|
||||
.pop li .n { flex: 1 1 0; min-width: 0; font-size: 11.5px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; }
|
||||
.pop li .favorite { display: none; align-items: center; justify-content: center; color: var(--faint); width: 24px; height: 24px; border-radius: 6px; flex: none; }
|
||||
.pop li:hover .favorite, .pop li .favorite.on { display: inline-flex; }
|
||||
@@ -1557,6 +1557,9 @@ body.window .ag-sk-icon { width: 22px; height: 22px; border-radius: 6px; }
|
||||
.badge.free { background: var(--green-soft); color: var(--green); }
|
||||
/* a model's context, when it is a large one: grey, as it is a fact, not a warning */
|
||||
.badge.ctx { background: var(--pill); color: var(--muted); box-shadow: inset 0 0 0 1px var(--line); letter-spacing: .02em; }
|
||||
/* a model's credit rate (0.5×): grey, a fact to pick by; a discount's price before it struck through */
|
||||
.badge.rate { background: var(--pill); color: var(--muted); letter-spacing: .02em; text-transform: none; font-variant-numeric: tabular-nums; display: inline-flex; gap: 3px; }
|
||||
.badge.rate s { font-weight: 500; opacity: .75; }
|
||||
.mchip .badge { margin: 0 -3px 0 1px; padding: 0 4px; font-size: 9px; line-height: 14px; }
|
||||
.row.model .name .badge { margin-left: 6px; padding: 0 4px; font: 600 9px/14px var(--font); }
|
||||
.provider .pswitch { margin-left: 8px; }
|
||||
|
||||
@@ -2373,6 +2373,23 @@ function freeBadge(plan) {
|
||||
return f;
|
||||
}
|
||||
|
||||
// rateTag: the plan's credits a request to a model costs, as its vendor
|
||||
// lists them (Qoder's 0.5×, WorkBuddy's x0.03), small and grey by it, so a
|
||||
// cheap one is picked without opening the vendor's app (01huadalang on
|
||||
// Discord); a discount's price before it struck through, as Qoder's
|
||||
// client shows it (a free one's beside its FREE)
|
||||
function rateTag(rate = 0, was = 0) {
|
||||
if (!(rate > 0) && !(was > rate)) return null;
|
||||
const times = (n) => (n < 0.01 ? "<0.01" : String(+n.toFixed(2))) + "×";
|
||||
const tag = el("span", "badge rate");
|
||||
if (was > rate) tag.append(el("s", "", times(was)));
|
||||
if (rate > 0) tag.append(el("span", "", times(rate)));
|
||||
tag.title = rate > 0
|
||||
? (was > rate ? t("costs {rate} credits a request, {was} before the discount", { rate: times(rate), was: times(was) }) : t("costs {rate} credits a request", { rate: times(rate) }))
|
||||
: t("free now, {was} before the discount", { was: times(was) });
|
||||
return tag;
|
||||
}
|
||||
|
||||
// contextTag: the small grey 1M by a model that holds a million tokens or
|
||||
// more (Cursor's names said it, and no longer do); the usual 128K–400K
|
||||
// aren't marked, as nearly every model has one of those; nor is a name
|
||||
@@ -2404,6 +2421,8 @@ function renderList() {
|
||||
const own = pick.field.label === "sign-in";
|
||||
words.append(el("span", "v", own ? t(o.label || o.value) : o.label || o.value));
|
||||
if (o.free || (pick.modelPicker && o.value && namedFree(o.value, o.label))) words.append(freeBadge(o.free));
|
||||
const rate = rateTag(o.rate, o.rateWas);
|
||||
if (rate) words.append(rate);
|
||||
const ctx = contextTag(o.context, o.label);
|
||||
if (ctx) words.append(ctx);
|
||||
let note = o.note && o.note !== (o.label || o.value) ? (own ? t(o.note) : o.note) : "";
|
||||
@@ -6015,6 +6034,8 @@ function renderModels(p) {
|
||||
// or one its vendor names free (#185)
|
||||
const free = m.free || namedFree(m.id, m.name);
|
||||
if (free) c.append(el("span", "badge free", t("free")));
|
||||
const rate = rateTag(m.rate, m.rateWas);
|
||||
if (rate) c.append(rate);
|
||||
const ctx = contextTag(m.context, m.name);
|
||||
if (ctx) c.append(ctx);
|
||||
if (m.default) c.title = `${m.id} · ${m.default}`;
|
||||
|
||||
@@ -366,6 +366,9 @@ const I18N = {
|
||||
"free": "免费",
|
||||
"Free": "免费",
|
||||
"free: it doesn't use the plan's credits": "免费:不消耗套餐积分",
|
||||
"costs {rate} credits a request": "每次请求消耗 {rate} 积分",
|
||||
"costs {rate} credits a request, {was} before the discount": "每次请求消耗 {rate} 积分,折扣前 {was}",
|
||||
"free now, {was} before the discount": "限时免费,原价 {was}",
|
||||
"free: so its name says": "免费:模型名称如此标明",
|
||||
"holds {n} tokens": "上下文 {n} tokens",
|
||||
"{n} model": "{n} 个模型",
|
||||
|
||||
@@ -29,15 +29,17 @@ type modelJSON struct {
|
||||
Default string `json:"default,omitempty"` // its own name, when the user gave it another
|
||||
Kept []string `json:"kept,omitempty"` // the reasoning levels the user keeps of Efforts, when not all
|
||||
Efforts []string `json:"efforts,omitempty"`
|
||||
Given bool `json:"given,omitempty"` // its levels aren't known: Efforts are those it can be given, Kept those it was
|
||||
Images bool `json:"images"` // agents are told it can see images
|
||||
ImageSet bool `json:"imageSet,omitempty"` // the user said so, rather than its vendor
|
||||
Given bool `json:"given,omitempty"` // its levels aren't known: Efforts are those it can be given, Kept those it was
|
||||
Images bool `json:"images"` // agents are told it can see images
|
||||
ImageSet bool `json:"imageSet,omitempty"` // the user said so, rather than its vendor
|
||||
Own bool `json:"ownImages,omitempty"` // its vendor's answer, which a staged Restore default shows
|
||||
On bool `json:"on"` // exposed to agents
|
||||
On bool `json:"on"` // exposed to agents
|
||||
Context int `json:"context,omitempty"`
|
||||
Max int `json:"max,omitempty"` // the most its context may be set to, above Context
|
||||
Free bool `json:"free,omitempty"` // costs the subscription nothing
|
||||
API string `json:"api,omitempty"` // the one API the user said it is asked on
|
||||
Max int `json:"max,omitempty"` // the most its context may be set to, above Context
|
||||
Free bool `json:"free,omitempty"` // costs the subscription nothing
|
||||
Rate float64 `json:"rate,omitempty"` // the credits a request costs the subscription, as a multiple
|
||||
RateWas float64 `json:"rateWas,omitempty"` // the rate before a discount running now
|
||||
API string `json:"api,omitempty"` // the one API the user said it is asked on
|
||||
}
|
||||
|
||||
type providerJSON struct {
|
||||
@@ -385,7 +387,7 @@ func providerInfo(p provider.Provider, agents []agentUse) providerJSON {
|
||||
own := images
|
||||
images, _ = provider.ApplyImage(p.ID, m.ID, images, m.ImageInput)
|
||||
_, imageSet := provider.ImageOverride(p.ID, m.ID)
|
||||
j := modelJSON{ID: m.ID, Name: m.Name, Efforts: provider.EffortsOf(m), On: on, Context: m.Context, Max: m.MaxContext, Free: m.Free, Images: images, ImageSet: imageSet, Own: own}
|
||||
j := modelJSON{ID: m.ID, Name: m.Name, Efforts: provider.EffortsOf(m), On: on, Context: m.Context, Max: m.MaxContext, Free: m.Free, Rate: m.Rate, RateWas: m.RateWas, Images: images, ImageSet: imageSet, Own: own}
|
||||
if i := slices.IndexFunc(most, func(c catalog.Model) bool { return c.ID == m.ID }); j.Max == 0 && i >= 0 {
|
||||
j.Max = most[i].MaxContext
|
||||
}
|
||||
|
||||
@@ -809,6 +809,16 @@ the first's, and the footer names the model. The right-click neither picks
|
||||
the chip nor moves the page, Esc closes the menu only, and Test models still
|
||||
asks every model. The API is faked.
|
||||
|
||||
`model-rate.test.cjs` shows a model's credit rate by it (01huadalang on
|
||||
Discord: pick the cheap Qoder or WorkBuddy models without opening their
|
||||
apps), in Chromium and WebKit, English and Chinese: the provider editor's
|
||||
chips carry a small grey badge, Qoder's 0.5× or WorkBuddy's 0.03×, a
|
||||
discount's price before it struck through (0.5× 0.2×; a free model's FREE
|
||||
then 0.1× struck through), its title saying what a request costs; a model
|
||||
with none has no badge. The badge is the context badge's grey, not FREE's
|
||||
green, and no stripe. A click on it picks the chip and moves nothing. An
|
||||
agent's model picker shows the same badges. The API is faked.
|
||||
|
||||
`whats-new.test.cjs` shows what changed after an update (a Discord user:
|
||||
to see whether their issue was fixed): after an upgrade the window opens a
|
||||
dialog with every release's notes since the version last run, newest first,
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
// Run with Node's test runner and Playwright on the module path; see README.md.
|
||||
// A model's credit rate, as its vendor lists it, shows by the model so a
|
||||
// cheap one is picked without opening the vendor's app (01huadalang on
|
||||
// Discord: qcoder 或者 workbuddy 能不能…展示列表模型的积分消耗倍率): Qoder's
|
||||
// 0.5×, WorkBuddy's x0.03 as 0.03×, a discount's price before it struck
|
||||
// through (Qwen3.8-Flash free, 0.1× struck through; 0.2× with 0.5× struck
|
||||
// through). A small grey badge on the provider editor's model chips and in
|
||||
// an agent's model picker, its title saying it; a model with no rate has
|
||||
// none. Clicking a chip picks it and scrolls nothing; no stripe. In English
|
||||
// and Chinese, Chromium and WebKit.
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs/promises");
|
||||
const path = require("node:path");
|
||||
const { test } = require("node:test");
|
||||
const { chromium, webkit } = require("playwright");
|
||||
|
||||
const assets = path.resolve(__dirname, "../assets");
|
||||
const fetched = new Date(Date.now() - 3600e3).toISOString();
|
||||
const qoder = {
|
||||
id: "qoder", name: "Qoder", icon: "generic", host: "qoder.example.com", chat: "https://qoder.example.com/v1", responses: "", anthropic: "", catalog: "",
|
||||
models: [
|
||||
{ id: "qmodel_38max", name: "Qwen3.8-Max", on: false, rate: 0.5 },
|
||||
{ id: "qmodel_offpeak", name: "Qwen3.8-Max Off-Peak", on: false, rate: 0.2, rateWas: 0.5 },
|
||||
{ id: "qfmodel", name: "Qwen3.8-Flash", on: false, free: true, rateWas: 0.1 },
|
||||
{ id: "wb-sg", name: "Deepseek-V4.1-Flash (SG)", on: false, rate: 0.03 },
|
||||
{ id: "plain", name: "Plain", on: false },
|
||||
],
|
||||
chosen: [], fetched, agents: [], fallback: [], headers: {},
|
||||
key: { set: true, masked: "sk-…one" }, keyList: [], balanceToken: { takes: false, set: false }, proxy: "",
|
||||
};
|
||||
const options = [
|
||||
{ value: "magpie/qoder/qmodel_38max", label: "Qwen3.8-Max", note: "Qoder · via magpie", group: "Qoder", rate: 0.5 },
|
||||
{ value: "magpie/qoder/qfmodel", label: "Qwen3.8-Flash", note: "Qoder · via magpie", group: "Qoder", free: true, rateWas: 0.1 },
|
||||
{ value: "magpie/qoder/plain", label: "Plain", note: "Qoder · via magpie", group: "Qoder" },
|
||||
];
|
||||
const agents = [{ id: "claude", name: "Claude Code", path: "/test/claude", fields: [{ key: "model", label: "model", value: "magpie/qoder/plain", options }] }];
|
||||
|
||||
function serve(lang) {
|
||||
const providers = { providers: [qoder], presets: [], excluded: [], gateway: { running: true, window: true } };
|
||||
return async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
const json = (data) => route.fulfill({ json: data });
|
||||
if (url.pathname === "/boot.js") return route.fulfill({ contentType: "text/javascript", body: `window.bootPrefs = {lang:"${lang}",theme:"light",web:true};` });
|
||||
if (url.pathname === "/wails/runtime.js") return route.fulfill({ contentType: "text/javascript", body: "export const Window = {};" });
|
||||
if (url.pathname === "/api/state") return json({ agents, profiles: [], settings: { lang, theme: "light" } });
|
||||
if (url.pathname === "/api/usage/quotas") return json([]);
|
||||
if (url.pathname === "/api/plugins") return json({ plugins: [] });
|
||||
if (url.pathname === "/api/agents/cli") return json({ agents: {}, pending: false });
|
||||
if (url.pathname === "/api/providers") return json(providers);
|
||||
if (url.pathname === "/api/groups") return json({ groups: [] });
|
||||
if (url.pathname.startsWith("/api/")) return json({});
|
||||
const file = path.join(assets, url.pathname === "/" ? "index.html" : url.pathname);
|
||||
const contentType = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".png": "image/png" }[path.extname(file)];
|
||||
await route.fulfill({ body: await fs.readFile(file), contentType });
|
||||
};
|
||||
}
|
||||
|
||||
const words = {
|
||||
en: { costs: "costs 0.5× credits a request", was: "costs 0.2× credits a request, 0.5× before the discount", free: "free now, 0.1× before the discount" },
|
||||
zh: { costs: "每次请求消耗 0.5× 积分", was: "每次请求消耗 0.2× 积分,折扣前 0.5×", free: "限时免费,原价 0.1×" },
|
||||
};
|
||||
|
||||
for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) {
|
||||
for (const lang of ["en", "zh"]) {
|
||||
const w = words[lang];
|
||||
test(`${engine} ${lang}: a model's credit rate shows by it`, async (t) => {
|
||||
const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" }));
|
||||
const page = await (await browser.newContext({ viewport: { width: 900, height: 600 }, reducedMotion: "reduce" })).newPage();
|
||||
t.after(async () => {
|
||||
if (process.env.ARTIFACT_DIR) {
|
||||
await fs.mkdir(process.env.ARTIFACT_DIR, { recursive: true });
|
||||
await page.screenshot({ path: path.join(process.env.ARTIFACT_DIR, `${engine}-${lang}-model-rate.png`) });
|
||||
}
|
||||
await browser.close();
|
||||
});
|
||||
page.setDefaultTimeout(5000);
|
||||
const errors = [];
|
||||
page.on("pageerror", (e) => errors.push(e.message));
|
||||
await page.route("**/*", serve(lang));
|
||||
|
||||
// the provider editor's chips
|
||||
await page.goto("http://magpie.test/?view=providers");
|
||||
const editor = page.locator("#modal:not([hidden]) .editor");
|
||||
await page.locator(".row.provider", { hasText: "Qoder" }).click();
|
||||
await editor.locator(".mchips .mchip").first().waitFor();
|
||||
const chips = () => editor.locator(".mchips .mchip").evaluateAll((cs) => cs.map((c) => {
|
||||
const r = c.querySelector(".badge.rate");
|
||||
return [c.querySelector("span").textContent, r ? [r.querySelector("s")?.textContent ?? "", r.querySelector("span")?.textContent ?? "", r.title] : null];
|
||||
}));
|
||||
assert.deepEqual(await chips(), [
|
||||
["Qwen3.8-Max", ["", "0.5×", w.costs]],
|
||||
["Qwen3.8-Max Off-Peak", ["0.5×", "0.2×", w.was]],
|
||||
["Qwen3.8-Flash", ["0.1×", "", w.free]],
|
||||
["Deepseek-V4.1-Flash (SG)", ["", "0.03×", lang === "en" ? "costs 0.03× credits a request" : "每次请求消耗 0.03× 积分"]],
|
||||
["Plain", null],
|
||||
]);
|
||||
// muted and small: grey, as the context badge, not the green FREE;
|
||||
// the old price struck through
|
||||
const look = await editor.locator(".mchip .badge.rate").first().evaluate((b) => {
|
||||
const s = getComputedStyle(b), ctx = document.createElement("span");
|
||||
ctx.className = "badge ctx";
|
||||
b.parentNode.append(ctx);
|
||||
const c = getComputedStyle(ctx), out = { color: s.color, bg: s.backgroundColor, ctxColor: c.color, ctxBg: c.backgroundColor, size: s.fontSize, upper: s.textTransform };
|
||||
ctx.remove();
|
||||
return out;
|
||||
});
|
||||
assert.equal(look.color, look.ctxColor, "grey as the context badge");
|
||||
assert.equal(look.bg, look.ctxBg);
|
||||
assert.equal(look.size, "9px");
|
||||
assert.equal(look.upper, "none");
|
||||
assert.match(await editor.locator(".mchip .badge.rate s").first().evaluate((e) => getComputedStyle(e).textDecorationLine), /line-through/);
|
||||
const border = await page.evaluate(() => [...document.querySelectorAll(".mchip, .mchip *")].map((e) => getComputedStyle(e).borderLeftWidth).filter((b) => parseFloat(b) > 1));
|
||||
assert.deepEqual(border, [], "no border stripes");
|
||||
|
||||
// a click on a chip's rate picks the chip and moves nothing (the
|
||||
// chip brought into view first, as a reader's would be)
|
||||
const scrolled = () => page.evaluate(() => [window.scrollY, ...[...document.querySelectorAll("*")].filter((e) => e.scrollTop > 0).map((e) => `${e.className}:${e.scrollTop}`)].join(" "));
|
||||
const badge = editor.locator(".mchip .badge.rate").first();
|
||||
await badge.scrollIntoViewIfNeeded();
|
||||
const before = await scrolled();
|
||||
await badge.click();
|
||||
assert.equal(await editor.locator(".mchips .mchip.on").count(), 1, "the chip picked");
|
||||
assert.equal(await scrolled(), before, "the page moved");
|
||||
await page.keyboard.press("Escape");
|
||||
await page.locator("#modal").waitFor({ state: "hidden" });
|
||||
|
||||
// an agent's model picker
|
||||
await page.goto("http://magpie.test/");
|
||||
await page.locator('.row.agent[data-id="claude"] .field[data-key="model"]').click();
|
||||
const list = page.locator("#pop:not([hidden]) #list");
|
||||
await list.locator("li").filter({ hasText: "Qwen3.8-Max" }).first().waitFor();
|
||||
const rows = await list.locator("li[data-i]").evaluateAll((ls) => ls.map((l) => {
|
||||
const r = l.querySelector(".badge.rate");
|
||||
return [l.querySelector(".v").textContent, !!l.querySelector(".badge.free"), r ? r.textContent : null];
|
||||
}));
|
||||
// the models' rows, by name (the picker's Default aside, the current
|
||||
// one shown first)
|
||||
const by = Object.fromEntries(rows.map((r) => [r[0], r.slice(1)]));
|
||||
assert.deepEqual([by["Qwen3.8-Max"], by["Qwen3.8-Flash"], by["Plain"]], [[false, "0.5×"], [true, "0.1×"], [false, null]]);
|
||||
assert.equal(await list.locator("li .badge.rate").first().getAttribute("title"), w.costs);
|
||||
|
||||
const missing = await page.evaluate(() => ["costs {rate} credits a request", "costs {rate} credits a request, {was} before the discount", "free now, {was} before the discount"].filter((k) => !I18N.zh[k]));
|
||||
assert.deepEqual(missing, [], "every string has its Chinese");
|
||||
assert.deepEqual(errors, []);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -693,6 +693,14 @@ function concurrencyOf(a) {
|
||||
return ok(readJSON(path.join(dir, "package.json"))?.magpie?.maxConcurrency)
|
||||
}
|
||||
|
||||
// rateOf reads a model's credit rate as a plugin gives it: a number (0.5)
|
||||
// or as the vendor's picker writes it ("x0.03", "0.5×"); 0 is none, as is
|
||||
// one it can't read
|
||||
function rateOf(v) {
|
||||
const n = typeof v === "string" ? Number(v.trim().replace(/^[x×]\s*|\s*[x×]$/gi, "")) : v
|
||||
return typeof n === "number" && Number.isFinite(n) && n > 0 ? n : 0
|
||||
}
|
||||
|
||||
// providers lists each provider and its accounts' models, each asked
|
||||
// through its proxy (proxies[provider][key], "" the provider's own), as a
|
||||
// built-in fetches each account's list through the account's.
|
||||
@@ -752,6 +760,10 @@ async function providers({ proxies } = {}) {
|
||||
cost: m.cost,
|
||||
variants: Object.keys(m.variants ?? {}),
|
||||
free: m.free === true,
|
||||
// what a request costs of the plan's credits, as a multiple,
|
||||
// and before a discount running now (Qoder's price_factor)
|
||||
rate: rateOf(m.rate),
|
||||
rateWas: rateOf(m.rateWas),
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -84,6 +84,10 @@ func TestFakePlugin(t *testing.T) {
|
||||
if want := map[string][2]bool{"fake-1": {false, false}, "fake-claude": {true, true}, "fake-gemini": {false, true}}[m.ID]; m.Image != want[0] || m.ImageSaid != want[1] {
|
||||
t.Errorf("%s: image %v, said %v; want %v", m.ID, m.Image, m.ImageSaid, want)
|
||||
}
|
||||
// a model's credit rate, as the plugin gave it, number or "x0.03"
|
||||
if want := map[string][2]float64{"fake-claude": {0.5, 1}, "fake-gemini": {0.03, 0}}[m.ID]; m.Rate != want[0] || m.RateWas != want[1] {
|
||||
t.Errorf("%s: rate %v, was %v; want %v", m.ID, m.Rate, m.RateWas, want)
|
||||
}
|
||||
}
|
||||
|
||||
// the browser method asks where, then a team only for work
|
||||
|
||||
@@ -54,7 +54,15 @@ type Model struct {
|
||||
// Free is set by the plugin on a model the plan serves at no cost to
|
||||
// its allowance (WorkBuddy's "credits": "x0.00")
|
||||
Free bool `json:"free"`
|
||||
Cost *struct {
|
||||
// Rate is set by the plugin on a model whose price its vendor lists:
|
||||
// the credits a request costs, as a multiple (Qoder's price_factor
|
||||
// 0.5, WorkBuddy's "credits": "x0.03"), and RateWas the price before a
|
||||
// discount running now. The plugin gives either as a number or as the
|
||||
// vendor writes it ("x0.03"), on the model it lists (m.rate,
|
||||
// m.rateWas); 0 is none.
|
||||
Rate float64 `json:"rate"`
|
||||
RateWas float64 `json:"rateWas"`
|
||||
Cost *struct {
|
||||
Input float64 `json:"input"`
|
||||
Output float64 `json:"output"`
|
||||
} `json:"cost"`
|
||||
|
||||
+4
@@ -123,6 +123,10 @@ export const FakePlugin = async ({ client }) => ({
|
||||
}
|
||||
// fake-1 costs the plan nothing, as a WorkBuddy model of x0.00 credits
|
||||
p.models["fake-1"].free = true
|
||||
// fake-claude's credits are a number, discounted; fake-gemini's as
|
||||
// WorkBuddy's picker writes them
|
||||
if (p.models["fake-claude"]) Object.assign(p.models["fake-claude"], { rate: 0.5, rateWas: 1 })
|
||||
if (p.models["fake-gemini"]) p.models["fake-gemini"].rate = "x0.03"
|
||||
if (auth?.key === "few") return { "fake-1": p.models["fake-1"] }
|
||||
// $FAKE_MODELS: the vendor's list, whose answer names one more model
|
||||
if (process.env.FAKE_MODELS && auth) {
|
||||
|
||||
@@ -827,6 +827,10 @@ type Entry struct {
|
||||
Family string `json:"family,omitempty"`
|
||||
// Free is set on a model its subscription serves at no cost to it.
|
||||
Free bool `json:"free,omitempty"`
|
||||
// Rate and RateWas are the credits a request costs its subscription,
|
||||
// as a multiple, and before a discount running now (catalog.Model's).
|
||||
Rate float64 `json:"rate,omitempty"`
|
||||
RateWas float64 `json:"rateWas,omitempty"`
|
||||
// Shared are a group's levels its members have in common: its Efforts,
|
||||
// unless the group names its own (Group.Levels).
|
||||
Shared []string `json:"-"`
|
||||
@@ -915,7 +919,7 @@ func entryFor(p Provider, m catalog.Model, s settings.Settings) Entry {
|
||||
}
|
||||
images, imageInput := ApplyImage(p.ID, m.ID, images, m.ImageInput)
|
||||
e := Entry{ID: p.ID + "/" + m.ID, Model: m.ID, Family: p.Family, Name: m.Name, Efforts: effortsOf(m), Provider: p,
|
||||
Images: images, ImageInput: imageInput, Context: ctx, Output: output, Free: m.Free}
|
||||
Images: images, ImageInput: imageInput, Context: ctx, Output: output, Free: m.Free, Rate: m.Rate, RateWas: m.RateWas}
|
||||
if n, ok := modelNameIn(s.ModelNames, p.ID, m.ID); ok {
|
||||
e.Name, e.Default = n, m.Name
|
||||
}
|
||||
|
||||
@@ -155,6 +155,7 @@ func pluginCatalog(pp plugin.Provider) []catalog.Model {
|
||||
ID: m.ID, Name: m.Name, Provider: pp.ID, Released: m.Released,
|
||||
APIs: []string{string(pluginProtocol(pp.ID, m))}, Images: m.Image,
|
||||
Context: m.Input, Output: m.Output, Free: m.Free,
|
||||
Rate: m.Rate, RateWas: m.RateWas,
|
||||
}
|
||||
if c.Context == 0 {
|
||||
c.Context = m.Context
|
||||
|
||||
@@ -24,3 +24,31 @@ func TestWBFreeCredits(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Each model carries the credits WorkBuddy's picker shows by it, so the
|
||||
// lists can say it (01huadalang on Discord: pick the cheap ones without
|
||||
// opening WorkBuddy): x0.03 is 0.03, x1.00 is 1; free and unreadable
|
||||
// rates give none.
|
||||
func TestWBCreditsRate(t *testing.T) {
|
||||
var cfg wbProductConfig
|
||||
if err := json.Unmarshal([]byte(`{"agents":[{"name":"cli","models":["deepseek-v4.1-flash","deepseek-v4.1-flash-sg","gpt-5.5","kimi-k3","hy3","odd","inf"]}],
|
||||
"models":[{"id":"deepseek-v4.1-flash","credits":"x0.00"},
|
||||
{"id":"deepseek-v4.1-flash-sg","credits":"x0.03"},
|
||||
{"id":"gpt-5.5","credits":"X1.00"},
|
||||
{"id":"kimi-k3","credits":1.5},
|
||||
{"id":"hy3"},
|
||||
{"id":"odd","credits":"lots"},
|
||||
{"id":"inf","credits":"x+Inf"}]}`), &cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := map[string]float64{"deepseek-v4.1-flash-sg": 0.03, "gpt-5.5": 1, "kimi-k3": 1.5}
|
||||
ms := cfg.cliModels()
|
||||
if len(ms) != 7 {
|
||||
t.Fatalf("models %+v", ms)
|
||||
}
|
||||
for _, m := range ms {
|
||||
if m.Rate != want[m.ID] || m.Free != (m.ID == "deepseek-v4.1-flash") {
|
||||
t.Errorf("%s: Rate = %v, Free = %v", m.ID, m.Rate, m.Free)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -107,7 +108,9 @@ func (c wbProductConfig) cliModels() []catalog.Model {
|
||||
m.Name = d.Name
|
||||
}
|
||||
m.Context, m.Output = d.MaxInputTokens, d.MaxOutputTokens
|
||||
m.Free = wbFreeCredits(d.Credits)
|
||||
if r, ok := wbCredits(d.Credits); ok {
|
||||
m.Free, m.Rate = r == 0, r
|
||||
}
|
||||
if d.SupportsImages != nil {
|
||||
m.Images, m.ImageInput = *d.SupportsImages, d.SupportsImages
|
||||
}
|
||||
@@ -127,16 +130,17 @@ func (c wbProductConfig) cliModels() []catalog.Model {
|
||||
return wbDistinctNames(out)
|
||||
}
|
||||
|
||||
// wbFreeCredits says whether a model's credits are none: "x0.00", "0" or 0.
|
||||
// A rate it can't read is not free.
|
||||
func wbFreeCredits(raw json.RawMessage) bool {
|
||||
// wbCredits reads a model's credits, the multiple WorkBuddy's picker shows
|
||||
// by it: "x0.03", "0" or 0; "x0.00" is free. A rate it can't read (or
|
||||
// none) is not ok, and the model is neither free nor rated.
|
||||
func wbCredits(raw json.RawMessage) (float64, bool) {
|
||||
var s string
|
||||
if json.Unmarshal(raw, &s) != nil {
|
||||
s = string(raw)
|
||||
}
|
||||
s = strings.TrimPrefix(strings.ToLower(strings.TrimSpace(s)), "x")
|
||||
f, err := strconv.ParseFloat(s, 64)
|
||||
return err == nil && f == 0
|
||||
return f, err == nil && f >= 0 && !math.IsInf(f, 1)
|
||||
}
|
||||
|
||||
// wbDistinctNames tells apart models the config gives the same name:
|
||||
|
||||
@@ -41,15 +41,22 @@ type ModelInfo struct {
|
||||
// Free is set on a model that costs the plan no credits, as Qoder's
|
||||
// client shows it: a price_factor of 0 (see free).
|
||||
Free bool `json:"-"`
|
||||
// Rate is the credits a request costs, as a multiple (its
|
||||
// price_factor), and RateWas the price struck through beside it while
|
||||
// a discount runs (see free); 0 when the listing says none.
|
||||
Rate float64 `json:"-"`
|
||||
RateWas float64 `json:"-"`
|
||||
}
|
||||
|
||||
// promotion is a listing entry's running discount: a price_factor of 0
|
||||
// while it is active, with a price before it, is a discount, not a free
|
||||
// model.
|
||||
type promotion struct {
|
||||
Active bool `json:"active"`
|
||||
Before *float64 `json:"before_promotion_price_factor"`
|
||||
BeforeC *float64 `json:"beforePromotionPriceFactor"`
|
||||
Active bool `json:"active"`
|
||||
Before *float64 `json:"before_promotion_price_factor"`
|
||||
BeforeC *float64 `json:"beforePromotionPriceFactor"`
|
||||
Discount float64 `json:"discount_factor"`
|
||||
DiscountC float64 `json:"discountFactor"`
|
||||
}
|
||||
|
||||
// free reads whether a model costs the plan no credits: a price_factor (the
|
||||
@@ -62,12 +69,20 @@ type promotion struct {
|
||||
// model; a limited-time free model (Qwen3.8-Flash: 0×, its
|
||||
// original_price_factor 0.1 struck through) is free while it is. The same
|
||||
// rule as the plugin's freeOf (packages/qoder/index.mjs).
|
||||
//
|
||||
// The price is kept too (Rate), as Qoder's client shows it beside the
|
||||
// model: 0.5×, and the price before a discount struck through (RateWas):
|
||||
// an active promotion's before_promotion_price_factor, else an
|
||||
// original_price_factor above the price. A promotion's 0 that isn't free
|
||||
// is its price before times its discount_factor.
|
||||
func (m *ModelInfo) free(raw json.RawMessage) {
|
||||
var v struct {
|
||||
IsFree *bool `json:"is_free"`
|
||||
IsFreeC *bool `json:"isFree"`
|
||||
PriceFactor *float64 `json:"price_factor"`
|
||||
PriceC *float64 `json:"priceFactor"`
|
||||
Original float64 `json:"original_price_factor"`
|
||||
OriginalC float64 `json:"originalPriceFactor"`
|
||||
Promotion *promotion `json:"promotion"`
|
||||
PromotionT *promotion `json:"prommotion"`
|
||||
}
|
||||
@@ -84,15 +99,25 @@ func (m *ModelInfo) free(raw json.RawMessage) {
|
||||
v.Promotion = v.PromotionT
|
||||
}
|
||||
if v.PriceFactor != nil {
|
||||
if *v.PriceFactor != 0 {
|
||||
m.Free = false
|
||||
return
|
||||
}
|
||||
p := v.Promotion
|
||||
if p != nil && p.Before == nil {
|
||||
p.Before = p.BeforeC
|
||||
}
|
||||
m.Free = !(p != nil && p.Active && p.Before != nil && *p.Before > 0)
|
||||
if p != nil && p.Discount == 0 {
|
||||
p.Discount = p.DiscountC
|
||||
}
|
||||
discounted := p != nil && p.Active && p.Before != nil && *p.Before > 0
|
||||
m.Rate, m.RateWas = max(*v.PriceFactor, 0), max(v.Original, v.OriginalC)
|
||||
if discounted {
|
||||
m.RateWas = *p.Before
|
||||
if m.Rate == 0 {
|
||||
m.Rate = *p.Before * p.Discount
|
||||
}
|
||||
}
|
||||
if m.RateWas <= m.Rate {
|
||||
m.RateWas = 0
|
||||
}
|
||||
m.Free = *v.PriceFactor == 0 && !discounted
|
||||
return
|
||||
}
|
||||
m.Free = v.IsFree != nil && *v.IsFree
|
||||
@@ -209,7 +234,8 @@ func ParseModels(body []byte, provider string) ([]catalog.Model, error) {
|
||||
name = m.Key
|
||||
}
|
||||
out = append(out, catalog.Model{ID: m.Key, Name: name, Provider: provider,
|
||||
Context: m.MaxInputTokens, Images: m.IsVL, Efforts: m.Efforts, Free: m.Free})
|
||||
Context: m.MaxInputTokens, Images: m.IsVL, Efforts: m.Efforts, Free: m.Free,
|
||||
Rate: m.Rate, RateWas: m.RateWas})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -60,3 +60,36 @@ func TestParseModelsFreeIsThePrice(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Each model carries its price as Qoder's client shows it beside the
|
||||
// model (01huadalang on Discord: pick the cheap ones without opening
|
||||
// Qoder): Qwen3.8-Max 0.5×, Kimi-K3 1.4×, Qwen3.8-Flash 0× (free) with
|
||||
// 0.1× struck through; a running promotion's price before it struck
|
||||
// through, and its 0 that isn't free read as that price times its
|
||||
// discount. A listing with no price gives none.
|
||||
func TestParseModelsRate(t *testing.T) {
|
||||
body := []byte(`{"chat":[
|
||||
{"key":"qmodel_38max","enable":true,"price_factor":0.5,"is_free":true,"promotion":{"active":false,"discount_factor":0.4,"before_promotion_price_factor":0.5}},
|
||||
{"key":"qmodel_38max-offpeak","enable":true,"price_factor":0.2,"promotion":{"active":true,"discount_factor":0.4,"before_promotion_price_factor":0.5}},
|
||||
{"key":"qfmodel","enable":true,"price_factor":0.0,"original_price_factor":0.1,"is_free":true},
|
||||
{"key":"qmodel_latest","enable":true,"price_factor":0.5,"original_price_factor":0.5},
|
||||
{"key":"kmodel_latest","enable":true,"priceFactor":1.4,"originalPriceFactor":2},
|
||||
{"key":"window","enable":true,"price_factor":0,"promotion":{"active":true,"discount_factor":0.5,"before_promotion_price_factor":1}},
|
||||
{"key":"window-camel","enable":true,"priceFactor":0,"promotion":{"active":true,"discountFactor":0.25,"beforePromotionPriceFactor":2}},
|
||||
{"key":"unpriced","enable":true,"is_free":true}
|
||||
]}`)
|
||||
ms, err := ParseModels(body, ProviderKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := map[string][2]float64{"qmodel_38max": {0.5, 0}, "qmodel_38max-offpeak": {0.2, 0.5}, "qfmodel": {0, 0.1},
|
||||
"qmodel_latest": {0.5, 0}, "kmodel_latest": {1.4, 2}, "window": {0.5, 1}, "window-camel": {0.5, 2}, "unpriced": {0, 0}}
|
||||
if len(ms) != len(want) {
|
||||
t.Fatalf("models %+v", ms)
|
||||
}
|
||||
for _, m := range ms {
|
||||
if w := want[m.ID]; m.Rate != w[0] || m.RateWas != w[1] {
|
||||
t.Errorf("%s: Rate, RateWas = %v, %v, want %v, %v", m.ID, m.Rate, m.RateWas, w[0], w[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user