From 5145088f78e02e692d43d80a1de8c9427281ad6e Mon Sep 17 00:00:00 2001 From: MYCF711 Date: Wed, 4 Nov 2026 07:03:13 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8C=89=E8=AE=A2=E9=98=85=E6=A1=A3?= =?UTF-8?q?=E4=BD=8D=E6=94=B6=E7=AA=84=E6=A8=A1=E5=9E=8B=E7=9B=AE=E5=BD=95?= =?UTF-8?q?=EF=BC=88basic=20=E8=B4=A6=E5=8F=B7=E4=B8=8D=E5=86=8D=E7=9C=8B?= =?UTF-8?q?=E5=88=B0=20pro/ultra=20=E6=B1=A0=EF=BC=89=EF=BC=880.2.38?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 平台把同一个模型的 basic / pro / ultra 三个额度池都列出来,basic 账号拿到 pro / ultra 那几条,选了就是 403 —— 这正是用户说的「怎么这么多」。 - credits.mjs 新增 resolveTier():先看 /users/subscription 的 plan, 拿不到再用钱包的 daily_token_limit 反推(10M/100M/300M),都不中就 undefined - catalog.mjs 新增 filterByTier():无前缀(积分)与 basic 池谁都能用, pro 要 pro 及以上,ultra 只有 ultra;档位未知 / 收窄后为空都原样返回 - warmCatalog / refreshCatalog / SSO 导入全部带上 tier - 实测:国际站 basic 账号 4 条 -> 2 条;国内站 21 条 -> 13 条 - 测试:filterByTier 四档 + 未知档位 + 空结果兜底 --- CHANGELOG.md | 9 ++++++ package.json | 2 +- src/monkeycode/catalog.mjs | 52 ++++++++++++++++++++++++++++++----- src/monkeycode/credits.mjs | 24 ++++++++++++++++ src/monkeycode/runtime.mjs | 24 ++++++++++++---- src/monkeycode/sso-import.mjs | 5 ++-- tests/catalog.test.mjs | 19 +++++++++++++ 7 files changed, 120 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d3aa496..c71badc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,15 @@ > 每个版本一节,**一条一行**,说用户能感知的变化。 > 格式与规矩见 [UPDATING.md](./UPDATING.md)。 +## 0.2.38 — 2026-11-04 + +**模型列表再按订阅档位收窄:basic 账号不再看到 pro / ultra 的池** + +- 平台把同一个模型的 `basic` / `pro` / `ultra` 三个额度池各列一条,basic 账号拿到 pro / ultra 那几条,选了就是 403。现在登录时先推档位(`/users/subscription` 的 `plan`,拿不到就用钱包的 `daily_token_limit` 反推 10M / 100M / 300M),只留该档及以下的池 + 无前缀(积分)条目 +- 实测:**国际站 basic 账号 4 条 → 2 条**(platform 给的 basic/glm 与 basic/deepseek-flash),**国内站 21 条 → 13 条**(5 basic + 8 积分) +- 档位拿不到就不收窄;收窄后一条不剩也原样返回 —— 宁可多列,也不把列表清空 +- `resolveTier()`(credits.mjs)+ `filterByTier()`(catalog.mjs)+ 单测四档 / 未知档位 / 空结果兜底 + ## 0.2.37 — 2026-11-04 **模型列表只列「你这个账号现在能调什么」,basic / pro / ultra / 积分四类混列没了** diff --git a/package.json b/package.json index 9d6c437..af2f487 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "magpie-monkeycode", - "version": "0.2.37", + "version": "0.2.38", "description": "MonkeyCode 国内站 / 国际站 + 百智云 magpie 插件:三家独立供应商,浏览器登录或 API Key 接入,网关签名与积分额度。", "type": "module", "main": "./index.mjs", diff --git a/src/monkeycode/catalog.mjs b/src/monkeycode/catalog.mjs index 22b9c5d..210c5d6 100644 --- a/src/monkeycode/catalog.mjs +++ b/src/monkeycode/catalog.mjs @@ -292,6 +292,39 @@ export async function probeCatalog(credential, site, models, { concurrency = 4, return out } +/** + * 订阅档位 → 能用的额度池。数字越大越贵。 + * + * 平台的目录是**按账号给的一份清单**,但它把同一个模型的三个额度池都列了 + * (`monkeycode-basic/x` `monkeycode-pro/x` `monkeycode-ultra/x`)。basic 账号拿到 + * pro / ultra 那几条,选了就是 403 —— 用户看到的是「怎么这么多」,挨个试才发现 + * 一半用不了。所以按账号档位收窄:低档只能吃低档的池。 + */ +const TIER_ORDER = Object.freeze({ basic: 0, pro: 1, ultra: 2 }) + +/** + * 按账号档位过滤目录条目。 + * + * 规则:无前缀(按积分计费)与 `monkeycode-basic/` 谁都能用;`monkeycode-pro/` + * 要 pro 及以上;`monkeycode-ultra/` 只有 ultra。 + * + * 档位未知(拿不到订阅、也没有可信的每日额度)时**原样返回** —— 不做无根据的 + * 收窄;过滤后为空也原样返回(宁可多列,也不把列表清空)。 + * + * @param {Array<{ apiId?: string }>} models + * @param {string} [tier] `basic` | `pro` | `ultra` + */ +export function filterByTier(models, tier) { + const cap = TIER_ORDER[tier] + if (cap === undefined) return models + const kept = models.filter((model) => { + const hit = /^monkeycode-(basic|pro|ultra)\//.exec(String(model?.apiId ?? "")) + if (hit === null) return true + return TIER_ORDER[hit[1]] <= cap + }) + return kept.length > 0 ? kept : models +} + /** * 拉一次并落盘(后台用)。永不抛错 —— 目录刷新失败不该打断任何用户操作。 * @@ -303,15 +336,16 @@ export async function probeCatalog(credential, site, models, { concurrency = 4, * 这个形状挑刺,都会让一条本来能用的模型被标成 `usable: false` 而不显示; * 2. **每刷一次目录就打 N 个真请求**,白白消耗额度。 * - * 所以可用性以**平台清单**为准。探测保留成显式开关(`{ probe: true, credential }`), - * 只在要复核「平台说了能调、实际 403」时才用。 + * 所以可用性以**平台清单 + 账号档位**为准。探测保留成显式开关 + * (`{ probe: true, credential }`),只在要复核「平台说了能调、实际 403」时才用。 */ export async function refreshCatalog(cookie, site, options = {}) { const result = await fetchCatalog(cookie, site, options) if (!result.ok) return false + const narrowed = filterByTier(result.models, options.tier) const models = options.probe === true && options.credential !== undefined - ? await probeCatalog(options.credential, site, result.models, options) - : result.models + ? await probeCatalog(options.credential, site, narrowed, options) + : narrowed return writeCatalog(site.id, models) } @@ -323,13 +357,17 @@ export async function refreshCatalog(cookie, site, options = {}) { * 缓存没有内容时,`configModels` 只能退回内置快照 —— 用户看到的是一张跟自己 * 账号无关的通用表(2026-11-04 用户反馈的「basic / pro / ultra / 积分怎么这么多」)。 * + * `tier` 传账号的订阅档位(由 `resolveTier()` 推出来):给了就按档位收窄, + * 拿不到就按平台清单原样落盘。 + * * 失败**不阻塞登录**:拉不到就退回内置快照,但要说得出原因。 * * @returns {Promise<{ ok: boolean, count?: number, error?: string }>} */ -export async function warmCatalog(cookie, site, { fetchImpl = fetch, timeoutMs = 20_000 } = {}) { +export async function warmCatalog(cookie, site, { fetchImpl = fetch, timeoutMs = 20_000, tier } = {}) { const result = await fetchCatalog(cookie, site, { fetchImpl, timeoutMs }) if (!result.ok) return { ok: false, error: result.error } - const written = writeCatalog(site.id, result.models) - return written ? { ok: true, count: result.models.length } : { ok: false, error: "目录缓存写不进去" } + const models = filterByTier(result.models, tier) + const written = writeCatalog(site.id, models) + return written ? { ok: true, count: models.length } : { ok: false, error: "目录缓存写不进去" } } diff --git a/src/monkeycode/credits.mjs b/src/monkeycode/credits.mjs index 7305f53..6ac1d6d 100644 --- a/src/monkeycode/credits.mjs +++ b/src/monkeycode/credits.mjs @@ -163,6 +163,30 @@ export async function fetchPlan(cookie, site, { fetchImpl = fetch, timeoutMs = 1 return plan === "basic" || plan === "pro" || plan === "ultra" ? plan : undefined } +/** + * 推断账号的订阅档位:`basic` / `pro` / `ultra`,拿不到就是 `undefined`。 + * + * 两级来源,按可信度排: + * + * 1. `GET /api/v1/users/subscription` 的 `plan`(明确的说法,最可信); + * 2. 该端点不给 plan 时(2026-11-04 国内站实测返回 undefined),用钱包的 + * `daily_token_limit` 反推 —— 三档的日额度是固定的 10M / 100M / 300M, + * 见 `PLAN_DAILY_LIMIT`。数值对不上就不猜。 + * + * 用途只有一个:把模型目录收窄到这个账号真能吃的额度池(`filterByTier`)。 + * 拿不到档位时**宁可不过滤**,也不要把列表清空。 + */ +export async function resolveTier(cookie, site, { fetchImpl = fetch } = {}) { + const plan = await fetchPlan(cookie, site, { fetchImpl }) + if (plan !== undefined) return plan + const wallet = await fetchWallet(cookie, site, { fetchImpl }) + if (!wallet.ok) return undefined + const limit = wallet.dailyTokenLimit + if (typeof limit !== "number" || !Number.isFinite(limit)) return undefined + const hit = Object.entries(PLAN_DAILY_LIMIT).find(([, daily]) => daily === limit) + return hit?.[0] +} + /** * 读签到状态:`GET /api/v1/users/wallet/checkin` → `{ checked_in: boolean }`。 * diff --git a/src/monkeycode/runtime.mjs b/src/monkeycode/runtime.mjs index 4f60623..b49a1ac 100644 --- a/src/monkeycode/runtime.mjs +++ b/src/monkeycode/runtime.mjs @@ -72,7 +72,7 @@ const LOGIN_AUTOPILOT_JS = `(() => { })()` import { - claimCheckin, cookieOf, exchangeSessionForKey, fetchPlan, fetchProfile, fetchWallet, PLAN_LABELS, + claimCheckin, cookieOf, exchangeSessionForKey, fetchPlan, fetchProfile, fetchWallet, PLAN_LABELS, resolveTier, } from "./credits.mjs" import { FALLBACK_SYSTEM, gatewayHeaders, isUsableCredential, parseBodyQuietly, parseCredential, signInSuccess, signingPrompt } from "./signing.mjs" @@ -159,6 +159,17 @@ function builtinModels(site) { return out } +/** + * 登录成功后把「这个账号能用哪些模型」落盘:先推出订阅档位,再按档位收窄目录。 + * + * 档位拿不到就不收窄(`filterByTier` 原样返回);整条链路失败也不阻塞登录 —— + * 最差退回内置快照。 + */ +async function warmSignInCatalog(cookie, site, fetchImpl) { + const tier = await resolveTier(cookie, site, { fetchImpl }).catch(() => undefined) + return warmCatalog(cookie, site, { fetchImpl, tier }).catch(() => {}) +} + /** 把凭据的 JSON 字符串解析成凭据对象;不可用时返回 undefined。 */ function credentialOf(auth) { if (!auth) return undefined @@ -271,7 +282,7 @@ async function signInWithBrowser(site, { fetchImpl, signal } = {}) { // 登录成功的**这一刻**就把「这个账号能调什么」落盘:magpie 紧接着会调 // `provider.models`,而那里只读缓存(钩子里发网络请求就会变成「已登录但 // 无法列出它」)。失败不阻塞登录 —— 拉不到就退回内置快照。 - await warmCatalog(cookie, site, { fetchImpl }).catch(() => {}) + await warmSignInCatalog(cookie, site, fetchImpl) return signInSuccess({ // MonkeyCode 的凭据是「一次性签发的静态材料」,没有 refresh_token —— // 整份 JSON 进 access;refresh 由 signInSuccess 填同一份(那一格是 @@ -383,7 +394,7 @@ export function makePlugin(site) { if (!issued.ok) { return { type: "failed", error: `登录成功,但换取网关凭据失败(${issued.error})` } } - await warmCatalog(session.cookie, site).catch(() => {}) + await warmSignInCatalog(session.cookie, site) const profile = await fetchProfile(session.cookie, site) return signInSuccess({ access: JSON.stringify({ @@ -463,7 +474,7 @@ export function makePlugin(site) { + "桌面版里的登录可能已经过期 —— 重新打开桌面版登录一次即可。", } } - await warmCatalog(desk.cookie, site).catch(() => {}) + await warmSignInCatalog(desk.cookie, site) const profile = await fetchProfile(desk.cookie, site) return signInSuccess({ access: JSON.stringify({ @@ -708,7 +719,10 @@ export function makePlugin(site) { // **不带凭据、不探测** —— 平台清单本身就是「这个账号能调什么」, // 再拿 max_tokens:1 的探测筛一遍只会误杀能用的模型(见 catalog.mjs)。 if (isStale(readCatalog(site.id)) && credential.session_cookie !== undefined) { - refreshCatalog(credential.session_cookie, site).catch(() => {}) + // 沿用同一套口径:先推档位,再按档位收窄,否则刷新一次又变回「四类混列」。 + resolveTier(credential.session_cookie, site) + .then((tier) => refreshCatalog(credential.session_cookie, site, { tier })) + .catch(() => {}) } const key = credential.user_id ?? credential.access_token try { diff --git a/src/monkeycode/sso-import.mjs b/src/monkeycode/sso-import.mjs index 5a3fb2f..60149c0 100644 --- a/src/monkeycode/sso-import.mjs +++ b/src/monkeycode/sso-import.mjs @@ -35,7 +35,7 @@ import { createServer } from "node:net" import { tmpdir } from "node:os" import { findBrowser } from "../browser-login.mjs" -import { exchangeSessionForKey, fetchProfile } from "./credits.mjs" +import { exchangeSessionForKey, fetchProfile, resolveTier } from "./credits.mjs" import { warmCatalog } from "./catalog.mjs" import { parseCredential } from "../credential.mjs" @@ -273,7 +273,8 @@ export async function signInWithBaizhiSession(baizhiCookie, site, { fetchImpl = // 顺手把平台目录拉一次落盘,并探测「这个号到底能用哪些」: // 模型 id 是平台下发的 UUID(内置快照早对不上),而上架 ≠ 有权限。 // 登录成功就落盘目录(不探测:平台清单本身就是这个账号能调什么)。 - await warmCatalog(cookieHeader, site, { fetchImpl }).catch(() => {}) + const tier = await resolveTier(cookieHeader, site, { fetchImpl }).catch(() => undefined) + await warmCatalog(cookieHeader, site, { fetchImpl, tier }).catch(() => {}) return { credential: { access_token: issued.apiKey, diff --git a/tests/catalog.test.mjs b/tests/catalog.test.mjs index 1f85745..cfa132d 100644 --- a/tests/catalog.test.mjs +++ b/tests/catalog.test.mjs @@ -112,3 +112,22 @@ test("签名 prompt:没有 system 的请求必须补 FALLBACK_SYSTEM(否则 const patched = JSON.stringify({ ...JSON.parse(bare), system: FALLBACK_SYSTEM }) assert.equal(signingPrompt(parseBodyQuietly(patched)), FALLBACK_SYSTEM) }) + +test("filterByTier:basic 账号只吃 basic 池与积分,够不着 pro / ultra", async () => { + const { filterByTier } = await import("../src/monkeycode/catalog.mjs") + const models = [ + { id: "b", apiId: "monkeycode-basic/deepseek-flash" }, + { id: "p", apiId: "monkeycode-pro/deepseek-flash" }, + { id: "u", apiId: "monkeycode-ultra/deepseek-v4-pro" }, + { id: "g", apiId: "glm-5" }, + ] + assert.deepEqual(filterByTier(models, "basic").map((m) => m.id), ["b", "g"]) + assert.deepEqual(filterByTier(models, "pro").map((m) => m.id), ["b", "p", "g"]) + assert.deepEqual(filterByTier(models, "ultra").map((m) => m.id), ["b", "p", "u", "g"]) + // 档位未知:不收窄(不做无根据的过滤)。 + assert.deepEqual(filterByTier(models, undefined).map((m) => m.id), ["b", "p", "u", "g"]) + assert.deepEqual(filterByTier(models, "free").map((m) => m.id), ["b", "p", "u", "g"]) + // 收窄后为空 → 原样返回(宁可多列,也不把列表清空)。 + const onlyPro = [{ id: "p", apiId: "monkeycode-pro/x" }] + assert.deepEqual(filterByTier(onlyPro, "basic").map((m) => m.id), ["p"]) +})