magicrew-auth 0.3.26 → 0.3.29

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.
@@ -14,7 +14,45 @@
14
14
  // ⚠ 不要在这里读流、不要手拼 x-api-key ——
15
15
  // 流由 magpie 处理,头由 SDK 包自动加。见 README「最小交付面」。
16
16
 
17
- import { ensureKey, accountOf } from "../keys.mjs"
17
+ import { ensureKey, accountOf, keyCandidates, adoptKey } from "../keys.mjs"
18
+
19
+ /**
20
+ * 上游「这个账号不行」的两种形态(2026-10-29 实测):
21
+ *
22
+ * · HTTP 403
23
+ * · **HTTP 200 + `{"code":403,"message":"token无效"}`** ← 绝大多数是这种
24
+ *
25
+ * 所以光看状态码会漏。只解析 JSON(SSE 一律不碰,读流会破坏它)。
26
+ */
27
+ async function isKeyRejected(res) {
28
+ if (!res) return false
29
+ if (res.status === 401 || res.status === 403) return true
30
+ const ct = res.headers.get("content-type") || ""
31
+ if (res.status !== 200 || !ct.includes("application/json")) return false
32
+ let text = ""
33
+ try {
34
+ text = await res.clone().text()
35
+ } catch {
36
+ return false
37
+ }
38
+ if (text.length > 8192) return false
39
+ let body
40
+ try {
41
+ body = JSON.parse(text)
42
+ } catch {
43
+ return false
44
+ }
45
+ // 只认**业务错误码**。正常 chat 完成的 JSON 是 {choices:[…]},没有顶层 code;
46
+ // 用文本正则会把模型回复里恰好出现的 "unauthorized" 当成 key 拒绝 ——
47
+ // 那会白白重发请求(可能重复计费),甚至最后把正常响应丢掉换成 402。
48
+ const code = Number(body?.code ?? body?.error?.code)
49
+ if (code === 401 || code === 403) return true
50
+ const msg = String(body?.message ?? body?.error?.message ?? "")
51
+ if (!msg) return false
52
+ if (/token无效|token 无效|invalid token/i.test(msg)) return true
53
+ // 兜底:只有 message、没有 code 的少量形态;有 choices 的正常响应绝不判
54
+ return !Array.isArray(body?.choices) && /unauthorized/i.test(msg)
55
+ }
18
56
 
19
57
  /**
20
58
  * 把 JSON Schema 里的 `additionalProperties` **非布尔值**原地换成 `true`。
@@ -166,6 +204,168 @@ export function loosenEmptyItems(node) {
166
204
  })
167
205
  }
168
206
 
207
+ /**
208
+ * 修 **`items` 子树内部**的 schema:上游的 JSON Schema 校验器对数组元素的
209
+ * schema 比对根节点**更严**,下面这些形态在 `items` 子树里一律 400:
210
+ *
211
+ * anyOf / oneOf / allOf / not / const → 400
212
+ * if / then / else → 400
213
+ * {}(空 schema) → 400
214
+ *
215
+ * ⚠ 同样形态放在**顶层 parameters** 里上游**收**(200)—— 只有 `items` 子树
216
+ * 里才炸。所以限制必须精确落在 `items` 之下,不能全局摊平 union。
217
+ *
218
+ * 实测(2026-10-28~29,直打国内站 `i-magic-service.letsmagic.cn`,
219
+ * deepseek-v4.1-flash,同一把 key、同一份请求,只改这一个字段;
220
+ * `_probe/items-subtree-upstream.mjs` 可复跑):
221
+ *
222
+ * items 内部 {x:{anyOf:[{type:"string"},{type:"null"}]}} → 400
223
+ * items 内部 {x:{oneOf:[…]}} → 400
224
+ * items 内部 {x:{allOf:[…]}} → 400
225
+ * items 内部 {x:{not:{type:"string"}}} → 400
226
+ * items 内部 {x:{const:"a"}} → 400
227
+ * items 内部 {x:{if:…,then:…}} → 400
228
+ * items 内部 {x:{}} → 400
229
+ * items 内部 {x:{type:["object","string",…,"null"]}} → 200 ← 「任意值」的合法写法
230
+ * items 内部 {x:{type:"object"}} → 200
231
+ * items 内部 {x:{type:"string",pattern:"^a$",enum:[…]}} → 200
232
+ * items 内部 {x:{$ref:"#/$defs/s"}}, $defs 同层 → 200
233
+ * items 是数组(tuple 形式) 国内站 → 200,**国际站 → 400**(两站不一致)
234
+ * 顶层 {x:{anyOf:…}} / {x:{const:…}} / {x:{not:…}} / {x:{}} → 200
235
+ * (国内站 `i-magic-service.letsmagic.cn` 与国际站 `magic-service.magicrew.ai`
236
+ * 逐条对比过:除 tuple 形式外结论一致)
237
+ *
238
+ * 命中来源:**MCP 服务器**的工具 schema。REA MCP(mcp__rea__build_call_path 等
239
+ * 7 个工具)的 `functions` 数组参数,元素 schema 用了
240
+ * `anyOf: [{...}, {type:"null"}]`(可选参数的标准写法)、`oneOf`,还有
241
+ * `{}` 空 schema —— 一份 65 工具的请求里就有 7 个工具中招,整轮 400。
242
+ *
243
+ * 修法(全部是**降级放宽**,方向安全:宁可约束松一点,也不要整轮失败):
244
+ *
245
+ * anyOf / oneOf / allOf → 取一个分支内联(**优先非纯 null 的分支**:
246
+ * `anyOf:[{…},{type:"null"}]` 取前者;反过来写时
247
+ * 照抄第一个会把「可选」压成「只能 null」,那是收紧)
248
+ * not / const / if+then+else → 删除(无等价物;删掉就是放宽)
249
+ * {} → `{type:["object","string","number","boolean","array","null"]}`
250
+ * (六种 JSON 类型全覆盖 = 任意值,与 `{}` 严格等价;
251
+ * **不能**用 `{type:"object"}` —— 那会把「任意」收紧成「只能是对象」)
252
+ *
253
+ * ⚠ 两个容易漏的坑(都踩过):
254
+ * 1. 删除 `not` / `const` 后节点可能变成 `{}` —— 必须在删除后再查一次空对象,
255
+ * 否则改完仍是 400(PR #10 初版就漏在这里,`not`/`const` 两例改写后仍 400)。
256
+ * 2. `items` 子树里 `{}` 和 `true` 都被拒,所以「任意值」只能落成显式 type 数组。
257
+ *
258
+ * ⚠ 只走 `items` 子树:顶层 parameters 的 union 是上游**收**的,动了它
259
+ * 会白白丢掉约束。
260
+ *
261
+ * @returns {boolean} 有没有改到
262
+ */
263
+ export function loosenItemsSchema(node) {
264
+ let changed = false
265
+ // 用 walkSchema 找**每一个** schema 位置上的 items(它只进入真正的 schema
266
+ // 位置:properties 的值、union 的分支、items 的值……不碰 enum/default 的字面值)。
267
+ walkSchema(node, (n) => {
268
+ if (!Object.hasOwn(n, "items")) return false
269
+ const v = n.items
270
+ // tuple 形式(draft-04):`items` 是 schema **数组**。
271
+ // 两站行为不一致 —— 国内站收、**国际站拒**(`[{"type":"string"}] is not of
272
+ // type 'object'`),实测见 `_probe/items-subtree-upstream.mjs`。
273
+ // 数组里的逐位约束在改写里表达不了(`anyOf` 在这层就是 400),
274
+ // 只能整体落成「任意值」—— 纯放宽,不会让原本合法的调用被拒。
275
+ if (Array.isArray(v)) {
276
+ n.items = { type: [...ANY_SCHEMA_TYPE] }
277
+ changed = true
278
+ return false
279
+ }
280
+ if (!v || typeof v !== "object") return false
281
+ if (fixItemsSubtree(v)) changed = true
282
+ return false
283
+ })
284
+ return changed
285
+ }
286
+
287
+ // 空 schema 在 JSON Schema 里等价于 `true` = 接受任意 JSON 值。但上游对
288
+ // `items` 子树里的 `{}` 和 `true` 都拒,只能落成显式的「任意类型」schema。
289
+ const ANY_SCHEMA_TYPE = ["object", "string", "number", "boolean", "array", "null"]
290
+
291
+ /** 把一个空 schema 节点原地改成「任意值」。返回是否真的改到(只对空对象生效)。 */
292
+ function toAnySchema(n) {
293
+ if (Object.keys(n).length > 0) return false
294
+ n.type = [...ANY_SCHEMA_TYPE]
295
+ return true
296
+ }
297
+
298
+ /** 分支是不是只声明了 null —— 取它内联会把「可选」压成「只能 null」(收紧) */
299
+ function isNullOnly(branch) {
300
+ if (!branch || typeof branch !== "object" || Array.isArray(branch)) return false
301
+ if (branch.type === "null") return true
302
+ return Array.isArray(branch.type) && branch.type.length === 1 && branch.type[0] === "null"
303
+ }
304
+
305
+ /**
306
+ * 在 `items` 子树里就地修:union 摊平、const/not/if 删除、空 schema 换形状。
307
+ *
308
+ * 递归同样遵守 schema 位置纪律(见 {@link SCHEMA_MAPS} / {@link SCHEMA_LIST} /
309
+ * {@link SCHEMA_ONE}):`enum` / `default` / `examples` 里的字面值不是 schema,
310
+ * 不能动。
311
+ *
312
+ * @returns {boolean} 有没有改到
313
+ */
314
+ function fixItemsSubtree(n) {
315
+ if (!n || typeof n !== "object" || Array.isArray(n)) return false
316
+ let dirty = false
317
+
318
+ // 空 schema {} → 显式「任意类型」(与 {} 严格等价)
319
+ if (toAnySchema(n)) return true
320
+
321
+ for (const [k, v] of Object.entries(n)) {
322
+ if (k === "anyOf" || k === "oneOf" || k === "allOf") {
323
+ if (!Array.isArray(v) || v.length === 0) {
324
+ delete n[k]
325
+ dirty = true
326
+ continue
327
+ }
328
+ // 优先第一个非纯 null 的分支(顺序反过来时不至于把可选压成「只能 null」)
329
+ const branch =
330
+ v.find((b) => b && typeof b === "object" && !Array.isArray(b) && !isNullOnly(b)) ??
331
+ v.find((b) => b && typeof b === "object" && !Array.isArray(b))
332
+ delete n[k]
333
+ dirty = true
334
+ if (branch) {
335
+ // 分支本身也可能带 union / 空 schema ⇒ 先递归修,再内联
336
+ if (fixItemsSubtree(branch)) dirty = true
337
+ Object.assign(n, branch)
338
+ }
339
+ continue
340
+ }
341
+ if (k === "not" || k === "const" || k === "if" || k === "then" || k === "else") {
342
+ delete n[k]
343
+ dirty = true
344
+ continue
345
+ }
346
+ // 其余按 schema 位置递归
347
+ if (SCHEMA_MAPS.has(k) && !Array.isArray(v)) {
348
+ for (const child of Object.values(v)) if (fixItemsSubtree(child)) dirty = true
349
+ continue
350
+ }
351
+ if (SCHEMA_LIST.has(k) && Array.isArray(v)) {
352
+ for (const child of v) if (fixItemsSubtree(child)) dirty = true
353
+ continue
354
+ }
355
+ if (SCHEMA_ONE.has(k)) {
356
+ if (Array.isArray(v)) {
357
+ for (const child of v) if (fixItemsSubtree(child)) dirty = true
358
+ } else if (fixItemsSubtree(v)) {
359
+ dirty = true
360
+ }
361
+ }
362
+ }
363
+ // 删除 not/const/if… 之后节点可能变空 —— 空 schema 在 items 子树里仍然 400,
364
+ // 必须补成「任意值」。
365
+ if (toAnySchema(n)) dirty = true
366
+ return dirty
367
+ }
368
+
169
369
  /**
170
370
  * 改写发往上游的请求体。**一次 parse,四处改写都在这**。
171
371
  *
@@ -221,6 +421,7 @@ export function rewriteRequestBody(raw) {
221
421
  if (!p || typeof p !== "object") continue
222
422
  if (loosenAdditionalProperties(p)) changed = true
223
423
  if (loosenEmptyItems(p)) changed = true
424
+ if (loosenItemsSchema(p)) changed = true
224
425
  }
225
426
  }
226
427
 
@@ -284,6 +485,8 @@ export async function rewriteCreditRefusal(res) {
284
485
  }
285
486
 
286
487
  export function makeLoader(c) {
488
+ /** 已经写回 store 的 key —— 避免每个请求都去读一次 store */
489
+ const adopted = new Set()
287
490
  return async (getAuth) => {
288
491
  const auth = await getAuth()
289
492
  if (!auth) return {}
@@ -326,12 +529,60 @@ export function makeLoader(c) {
326
529
  // 四处请求体改写,全在 rewriteRequestBody 里(那里有实测出处)
327
530
  body = rewriteRequestBody(body)
328
531
 
329
- return rewriteCreditRefusal(await fetch(url, {
330
- method: init?.method ?? "POST",
331
- headers,
332
- body,
333
- signal: init?.signal ?? req?.signal ?? AbortSignal.timeout(300_000),
334
- }))
532
+ const signal = init?.signal ?? req?.signal ?? AbortSignal.timeout(300_000)
533
+ const sendWith = (apiKey) =>
534
+ fetch(url, {
535
+ method: init?.method ?? "POST",
536
+ headers: { ...headers, authorization: `Bearer ${apiKey}` },
537
+ body,
538
+ signal,
539
+ })
540
+
541
+ // 同一账号可能握着两把 key(magpie 的 refresh 一条、插件 store 一条),
542
+ // 哪把还有效本地无法预判 —— 静态挑必然有一半踩空,只能逐把试。
543
+ // 详见 src/keys.mjs 的 keyCandidates 注释(2026-10-29 五个账号的实测表)。
544
+ // 传 fresh 而不是 getAuth:候选与下面 adoptKey 必须落在同一个账号上,
545
+ // 不能让两次 getAuth() 返回不同结果。
546
+ const cands = await keyCandidates(c, fresh)
547
+ if (!cands.length) throw new Error(`${c.SHORT} 未登录(拿不到可用的 key)`)
548
+
549
+ for (const cand of cands) {
550
+ const res = await sendWith(cand.apiKey)
551
+ if (!(await isKeyRejected(res))) {
552
+ // 这把能用 —— 写回 store,让 ensureKey / 额度那几路也用同一把
553
+ if (!adopted.has(cand.apiKey)) {
554
+ adopted.add(cand.apiKey)
555
+ adoptKey(c, fresh, cand.apiKey, cand.expiresAt)
556
+ }
557
+ return rewriteCreditRefusal(res)
558
+ }
559
+ // 这把被拒:响应不再使用,显式取消 body —— 否则逐把试时
560
+ // 每条死连接的响应体都挂着,等 GC 才释放。
561
+ try {
562
+ await res.body?.cancel()
563
+ } catch {}
564
+ }
565
+
566
+ // 这个账号的 key 全被拒。上游给的是 403「token无效」,magpie 会判成
567
+ // 「不可重试」直接抛给客户端(ZCode 显示 auth_failed / token无效)。
568
+ // 改写成 402:magpie 的 failure(402) 是 failCredit → 把这个账号 rest 掉、
569
+ // 去问下一个账号 / 组的下一个成员。与 0.3.26 的「积分不足」同一条通路。
570
+ return new Response(
571
+ JSON.stringify({
572
+ error: {
573
+ message:
574
+ `${c.SHORT}:本账号的 ${cands.length} 把密钥均已被上游拒绝,已交给 magpie 换下一个账号。` +
575
+ `(常见于登录 / 续期分叉;请重新登录这个账号)`,
576
+ type: "account_unavailable",
577
+ code: 402,
578
+ },
579
+ }),
580
+ {
581
+ status: 402,
582
+ statusText: "Payment Required",
583
+ headers: { "content-type": "application/json" },
584
+ },
585
+ )
335
586
  },
336
587
  }
337
588
  }