magicrew-auth 0.3.25 → 0.3.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -68,6 +68,7 @@ curl -s "https://usemagpie.ai/api/latest?channel=stable" | grep -o '"version":"[
68
68
  | 15 | 接 ZCode / Claude Code 系客户端,带工具的请求回 `400 {"error":{"message":"模型响应失败","code":4017}}` | **上游的 `additionalProperties` 只认布尔值,任何对象形态都拒** —— 不只是 `{}`,`{type:"string"}` 这种有约束的 schema 一样 400(两个模型逐行一致)。判据:同一请求只把该字段换成 `true` → 200。改写已在 `rewriteRequestBody` 里做(非布尔一律换 true;**这是降级放宽、会丢约束**,取舍见 internals 十七) | internals 十七 · `tests/body-rewrite.mjs` |
69
69
  | 16 | 网关路径(agent 从 magpie 的 `/v1` 进来)报 `502 did not stream: token无效`,而 provider 显示已登录 | auth 里没带 org → `accountOf()` 落 `"default"` → 读不到真实账号的 key 槽 → 只能用 `auth.access`,它过了 2 小时就 403。`accountFor()` 已在**恰好一个真实账号**时兜到它;**多账号不猜**(猜错就是拿别人的 key 扣别人的积分) | internals 十四 · 十五 · `tests/account-fallback.mjs` |
70
70
  | 17 | 单轮回 `400 ... tools.N.function.parameters/properties/functions/items: invalid JSON Schema` | 上游不接受**空 schema 对象**当 `items`:`items:{}` 必 400,`items:{description:"x"}` 却 200。判据:同一请求只删 `items:{}` → 200。改写已做(删 `items:{}` / `true`,**严格等价**;`false` 不动) | internals 十七 · `tests/body-rewrite.mjs` |
71
+ | 18 | 某个账号积分耗尽后整轮回 400「可用积分已不足」,其它有余额的账号一次没被问 | **上游用 HTTP 400 报额度耗尽,magpie 的 `retryable()` 400 分支只认 `quotaWords`(不含「积分」「充值」)** ⇒ `hw.failed()=false`,既不走 `!last && hw.failed()` 的换号分支,也不 rest 该账号。插件 `rewriteCreditRefusal()` 把这种 400/422 改写成 402 Payment Required(`tests/credit-refusal.mjs` 锁死);magpie 侧修复方案见 issue(`retryable()` 的 400 分支补 `creditWords`) | CHANGELOG 0.3.26 |
71
72
 
72
73
  **坑 14 的两个附带判据**(都实测过):
73
74
 
package/CHANGELOG.md CHANGED
@@ -1,5 +1,55 @@
1
1
  # 更新记录
2
2
 
3
+ ## 0.3.26 — 2026-10-28
4
+
5
+ **积分耗尽的 400 不再让整轮对话失败:改写成 402,让 magpie 换下一个账号**
6
+
7
+ ### 症状
8
+
9
+ 三个「超级麦吉」账号(余额 6,467 / 82 / 0 积分)接在同一个路由组里。余额 82 的账号
10
+ 被会话亲和(affinity)留在回复链上,一轮对话把它烧到负数,上游回:
11
+
12
+ ```
13
+ HTTP 400
14
+ 超级麦吉: 非常抱歉,当前对话需要调用大模型 / 付费工具,您的可用积分已不足。
15
+ 您可以点击右上角「用户头像」,通过「充值」通道补充积分,完成后即可继续对话。
16
+ ```
17
+
18
+ DSH 界面显示「本轮运行失败」,magpie 的 trace 里只有两次 try —— 都在
19
+ `magicrew@181****710`,余额 6,467 的 `magicrew`(189****051)**一次都没被问**。
20
+
21
+ ### 根因
22
+
23
+ magpie 的 `failure()` 已经能认出这条错误(`creditWords` 命中「充值」)并把
24
+ `try.Fail` 记成 `credit`;但 `retryable()` 的 `status == 400, 422` 分支只查
25
+ `quotaWords`,而 `quotaWords` 不含「积分」「充值」。于是:
26
+
27
+ ```
28
+ hw.failed() == false
29
+ ⇒ 走不到 `!last && hw.failed()` 的换账号分支
30
+ ⇒ 不 rest、不换号、直接把 400 交给客户端
31
+ ```
32
+
33
+ 账号于是永远留在轮换里,下一次请求继续撞同一个错误。
34
+
35
+ ### 修法(插件层)
36
+
37
+ `src/hooks/loader.mjs` 增加 `rewriteCreditRefusal()`:只在
38
+ **400/422 + 文案确认是「积分/余额不足」** 时把状态码改成 `402 Payment Required`,
39
+ body 原样保留。402 在 magpie 的 `retryable()` 里无条件可重试、`failure(402)` 也是
40
+ `failCredit`,因此会 rest 该账号 30 分钟并继续问同 provider 的下一个账号 / 组的下一个成员。
41
+
42
+ 边界钉死:schema 错误的 400、参数错误的 400、200/402 响应一律原样透传,不读成功流。
43
+
44
+ ### 验证
45
+
46
+ - `tests/credit-refusal.mjs`:18 个断言 —— 中文真实文案 / 英文 insufficient credits /
47
+ 余额不足 / schema 400 不命中 / 参数 400 不命中 / 402 与 200 不碰 / content-length 与
48
+ content-encoding 被丢掉 / **端到端**:拦全局 fetch,`makeLoader().fetch` 收到 400 积分
49
+ 不足时交给 magpie 的是 402,收到 schema 400 时仍是 400。
50
+ - `tests/body-rewrite.mjs` 36 通过、`tests/account-fallback.mjs` 10 通过。
51
+ - 两站共用同一 Go 服务与同一错误文案(AGENTS 坑 1),逻辑按站点无关实现。
52
+
3
53
  ## 0.3.25 — 2026-10-28
4
54
 
5
55
  **0.3.24 的 schema 扫描范围收窄:只走 schema 位置**
package/README.md CHANGED
@@ -27,6 +27,7 @@
27
27
  - **本机浏览器登过就直接认** —— 你平时那个 Edge / Chrome / Brave 里登过 magicrew,点登录时插件读它的 localStorage 接上:不开窗口、不用重登
28
28
  - **模型跟着账号走** —— 每次登录后按 `/api/v1/flows/models` 实时拉,账户当前能选什么就展示什么,生图和 embedding 不会混进来
29
29
  - **积分算得准** —— 余额读的是 `/api/v1/quota/points/total`;空号就显示空,不会被优先派活
30
+ - **积分耗尽自动换号** —— 上游用 HTTP 400 报「可用积分已不足」时,插件把它改写为语义正确的 **402 Payment Required**,magpie 会 rest 这个账号并继续问同 provider 的下一个账号 / 路由组的下一个成员,不再让整轮对话直接失败
30
31
  - **登录一次管 30 天** —— 网页 JWT 顶 30 天,`mgw_` api_key 每 2 小时自续,refresh_token 每次续期时自滚
31
32
  - **点一下,全自动** —— 插件自己拉起浏览器停在官方登录页,你在里面登(手机号 / 密码 / Google / 邮箱验证码 / 飞书 / 钉钉都行),登录完成那一刻它自己把凭据读走,不用回来点任何东西
32
33
  - **登录方式还有两条备选** —— 手机号+密码走 API;或者直接粘 JWT + 组织码
@@ -197,6 +198,8 @@ magpie plugin list
197
198
 
198
199
  **npm 上叫 `magicrew-auth`,为什么卡片显示 `Magicrew`?** 卡片标题取包内 `package.json` 的 `name`(`Magicrew`),而 npm 拒绝含大写字母的新包名。发布时用 `publish-npm.mjs` 临时换成 `magicrew-auth`、发完还原 —— 所以 npm 页面是小写包名,git 源装出来是 `Magicrew`。功能完全一样。
199
200
 
201
+ **一个账号积分用完了,会换到另一个还有积分的账号吗?** 会。0.3.26 起插件把上游的 400「可用积分已不足」改写成 402 Payment Required —— magpie 对 402 一律视为可重试:先 rest 这个账号 30 分钟,再问同 provider 的下一个账号;如果整个组都没有额度,才会把错误交给客户端。
202
+
200
203
  **为什么模型列表会变?** 因为清单是从 `/api/v1/flows/models` 实时拉的。订阅档位变了、平台方上下架变了,清单就跟着变 —— 插件里没有需要维护的模型表。
201
204
 
202
205
  **国际站 `/v1/chat/completions` 报 500,22 字节的纯文本?** 那是缺 `organization-code` 请求头。国内站不需要,国际站强制。插件按站点自动带。
package/docs/internals.md CHANGED
@@ -1185,3 +1185,60 @@ HTTP 400
1185
1185
  2. **插件宿主是运行时从 `~/.cache/magpie/bun/install-cache/` 加载的**,
1186
1186
  不在 `plugin-host/host-*.js` 里;magpie 更新插件后会换目录(目录名带 commit hash),
1187
1187
  要改当前实际加载的那个。
1188
+
1189
+ ---
1190
+
1191
+ ## 十八、上游 400「可用积分已不足」:插件改写成 402
1192
+
1193
+ ### 症状
1194
+
1195
+ 多账号(同 provider 三号,余额 6,467 / 82 / 0 积分)时,一个账号被会话亲和留在
1196
+ 回复链上,一轮把余额烧穿,上游回 HTTP **400**:
1197
+
1198
+ ```
1199
+ 超级麦吉: 非常抱歉,当前对话需要调用大模型 / 付费工具,您的可用积分已不足。
1200
+ 您可以点击右上角「用户头像」,通过「充值」通道补充积分,完成后即可继续对话。
1201
+ ```
1202
+
1203
+ DSH 显示「本轮运行失败」;trace 里只有两次 try(都在那个账号),余额 6,467 的账号一次没问。
1204
+
1205
+ ### 根因(magpie 0.1.1899 / origin main `69626580`)
1206
+
1207
+ `internal/gateway/routing.go` 的 `failure()` 能认出(`creditWords` 命中「充值」):
1208
+
1209
+ ```go
1210
+ creditWords = ...|余额|欠费|充值|账户.*(不足|停)
1211
+ case status == 402, creditWords.Match(body) && ...:
1212
+ return failCredit
1213
+ ```
1214
+
1215
+ 但 `internal/gateway/fallback.go` 的 `retryable()` 400/422 分支只查 `quotaWords`:
1216
+
1217
+ ```go
1218
+ case status == 400, status == 422:
1219
+ return quotaWords.Match(body) || unservedWords.Match(body) || ...
1220
+ ```
1221
+
1222
+ `quotaWords` 不含「积分」「充值」。⇒ `retryable(400, body) == false` ⇒
1223
+ `hw.failed() == false` ⇒ 走不到 `!last && hw.failed()` 的换号分支,也不记 `Rest`。
1224
+
1225
+ ### 插件修法(0.3.26)
1226
+
1227
+ `src/hooks/loader.mjs` 的 `rewriteCreditRefusal()`:只在 **400/422 + 文案命中
1228
+ 「积分/余额不足」** 时改写成 **402 Payment Required**,body 原样。402 在 magpie
1229
+ 的 `retryable()` 里无条件 true、`failure(402)` 也是 `failCredit`,于是会 rest 该账号
1230
+ 30 分钟并继续问同 provider 的下一个账号 / 组的下一个成员。
1231
+
1232
+ 边界:`schema` 400、参数 400、200、402 一律原样透传;不读成功流(只克隆错误响应)。
1233
+ `tests/credit-refusal.mjs` 18 个断言锁死,含 `makeLoader().fetch` 的端到端用例。
1234
+
1235
+ > 这是**绕过**,不是 magpie 根修。根修建议 `retryable()` 的 400/422 分支补
1236
+ > `creditWords.Match(body)`(或直接 `failure(status, body) == failCredit`);
1237
+ > 已报 yetone/magpie#4095。
1238
+
1239
+ ### 为什么这是插件能做的边界
1240
+
1241
+ 插件能改上游响应(loader 的 `fetch` 返回 Response),改不了 magpie 的 `retryable()`。
1242
+ 把「支付问题」伪装成语义正确的 402,比让每个用户手动避开耗尽账号现实;等 magpie 修好后,
1243
+ 这段转换仍然无害(402 本来就是正确的语义)。
1244
+
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "magicrew-auth",
3
- "version": "0.3.25",
3
+ "version": "0.3.26",
4
4
  "description": "超级麦吉 / MagiCrew for magpie and OpenCode — one package, two providers: letsmagic.cn (国内) and magicrew.ai (international). Password or web-JWT sign-in, the account's own live model list, points balance, newcomer rewards, invite codes.",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",
@@ -1,14 +1,18 @@
1
1
  // ④ auth.loader(getAuth) —— 请求接管
2
2
  //
3
3
  // 返回 baseURL / apiKey / headers / fetch(a,b),magpie 拿它发真实请求。
4
- // 这是唯一能改请求体的地方。本项目用它干两件事:
4
+ // 这是唯一能改请求体和上游错误状态的地方。本项目用它干三件事:
5
5
  //
6
6
  // · 国际站网关强制要 organization-code(国内站不要),不带就是
7
7
  // HTTP 500 "Internal Server Error."(22 字节纯文本,不是 JSON)
8
8
  // · 推理模型低于 512 的 max_tokens 会被 reasoning 吃光,content 返回空串
9
+ // · 上游用 HTTP 400 报「可用积分已不足」:这个 400 在 magpie 的
10
+ // retryable() 里不被识别为可重试(400 分支只查 quotaWords,而这条文案
11
+ // 命中的是 creditWords 的「充值」),于是整轮直接失败、账号也不 rest。
12
+ // 插件把它改写为语义正确的 402 Payment Required,让 magpie 换下一个账号。
9
13
  //
10
- // ⚠ 不要在这里读流、不要重试换号、不要手拼 x-api-key ——
11
- // 前两件 magpie 做了,第三件看 SDK 包自动加。见 README「最小交付面」。
14
+ // ⚠ 不要在这里读流、不要手拼 x-api-key ——
15
+ // 流由 magpie 处理,头由 SDK 包自动加。见 README「最小交付面」。
12
16
 
13
17
  import { ensureKey, accountOf } from "../keys.mjs"
14
18
 
@@ -223,6 +227,62 @@ export function rewriteRequestBody(raw) {
223
227
  return changed ? JSON.stringify(j) : raw
224
228
  }
225
229
 
230
+ // ═══ 上游「积分/余额不足」的 400 → 402 ═══════════════════════════════════
231
+ //
232
+ // 实测(2026-10-28 23:38,magpie 0.1.1899,DSH 走 group):
233
+ //
234
+ // try #1 magicrew@181****710 200 fail=loop (模型自己绕圈,不是账号)
235
+ // try #2 magicrew@181****710 400 fail=credit
236
+ // body: 超级麦吉: 非常抱歉,当前对话需要调用大模型 / 付费工具,
237
+ // 您的可用积分已不足。…通过「充值」通道补充积分…
238
+ // route done=true status=400 ← 第三个账号(余额 6,467)一次都没被问
239
+ //
240
+ // magpie 的 failure() 已经把这条归成 failCredit(creditWords 命中「充值」),
241
+ // 但 retryable() 的 400/422 分支只查 quotaWords(quota|余额|额度|欠费|…),
242
+ // 不含「积分」「充值」⇒ hw.failed() 为 false ⇒ 走不到 `!last && hw.failed()`
243
+ // 的换账号分支,连 rest 都不记。账号于是永远留在轮换里,下一轮继续撞。
244
+ //
245
+ // 修法只改插件这一层:400/422 + 这条文案 → 402 Payment Required。
246
+ // 402 在 magpie 的 retryable() 里无条件 true,failure(402) 也是 failCredit,
247
+ // 会 rest 该账号 30 分钟并继续问同 provider 的下一个账号(或组的下一个成员)。
248
+ // body 原样保留,最终错误文案不变。
249
+ //
250
+ // 注意:只转换「上游拒绝」这一个语义;400 的 schema 错误、参数错误绝不碰。
251
+ const CREDIT_EXHAUSTED = /(?:积分|余额)(?:已|额度)?不足|可用积分不足|insufficient\s+(?:credit|credits|balance|fund|funds|point|points)|(?:credit|credits|point|points|balance)\s+(?:is\s+|are\s+)?(?:insufficient|exhausted)|out\s+of\s+(?:credit|credits|points?|balance)/i
252
+
253
+ /** 这条错误文案是不是「账户没钱了」(而不是请求本身有问题) */
254
+ export function isCreditExhausted(text) {
255
+ return CREDIT_EXHAUSTED.test(String(text ?? ""))
256
+ }
257
+
258
+ /**
259
+ * 把上游的「积分/余额不足」400/422 改写成 402,让 magpie 的 `retryable()`
260
+ * 愿意换下一个账号;其余响应原样返回(同一个 Response 对象)。
261
+ *
262
+ * @param {Response} res
263
+ * @returns {Promise<Response>}
264
+ */
265
+ export async function rewriteCreditRefusal(res) {
266
+ if (!res || (res.status !== 400 && res.status !== 422)) return res
267
+ let text
268
+ try {
269
+ // 只克隆错误响应:成功响应(尤其是 SSE)不能在这里读,否则会破坏流
270
+ text = await res.clone().text()
271
+ } catch {
272
+ return res
273
+ }
274
+ if (!isCreditExhausted(text)) return res
275
+ const headers = new Headers(res.headers)
276
+ // body 已由 undici 解压,原 content-encoding/length 不再成立
277
+ headers.delete("content-length")
278
+ headers.delete("content-encoding")
279
+ return new Response(text, {
280
+ status: 402,
281
+ statusText: "Payment Required",
282
+ headers,
283
+ })
284
+ }
285
+
226
286
  export function makeLoader(c) {
227
287
  return async (getAuth) => {
228
288
  const auth = await getAuth()
@@ -266,12 +326,12 @@ export function makeLoader(c) {
266
326
  // 四处请求体改写,全在 rewriteRequestBody 里(那里有实测出处)
267
327
  body = rewriteRequestBody(body)
268
328
 
269
- return fetch(url, {
329
+ return rewriteCreditRefusal(await fetch(url, {
270
330
  method: init?.method ?? "POST",
271
331
  headers,
272
332
  body,
273
333
  signal: init?.signal ?? req?.signal ?? AbortSignal.timeout(300_000),
274
- })
334
+ }))
275
335
  },
276
336
  }
277
337
  }