From 4af01cd742f5e454e4a4dc91c408179dbdbce9dd Mon Sep 17 00:00:00 2001 From: yetone Date: Thu, 1 Oct 2026 16:43:20 +0800 Subject: [PATCH] =?UTF-8?q?providers:=20Command=20Code's=20free=20models?= =?UTF-8?q?=20are=20marked=20free,=20and=20providers=20switched=20off=20fo?= =?UTF-8?q?ld=20below=20the=20ones=20on=20(01huadalang=20on=20Discord:=20S?= =?UTF-8?q?pace=20Bunny=20Alpha=20=E5=9C=A8=20Command=20Code=20=E9=87=8C?= =?UTF-8?q?=E6=98=AF=20FREE=EF=BC=8Cmagpie=20=E5=8F=AA=E7=BB=99=20Ling=203?= =?UTF-8?q?.1=20Flash=20=E6=A0=87=E4=BA=86=E5=85=8D=E8=B4=B9=EF=BC=9B?= =?UTF-8?q?=E5=B7=B2=E5=85=B3=E9=97=AD=E7=9A=84=E4=BE=9B=E5=BA=94=E5=95=86?= =?UTF-8?q?=E8=BF=98=E5=8D=A0=E7=9D=80=E4=BD=8D=E7=BD=AE)=20=E2=80=94=20ma?= =?UTF-8?q?gpie=20marked=20a=20model=20free=20when=20the=20plan=20said=20s?= =?UTF-8?q?o=20(m.free)=20or=20its=20id=20or=20name=20said=20"free";=20Com?= =?UTF-8?q?mand=20Code's=20/provider/v1/models=20says=20nothing=20of=20it,?= =?UTF-8?q?=20so=20only=20ids=20like=20inclusionai/ling-3.1-flash:free=20g?= =?UTF-8?q?ot=20=E5=85=8D=E8=B4=B9.=20Its=20CLI=20(1.73.2)=20badges=20elev?= =?UTF-8?q?en=20models=20free=20in=20its=20own=20table=20(badge:"free"),?= =?UTF-8?q?=20Space=20Bunny=20Alpha=20and=20Pixel=20Canary=20among=20them;?= =?UTF-8?q?=20those=20are=20now=20marked=20Free=20on=20the=20Go=20table,?= =?UTF-8?q?=20on=20Go's=20fetched=20list=20and=20on=20a=20keyed=20plan's,?= =?UTF-8?q?=20so=20the=20chips=20say=20=E5=85=8D=E8=B4=B9=20with=20"doesn'?= =?UTF-8?q?t=20use=20the=20plan's=20credits".=20Neither=20the=20CLI=20nor?= =?UTF-8?q?=20the=20API=20says=20a=20discount=20(no=20"-99%"=20anywhere),?= =?UTF-8?q?=20so=20none=20is=20shown.=20On=20the=20Providers=20page=20the?= =?UTF-8?q?=20providers=20switched=20off=20now=20sit=20below=20the=20list?= =?UTF-8?q?=20under=20a=20"Turned=20off=20(N)"=20fold=20(=E5=B7=B2?= =?UTF-8?q?=E5=85=B3=E9=97=AD=E7=9A=84=E4=BE=9B=E5=BA=94=E5=95=86),=20fold?= =?UTF-8?q?ed=20at=20first=20and=20remembered=20per=20viewer;=20a=20click?= =?UTF-8?q?=20on=20it=20moves=20nothing,=20and=20a=20provider=20switched?= =?UTF-8?q?=20off=20moves=20under=20it.=20TestCommandCodeFreeModels=20and?= =?UTF-8?q?=20providers-off-fold.test.cjs=20fail=20without=20the=20change.?= =?UTF-8?q?=20go=20vet,=20go=20build=20-tags=20nogui=20(darwin,=20GOOS=3Dl?= =?UTF-8?q?inux),=20GOOS=3Dwindows=20go=20build,=20go=20test=20-tags=20nog?= =?UTF-8?q?ui=20./...=20pass=20(TestClaudeRunEndsWhenTheClientLeaves=20a?= =?UTF-8?q?=20load=20flake,=20passes=20alone);=20providers-off-fold,=20cli?= =?UTF-8?q?ck-scroll,=20old-webkit,=20provider-search,=20provider-remove,?= =?UTF-8?q?=20add-sheet,=20nokey-editor,=20plain-names,=20model-test-one?= =?UTF-8?q?=20pass.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/gui/assets/app.css | 3 + internal/gui/assets/app.js | 31 ++++- internal/gui/assets/i18n.js | 3 + internal/gui/assets/index.html | 2 + internal/gui/tests/README.md | 10 +- .../gui/tests/providers-off-fold.test.cjs | 120 ++++++++++++++++++ internal/provider/commandcode_free_test.go | 81 ++++++++++++ internal/provider/commandcode_plan.go | 32 ++++- 8 files changed, 276 insertions(+), 6 deletions(-) create mode 100644 internal/gui/tests/providers-off-fold.test.cjs create mode 100644 internal/provider/commandcode_free_test.go diff --git a/internal/gui/assets/app.css b/internal/gui/assets/app.css index 23674c66..c810db18 100644 --- a/internal/gui/assets/app.css +++ b/internal/gui/assets/app.css @@ -1151,6 +1151,9 @@ body.window .ag-sk-icon { width: 22px; height: 22px; border-radius: 6px; } #view-gateway .row-head, #view-usage .row-head { margin: 14px 0 6px; padding: 0 2px; } #view-usage .row-head.quota-head { margin: 6px 0 6px; } #view-gateway .row-head .note { font-size: 11px; color: var(--faint); } +/* providers switched off, folded below the ones on */ +#view-providers .off-head { margin: 14px 0 6px; padding: 0 2px; flex: none; } +#view-providers .off-head .note { font-size: 11px; color: var(--faint); } /* the Models head folds the list away: a chevron, the label, how many */ .fold { display: inline-flex; align-items: center; gap: 5px; margin-left: -4px; padding: 3px 6px 3px 4px; border-radius: 6px; color: var(--faint); transition: background .12s, color .12s; } .fold:hover { background: var(--pill); color: var(--muted); } diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js index 892de349..003e9271 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -2243,9 +2243,23 @@ function renderProvidersLoading() { row.append(el("span", "skeleton pv-sk-icon"), who, el("span", "skeleton pv-sk-key")); list.append(row); } + $("#offHead").hidden = $("#offProviders").hidden = true; $("#excluded").replaceChildren(); } +// Providers switched off are kept apart, below the ones on, under a +// "Turned off (N)" fold that starts folded: among the rest they took the +// room of the ones in use (01huadalang on Discord). The fold is +// remembered, per viewer. +let offFolded = true; +try { offFolded = localStorage.getItem("magpie.offProvidersOpen") !== "1"; } catch {} +$("#foldOff").prepend(svg(CHEV_R, 11, 1.6)); +$("#foldOff").onclick = () => { + offFolded = !offFolded; + try { localStorage.setItem("magpie.offProvidersOpen", offFolded ? "0" : "1"); } catch {} + renderProviders(); +}; + // One row per provider: logo, name, the agents pointed at it, key status. // Everything else lives in the editor, a dialog over the page. function renderProviders() { @@ -2253,16 +2267,25 @@ function renderProviders() { // scroll to the top; put it back so closing the editor leaves the reader // where they were. const view = $("#view-providers"), top = view.scrollTop; - keepIcons($("#providers"), $("#addSheet"), $("#excluded")); + keepIcons($("#providers"), $("#offProviders"), $("#addSheet"), $("#excluded")); view.classList.remove("loading"); view.removeAttribute("aria-busy"); closeProtoMenu(); syncURL(); - const list = $("#providers"); - list.replaceChildren(); - list.hidden = !providers.providers.length; + const onList = $("#providers"), offList = $("#offProviders"); + onList.replaceChildren(); + offList.replaceChildren(); + const offs = providers.providers.filter((p) => p.off).length; + onList.hidden = offs === providers.providers.length; + $("#offHead").hidden = !offs; + offList.hidden = !offs || offFolded; + const fold = $("#foldOff"); + fold.setAttribute("aria-expanded", String(!offFolded)); + fold.title = t(offFolded ? "Show the providers turned off" : "Fold the providers turned off away"); + $("#offCount").textContent = offs ? String(offs) : ""; let dialog = null; // the editor, if one is open for (const p of providers.providers) { + const list = p.off ? offList : onList; const open = editing === p.id; const row = el("div", "row provider" + (open ? " selected" : "") + (p.off ? " off" : "")); row.dataset.id = p.id; diff --git a/internal/gui/assets/i18n.js b/internal/gui/assets/i18n.js index a3ca27e3..630471c8 100644 --- a/internal/gui/assets/i18n.js +++ b/internal/gui/assets/i18n.js @@ -286,6 +286,9 @@ const I18N = { "no key": "无需密钥", "needs a key": "需要密钥", "Switched off": "已关闭", + "Turned off": "已关闭的供应商", + "Show the providers turned off": "展开已关闭的供应商", + "Fold the providers turned off away": "收起已关闭的供应商", "Use {name}": "使用 {name}", "On · switch it off and agents no longer get its models; its keys and settings are kept": "已开启 · 关闭后不再把它的模型写入 Agent,密钥和设置都会保留", "Off · switch it on and agents get its models again": "已关闭 · 开启后它的模型会重新写入 Agent", diff --git a/internal/gui/assets/index.html b/internal/gui/assets/index.html index 9f3ccfa2..5cd1188d 100644 --- a/internal/gui/assets/index.html +++ b/internal/gui/assets/index.html @@ -73,6 +73,8 @@
+ +
diff --git a/internal/gui/tests/README.md b/internal/gui/tests/README.md index e56ab2bb..c3372525 100644 --- a/internal/gui/tests/README.md +++ b/internal/gui/tests/README.md @@ -700,10 +700,18 @@ and can be asked again, and a call it didn't keep has nothing of it. No click moves the page; no left-border accent. English and Chinese, Chromium and WebKit, with `/api/plugins` faked. +`providers-off-fold.test.cjs` checks that providers switched off are kept +apart (01huadalang on Discord): the Providers list holds the ones on, and the +ones off sit below it under a "Turned off (N)" fold (已关闭的供应商), folded at +first. Opening it with the page scrolled leaves the head and the page where +they are, the fold is remembered across a reload, and a provider switched +off moves under it. English and Chinese, Chromium and WebKit, with +`/api/plugins` faked. + With Node.js and Playwright available: ```sh -node --test internal/gui/tests/menu-scroll.test.cjs internal/gui/tests/click-scroll.test.cjs internal/gui/tests/panel-fold.test.cjs internal/gui/tests/rollup-short.test.cjs internal/gui/tests/panel-routing.test.cjs internal/gui/tests/gateway-fold.test.cjs internal/gui/tests/routing-kind.test.cjs internal/gui/tests/balance-fix.test.cjs internal/gui/tests/cli-update.test.cjs internal/gui/tests/login-import.test.cjs internal/gui/tests/agy-launch.test.cjs internal/gui/tests/currency.test.cjs internal/gui/tests/usage-ledger.test.cjs internal/gui/tests/update-check.test.cjs internal/gui/tests/omarchy.test.cjs internal/gui/tests/brand.test.cjs internal/gui/tests/library-loading.test.cjs node --test internal/gui/tests/signin-callback.test.cjs internal/gui/tests/routing-steady.test.cjs internal/gui/tests/old-webkit.test.cjs internal/gui/tests/session-terminal.test.cjs internal/gui/tests/text-size.test.cjs internal/gui/tests/zcode-site.test.cjs internal/gui/tests/rail-tip.test.cjs internal/gui/tests/sync-update.test.cjs internal/gui/tests/plugin-signin.test.cjs internal/gui/tests/plugin-market.test.cjs internal/gui/tests/s3-sync.test.cjs internal/gui/tests/routing-wb-refused.test.cjs internal/gui/tests/routing-blocked.test.cjs internal/gui/tests/routing-shape.test.cjs internal/gui/tests/market-have.test.cjs internal/gui/tests/sessions-today.test.cjs internal/gui/tests/routing-side-calls.test.cjs internal/gui/tests/import-all-skills.test.cjs internal/gui/tests/routing-flood.test.cjs internal/gui/tests/routing-busy.test.cjs internal/gui/tests/own-skill.test.cjs internal/gui/tests/routing-manual.test.cjs internal/gui/tests/tray-usages.test.cjs internal/gui/tests/provider-remove.test.cjs internal/gui/tests/signin-link.test.cjs internal/gui/tests/tray-inuse.test.cjs internal/gui/tests/codex-auto-reset.test.cjs internal/gui/tests/routing-auto-reset.test.cjs internal/gui/tests/claude-usage-asked.test.cjs internal/gui/tests/claude-risk.test.cjs internal/gui/tests/library-toggle-status.test.cjs internal/gui/tests/library-all.test.cjs internal/gui/tests/routing-reroute-title.test.cjs internal/gui/tests/plain-names.test.cjs internal/gui/tests/panel-effort.test.cjs internal/gui/tests/agent-models-all-hidden.test.cjs internal/gui/tests/omp-roles.test.cjs internal/gui/tests/claude-effort.test.cjs internal/gui/tests/remote-magpie.test.cjs internal/gui/tests/qoder-cn-tile.test.cjs internal/gui/tests/provider-levels-scroll.test.cjs internal/gui/tests/signin-paste-codex.test.cjs internal/gui/tests/usage-alert.test.cjs internal/gui/tests/quota-families.test.cjs internal/gui/tests/provider-search.test.cjs internal/gui/tests/webdav-settings-refresh.test.cjs internal/gui/tests/lan-docker.test.cjs internal/gui/tests/plugin-key-hint.test.cjs internal/gui/tests/update-hide.test.cjs internal/gui/tests/tray-logos.test.cjs internal/gui/tests/routing-proxy.test.cjs internal/gui/tests/sse-body.test.cjs internal/gui/tests/remote-group-served.test.cjs internal/gui/tests/plugin-move-overflow.test.cjs internal/gui/tests/routing-time.test.cjs internal/gui/tests/unlisted-models.test.cjs internal/gui/tests/plugin-updates.test.cjs internal/gui/tests/request-archive.test.cjs internal/gui/tests/group-member-fast.test.cjs +node --test internal/gui/tests/menu-scroll.test.cjs internal/gui/tests/click-scroll.test.cjs internal/gui/tests/panel-fold.test.cjs internal/gui/tests/rollup-short.test.cjs internal/gui/tests/panel-routing.test.cjs internal/gui/tests/gateway-fold.test.cjs internal/gui/tests/routing-kind.test.cjs internal/gui/tests/balance-fix.test.cjs internal/gui/tests/cli-update.test.cjs internal/gui/tests/login-import.test.cjs internal/gui/tests/agy-launch.test.cjs internal/gui/tests/currency.test.cjs internal/gui/tests/usage-ledger.test.cjs internal/gui/tests/update-check.test.cjs internal/gui/tests/omarchy.test.cjs internal/gui/tests/brand.test.cjs internal/gui/tests/library-loading.test.cjs node --test internal/gui/tests/signin-callback.test.cjs internal/gui/tests/routing-steady.test.cjs internal/gui/tests/old-webkit.test.cjs internal/gui/tests/session-terminal.test.cjs internal/gui/tests/text-size.test.cjs internal/gui/tests/zcode-site.test.cjs internal/gui/tests/rail-tip.test.cjs internal/gui/tests/sync-update.test.cjs internal/gui/tests/plugin-signin.test.cjs internal/gui/tests/plugin-market.test.cjs internal/gui/tests/s3-sync.test.cjs internal/gui/tests/routing-wb-refused.test.cjs internal/gui/tests/routing-blocked.test.cjs internal/gui/tests/routing-shape.test.cjs internal/gui/tests/market-have.test.cjs internal/gui/tests/sessions-today.test.cjs internal/gui/tests/routing-side-calls.test.cjs internal/gui/tests/import-all-skills.test.cjs internal/gui/tests/routing-flood.test.cjs internal/gui/tests/routing-busy.test.cjs internal/gui/tests/own-skill.test.cjs internal/gui/tests/routing-manual.test.cjs internal/gui/tests/tray-usages.test.cjs internal/gui/tests/provider-remove.test.cjs internal/gui/tests/signin-link.test.cjs internal/gui/tests/tray-inuse.test.cjs internal/gui/tests/codex-auto-reset.test.cjs internal/gui/tests/routing-auto-reset.test.cjs internal/gui/tests/claude-usage-asked.test.cjs internal/gui/tests/claude-risk.test.cjs internal/gui/tests/library-toggle-status.test.cjs internal/gui/tests/library-all.test.cjs internal/gui/tests/routing-reroute-title.test.cjs internal/gui/tests/plain-names.test.cjs internal/gui/tests/panel-effort.test.cjs internal/gui/tests/agent-models-all-hidden.test.cjs internal/gui/tests/omp-roles.test.cjs internal/gui/tests/claude-effort.test.cjs internal/gui/tests/remote-magpie.test.cjs internal/gui/tests/qoder-cn-tile.test.cjs internal/gui/tests/provider-levels-scroll.test.cjs internal/gui/tests/signin-paste-codex.test.cjs internal/gui/tests/usage-alert.test.cjs internal/gui/tests/quota-families.test.cjs internal/gui/tests/provider-search.test.cjs internal/gui/tests/webdav-settings-refresh.test.cjs internal/gui/tests/lan-docker.test.cjs internal/gui/tests/plugin-key-hint.test.cjs internal/gui/tests/update-hide.test.cjs internal/gui/tests/tray-logos.test.cjs internal/gui/tests/routing-proxy.test.cjs internal/gui/tests/sse-body.test.cjs internal/gui/tests/remote-group-served.test.cjs internal/gui/tests/plugin-move-overflow.test.cjs internal/gui/tests/routing-time.test.cjs internal/gui/tests/unlisted-models.test.cjs internal/gui/tests/plugin-updates.test.cjs internal/gui/tests/request-archive.test.cjs internal/gui/tests/group-member-fast.test.cjs internal/gui/tests/providers-off-fold.test.cjs ``` If Playwright is installed outside the repository, set `NODE_PATH` to the diff --git a/internal/gui/tests/providers-off-fold.test.cjs b/internal/gui/tests/providers-off-fold.test.cjs new file mode 100644 index 00000000..25fbc5a0 --- /dev/null +++ b/internal/gui/tests/providers-off-fold.test.cjs @@ -0,0 +1,120 @@ +// Run with Node's test runner and Playwright on the module path; see README.md. +// Providers switched off are kept apart (01huadalang on Discord: 已关闭的 +// 供应商还占着位置): the list holds the ones on, and the ones off sit below +// it under a "Turned off (N)" fold, folded at first. The fold opens and +// closes on a click that leaves the page where it is, and is remembered +// across a reload; a provider switched off moves under it. 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 provider = (i, off) => ({ + id: "p" + i, name: "Provider " + i, icon: "generic", chat: "https://p" + i + ".example.com/v1", responses: "", anthropic: "", catalog: "", + host: "p" + i + ".example.com", models: [{ id: "model-" + i, name: "Model " + i, on: true }], agents: [], fallback: [], headers: {}, + key: { set: true, masked: "sk-…" + i }, keyList: [], balanceToken: { takes: false, set: false }, proxy: "", off, +}); +const OFF = new Set([1, 5, 9]); + +function serve(lang) { + const list = Array.from({ length: 12 }, (_, i) => provider(i, OFF.has(i))); + const providers = () => ({ providers: list, presets: [], excluded: [], gateway: { running: true, window: true } }); + const state = { agents: [], profiles: [], settings: { lang, theme: "light" } }; + 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(state); + if (url.pathname === "/api/providers") return json(providers()); + if (url.pathname === "/api/plugins") return json({ plugins: [] }); + if (url.pathname === "/api/groups") return json({ groups: [] }); + if (url.pathname === "/api/provider/off" || url.pathname === "/api/provider/on") { + const { id } = route.request().postDataJSON(); + for (const p of list) if (p.id === id) p.off = url.pathname.endsWith("/off"); + return json(providers()); + } + 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: "Turned off", zh: "已关闭的供应商" }; +const top = (page, sel) => page.locator(sel).evaluate((e) => e.getBoundingClientRect().top); +const ids = (page, sel) => page.locator(sel + " > .row.provider").evaluateAll((rs) => rs.map((r) => r.dataset.id)); + +for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) { + for (const lang of ["en", "zh"]) { + test(`${engine} ${lang}: providers switched off fold away below the ones on`, async (t) => { + const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" })); + const page = await (await browser.newContext({ viewport: { width: 900, height: 560 }, 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}-providers-off-fold.png`) }); + } + await browser.close(); + }); + page.setDefaultTimeout(5000); + const errors = []; + page.on("pageerror", (e) => errors.push(e.message)); + await page.route("**/*", serve(lang)); + + await page.goto("http://magpie.test/?view=providers"); + await page.locator("#providers .row.provider").first().waitFor(); + // the list holds the ones on; the ones off are under the fold, folded + assert.deepEqual(await ids(page, "#providers"), ["p0", "p2", "p3", "p4", "p6", "p7", "p8", "p10", "p11"]); + const fold = page.locator("#foldOff"); + assert(await fold.isVisible(), "the fold shows"); + assert.equal((await fold.locator(".label").textContent()).trim(), words[lang]); + assert.equal((await page.locator("#offCount").textContent()).trim(), "3"); + assert.equal(await fold.getAttribute("aria-expanded"), "false", "folded at first"); + assert(await page.locator("#offProviders").evaluate((l) => l.hidden), "folded, the ones off are hidden"); + const listBottom = await page.locator("#providers").evaluate((e) => e.getBoundingClientRect().bottom); + assert(await top(page, "#offHead") >= listBottom, "the fold is below the list"); + + // opened with the page scrolled to it, the head stays where it is + const view = page.locator("#view-providers"); + await page.mouse.move(450, 200); + await page.mouse.wheel(0, 400); + await page.waitForTimeout(400); + const scrolled = await view.evaluate((v) => v.scrollTop); + assert(scrolled > 20, "the page scrolled to the fold"); + const was = await top(page, "#foldOff"); + await fold.click(); + await page.waitForTimeout(400); + assert.equal(await fold.getAttribute("aria-expanded"), "true"); + assert.deepEqual(await ids(page, "#offProviders"), ["p1", "p5", "p9"]); + assert(await page.locator("#offProviders").isVisible()); + assert(Math.abs(await top(page, "#foldOff") - was) <= 1, "opening moved the head"); + assert(Math.abs(await view.evaluate((v) => v.scrollTop) - scrolled) <= 1, "opening scrolled the page"); + + // remembered across a reload + await page.reload(); + await page.locator("#providers .row.provider").first().waitFor(); + assert.equal(await page.locator("#foldOff").getAttribute("aria-expanded"), "true", "the fold is remembered"); + assert.deepEqual(await ids(page, "#offProviders"), ["p1", "p5", "p9"]); + + // a provider switched off moves under it + await page.locator('#providers .row.provider[data-id="p3"] .pswitch').click(); + await page.waitForFunction(() => document.querySelector('#offProviders .row.provider[data-id="p3"]')); + assert.equal((await page.locator("#offCount").textContent()).trim(), "4"); + assert.equal(await page.locator('#providers .row.provider[data-id="p3"]').count(), 0); + + // and folds again + await page.mouse.move(450, 200); + await page.mouse.wheel(0, 200); + await page.waitForTimeout(400); + await page.locator("#foldOff").click(); + await page.waitForTimeout(300); + assert.equal(await page.locator("#foldOff").getAttribute("aria-expanded"), "false"); + assert(await page.locator("#offProviders").evaluate((l) => l.hidden)); + assert.deepEqual(errors, []); + }); + } +} diff --git a/internal/provider/commandcode_free_test.go b/internal/provider/commandcode_free_test.go new file mode 100644 index 00000000..88e10908 --- /dev/null +++ b/internal/provider/commandcode_free_test.go @@ -0,0 +1,81 @@ +package provider + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" +) + +// Command Code's CLI marks Space Bunny Alpha FREE, as it does Ling 3.1 +// Flash (01huadalang on Discord): magpie's chips showed 免费 on Ling only, +// whose id says :free, as Command Code's list says nothing of it. The +// models the CLI calls free are marked so, on Go's list and on a keyed +// plan's, before a fetch and after. +func TestCommandCodeFreeModels(t *testing.T) { + list, err := os.ReadFile(filepath.Join("testdata", "commandcode_models.json")) + if err != nil { + t.Fatal(err) + } + for _, plan := range []string{"individual-go-monthly", "individual-max-monthly"} { + t.Run(plan, func(t *testing.T) { + home := signIn(t) + writeFile(t, filepath.Join(home, ".commandcode", "auth.json"), map[string]any{"apiKey": "cc-key", "userName": "ccuser"}) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/provider/v1/models": + _, _ = w.Write(list) + case "/alpha/billing/subscriptions": + _, _ = w.Write([]byte(`{"success":true,"data":{"planId":"` + plan + `","status":"active"}}`)) + default: + w.WriteHeader(404) + } + })) + defer srv.Close() + oldAPI := cmdAPI + cmdAPI = srv.URL + defer func() { cmdAPI = oldAPI }() + cmdPlansSeen.Lock() + cmdPlansSeen.m = map[string]cmdSeen{} + cmdPlansSeen.Unlock() + + p, found := find(All(), CommandCodePlanID) + if !found { + t.Fatal("no Command Code account") + } + CommandCodeGenerate(context.Background(), p) // the plan read + if plan == "individual-go-monthly" { + p, _ = find(All(), CommandCodePlanID) + free := map[string]bool{} + for _, m := range p.Available() { + free[m.ID] = m.Free + } + if !free["stealth/space-bunny-alpha"] || !free["inclusionai/ling-3.1-flash:free"] { + t.Errorf("before a fetch: Space Bunny Alpha free %v, Ling 3.1 Flash free %v", free["stealth/space-bunny-alpha"], free["inclusionai/ling-3.1-flash:free"]) + } + } + if _, err := p.Fetch(context.Background()); err != nil { + t.Fatal(err) + } + p, _ = find(All(), CommandCodePlanID) + free, seen := map[string]bool{}, map[string]bool{} + for _, m := range p.Available() { + free[m.ID], seen[m.ID] = m.Free, true + } + for _, id := range []string{"stealth/space-bunny-alpha", "inclusionai/ling-3.1-flash:free", "poolside/laguna-s-2.1-free"} { + if !seen[id] { + t.Errorf("%s not listed", id) + } else if !free[id] { + t.Errorf("%s not marked free", id) + } + } + for _, id := range []string{"deepseek/deepseek-v4-flash", "thinkingmachines/inkling"} { + if free[id] { + t.Errorf("%s marked free", id) + } + } + }) + } +} diff --git a/internal/provider/commandcode_plan.go b/internal/provider/commandcode_plan.go index 61420ab0..30feda22 100644 --- a/internal/provider/commandcode_plan.go +++ b/internal/provider/commandcode_plan.go @@ -168,9 +168,34 @@ func cmdGoFetch(ctx context.Context) ([]catalog.Model, error) { if len(out) == 0 { return nil, errors.New("Command Code listed no models for the Go plan") } + cmdMarkFree(out) return out, catalog.SaveLive(CommandCodePlanID, base, out) } +// cmdFree are the models Command Code's CLI marks FREE in its picker +// (1.73.2: badge:"free", "{name} is free and uses shared capacity"); its +// API's list doesn't say so, and Space Bunny Alpha's and Pixel Canary's +// ids name nothing free. Neither says a model is served at a discount. +var cmdFree = map[string]bool{ + "stealth/space-bunny-alpha": true, "stealth/pixel-canary": true, + "poolside/laguna-s-2.1-free": true, "inclusionai/ling-3.0-flash-free": true, + "inclusionai/ling-3.0-flash-sante:free": true, "inclusionai/ling-3.1-flash:free": true, + "MiniMaxAI/MiniMax-M3-Free": true, "minimax/minimax-m3-free": true, + "minimax/minimax-m2.7-free": true, "meituan/LongCat-2.0:free": true, "tencent/Hy3": true, +} + +// cmdMarkFree marks the models of ms the CLI calls free, in place. +func cmdMarkFree(ms []catalog.Model) []catalog.Model { + for i := range ms { + if cmdFree[ms[i].ID] { + ms[i].Free = true + } + } + return ms +} + +func init() { cmdMarkFree(cmdGoModels) } + // cmdAuth is an account's key, as auth.json and the sign-in name it. type cmdAuth struct { APIKey string `json:"apiKey"` @@ -303,7 +328,12 @@ func cmdProvider(who, plan string, a cmdAuth) Provider { } keyed := p keyed.Account, keyed.Key = nil, a.APIKey - return keyed.Fetch(ctx) + ms, base, err := keyed.fetchOne(keyed.Via(ctx)) + if err != nil { + return nil, err + } + cmdMarkFree(ms) + return catalog.Chat(ms), catalog.SaveLive(p.ID, base, ms) } acct.generate = func(ctx context.Context) (string, bool) { return a.APIKey, cmdPlanNow(ctx, a, plan) == "Go"