Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 一格都没存**
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
38 changes: 33 additions & 5 deletions src/monkeycode/catalog.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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: "目录缓存写不进去" }
}
34 changes: 14 additions & 20 deletions src/monkeycode/runtime.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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"

/**
Expand Down Expand Up @@ -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 也能立刻变好看,不用重拉目录。
Expand Down Expand Up @@ -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 填同一份(那一格是
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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 {
Expand Down
8 changes: 3 additions & 5 deletions src/monkeycode/sso-import.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 同一套约定)。 */
Expand Down Expand Up @@ -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,
Expand Down
20 changes: 12 additions & 8 deletions src/sites.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 把
* `<a>/<b>` 读成「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],
Expand Down
16 changes: 13 additions & 3 deletions tests/multi-provider.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})

Expand Down
Loading