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.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 / 积分四类混列没了**
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.37",
"version": "0.2.38",
"description": "MonkeyCode 国内站 / 国际站 + 百智云 magpie 插件:三家独立供应商,浏览器登录或 API Key 接入,网关签名与积分额度。",
"type": "module",
"main": "./index.mjs",
Expand Down
52 changes: 45 additions & 7 deletions src/monkeycode/catalog.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

/**
* 拉一次并落盘(后台用)。永不抛错 —— 目录刷新失败不该打断任何用户操作。
*
Expand All @@ -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)
}

Expand All @@ -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: "目录缓存写不进去" }
}
24 changes: 24 additions & 0 deletions src/monkeycode/credits.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 }`。
*
Expand Down
24 changes: 19 additions & 5 deletions src/monkeycode/runtime.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 填同一份(那一格是
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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 {
Expand Down
5 changes: 3 additions & 2 deletions src/monkeycode/sso-import.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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,
Expand Down
19 changes: 19 additions & 0 deletions tests/catalog.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
})
Loading