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.
- package/AGENTS.md +4 -0
- package/CHANGELOG.md +330 -0
- package/README.md +135 -58
- package/docs/internals.md +179 -0
- package/package.json +1 -1
- package/src/captcha.mjs +146 -43
- package/src/headless.mjs +79 -29
- package/src/hooks/loader.mjs +228 -5
- package/src/hooks/methods.mjs +238 -147
- package/src/vision.mjs +83 -25
package/src/hooks/loader.mjs
CHANGED
|
@@ -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
|
-
// ⚠
|
|
11
|
-
//
|
|
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
|
}
|