magicrew-auth 0.3.24 → 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,79 @@
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
+
53
+ ## 0.3.25 — 2026-10-28
54
+
55
+ **0.3.24 的 schema 扫描范围收窄:只走 schema 位置**
56
+
57
+ 0.3.24 的 `loosenEmptyItems()` 与 `loosenAdditionalProperties()` 递归了**所有**对象。
58
+ 那会把 `properties` 里恰好叫 `items` / `additionalProperties` 的工具参数当成 schema
59
+ 关键字:`properties.items = {}` 的参数会被删掉,`properties.additionalProperties =
60
+ {type:"string"}` 会被改成 `true`;`enum` / `default` 值里的同名键也会被误改。
61
+
62
+ 两者现在共用 `walkSchema()`,只进入真正的 schema 位置:
63
+
64
+ - `properties` / `patternProperties` / `$defs` / `definitions` / `dependentSchemas` 的**值**
65
+ - `allOf` / `anyOf` / `oneOf` / `prefixItems`
66
+ - `items` / `additionalProperties` / `unevaluatedProperties` / `propertyNames` /
67
+ `contains` / `not` / `if` / `then` / `else` / `contentSchema` / `dependencies`
68
+ - 不扫键名,不扫 `enum` / `default` / `const` / `examples`(它们不是 schema)
69
+
70
+ 0.3.24 的 `items:{}` 等价替换规则不变,本版只修扫描范围。
71
+
72
+ ### 验证
73
+
74
+ - `tests/body-rewrite.mjs`:36 个断言(新增 5 个同名属性 / `enum` 值反例)。
75
+ - 端到端复跑:41 个工具、`properties.functions.items:{}` → 200(国内 / 国际)。
76
+
3
77
  ## 0.3.24 — 2026-10-28
4
78
 
5
79
  **修 `items: {}` 导致的整轮 400**
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
@@ -1119,7 +1119,7 @@ POST /v1/chat/completions
1119
1119
  它与 `max_tokens`、`stream_options`、`items: {}` 四处改写共用**一次** `JSON.parse`;
1120
1120
  没改到时返回原始字符串(省一次序列化 —— 54 工具的请求体可达上百 KB)。
1121
1121
 
1122
- 边界由 `tests/body-rewrite.mjs` 钉住(31 个断言):非布尔一律换、布尔一律不动、
1122
+ 边界由 `tests/body-rewrite.mjs` 钉住(36 个断言):非布尔一律换、布尔一律不动、
1123
1123
  其余字段一字不动、没改到就不重新序列化。
1124
1124
 
1125
1125
  ### 二、`items: {}` → `400 invalid JSON Schema`
@@ -1157,9 +1157,14 @@ HTTP 400
1157
1157
  `items: {}` 删掉是**严格等价**,不是降级放宽;`items: true` 同理。
1158
1158
  `items: false`("数组必须为空")语义不同,不能删,原样留着。
1159
1159
 
1160
- **修法**:`loosenEmptyItems()` 递归删 `items: {}` 与 `items: true`,与
1160
+ **修法**:`loosenEmptyItems()` 删 `items: {}` 与 `items: true`,与
1161
1161
  `loosenAdditionalProperties()` 在同一个 `rewriteRequestBody` 里各跑一遍。
1162
1162
 
1163
+ 两者共用 `walkSchema()`,**只进入真正的 schema 位置**(`properties` 等映射的**值**、
1164
+ `allOf` / `anyOf` / `oneOf`、`items` / `additionalProperties` / `not` / `if` 等),
1165
+ 不扫键名,也不扫 `enum` / `default` / `const`。否则一个恰好叫 `items` 或
1166
+ `additionalProperties` 的工具参数会被当成关键字误删/误改(0.3.24 的教训,0.3.25 修)。
1167
+
1163
1168
  **端到端验证**(2026-10-28):同一份 41 工具请求
1164
1169
  `raw → 400 tools.40.../items`;过 `rewriteRequestBody` 后 `→ 200`。
1165
1170
  国内站、国际站都验过。
@@ -1180,3 +1185,60 @@ HTTP 400
1180
1185
  2. **插件宿主是运行时从 `~/.cache/magpie/bun/install-cache/` 加载的**,
1181
1186
  不在 `plugin-host/host-*.js` 里;magpie 更新插件后会换目录(目录名带 commit hash),
1182
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.24",
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
 
@@ -42,30 +46,82 @@ import { ensureKey, accountOf } from "../keys.mjs"
42
46
  * 一次真实请求里 54 个工具中的 `ExitPlanMode` 与 `CreateWorkflow` 就这么写。
43
47
  * 客户端没写错(按规范 `{}` 与 `true` 等价),是上游兼容性不足,插件这边兜住。
44
48
  *
49
+ * 只走 schema 位置(见 `walkSchema`):一个恰好叫 `additionalProperties` 的工具参数
50
+ * 不会被误改。
51
+ *
45
52
  * @returns {boolean} 有没有改到
46
53
  */
47
- export function loosenAdditionalProperties(node) {
48
- if (!node || typeof node !== "object") return false
49
- if (Array.isArray(node)) {
50
- let any = false
51
- for (const n of node) if (loosenAdditionalProperties(n)) any = true
52
- return any
53
- }
54
- let changed = false
54
+ // JSON Schema 里"值是 schema"的关键字。只走这些位置,**不扫 properties 的键名**,
55
+ // 也不扫 enum / default / const / examples —— 那些不是 schema。
56
+ // 旧写法递归所有对象,会把一个叫 `additionalProperties` 的工具参数(在
57
+ // properties 的键上)当成关键字改掉,也会把 enum 值里的 `items` 键删掉。
58
+ const SCHEMA_MAPS = new Set([
59
+ "properties",
60
+ "patternProperties",
61
+ "$defs",
62
+ "definitions",
63
+ "dependentSchemas",
64
+ ])
65
+ const SCHEMA_ONE = new Set([
66
+ "additionalProperties",
67
+ "unevaluatedProperties",
68
+ "propertyNames",
69
+ "contains",
70
+ "not",
71
+ "if",
72
+ "then",
73
+ "else",
74
+ "contentSchema",
75
+ "items",
76
+ "additionalItems",
77
+ "unevaluatedItems",
78
+ ])
79
+ const SCHEMA_LIST = new Set(["allOf", "anyOf", "oneOf", "prefixItems"])
80
+
81
+ /**
82
+ * 在 schema 对象上原地跑 `visit`,并只递归进真正的 schema 位置。
83
+ * @returns {boolean} `visit` 或任一子 schema 改到过
84
+ */
85
+ function walkSchema(node, visit) {
86
+ if (!node || typeof node !== "object" || Array.isArray(node)) return false
87
+ let changed = visit(node)
55
88
  for (const [k, v] of Object.entries(node)) {
56
- if (k === "additionalProperties") {
57
- // 布尔值上游认,原样留着(false 是"不许额外属性",语义相反,绝对不能动)
58
- if (typeof v !== "boolean") {
59
- node[k] = true
60
- changed = true
61
- }
89
+ if (!v || typeof v !== "object") continue
90
+ if (SCHEMA_MAPS.has(k) && !Array.isArray(v)) {
91
+ for (const child of Object.values(v)) if (walkSchema(child, visit)) changed = true
62
92
  continue
63
93
  }
64
- if (v && typeof v === "object" && loosenAdditionalProperties(v)) changed = true
94
+ if (SCHEMA_LIST.has(k) && Array.isArray(v)) {
95
+ for (const child of v) if (walkSchema(child, visit)) changed = true
96
+ continue
97
+ }
98
+ if (SCHEMA_ONE.has(k)) {
99
+ if (Array.isArray(v)) {
100
+ for (const child of v) if (walkSchema(child, visit)) changed = true
101
+ } else if (walkSchema(v, visit)) changed = true
102
+ continue
103
+ }
104
+ if (k === "dependencies" && !Array.isArray(v)) {
105
+ for (const child of Object.values(v)) {
106
+ if (child && typeof child === "object" && !Array.isArray(child) && walkSchema(child, visit)) {
107
+ changed = true
108
+ }
109
+ }
110
+ }
65
111
  }
66
112
  return changed
67
113
  }
68
114
 
115
+ export function loosenAdditionalProperties(node) {
116
+ return walkSchema(node, (n) => {
117
+ // 布尔值上游认,原样留着(false 是"不许额外属性",语义相反,绝对不能动)
118
+ if (!Object.hasOwn(n, "additionalProperties")) return false
119
+ if (typeof n.additionalProperties === "boolean") return false
120
+ n.additionalProperties = true
121
+ return true
122
+ })
123
+ }
124
+
69
125
  /**
70
126
  * 上游的 JSON Schema 校验器不接受 `items: {}`,整轮请求回 HTTP 400:
71
127
  *
@@ -93,29 +149,21 @@ export function loosenAdditionalProperties(node) {
93
149
  * 数组元素会序列化成 `{}`。一个真实请求里第 41 个工具的参数是
94
150
  * `properties.functions.items: {}`,于是整轮失败。
95
151
  *
152
+ * 只走 schema 位置:`properties` 里一个恰好叫 `items` 的工具参数(哪怕 schema 就是
153
+ * `{}`)不会被误删;`enum` / `default` 值里的 `items` 键同样不碰。
154
+ *
96
155
  * @returns {boolean} 有没有改到
97
156
  */
98
157
  export function loosenEmptyItems(node) {
99
- if (!node || typeof node !== "object") return false
100
- if (Array.isArray(node)) {
101
- let any = false
102
- for (const n of node) if (loosenEmptyItems(n)) any = true
103
- return any
104
- }
105
- let changed = false
106
- for (const [k, v] of Object.entries(node)) {
107
- if (k === "items") {
108
- const emptyObject =
109
- v && typeof v === "object" && !Array.isArray(v) && Object.keys(v).length === 0
110
- if (emptyObject || v === true) {
111
- delete node[k]
112
- changed = true
113
- continue
114
- }
115
- }
116
- if (v && typeof v === "object" && loosenEmptyItems(v)) changed = true
117
- }
118
- return changed
158
+ return walkSchema(node, (n) => {
159
+ if (!Object.hasOwn(n, "items")) return false
160
+ const v = n.items
161
+ const emptyObject =
162
+ v && typeof v === "object" && !Array.isArray(v) && Object.keys(v).length === 0
163
+ if (!emptyObject && v !== true) return false
164
+ delete n.items
165
+ return true
166
+ })
119
167
  }
120
168
 
121
169
  /**
@@ -179,6 +227,62 @@ export function rewriteRequestBody(raw) {
179
227
  return changed ? JSON.stringify(j) : raw
180
228
  }
181
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
+
182
286
  export function makeLoader(c) {
183
287
  return async (getAuth) => {
184
288
  const auth = await getAuth()
@@ -222,12 +326,12 @@ export function makeLoader(c) {
222
326
  // 四处请求体改写,全在 rewriteRequestBody 里(那里有实测出处)
223
327
  body = rewriteRequestBody(body)
224
328
 
225
- return fetch(url, {
329
+ return rewriteCreditRefusal(await fetch(url, {
226
330
  method: init?.method ?? "POST",
227
331
  headers,
228
332
  body,
229
333
  signal: init?.signal ?? req?.signal ?? AbortSignal.timeout(300_000),
230
- })
334
+ }))
231
335
  },
232
336
  }
233
337
  }