From 33acb6bca0cfcda11c060e843809c71c595e19e6 Mon Sep 17 00:00:00 2001 From: yetone Date: Fri, 2 Oct 2026 22:48:41 +0800 Subject: [PATCH] =?UTF-8?q?providers:=20each=20model's=20credit=20rate=20s?= =?UTF-8?q?hows=20by=20it=20in=20the=20provider's=20model=20list=20and=20a?= =?UTF-8?q?n=20agent's=20model=20picker,=20Qoder's=200.5=C3=97=20and=20Wor?= =?UTF-8?q?kBuddy's=200.03=C3=97,=20a=20discount's=20price=20before=20it?= =?UTF-8?q?=20struck=20through=20(01huadalang=20on=20Discord:=20qcoder=20?= =?UTF-8?q?=E6=88=96=E8=80=85=20workbuddy=20=E8=83=BD=E4=B8=8D=E8=83=BD?= =?UTF-8?q?=E5=AE=9E=E6=97=B6=E6=8A=93=E5=8F=96=E6=88=96=E8=80=85=E6=8C=89?= =?UTF-8?q?=E4=B8=80=E6=AE=B5=E6=97=B6=E9=97=B4=E6=8A=93=E5=8F=96=E8=83=BD?= =?UTF-8?q?=E5=B1=95=E7=A4=BA=E5=88=97=E8=A1=A8=E6=A8=A1=E5=9E=8B=E7=9A=84?= =?UTF-8?q?=E7=A7=AF=E5=88=86=E6=B6=88=E8=80=97=E5=80=8D=E7=8E=87=EF=BC=8C?= =?UTF-8?q?=E8=BF=99=E6=A0=B7=E4=B8=8D=E7=94=A8=E6=89=93=E5=BC=80=E8=BD=AF?= =?UTF-8?q?=E4=BB=B6=E6=9F=A5=E7=9C=8B=EF=BC=8C=E8=83=BD=E9=80=89=E6=8B=A9?= =?UTF-8?q?=E5=88=B0=E4=BE=BF=E5=AE=9C=E7=9A=84=E6=A8=A1=E5=9E=8B)=20?= =?UTF-8?q?=E2=80=94=20magpie=20read=20each=20model's=20price=20only=20to?= =?UTF-8?q?=20tell=20free=20from=20not:=20Qoder's=20price=5Ffactor=20(0658?= =?UTF-8?q?4e15)=20and=20WorkBuddy's=20"credits"=20("x0.03",=20wbFreeCredi?= =?UTF-8?q?ts),=20so=20the=20lists=20said=20FREE=20or=20nothing,=20and=20p?= =?UTF-8?q?icking=20a=20cheap=20model=20meant=20opening=20Qoder=20or=20Wor?= =?UTF-8?q?kBuddy.=20catalog.Model=20now=20keeps=20the=20price,=20Rate=20(?= =?UTF-8?q?the=20credits=20a=20request=20costs,=20as=20a=20multiple;=200?= =?UTF-8?q?=20is=20not=20listed,=20or=20Free)=20and=20RateWas=20(the=20pri?= =?UTF-8?q?ce=20before=20a=20discount=20running=20now),=20and=20carries=20?= =?UTF-8?q?both=20through=20provider.Entry,=20agent.Option=20and=20the=20p?= =?UTF-8?q?roviders=20page's=20models.=20Qoder=20(and=20Qoder=20CN):=20Rat?= =?UTF-8?q?e=20is=20the=20price=5Ffactor;=20RateWas=20an=20active=20promot?= =?UTF-8?q?ion's=20before=5Fpromotion=5Fprice=5Ffactor,=20else=20an=20orig?= =?UTF-8?q?inal=5Fprice=5Ffactor=20above=20the=20price=20(Qwen3.8-Flash:?= =?UTF-8?q?=20free,=200.1=C3=97=20struck=20through),=20and=20a=20promotion?= =?UTF-8?q?'s=200=20that=20isn't=20free=20is=20its=20price=20before=20time?= =?UTF-8?q?s=20its=20discount=5Ffactor.=20WorkBuddy=20and=20WorkBuddy=20AI?= =?UTF-8?q?=20(the=20CodeBuddy=20plan;=20both=20builds=20read=20the=20same?= =?UTF-8?q?=20/v3/config):=20Rate=20is=20the=20credits=20(wbCredits;=20x0.?= =?UTF-8?q?00=20stays=20FREE,=20an=20unreadable=20or=20infinite=20rate=20i?= =?UTF-8?q?s=20none).=20A=20plugin's=20model=20gives=20it=20as=20it=20give?= =?UTF-8?q?s=20free:=20rate=20and=20rateWas=20on=20the=20model=20its=20pro?= =?UTF-8?q?vider.models=20hook=20lists,=20a=20number=20(0.5)=20or=20as=20t?= =?UTF-8?q?he=20vendor=20writes=20it=20("x0.03",=20"0.5=C3=97"),=20which?= =?UTF-8?q?=20host.js=20reads=20(rateOf)=20into=20plugin.Model.Rate/RateWa?= =?UTF-8?q?s=20and=20pluginCatalog=20keeps;=20the=20moved=20Qoder=20and=20?= =?UTF-8?q?WorkBuddy=20users=20get=20it=20once=20their=20plugins=20send=20?= =?UTF-8?q?it.=20The=20badge=20is=20small=20and=20grey=20(the=20context=20?= =?UTF-8?q?badge's=20colours,=20no=20uppercase),=20beside=20FREE=20where?= =?UTF-8?q?=20that=20shows,=20its=20title=20saying=20what=20a=20request=20?= =?UTF-8?q?costs=20(=E6=AF=8F=E6=AC=A1=E8=AF=B7=E6=B1=82=E6=B6=88=E8=80=97?= =?UTF-8?q?=200.5=C3=97=20=E7=A7=AF=E5=88=86=EF=BC=9B=E6=8A=98=E6=89=A3?= =?UTF-8?q?=E5=89=8D=20/=20=E9=99=90=E6=97=B6=E5=85=8D=E8=B4=B9=EF=BC=8C?= =?UTF-8?q?=E5=8E=9F=E4=BB=B7).=20Rates=20are=20as=20of=20the=20provider's?= =?UTF-8?q?=20last=20list:=20a=20built-in's=20is=20fetched=20at=20start-up?= =?UTF-8?q?=20when=20it=20has=20none,=20on=20Refresh=20and=20when=20its=20?= =?UTF-8?q?key=20changes,=20a=20plugin's=20when=20its=20plugins=20are=20as?= =?UTF-8?q?ked=20again=20(start-up,=20a=20sign-in,=20a=20plugin=20change);?= =?UTF-8?q?=20nothing=20asks=20on=20a=20timer.=20Kiro,=20Cursor,=20ZCode?= =?UTF-8?q?=20and=20Devin=20list=20no=20per-model=20rate=20magpie=20reads,?= =?UTF-8?q?=20so=20they=20show=20none.=20TestParseModelsRate=20(qoder)=20a?= =?UTF-8?q?nd=20TestWBCreditsRate=20(provider)=20are=20new=20and=20don't?= =?UTF-8?q?=20build=20without=20the=20change;=20TestFakePlugin=20now=20che?= =?UTF-8?q?cks=20a=20plugin's=20rate=20given=20as=20a=20number=20and=20as?= =?UTF-8?q?=20"x0.03"=20(0=20for=20both=20with=20host.js=20not=20reading?= =?UTF-8?q?=20them).=20model-rate.test.cjs=20(WebKit,=20en=20and=20zh:=20t?= =?UTF-8?q?he=20chips'=20badges=20and=20titles,=20struck-through=20prices,?= =?UTF-8?q?=20grey=20not=20green,=20no=20stripe,=20a=20click=20picks=20and?= =?UTF-8?q?=20scrolls=20nothing,=20the=20picker's=20badges)=20fails=20with?= =?UTF-8?q?out=20the=20change=20and=20passes=20with=20it;=20click-scroll,?= =?UTF-8?q?=20models-free-only,=20model-pick=20and=20favorite-star=20pass?= =?UTF-8?q?=20in=20WebKit.=20No=20request=20was=20made=20to=20Qoder,=20Wor?= =?UTF-8?q?kBuddy=20or=20any=20vendor.=20go=20vet=20./internal/...,=20go?= =?UTF-8?q?=20build=20-tags=20nogui=20(darwin,=20GOOS=3Dlinux),=20GOOS=3Dw?= =?UTF-8?q?indows=20go=20build,=20go=20test=20-tags=20nogui=20-count=3D1?= =?UTF-8?q?=20./...=20under=20a=20temp=20HOME,=20go=20test=20./internal/gu?= =?UTF-8?q?i=20pass.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/agent/agent.go | 5 + internal/agent/route.go | 2 +- internal/catalog/catalog.go | 7 ++ internal/gui/assets/app.css | 5 +- internal/gui/assets/app.js | 21 ++++ internal/gui/assets/i18n.js | 3 + internal/gui/providers.go | 18 +-- internal/gui/tests/README.md | 10 ++ internal/gui/tests/model-rate.test.cjs | 147 +++++++++++++++++++++++ internal/plugin/host.js | 12 ++ internal/plugin/plugin_test.go | 4 + internal/plugin/providers.go | 10 +- internal/plugin/testdata/fake/index.js | 4 + internal/provider/models.go | 6 +- internal/provider/plugins.go | 1 + internal/provider/workbuddy_free_test.go | 28 +++++ internal/provider/workbuddy_models.go | 14 ++- internal/qoder/models.go | 44 +++++-- internal/qoder/models_test.go | 33 +++++ 19 files changed, 348 insertions(+), 26 deletions(-) create mode 100644 internal/gui/tests/model-rate.test.cjs diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 2062a148..4034c2bf 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -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"` diff --git a/internal/agent/route.go b/internal/agent/route.go index e0e213d7..7abb5703 100644 --- a/internal/agent/route.go +++ b/internal/agent/route.go @@ -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...) } diff --git a/internal/catalog/catalog.go b/internal/catalog/catalog.go index 02945eef..53f7002f 100644 --- a/internal/catalog/catalog.go +++ b/internal/catalog/catalog.go @@ -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"` diff --git a/internal/gui/assets/app.css b/internal/gui/assets/app.css index 4607b09e..a6fb9682 100644 --- a/internal/gui/assets/app.css +++ b/internal/gui/assets/app.css @@ -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; } diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js index f0fbe16f..642a0211 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -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}`; diff --git a/internal/gui/assets/i18n.js b/internal/gui/assets/i18n.js index 490f010d..849235a7 100644 --- a/internal/gui/assets/i18n.js +++ b/internal/gui/assets/i18n.js @@ -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} 个模型", diff --git a/internal/gui/providers.go b/internal/gui/providers.go index b64b0f4a..cc0a58ce 100644 --- a/internal/gui/providers.go +++ b/internal/gui/providers.go @@ -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 } diff --git a/internal/gui/tests/README.md b/internal/gui/tests/README.md index c29e473a..7d653878 100644 --- a/internal/gui/tests/README.md +++ b/internal/gui/tests/README.md @@ -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, diff --git a/internal/gui/tests/model-rate.test.cjs b/internal/gui/tests/model-rate.test.cjs new file mode 100644 index 00000000..253c89d9 --- /dev/null +++ b/internal/gui/tests/model-rate.test.cjs @@ -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, []); + }); + } +} diff --git a/internal/plugin/host.js b/internal/plugin/host.js index dd42d3bb..5bf58d2a 100644 --- a/internal/plugin/host.js +++ b/internal/plugin/host.js @@ -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), })), }) } diff --git a/internal/plugin/plugin_test.go b/internal/plugin/plugin_test.go index c6629abb..63c5189e 100644 --- a/internal/plugin/plugin_test.go +++ b/internal/plugin/plugin_test.go @@ -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 diff --git a/internal/plugin/providers.go b/internal/plugin/providers.go index 67d3da61..b2b31744 100644 --- a/internal/plugin/providers.go +++ b/internal/plugin/providers.go @@ -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"` diff --git a/internal/plugin/testdata/fake/index.js b/internal/plugin/testdata/fake/index.js index eab657ba..b9108b32 100644 --- a/internal/plugin/testdata/fake/index.js +++ b/internal/plugin/testdata/fake/index.js @@ -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) { diff --git a/internal/provider/models.go b/internal/provider/models.go index 0b664982..f61c4bf7 100644 --- a/internal/provider/models.go +++ b/internal/provider/models.go @@ -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 } diff --git a/internal/provider/plugins.go b/internal/provider/plugins.go index 1e196d0f..751b59f4 100644 --- a/internal/provider/plugins.go +++ b/internal/provider/plugins.go @@ -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 diff --git a/internal/provider/workbuddy_free_test.go b/internal/provider/workbuddy_free_test.go index 518f263a..83a23ff1 100644 --- a/internal/provider/workbuddy_free_test.go +++ b/internal/provider/workbuddy_free_test.go @@ -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) + } + } +} diff --git a/internal/provider/workbuddy_models.go b/internal/provider/workbuddy_models.go index 5f8a9f5d..156224e3 100644 --- a/internal/provider/workbuddy_models.go +++ b/internal/provider/workbuddy_models.go @@ -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: diff --git a/internal/qoder/models.go b/internal/qoder/models.go index 696e5c8b..146fe03d 100644 --- a/internal/qoder/models.go +++ b/internal/qoder/models.go @@ -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 } diff --git a/internal/qoder/models_test.go b/internal/qoder/models_test.go index 337927bf..1103bc1c 100644 --- a/internal/qoder/models_test.go +++ b/internal/qoder/models_test.go @@ -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]) + } + } +}