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.
- package/AGENTS.md +6 -2
- package/CHANGELOG.md +373 -0
- package/README.md +133 -59
- package/docs/internals.md +139 -3
- package/package.json +1 -1
- package/src/captcha.mjs +146 -43
- package/src/headless.mjs +79 -29
- package/src/hooks/loader.mjs +258 -7
- package/src/hooks/methods.mjs +238 -147
- package/src/hooks/refresh.mjs +14 -7
- package/src/keys.mjs +150 -11
- package/src/vision.mjs +83 -25
package/src/hooks/loader.mjs
CHANGED
|
@@ -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
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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
|
}
|