From d26ba6060cd3931d33715cd2840e2cbfbb710681 Mon Sep 17 00:00:00 2001 From: MYCF711 Date: Wed, 4 Nov 2026 06:54:53 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=A8=A1=E5=9E=8B=E5=88=97=E8=A1=A8?= =?UTF-8?q?=E5=8F=AA=E5=88=97=E8=BF=99=E4=B8=AA=E8=B4=A6=E5=8F=B7=E5=BD=93?= =?UTF-8?q?=E5=89=8D=E8=83=BD=E8=B0=83=E7=9A=84=EF=BC=880.2.37=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户反馈:「basic 额度 pro 额度 ultra 额度还有积分计费,怎么这么多,直接捕获 账号当前可用的模型就行了」。 - 登录成功那一刻就 await warmCatalog() 把平台目录落盘:magpie 紧接着会调 provider.models,而那里只读缓存;缓存空着就只能退回内置快照(一张跟账号无关 的通用表)。国际站实测:全站 45 条,这个账号只回 4 条 - 内置快照 9 条 -> 4 条,去掉 monkeycode-basic/pro/ultra 三档重复项,只留无前缀 (按积分计费)的兜底 - 默认不再用 max_tokens:1 探测筛模型(会误杀慢的/被限流的,且白烧额度); probe 保留成显式开关 - configModels 不再按 usable 二次过滤,平台目录原样列出 - 测试:内置快照不许出现档位前缀 --- CHANGELOG.md | 9 +++++++++ package.json | 2 +- src/monkeycode/catalog.mjs | 38 ++++++++++++++++++++++++++++++----- src/monkeycode/runtime.mjs | 34 +++++++++++++------------------ src/monkeycode/sso-import.mjs | 8 +++----- src/sites.mjs | 20 ++++++++++-------- tests/multi-provider.test.mjs | 16 ++++++++++++--- 7 files changed, 85 insertions(+), 42 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 96583a6..d3aa496 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,15 @@ > 每个版本一节,**一条一行**,说用户能感知的变化。 > 格式与规矩见 [UPDATING.md](./UPDATING.md)。 +## 0.2.37 — 2026-11-04 + +**模型列表只列「你这个账号现在能调什么」,basic / pro / ultra / 积分四类混列没了** + +- 登录成功的**那一刻**就拉一次平台目录(`GET /api/v1/users/models`)并落盘 —— magpie 紧接着会调 `provider.models`,而那里只读缓存;缓存空着就只能退回内置快照,用户看到的是一张跟自己账号无关的通用表。国际站实测:全站 45 条,这个账号只回 4 条,那就只列 4 条 +- 内置快照从 9 条砍到 4 条,**去掉 basic / pro / ultra 三档的重复项**:那是同一个模型在不同额度池的各一条,把三档都写进兜底,basic 账号会看到一堆自己调不了的 ultra 条目(用户原话:「basic 额度 pro 额度 ultra 额度还有积分计费,怎么这么多,直接捕获账号当前可用的模型就行了」)。兜底只留无前缀(按积分计费)的四条,任何账号都能走 +- **默认不再用 `max_tokens: 1` 探测筛模型**:平台清单本身就是「这个账号能调什么」,再探一遍有两个坏处 —— 会误杀慢的 / 被限流的模型(探测失败 ≠ 不可用),而且每刷一次目录就打 N 个真请求白烧额度。探测保留成显式开关 `refreshCatalog(…, { probe: true, credential })` +- `configModels` 不再按 `usable` 二次过滤:平台给的目录原样列出 + ## 0.2.36 — 2026-11-04 **「已登录,但 magpie 无法列出它」的真因找到了:登录结果少了 `refresh`,magpie 一格都没存** diff --git a/package.json b/package.json index 941a190..9d6c437 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "magpie-monkeycode", - "version": "0.2.36", + "version": "0.2.37", "description": "MonkeyCode 国内站 / 国际站 + 百智云 magpie 插件:三家独立供应商,浏览器登录或 API Key 接入,网关签名与积分额度。", "type": "module", "main": "./index.mjs", diff --git a/src/monkeycode/catalog.mjs b/src/monkeycode/catalog.mjs index c8e8b1f..22b9c5d 100644 --- a/src/monkeycode/catalog.mjs +++ b/src/monkeycode/catalog.mjs @@ -294,14 +294,42 @@ export async function probeCatalog(credential, site, models, { concurrency = 4, /** * 拉一次并落盘(后台用)。永不抛错 —— 目录刷新失败不该打断任何用户操作。 + * + * ⚠ **默认不探测**(`probe !== true`):平台的 `GET /api/v1/users/models` 返回的 + * 本来就是「这个账号当前能调什么」(按权限 / 订阅过滤过的清单,2026-11-04 国际站 + * 实测:全站 45 条、这个账号只有 4 条)。再用 `probeModel` 二次筛一遍有两个坏处: + * + * 1. **会误杀**:探测发的是 `max_tokens: 1` 的最小请求,慢一点 / 被限流 / 上游对 + * 这个形状挑刺,都会让一条本来能用的模型被标成 `usable: false` 而不显示; + * 2. **每刷一次目录就打 N 个真请求**,白白消耗额度。 + * + * 所以可用性以**平台清单**为准。探测保留成显式开关(`{ probe: true, credential }`), + * 只在要复核「平台说了能调、实际 403」时才用。 */ export async function refreshCatalog(cookie, site, options = {}) { const result = await fetchCatalog(cookie, site, options) if (!result.ok) return false - // 有网关凭据就顺手探一遍可用性:目录说的是「上架了什么」, - // 这里才回答「你这个号能用什么」。 - const models = options.credential === undefined - ? result.models - : await probeCatalog(options.credential, site, result.models, options) + const models = options.probe === true && options.credential !== undefined + ? await probeCatalog(options.credential, site, result.models, options) + : result.models return writeCatalog(site.id, models) } + +/** + * 登录成功的那一刻就把目录落盘(调用方 `await`)。 + * + * 为什么必须在这一刻:magpie 紧接着会调 `provider.models`,而那里**只读缓存** + * (在钩子里发网络请求就会变成「已登录但 magpie 无法列出它」,见本文件顶部)。 + * 缓存没有内容时,`configModels` 只能退回内置快照 —— 用户看到的是一张跟自己 + * 账号无关的通用表(2026-11-04 用户反馈的「basic / pro / ultra / 积分怎么这么多」)。 + * + * 失败**不阻塞登录**:拉不到就退回内置快照,但要说得出原因。 + * + * @returns {Promise<{ ok: boolean, count?: number, error?: string }>} + */ +export async function warmCatalog(cookie, site, { fetchImpl = fetch, timeoutMs = 20_000 } = {}) { + 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: "目录缓存写不进去" } +} diff --git a/src/monkeycode/runtime.mjs b/src/monkeycode/runtime.mjs index 9ecd33e..4f60623 100644 --- a/src/monkeycode/runtime.mjs +++ b/src/monkeycode/runtime.mjs @@ -26,7 +26,7 @@ import { profileDirFor, readCookiesSilently, startBrowserLogin } from "../sessio import { signInWithBaizhiSession } from "./sso-import.mjs" import { readDesktopMonkeyCodeSession } from "./desktop-session.mjs" import { loginWithPassword } from "./password-login.mjs" -import { KNOWN_CONTEXT, isStale, prettyModelName, readCatalog, refreshCatalog } from "./catalog.mjs" +import { KNOWN_CONTEXT, isStale, prettyModelName, readCatalog, refreshCatalog, warmCatalog } from "./catalog.mjs" import { AGREE_PROTOCOL_JS } from "../baizhi/runtime.mjs" /** @@ -90,13 +90,10 @@ function configModels(site) { const cached = readCatalog(site.id) if (cached !== undefined && cached.models.length > 0) { const out = {} - // 只列**这个账号真的能用**的:探测过的条目带 usable,未探测(老缓存)视为可用。 - const visible = cached.models.filter((model) => model.usable !== false) - if (visible.length === 0) { - // 探测过但一条都不行 —— 别列 21 个点哪个都 403 的假选项。 - return {} - } - for (const model of visible) { + // 平台目录给的就是「这个账号能调什么」(按权限 / 订阅过滤过的),原样列出。 + // 不再拿 `usable` 二次筛选:那是 `max_tokens: 1` 的探测结论,会误杀慢的 / + // 被限流的模型,也会让账号看起来「模型怎么这么少」。 + for (const model of cached.models) { out[model.id] = { id: model.id, // 显示名在读取时算:老缓存里没有 displayName 也能立刻变好看,不用重拉目录。 @@ -271,6 +268,10 @@ async function signInWithBrowser(site, { fetchImpl, signal } = {}) { const cookie = credential.session_cookie const profile = await fetchProfile(cookie, site, { fetchImpl }) const nickname = profile?.nickname + // 登录成功的**这一刻**就把「这个账号能调什么」落盘:magpie 紧接着会调 + // `provider.models`,而那里只读缓存(钩子里发网络请求就会变成「已登录但 + // 无法列出它」)。失败不阻塞登录 —— 拉不到就退回内置快照。 + await warmCatalog(cookie, site, { fetchImpl }).catch(() => {}) return signInSuccess({ // MonkeyCode 的凭据是「一次性签发的静态材料」,没有 refresh_token —— // 整份 JSON 进 access;refresh 由 signInSuccess 填同一份(那一格是 @@ -382,6 +383,7 @@ export function makePlugin(site) { if (!issued.ok) { return { type: "failed", error: `登录成功,但换取网关凭据失败(${issued.error})` } } + await warmCatalog(session.cookie, site).catch(() => {}) const profile = await fetchProfile(session.cookie, site) return signInSuccess({ access: JSON.stringify({ @@ -461,6 +463,7 @@ export function makePlugin(site) { + "桌面版里的登录可能已经过期 —— 重新打开桌面版登录一次即可。", } } + await warmCatalog(desk.cookie, site).catch(() => {}) const profile = await fetchProfile(desk.cookie, site) return signInSuccess({ access: JSON.stringify({ @@ -701,20 +704,11 @@ export function makePlugin(site) { const auth = await getAuth() const credential = credentialOf(auth) if (credential === undefined) return { error: "未登录", windows: [] } - // 平台目录里一条能用的都没有 —— 这个号在平台侧就是空的(sub 没开/没进团队)。 - // 卡片上直说原因,比让用户去猜「为什么模型列表是空的」强。 - const catalog = readCatalog(site.id) - if (catalog !== undefined && catalog.models.length > 0 && catalog.models.every((model) => model.usable === false)) { - return { - error: `MonkeyCode 平台显示这个账号没有任何可用模型(${catalog.models.length} 条全部 403)` - + " —— 去平台开通额度 / 加入团队后再点重试", - windows: [], - } - } // 目录过期就后台刷一次(不 await:额度卡片的响应不能被目录拖慢)。 + // **不带凭据、不探测** —— 平台清单本身就是「这个账号能调什么」, + // 再拿 max_tokens:1 的探测筛一遍只会误杀能用的模型(见 catalog.mjs)。 if (isStale(readCatalog(site.id)) && credential.session_cookie !== undefined) { - // 带上网关凭据:刷新时顺带探测「这个号到底能用哪些模型」。 - refreshCatalog(credential.session_cookie, site, { credential }).catch(() => {}) + refreshCatalog(credential.session_cookie, site).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 64824a7..5a3fb2f 100644 --- a/src/monkeycode/sso-import.mjs +++ b/src/monkeycode/sso-import.mjs @@ -36,7 +36,7 @@ import { tmpdir } from "node:os" import { findBrowser } from "../browser-login.mjs" import { exchangeSessionForKey, fetchProfile } from "./credits.mjs" -import { refreshCatalog } from "./catalog.mjs" +import { warmCatalog } from "./catalog.mjs" import { parseCredential } from "../credential.mjs" /** magpie 的账号库位置(Windows / macOS / Linux 同一套约定)。 */ @@ -272,10 +272,8 @@ export async function signInWithBaizhiSession(baizhiCookie, site, { fetchImpl = const profile = await fetchProfile(cookieHeader, site, { fetchImpl, timeoutMs: 30_000 }) // 顺手把平台目录拉一次落盘,并探测「这个号到底能用哪些」: // 模型 id 是平台下发的 UUID(内置快照早对不上),而上架 ≠ 有权限。 - await refreshCatalog(cookieHeader, site, { - fetchImpl, - credential: { access_token: issued.apiKey, signing_secret: issued.signingSecret, session_cookie: cookieHeader }, - }).catch(() => {}) + // 登录成功就落盘目录(不探测:平台清单本身就是这个账号能调什么)。 + await warmCatalog(cookieHeader, site, { fetchImpl }).catch(() => {}) return { credential: { access_token: issued.apiKey, diff --git a/src/sites.mjs b/src/sites.mjs index 28efee6..d15b99f 100644 --- a/src/sites.mjs +++ b/src/sites.mjs @@ -27,20 +27,24 @@ const DEEPSEEK_CONTEXT = 1_048_576 /** * [magpie 的 id, 显示名, 上下文窗口(undefined = 不声明), 上游 id(与 id 不同时才写)] * + * ⚠ 这里只是**兜底快照**。真正该列的是「这个账号当前能调什么」,来源是登录后 + * `GET /api/v1/users/models` 的返回值(平台按账号权限 / 订阅给清单,2026-11-04 + * 国际站实测只回 4 条,而不是全站的 45 条)。快照只在「还没登录 / 目录一次都 + * 没拉到」时顶上,所以**刻意不带档位前缀**: + * + * - 带 `monkeycode-basic/` `monkeycode-pro/` `monkeycode-ultra/` 的是**同一个模型 + * 在不同额度池**里的各一条;把三档全写进兜底,basic 账号会看到一堆自己调不了的 + * ultra 条目(2026-11-04 用户反馈:「basic 额度 pro 额度 ultra 额度还有积分计费, + * 怎么这么多,直接捕获账号当前可用的模型就行了」); + * - 不带前缀的直接扣积分,是任何账号都能走的兜底口径。 + * * ⚠ **magpie 的模型 id 里不能出现 `/`**(2026-11-03 真机踩到):magpie 把 * `/` 读成「provider a 的模型 b」,于是 `monkeycode-basic/deepseek-flash` * 这类上游 id 会凭空长出 `monkeycode-basic` / `monkeycode-pro` / `monkeycode-ultra` * 三个不存在的供应商,卡片的模型列表整个对不上(界面提示「已登录,但 magpie - * 无法列出它」)。 - * - * 所以 magpie 里用连字符压平的 id,上游真实 id 交给 `apiId` —— 发请求时用它。 + * 无法列出它」)。所以 magpie 里用连字符压平的 id,上游真实 id 交给 `apiId`。 */ const MODELS = [ - ["monkeycode-basic-deepseek-flash", "DeepSeek Flash(basic 额度)", DEEPSEEK_CONTEXT, "monkeycode-basic/deepseek-flash"], - ["monkeycode-basic-deepseek-v4-flash", "DeepSeek V4 Flash(basic 额度)", DEEPSEEK_CONTEXT, "monkeycode-basic/deepseek-v4-flash"], - ["monkeycode-pro-deepseek-flash", "DeepSeek Flash(pro 额度)", DEEPSEEK_CONTEXT, "monkeycode-pro/deepseek-flash"], - ["monkeycode-pro-deepseek-v4-pro", "DeepSeek V4 Pro(pro 额度)", DEEPSEEK_CONTEXT, "monkeycode-pro/deepseek-v4-pro"], - ["monkeycode-ultra-deepseek-v4-pro", "DeepSeek V4 Pro(ultra 额度)", DEEPSEEK_CONTEXT, "monkeycode-ultra/deepseek-v4-pro"], ["deepseek-flash", "DeepSeek Flash(按积分计费)", DEEPSEEK_CONTEXT], ["deepseek-v4-flash", "DeepSeek V4 Flash(按积分计费)", DEEPSEEK_CONTEXT], ["deepseek-v4-pro", "DeepSeek V4 Pro(按积分计费)", DEEPSEEK_CONTEXT], diff --git a/tests/multi-provider.test.mjs b/tests/multi-provider.test.mjs index d890d2a..a1cb65a 100644 --- a/tests/multi-provider.test.mjs +++ b/tests/multi-provider.test.mjs @@ -49,9 +49,19 @@ test("三个插件各自只注册自己的 provider", async () => { assert.equal(cfg.provider.monkeycode.api, "https://proxy.monkeycode-ai.com/v1") assert.equal(cfg.provider.monkeycodeintl.api, "https://proxy.monkeycode-ai.net/v1") assert.equal(cfg.provider.baizhi.api, "https://ai-models.app.baizhi.cloud/api/anthropic/v1") - // 模型数取决于「平台目录缓存有没有落盘」:内置快照 9 条是兜底, - // 登录后拉到的真实目录(21 条)优先。这里只要求非空、且不写死数字。 - assert.ok(Object.keys(cfg.provider.monkeycode.models).length >= 9) + // 模型数取决于「平台目录缓存有没有落盘」:内置快照 4 条是兜底, + // 登录后拉到的是**这个账号的**真实目录(平台按权限给,国际站实测 4 条)优先。 + // 这里只要求非空、且不写死具体数字。 + assert.ok(Object.keys(cfg.provider.monkeycode.models).length >= 4) + // 兜底**不许带档位前缀**:basic / pro / ultra 三档各一条会让账号看到自己调不了的 + // 模型(用户原话:「basic 额度 pro 额度 ultra 额度还有积分计费,怎么这么多」)。 + for (const id of Object.keys(cfg.provider.monkeycode.models)) { + assert.equal( + /^monkeycode-(basic|pro|ultra)-/.test(id), + false, + `内置快照不该列档位条目:${id}`, + ) + } assert.equal(Object.keys(cfg.provider.baizhi.models).length, 15) })