magicrew-auth 0.3.25 → 0.3.28

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.
@@ -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
 
@@ -162,6 +166,168 @@ export function loosenEmptyItems(node) {
162
166
  })
163
167
  }
164
168
 
169
+ /**
170
+ * 修 **`items` 子树内部**的 schema:上游的 JSON Schema 校验器对数组元素的
171
+ * schema 比对根节点**更严**,下面这些形态在 `items` 子树里一律 400:
172
+ *
173
+ * anyOf / oneOf / allOf / not / const → 400
174
+ * if / then / else → 400
175
+ * {}(空 schema) → 400
176
+ *
177
+ * ⚠ 同样形态放在**顶层 parameters** 里上游**收**(200)—— 只有 `items` 子树
178
+ * 里才炸。所以限制必须精确落在 `items` 之下,不能全局摊平 union。
179
+ *
180
+ * 实测(2026-10-28~29,直打国内站 `i-magic-service.letsmagic.cn`,
181
+ * deepseek-v4.1-flash,同一把 key、同一份请求,只改这一个字段;
182
+ * `_probe/items-subtree-upstream.mjs` 可复跑):
183
+ *
184
+ * items 内部 {x:{anyOf:[{type:"string"},{type:"null"}]}} → 400
185
+ * items 内部 {x:{oneOf:[…]}} → 400
186
+ * items 内部 {x:{allOf:[…]}} → 400
187
+ * items 内部 {x:{not:{type:"string"}}} → 400
188
+ * items 内部 {x:{const:"a"}} → 400
189
+ * items 内部 {x:{if:…,then:…}} → 400
190
+ * items 内部 {x:{}} → 400
191
+ * items 内部 {x:{type:["object","string",…,"null"]}} → 200 ← 「任意值」的合法写法
192
+ * items 内部 {x:{type:"object"}} → 200
193
+ * items 内部 {x:{type:"string",pattern:"^a$",enum:[…]}} → 200
194
+ * items 内部 {x:{$ref:"#/$defs/s"}}, $defs 同层 → 200
195
+ * items 是数组(tuple 形式) 国内站 → 200,**国际站 → 400**(两站不一致)
196
+ * 顶层 {x:{anyOf:…}} / {x:{const:…}} / {x:{not:…}} / {x:{}} → 200
197
+ * (国内站 `i-magic-service.letsmagic.cn` 与国际站 `magic-service.magicrew.ai`
198
+ * 逐条对比过:除 tuple 形式外结论一致)
199
+ *
200
+ * 命中来源:**MCP 服务器**的工具 schema。REA MCP(mcp__rea__build_call_path 等
201
+ * 7 个工具)的 `functions` 数组参数,元素 schema 用了
202
+ * `anyOf: [{...}, {type:"null"}]`(可选参数的标准写法)、`oneOf`,还有
203
+ * `{}` 空 schema —— 一份 65 工具的请求里就有 7 个工具中招,整轮 400。
204
+ *
205
+ * 修法(全部是**降级放宽**,方向安全:宁可约束松一点,也不要整轮失败):
206
+ *
207
+ * anyOf / oneOf / allOf → 取一个分支内联(**优先非纯 null 的分支**:
208
+ * `anyOf:[{…},{type:"null"}]` 取前者;反过来写时
209
+ * 照抄第一个会把「可选」压成「只能 null」,那是收紧)
210
+ * not / const / if+then+else → 删除(无等价物;删掉就是放宽)
211
+ * {} → `{type:["object","string","number","boolean","array","null"]}`
212
+ * (六种 JSON 类型全覆盖 = 任意值,与 `{}` 严格等价;
213
+ * **不能**用 `{type:"object"}` —— 那会把「任意」收紧成「只能是对象」)
214
+ *
215
+ * ⚠ 两个容易漏的坑(都踩过):
216
+ * 1. 删除 `not` / `const` 后节点可能变成 `{}` —— 必须在删除后再查一次空对象,
217
+ * 否则改完仍是 400(PR #10 初版就漏在这里,`not`/`const` 两例改写后仍 400)。
218
+ * 2. `items` 子树里 `{}` 和 `true` 都被拒,所以「任意值」只能落成显式 type 数组。
219
+ *
220
+ * ⚠ 只走 `items` 子树:顶层 parameters 的 union 是上游**收**的,动了它
221
+ * 会白白丢掉约束。
222
+ *
223
+ * @returns {boolean} 有没有改到
224
+ */
225
+ export function loosenItemsSchema(node) {
226
+ let changed = false
227
+ // 用 walkSchema 找**每一个** schema 位置上的 items(它只进入真正的 schema
228
+ // 位置:properties 的值、union 的分支、items 的值……不碰 enum/default 的字面值)。
229
+ walkSchema(node, (n) => {
230
+ if (!Object.hasOwn(n, "items")) return false
231
+ const v = n.items
232
+ // tuple 形式(draft-04):`items` 是 schema **数组**。
233
+ // 两站行为不一致 —— 国内站收、**国际站拒**(`[{"type":"string"}] is not of
234
+ // type 'object'`),实测见 `_probe/items-subtree-upstream.mjs`。
235
+ // 数组里的逐位约束在改写里表达不了(`anyOf` 在这层就是 400),
236
+ // 只能整体落成「任意值」—— 纯放宽,不会让原本合法的调用被拒。
237
+ if (Array.isArray(v)) {
238
+ n.items = { type: [...ANY_SCHEMA_TYPE] }
239
+ changed = true
240
+ return false
241
+ }
242
+ if (!v || typeof v !== "object") return false
243
+ if (fixItemsSubtree(v)) changed = true
244
+ return false
245
+ })
246
+ return changed
247
+ }
248
+
249
+ // 空 schema 在 JSON Schema 里等价于 `true` = 接受任意 JSON 值。但上游对
250
+ // `items` 子树里的 `{}` 和 `true` 都拒,只能落成显式的「任意类型」schema。
251
+ const ANY_SCHEMA_TYPE = ["object", "string", "number", "boolean", "array", "null"]
252
+
253
+ /** 把一个空 schema 节点原地改成「任意值」。返回是否真的改到(只对空对象生效)。 */
254
+ function toAnySchema(n) {
255
+ if (Object.keys(n).length > 0) return false
256
+ n.type = [...ANY_SCHEMA_TYPE]
257
+ return true
258
+ }
259
+
260
+ /** 分支是不是只声明了 null —— 取它内联会把「可选」压成「只能 null」(收紧) */
261
+ function isNullOnly(branch) {
262
+ if (!branch || typeof branch !== "object" || Array.isArray(branch)) return false
263
+ if (branch.type === "null") return true
264
+ return Array.isArray(branch.type) && branch.type.length === 1 && branch.type[0] === "null"
265
+ }
266
+
267
+ /**
268
+ * 在 `items` 子树里就地修:union 摊平、const/not/if 删除、空 schema 换形状。
269
+ *
270
+ * 递归同样遵守 schema 位置纪律(见 {@link SCHEMA_MAPS} / {@link SCHEMA_LIST} /
271
+ * {@link SCHEMA_ONE}):`enum` / `default` / `examples` 里的字面值不是 schema,
272
+ * 不能动。
273
+ *
274
+ * @returns {boolean} 有没有改到
275
+ */
276
+ function fixItemsSubtree(n) {
277
+ if (!n || typeof n !== "object" || Array.isArray(n)) return false
278
+ let dirty = false
279
+
280
+ // 空 schema {} → 显式「任意类型」(与 {} 严格等价)
281
+ if (toAnySchema(n)) return true
282
+
283
+ for (const [k, v] of Object.entries(n)) {
284
+ if (k === "anyOf" || k === "oneOf" || k === "allOf") {
285
+ if (!Array.isArray(v) || v.length === 0) {
286
+ delete n[k]
287
+ dirty = true
288
+ continue
289
+ }
290
+ // 优先第一个非纯 null 的分支(顺序反过来时不至于把可选压成「只能 null」)
291
+ const branch =
292
+ v.find((b) => b && typeof b === "object" && !Array.isArray(b) && !isNullOnly(b)) ??
293
+ v.find((b) => b && typeof b === "object" && !Array.isArray(b))
294
+ delete n[k]
295
+ dirty = true
296
+ if (branch) {
297
+ // 分支本身也可能带 union / 空 schema ⇒ 先递归修,再内联
298
+ if (fixItemsSubtree(branch)) dirty = true
299
+ Object.assign(n, branch)
300
+ }
301
+ continue
302
+ }
303
+ if (k === "not" || k === "const" || k === "if" || k === "then" || k === "else") {
304
+ delete n[k]
305
+ dirty = true
306
+ continue
307
+ }
308
+ // 其余按 schema 位置递归
309
+ if (SCHEMA_MAPS.has(k) && !Array.isArray(v)) {
310
+ for (const child of Object.values(v)) if (fixItemsSubtree(child)) dirty = true
311
+ continue
312
+ }
313
+ if (SCHEMA_LIST.has(k) && Array.isArray(v)) {
314
+ for (const child of v) if (fixItemsSubtree(child)) dirty = true
315
+ continue
316
+ }
317
+ if (SCHEMA_ONE.has(k)) {
318
+ if (Array.isArray(v)) {
319
+ for (const child of v) if (fixItemsSubtree(child)) dirty = true
320
+ } else if (fixItemsSubtree(v)) {
321
+ dirty = true
322
+ }
323
+ }
324
+ }
325
+ // 删除 not/const/if… 之后节点可能变空 —— 空 schema 在 items 子树里仍然 400,
326
+ // 必须补成「任意值」。
327
+ if (toAnySchema(n)) dirty = true
328
+ return dirty
329
+ }
330
+
165
331
  /**
166
332
  * 改写发往上游的请求体。**一次 parse,四处改写都在这**。
167
333
  *
@@ -217,12 +383,69 @@ export function rewriteRequestBody(raw) {
217
383
  if (!p || typeof p !== "object") continue
218
384
  if (loosenAdditionalProperties(p)) changed = true
219
385
  if (loosenEmptyItems(p)) changed = true
386
+ if (loosenItemsSchema(p)) changed = true
220
387
  }
221
388
  }
222
389
 
223
390
  return changed ? JSON.stringify(j) : raw
224
391
  }
225
392
 
393
+ // ═══ 上游「积分/余额不足」的 400 → 402 ═══════════════════════════════════
394
+ //
395
+ // 实测(2026-10-28 23:38,magpie 0.1.1899,DSH 走 group):
396
+ //
397
+ // try #1 magicrew@181****710 200 fail=loop (模型自己绕圈,不是账号)
398
+ // try #2 magicrew@181****710 400 fail=credit
399
+ // body: 超级麦吉: 非常抱歉,当前对话需要调用大模型 / 付费工具,
400
+ // 您的可用积分已不足。…通过「充值」通道补充积分…
401
+ // route done=true status=400 ← 第三个账号(余额 6,467)一次都没被问
402
+ //
403
+ // magpie 的 failure() 已经把这条归成 failCredit(creditWords 命中「充值」),
404
+ // 但 retryable() 的 400/422 分支只查 quotaWords(quota|余额|额度|欠费|…),
405
+ // 不含「积分」「充值」⇒ hw.failed() 为 false ⇒ 走不到 `!last && hw.failed()`
406
+ // 的换账号分支,连 rest 都不记。账号于是永远留在轮换里,下一轮继续撞。
407
+ //
408
+ // 修法只改插件这一层:400/422 + 这条文案 → 402 Payment Required。
409
+ // 402 在 magpie 的 retryable() 里无条件 true,failure(402) 也是 failCredit,
410
+ // 会 rest 该账号 30 分钟并继续问同 provider 的下一个账号(或组的下一个成员)。
411
+ // body 原样保留,最终错误文案不变。
412
+ //
413
+ // 注意:只转换「上游拒绝」这一个语义;400 的 schema 错误、参数错误绝不碰。
414
+ 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
415
+
416
+ /** 这条错误文案是不是「账户没钱了」(而不是请求本身有问题) */
417
+ export function isCreditExhausted(text) {
418
+ return CREDIT_EXHAUSTED.test(String(text ?? ""))
419
+ }
420
+
421
+ /**
422
+ * 把上游的「积分/余额不足」400/422 改写成 402,让 magpie 的 `retryable()`
423
+ * 愿意换下一个账号;其余响应原样返回(同一个 Response 对象)。
424
+ *
425
+ * @param {Response} res
426
+ * @returns {Promise<Response>}
427
+ */
428
+ export async function rewriteCreditRefusal(res) {
429
+ if (!res || (res.status !== 400 && res.status !== 422)) return res
430
+ let text
431
+ try {
432
+ // 只克隆错误响应:成功响应(尤其是 SSE)不能在这里读,否则会破坏流
433
+ text = await res.clone().text()
434
+ } catch {
435
+ return res
436
+ }
437
+ if (!isCreditExhausted(text)) return res
438
+ const headers = new Headers(res.headers)
439
+ // body 已由 undici 解压,原 content-encoding/length 不再成立
440
+ headers.delete("content-length")
441
+ headers.delete("content-encoding")
442
+ return new Response(text, {
443
+ status: 402,
444
+ statusText: "Payment Required",
445
+ headers,
446
+ })
447
+ }
448
+
226
449
  export function makeLoader(c) {
227
450
  return async (getAuth) => {
228
451
  const auth = await getAuth()
@@ -266,12 +489,12 @@ export function makeLoader(c) {
266
489
  // 四处请求体改写,全在 rewriteRequestBody 里(那里有实测出处)
267
490
  body = rewriteRequestBody(body)
268
491
 
269
- return fetch(url, {
492
+ return rewriteCreditRefusal(await fetch(url, {
270
493
  method: init?.method ?? "POST",
271
494
  headers,
272
495
  body,
273
496
  signal: init?.signal ?? req?.signal ?? AbortSignal.timeout(300_000),
274
- })
497
+ }))
275
498
  },
276
499
  }
277
500
  }