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 CHANGED
@@ -54,7 +54,7 @@ curl -s "https://usemagpie.ai/api/latest?channel=stable" | grep -o '"version":"[
54
54
  | 1 | 改好了国内站,国际站用户报同样的错 | **两站是同一个 Go 服务、逐字符同构**,改一处就要两站都验;`SITES.cn` / `SITES.intl` 只是 host 不同 | internals 一 |
55
55
  | 2 | 国际站 `/v1/chat/completions` 回 `HTTP 500` + 22 字节裸文本(不是 JSON) | 国际站网关**强制**要 `organization-code` 头,缺了就是这个 500 | internals 一 / 五 |
56
56
  | 3 | 换了 key 之后老 key 立刻失效、用户被要求重登 | **「签新 key 会吊销旧 key」已被实测推翻(2026-10-28)** —— 见下方「最容易误信的旧说法」。仍然只留一把、只走 `src/keys.mjs` 的唯一出口,理由是**本地别分叉**,不是互相吊销 | internals 十四 |
57
- | 4 | 刷新 token 之后两处 refresh_token 不一致、用户莫名掉登录 | 只允许**一个**出口持有 refresh_token;`src/hooks/refresh.mjs` 绕过出口就会分叉 | internals 二 / 十四 |
57
+ | 4 | 刷新 token 之后两处 refresh_token 不一致、用户莫名掉登录 | 只允许**一个**出口持有 refresh_token;`src/hooks/refresh.mjs` 必须走 `keys.mjs` 的 `renewViaStore()`(0.3.29 起已收敛,与 `ensureKey` 共用 `renewOnce` 锁和同一个 key-store)。若改回自调 `refreshGatewayKey` 就会分叉 | internals 二 / 十四 |
58
58
  | 5 | agent 说「上下文放不下」,而实际模型能吃更长 | 上下文窗口**必须查 magpie 的模型库**,不能填保守值(早前一刀切 256K,实际 1M) | internals 十六 |
59
59
  | 6 | 流式回复拿到了,但 `usage` 永远是空、额度不扣 | 流式请求必须补 `stream_options.include_usage`,否则上游不回 usage | internals 十二 |
60
60
  | 7 | 模型被标成免费、Free first 一直派到它 | `free` 只认厂商声明(`input_points === 0`),**不能从 `rate === 0` 反推**;价格缺失 = 不列,不猜 | internals 十六 · HOOKS 十一之二 |
@@ -68,7 +68,11 @@ 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
+ | 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 侧修复方案见 https://github.com/yetone/magpie/issues/4095 (`retryable()` 的 400 分支补 `creditWords`) | CHANGELOG 0.3.26 |
72
+ | 19 | 单轮回 `400 ... tools.N.function.parameters/properties/functions/items: invalid JSON Schema`,但请求里**没有**空 `items` | 上游对 **`items` 子树内部**比根节点严:`anyOf`/`oneOf`/`allOf`/`not`/`const`/`if`+`then`+`else`/`{}` 全 400,**tuple 形式的 `items`(数组)国际站拒**,而同样形态放在顶层 parameters 里 200。命中来源是 **MCP 工具 schema**(REA MCP 7 个工具的 `functions` 数组)。判据:同一请求只把 `items` 子树里的 `anyOf` 摊平 → 200;改完**必须复查没留下空 `{}`**(删 `not`/`const` 会删空)。⚠ **上游 2026-10-29 00:25 起放宽了这层校验(raw 也 200),同一夜里还出现过波动** —— 判要不要改写请看 `_probe/items-subtree-stability.mjs` 的多轮统计,别信单次探针。改写已做(`loosenItemsSchema`,**只动 `items` 子树**、顶层 union 不动) | internals 十七 · `tests/body-rewrite.mjs` · `_probe/items-subtree-upstream.mjs` |
73
+ | 20 | 邮箱验证码登录报「phone 字段是必须的」或「captcha 字段是必须的」 | 邮箱和手机是**两套端点**:发信 `/v4/user/send-email` vs `/send-sms`,换登录态 `/v4/user/login-email` vs `/login`;邮箱那条验证码字段叫 **`captcha`**(不是 `code`)。判据:往 `/v4/user/login` 发任何 type 都回「phone 字段是必须的」 | `src/captcha.mjs` 头 · CHANGELOG 0.3.28 |
74
+ | 21 | 无头登录「旋转验证连试 4 张图都没过」,而这号本来就是自己的 | 先分清两件事:① **已注册的号服务端根本不出图**(`/captcha/generate` 对已注册账号直接回 `business_token`)—— 所以必须**先取图、再判识图模型**,一上来就 `if (!vision) throw` 会把这条免费通道堵死;② 识图模型 `max_tokens` 太小会把推理答案饿死(`workbuddy/deepseek-v4.1-flash` 给 400 → `content:null`,判官抓空串) | CHANGELOG 0.3.28 · `src/vision.mjs` |
75
+ | 22 | 同一账号有的请求 200、有的 403 `token无效`(`retryable=false`),换个时间又好了 | key 有两条签发路径时 store 与 magpie 各存一把,**静态挑一把必有一半踩空**。0.3.29 起 `keyCandidates()` 把 store 两槽 + `auth.access` 逐把打 chat,命中的写回 store;全被拒才交 402 让 magpie 换号。判据:`GET /v1/models` 对死 key **放行**,逐把探活必须用 `/v1/chat/completions` | CHANGELOG 0.3.29 · `tests/key-failover.mjs` |
72
76
 
73
77
  **坑 14 的两个附带判据**(都实测过):
74
78
 
package/CHANGELOG.md CHANGED
@@ -1,5 +1,375 @@
1
1
  # 更新记录
2
2
 
3
+ ## 0.3.29 — 2026-10-29
4
+
5
+ **同一账号多把 key 逐把试 · 全部被拒改 402 · 续期收敛到 key-store**
6
+
7
+ (核心改动来自 PR #12,作者 @baijiu666;合入时补了五处,见文末「合入时补的五处」。)
8
+
9
+ ### 症状
10
+
11
+ ZCode 经 magpie 网关调 `magicrew-intl/deepseek-v4.1-flash` 整轮失败:
12
+
13
+ ```
14
+ provider_code=403 reason=auth_failed status=403 retryable=false
15
+ 界面显示:token无效
16
+ ```
17
+
18
+ `retryable=false` ⇒ magpie 不换号,错误直接抛给客户端。同一 provider 下其他账号明明还有余额。
19
+
20
+ ### 根因:key 有两条签发路径,本地各存一把、静态挑一把
21
+
22
+ - `src/hooks/refresh.mjs` —— magpie 的续期,签发后只交给 magpie,**不写** key-store
23
+ - `src/keys.mjs` 的 `ensureKey` —— 请求路径自己续期,写 key-store
24
+
25
+ 两边各存一把;api_key 只活 2 小时,两条路各自什么时候续、续到哪把,本地并不一致。
26
+ 静态挑其中一把,踩空就是 403「token无效」。
27
+
28
+ PR 提交者 2026-10-29 逐把打 `/v1/chat/completions` 实测(5 个国际站账号):
29
+
30
+ | 账号 | `auth.access` | store 当前槽 |
31
+ |---|---|---|
32
+ | magicrew-intl | ✅ | ❌ |
33
+ | magicrew-intl#z983c8 | ✅ | ❌ |
34
+ | magicrew-intl#3oqx5z | ❌ | ✅ |
35
+ | magicrew-intl#h2mcxi | ❌ | ✅ |
36
+ | magicrew-intl#8amjca | ❌ | ❌ |
37
+
38
+ **5 个里 3 个两路状态不一致,方向还各不相同** —— 静态顺序(先 store 或先 auth)都救不了。
39
+
40
+ ⚠ 不要把这条读成「服务端同一账号只认最新签发的那把」—— 2026-10-28 的受控实验
41
+ 已经推翻过这个说法:同账号相隔 67 秒签的两把同时有效;12 把逐把探活,403 **全部**
42
+ 对应 `expiresAt` 已过。真正的失效原因是**手里那把过期了**,而两条路径让本地不知道
43
+ 自己手里的是哪把。逐把试解决的是这个「不知道」。
44
+
45
+ 补一条取证经验:对这种死 key,`GET /v1/models` 是**放行**的,只有 chat 才 403。逐把探活必须用 chat。
46
+
47
+ ### 修法(三处)
48
+
49
+ 1. **`src/keys.mjs` 新增 `keyCandidates()`** —— 同一账号的所有候选 key(store 当前槽 / 另一槽 / `auth.access`),去重后按 `expiresAt` 倒序。到期越晚 = 签发越晚 = 越可能是活着的那把。配套 `adoptKey()` 把命中的那把写回 store。
50
+
51
+ 2. **`src/hooks/loader.mjs` 逐把试 + 全挂改 402** —— `isKeyRejected()` 认两种形态:`HTTP 403`,以及 **`HTTP 200` + `{"code":403,"message":"token无效"}`**(绝大多数是这种,只看状态码会漏)。全部被拒时返回 **402** 而不是 403 —— 402 在 magpie 里是 `failCredit`,会把这个账号 rest 30 分钟、去问下一个账号 / 组的下一个成员,与 0.3.26「积分不足改写 402」同一条通路。SSE 一律不碰。
52
+
53
+ 3. **`src/hooks/refresh.mjs` 收敛到 key-store** —— 改调 `renewViaStore()`,由 store 统一持有 `refresh_token` 并写回新 key,magpie 拿到的是同一把;且与 `ensureKey` 共用 `renewOnce` 锁(按 siteId+account 去重),避免两条路径同时续期、后到的那次用废 token 被误判成「登录已失效」。
54
+
55
+ ### 验证(PR 提交者实测,2026-10-29)
56
+
57
+ `makeLoader().fetch` 直接喂真实账号(不经过 magpie):
58
+
59
+ ```
60
+ ① store 那把已死 / auth 活的(z983c8) → HTTP 200 ✅
61
+ ② 两把都死(8amjca) → HTTP 402 + 可读原因(交给 magpie 换号)
62
+ ③ auth 已死 / store 活的(h2mcxi) → HTTP 200 ✅
63
+ ```
64
+
65
+ 全账号跑一遍:国际站 5/5 → 200;国内站 `magicrew` / `lky0b4` / `0ok4oa` → 200;`4rm3fp`(积分 -28)→ 402「可用积分已不足」(0.3.26 的改写,正确)。
66
+
67
+ 经网关三条链路 `magicrew/…`、`magicrew-intl/…`、`group/never-stuck` 全部 200。
68
+
69
+ ### 验证(合入后复核,2026-10-29 02:15)
70
+
71
+ 两站各取真实账号(国际 `magicrew-intl#m2orth` / 国内 `magicrew#3wbzad`),隔离 HOME、
72
+ 复制真实 key 构造四场景,用 magpie 自带的 bun 直打上游:
73
+
74
+ | 场景 | 国际站 | 国内站 |
75
+ |---|---|---|
76
+ | A 基线(store 活 + auth 活) | 200 | 200 |
77
+ | B store 死 / auth 活 | 200,且写回 store 另一槽 | 200,且写回 |
78
+ | C store 活 / auth 死 | 200(命中 store 那把,不重写) | 200 |
79
+ | D 三把全死 | 402 + 可读原因 | 402 + 可读原因 |
80
+
81
+ `tests/key-failover.mjs` 25 条断言把以上全部离线钉死(`node tests/key-failover.mjs` 退出 0)。
82
+
83
+ ### 边界
84
+
85
+ - 不跨账号借身份:只用**本账号**的 key;本账号全挂就交 402 让 magpie 决定换谁,与 0.3.23 `accountFor()` 的保守立场一致
86
+ - 只有第一把被拒时才会多发请求;命中那把只走 1 次
87
+
88
+ ### 合入时补的五处
89
+
90
+ 1. **`renewViaStore` 会试两处 refresh_token** —— 升级上来的第一次续期,store 那份可能早被旧的 `refresh.mjs` 轮换废了;只认 store 会让用户莫名被要求重新登录。现在 store → `auth.refresh` 依次试,谁活谁写回。
91
+ 2. **`adoptKey` 空 store 时保留 `auth.refresh`** —— 首次接管、续期失败但 `auth.access` 还能用时,PR 初版写回 store 会丢掉 refresh_token,2 小时后没人能续。
92
+ 3. **`isKeyRejected` 只认业务错误码** —— PR 初版用文本正则,正常回复正文里恰好出现 `unauthorized` / `token无效` 就会被当成 key 拒绝,白白重发(可能重复计费)。现在先 `JSON.parse`,只看 `code` / `message` 字段;有 `choices` 的正常响应绝不判。
93
+ 4. **被拒响应的 body 显式取消** —— 逐把试时,每条死连接的响应体不会一直挂着等 GC。
94
+ 5. **`refresh.mjs` 不再要求 `auth.refresh` 存在** —— refresh_token 只躺在 key-store 里时(`ensureKey` 续过、magpie 那份已轮换),PR 初版直接 `return undefined`,key 到期就掉登录。
95
+
96
+ ## 0.3.28 — 2026-10-29
97
+
98
+ **邮箱验证码登录(内置)· 已有账号不再过旋转图 · 识图模型被 `max_tokens` 饿死的修复**
99
+
100
+ ### 一、邮箱验证码登录
101
+
102
+ 国际站用户大多是邮箱注册的,之前只有手机号验证码这一条零浏览器路。现在两条都有,
103
+ **而且共用同一份实现**(`makeCodeMethod(c, kind)`)—— 复制成两份的那天,下次改超时
104
+ 或改文案必然只改一份。
105
+
106
+ 协议是逆前端 chunk + 两站实测出来的,全部记在 `src/captcha.mjs` 的头部注释里:
107
+
108
+ ```
109
+ 取图 POST /captcha/generate {account_type:"email", email} ← 无 state_code
110
+ 过验证 POST /captcha/verify {token, angle, account_type, email}
111
+ 发信 POST /v4/user/send-email {email, type:"account_login"} + X-Captcha-Token
112
+ 换登录态 POST /v4/user/login-email {email, captcha, device, auto_register, redirect}
113
+ ```
114
+
115
+ 三个**必须记住**的点(都实测过,写错的表现完全看不出是它们的错):
116
+
117
+ 1. 邮箱**必须**走 `/v4/user/login-email`。往 `/v4/user/login` 发任何 type
118
+ (`email` / `email_captcha` / `email_code`)都回 `2000「phone 字段是必须的」`。
119
+ 2. 邮箱那条的**验证码字段叫 `captcha`,不叫 `code`**。发 `code` 回
120
+ `2000「captcha 字段是必须的」`;发 `captcha` 回 `2168「验证码错误」`(= 参数对了)。
121
+ 3. 发信端点的 `type` 也是 `"account_login"`(与手机号同)。填 `"login"` 回
122
+ `2000「服务异常,请联系管理员」`——后端内部炸了,不是参数校验失败。
123
+
124
+ 前端来源:`LoginCoreForm` chunk 里的
125
+ `loginService.sendEmailVerificationCode(email, "account_login", captchaToken)` 与
126
+ `login(LoginType.EmailVerificationCode, …)`(枚举值就是字符串 `"email"`);
127
+ 端点定义在 `UserApi`(`loginByEmail()` 显式 `delete("organization-code")`)。
128
+
129
+ **⚠ 这条方法只在国际站出现,国内站不给。** 两条实测判据:
130
+
131
+ 1. `www.letsmagic.cn/login` 的登录方式只有 **微信 / 苹果 / 密码 / 手机 / 私有部署**,
132
+ 没有邮箱(国际站 `www.magicrew.ai/login` 才有 `switch-to-email`)⇒ 国内站
133
+ 不存在邮箱注册的账号,摆出来只会让用户填完邮箱撞一个看不懂的错;
134
+ 2. 同一个发信请求,国内站 `/v4/user/send-email` 回 `2000 服务异常,请联系管理员`,
135
+ 国际站回 `1000 请求成功`。
136
+
137
+ 所以方法表按站点分叉:国际站 `邮箱 → 手机号 → 浏览器 → 登录态`,
138
+ 国内站 `手机号 → 浏览器 → 登录态`(`tests/code-login.mjs` 把条数也钉死了 ——
139
+ 国内站必须**恰好 3 条**,多一条就红)。
140
+
141
+ ### 二、已有账号**不再出现**旋转验证图
142
+
143
+ 这是顺手挖出来的、比邮箱本身更值钱的一条:**服务端对已注册的账号本来就不出图。**
144
+
145
+ 实测(2026-10-29,两站):
146
+
147
+ | 请求对象 | `/captcha/generate` 返回 |
148
+ |---|---|
149
+ | 已注册邮箱 | `business_token`(**直接放行,连图都不给**) |
150
+ | 从没注册过的邮箱 | `token` + `image`(要给图) |
151
+
152
+ 也就是说「把我已有的账号加进 magpie」这条路**根本不需要识图模型**,全程只有
153
+ 「填邮箱/手机号 → 填验证码」两步。而之前 `startCodeLogin()` 第一句就是
154
+ `if (!readVisionModel()) throw no-vision-model` —— **把这条免费通道堵死了**:
155
+ 没配识图模型的人连自己已有的账号都加不进来。
156
+
157
+ 改动:
158
+
159
+ - `startCodeLogin()` 改成**先取图,再决定要不要识图模型**:`registered` → 直接发码;
160
+ 只有确实要给新号过图时才要求模型(抛 `code: "no-vision-model"`)
161
+ - `passCaptcha(site, ident, firstCap)` 接受调用方已经取到的那张图,省一次请求
162
+ - `methods.mjs` 里「回退浏览器」那段从 `authorize` 开头挪进 `catch (no-vision-model)` ——
163
+ 只有真新号注册才回退
164
+ - 提示语跟着分叉:免图时告诉用户「这一环跳过了,识图模型这次也用不上」
165
+
166
+ ### 三、识图模型的 `max_tokens` 把它自己饿死(真 bug)
167
+
168
+ `src/vision.mjs` 原本写 `max_tokens: 400`。实测 `workbuddy/deepseek-v4.1-flash`:
169
+
170
+ ```
171
+ max_tokens=400 → finish_reason:"length" reasoning_tokens:400 content:null
172
+ max_tokens=2000 → finish_reason:"length" reasoning_tokens:2000 content:null
173
+ max_tokens=8000 → finish_reason:"stop" reasoning_tokens:899 content:"10"
174
+ ```
175
+
176
+ 它是**推理模型**,400 个 token 全被 `reasoning_content` 吃掉,答案一个字都没输出 ⇒
177
+ 判官拿到空串 ⇒ 判成「判角度不可信」⇒ 每轮都换图 ⇒ 整条无头登录直接挂。
178
+ **这个值买的是思考空间,不是答案长度。**
179
+
180
+ 改成 `max_tokens: 6000`,并加一层兜底:`content` 为空时从 `reasoning_content`
181
+ 的**最后 300 字**里找数字(实测尾巴就长「最终决定:10。」这样)。
182
+
183
+ ### 四、删掉 `tools/first-ai-work.mjs`
184
+
185
+ 那个脚本是早期用 CDP 拉浏览器、点输入框、按回车替账号发第一条消息的。
186
+ `src/tasks.mjs` 的 `autoFirstWork()` 早就用纯协议(`chat-task.mjs`)取代了它,
187
+ 留着只会让人以为「第一项任务还得手动跑脚本」。新手奖励 8 项现在**全自动**。
188
+
189
+ ### 五、识图模型:思考关不掉,但能砍一半
190
+
191
+ `workbuddy/deepseek-v4.1-flash` 是推理模型。让它**别思考**(更快、更省、不怕
192
+ `max_tokens` 被思考吃光)这件事,12 组参数逐个试过 —— 同一模型、同一张图:
193
+
194
+ | 参数 | reasoning 字数 | content | 结论 |
195
+ |---|---|---|---|
196
+ | 不传 | 1867 | `10.5` | 基线 |
197
+ | `reasoning_effort: "none"` | 2824 | `10.5` | ❌ 比不传**还长**(网关按未知参数丢了) |
198
+ | `reasoning_effort: "minimal"` | 2514 | `10.2` | ❌ 同上 |
199
+ | **`reasoning_effort: "low"`** | **835** | `10.5` | ✅ 唯一有效的 |
200
+ | `enable_thinking: false` | 3346 | **空** | ❌ 思考更长,且交白卷 |
201
+ | `thinking_budget: 0` | 3156 | **空** | ❌ 同上 |
202
+ | `thinking: {type:"disabled"}` | 1533 | `10.7` | 小幅 |
203
+ | `chat_template_kwargs` / `extra_body` / `reasoning:{enabled:false}` | 1161–1563 | 有 | 小幅 |
204
+ | 模型名后缀 `-nothink` / `/no_think` | — | — | HTTP 400 模型不存在 |
205
+
206
+ ⇒ **这个模型在网关侧关不掉思考。** 真想「不思考」,只能换一个**非推理的视觉模型**
207
+ (那是用户在 magpie 设置里选的,插件不替用户选)。
208
+
209
+ 于是取唯一有效的那条,再连跑 6 轮统计(3 张图 × 2 轮,同一张图重复问):
210
+
211
+ | | 出数字 | 平均耗时 | 平均 tokens | 平均 reasoning |
212
+ |---|---|---|---|---|
213
+ | 不传 | 5/6 | 20.6 s | 2343 | 3581 字 |
214
+ | `reasoning_effort: "low"` | **6/6** | **8.4 s** | **1007** | 1646 字 |
215
+
216
+ `low` 不但更快更省,**还更稳** —— 不传的那一组有一次把 6000 token 全烧在思考上、
217
+ `content` 交白卷(和 `max_tokens: 400` 那个坑是同一个形态)。两种配置判出来的角度一致。
218
+
219
+ 另外把**两个判官改成并行**:原来 `await a; await b` 串行,把延迟叠成两倍;
220
+ 现在耗时等于较慢的那一次。`reasoning_effort: "low"` + 并行之后,一次真实验证
221
+ (国内站,取图 → 识图 → 过旋转验证)判出 `clock=6.00 / deg=180`,verify 回
222
+ `1000 验证成功`。
223
+
224
+ **那换一个不思考的模型呢?** 能换,但别指望白捡。同一张图喂几个候选(都在
225
+ `magicrew-intl` 下):
226
+
227
+ | 模型 | 耗时 | reasoning | 完成 tokens | 答案 |
228
+ |---|---|---|---|---|
229
+ | `gpt-4o` | **3.2 s** | **0** | **3** | 7.5 |
230
+ | `gpt-5.4-mini` | **2.9 s** | **0** | **12** | 10.5 |
231
+ | `gemini-3.7-flash` | 7.9 s | 0 | 425 | 2.5 |
232
+ | `glm-5.3-flash` | 27.5 s | 3588 | 879 | 1.5 |
233
+ | `qwen3.5-flash` | 45.1 s | 9087 | 5575 | 2.0 |
234
+ | `workbuddy/deepseek-v4.1-flash`(当前) | 19.4 s | 3769 | 2475 | 2.5 |
235
+
236
+ `gpt-4o` / `gpt-5.4-mini` 确实**完全不思考**(`reasoning_tokens: 0`,2~3 秒)。
237
+ 但同一张图六个模型给出 **1.5~10.5 钟点**的答案 —— 差得离谱,说明「谁对」必须用
238
+ **通过率**判,不能只看快。小样本(每模型 3 张图、完整跑两问 + 提交)的结果:
239
+
240
+ | 模型 | 通过 | 平均判角度耗时 |
241
+ |---|---|---|
242
+ | `workbuddy/deepseek-v4.1-flash`(当前) | 2/3 | 23.6 s |
243
+ | `gpt-5.4-mini` | 1/3 | 7.4 s |
244
+ | `gemini-3.7-flash` | 1/3 | 9.8 s |
245
+ | `gpt-4o` | 0/3 | 1.1 s(三次都解析不出数字) |
246
+
247
+ ⚠ 样本太小(3 张/模型),**不能据此换模型**。但能确定两件事:① 换模型是唯一能
248
+ 真「不思考」的路;② 换之前必须用 `_probe/vision-accuracy.mjs` 跑 10 张以上比通过率,
249
+ 别拿速度当唯一指标。当前默认保持原模型 + `effort=low` + 并行。
250
+
251
+ **兜底:`reasoning_effort` 被 400 就退一步。** 它**不是** OpenAI 规范字段,而识图模型是
252
+ 用户自己在 magpie 里选的(模型库 + 自定义 provider 什么都可能有)—— 有的端点认得,
253
+ 有的会为未知字段直接回 400。所以 `askClock()` 里加了一层:带它发出去被 400 时,
254
+ 去掉该字段重发一次,行为退回「不传」那一档(少省几秒,但也别把整条无头登录打死)。
255
+ 实测该路径:首次 400 → 去掉字段 → 200 → 正常解析出答案。
256
+
257
+ ### 验证
258
+
259
+ - **新增 `tests/code-login.mjs`:34 个断言** —— 拦全局 fetch 换成可编程假服务端,
260
+ 逐个钉死请求本身:邮箱不带 `state_code`、邮箱不进 `send-sms`、
261
+ `login-email` 用 `captcha` 而 `login` 用 `code`、`device` 是 64 位 hex、
262
+ `auto_register` / `redirect` 都在、2168 原样透出;另含**免图通道**两条
263
+ (已注册 → 只走 generate + send-email、不碰 verify;新号 → 取了图但**不**直接发码)
264
+ - **`tests/browser-login.mjs` 更新**:改成按 label 找「浏览器登录」,
265
+ 并新增四条方法的顺序断言(国际站 邮箱 → 手机号 → 浏览器 → 登录态;
266
+ 国内站 手机号 → 浏览器 → 登录态,**没有邮箱那条**)。`authorize` 的耗时阈值从 50ms 放宽到
267
+ 5s —— 它允许花几百毫秒拉起浏览器,红线是「**不卡在等用户**」;剪贴板起不来的
268
+ 环境里那一段改成跳过,而不是让整份测试崩
269
+ - 回归:`multi-provider` 全过、`credit-refusal` 18 通过、`account-fallback` 10 通过、
270
+ `body-rewrite` **48 通过**(本版落地前先并了 0.3.27 的 `items` 子树改写,那一版把
271
+ 它的断言从 36 加到 48)、`login-profile` / `local-browser` / `browser-detect` / `smoke` 全过;
272
+ `node ../magpie-plugin-scaffold/scripts/check.mjs .` → 30 通过 / 0 失败
273
+ - 两站实测:邮箱取图 / 过验证(国际站 50° 通过)/ 发信(`code 1000`)/ 换登录态
274
+ (`2168` = 参数结构正确)都验过
275
+
276
+ ## 0.3.27 — 2026-10-29
277
+
278
+ **修 `items` 子树内部的 union / 空 schema 导致的整轮 400**
279
+
280
+ (内容来自 PR #10,作者 @Jet;合入时补了三处漏洞,见「合入时补的三处」。)
281
+
282
+ ### 症状
283
+
284
+ DSH 经 magpie 网关调用 DeepSeek-V4.1-Flash 时整轮失败:
285
+
286
+ ```
287
+ 本轮运行失败 400: {"code":null,"message":"超级麦吉: HTTP 400 请求错误:
288
+ {\"error\":{\"code\":\"InvalidParameter\",\"message\":\"tools.40.function.parameters/
289
+ properties/functions/items: invalid JSON Schema; check the schema keywords
290
+ and references Request id: ...\",\"param\":\"\",\"type\":\"BadRequest\"}}",
291
+ "param":null,"type":"invalid_request_error"}
292
+ ```
293
+
294
+ 0.3.24 已修过 `items: {}`,但这一类**没被覆盖** —— 0.3.24 只删 `items`
295
+ **自己**的空对象,而这里出问题的是 `items` **内部**的 schema。
296
+
297
+ ### 根因(2026-10-29 实测,直打上游、同一把 key、同一份请求、只改一个字段)
298
+
299
+ 上游的 JSON Schema 校验器对**数组元素的 schema** 比对根节点更严。放在
300
+ **顶层 parameters** 里上游**收**(200)的形态,放进 `items` 子树就一律 400:
301
+
302
+ | `items` 内部的形态 | 结果 |
303
+ |---|---|
304
+ | `anyOf` / `oneOf` / `allOf` | **400** |
305
+ | `not` / `const` | **400** |
306
+ | `if` + `then`(`else` 同理) | **400** |
307
+ | `{}`(空 schema) | **400** |
308
+ | `items` 本身是**数组**(draft-04 tuple 形式) | 国内站 200 / **国际站 400** |
309
+ | `type` / `enum` / `pattern` / `minLength` / `minItems` / `format` / `$ref` / 数值边界 | 200 |
310
+ | `type:["object","string","number","boolean","array","null"]` | 200 ← 「任意值」的合法写法 |
311
+ | 顶层 parameters 里的 `anyOf` / `const` / `not` / `{}` | 200 |
312
+
313
+ 命中来源是**MCP 服务器**的工具 schema:REA MCP(`mcp__rea__build_call_path`、
314
+ `mcp__rea__compare_application_versions` 等 **7 个工具**)的 `functions` 数组参数,
315
+ 元素 schema 用了 `anyOf: [{…}, {type:"null"}]`(可选参数的标准写法)、`oneOf`,
316
+ 还有 `{}` 空 schema。一份 65 工具的请求里 7 个工具中招 ⇒ 整轮 400。
317
+
318
+ ### 修法
319
+
320
+ `src/hooks/loader.mjs` 新增 `loosenItemsSchema()`,只作用于 **`items` 子树**:
321
+
322
+ ```
323
+ anyOf / oneOf / allOf → 取一个分支内联(分支本身先递归修),优先取非纯 null 的分支
324
+ not / const → 删除
325
+ if / then / else → 删除
326
+ {} → {type:["object","string","number","boolean","array","null"]}
327
+ items 是数组(tuple) → {type:[同上]}
328
+ ```
329
+
330
+ 全部是**降级放宽**(方向安全:宁可约束松一点,也不要整轮失败):
331
+
332
+ - union 取一个分支:`anyOf:[{…},{type:"null"}]` 取到的正是「非空」那个。
333
+ ⚠ 顺序反过来(null 在前)时要跳过 null 分支 —— 照抄第一个会把「可选」压成
334
+ 「只能 null」,那是**收紧**;
335
+ - `const` 删除 ⇒ 「只能是这一个值」变「任意值」,放宽;
336
+ - `not` / `if+then+else` 无等价物,删掉同样是放宽;
337
+ - `{}` → 六类型 `type` 数组:与空 schema **严格等价**(六种 JSON 类型全覆盖),
338
+ 而 `{}` / `true` 在这层都被上游拒,只能落成这个显式写法。
339
+
340
+ ⚠ 顶层 parameters 的 union **不动** —— 上游收它,动了白白丢约束。
341
+
342
+ 递归同样遵守 schema 位置纪律(`properties` 的值 / union 的分支 / `items` 的值……),
343
+ 不碰 `enum` / `default` / `examples` 里的字面值。
344
+
345
+ ### 合入时补的三处(PR 初版的漏洞)
346
+
347
+ 1. **删 `not` / `const` 会删成空节点** —— 初版只把 ``{}` 当空 schema 处理,删完没复查,
348
+ 于是 `{const:"x"}` 变成 `{}` 留在 `items` 子树里,**改写后仍然 400**(实测:
349
+ `items/not` 与 `items/const` 两例 raw=400 → 改写后 400)。现在删除后统一补
350
+ 「任意值」,两例都变成 200。
351
+ 2. **`{}` 曾被改成 `{type:"object",additionalProperties:true}`** —— 那不是等价改写,
352
+ 是把「任意值」收紧成「只能是对象」。改成六类型 `type` 数组。
353
+ 3. **`if` / `then` / `else` 与 tuple 形式的 `items` 没被覆盖** —— 前者在 `items` 子树里
354
+ 实测 400,后者国际站 400;两者现在都处理。
355
+
356
+ ### 验证
357
+
358
+ - `tests/body-rewrite.mjs`:**48 个断言**(初版 45 个;新增断言覆盖删空补位、
359
+ `if/then` 删除、null 在前的 union、tuple 形式)。
360
+ - 端到端(00:19~00:24 实测,那时上游还在拒):真实报障形态的 65 工具请求体
361
+ (58 填充 + 7 个中招的 REA 工具)**国内站** 原样 → 400、过 `rewriteRequestBody` → **200**;
362
+ **国际站** 原样 → 400(`tools.58…/items`)、过 `rewriteRequestBody` → **200**。
363
+ 单点矩阵:21 个形态 × raw/改写后 × 两站,所有 `items` 子树非法形态 400 → 200,
364
+ 顶层对照形态保持 200,合法关键字原样保留。
365
+ - ⚠ **上游在 00:25~00:29 之间放宽了这层校验**:同一批形态的 raw 请求随后也回 200
366
+ (含真实报障形态)。同一夜里 `tuple` 形式与 `if+then` 还出现过 200/400 波动。
367
+ ⇒ 这个改写现在是**保险**,不是必需;上游随时可能收回去。
368
+ 判「要不要改写」请看多轮统计,别信单次探针。
369
+ - 复跑:`_probe/items-subtree-upstream.mjs`(`[cn|intl]`)、
370
+ `_probe/items-subtree-stability.mjs`(`N 轮`,输出 `400 次数 / 轮数`)。
371
+
372
+
3
373
  ## 0.3.26 — 2026-10-28
4
374
 
5
375
  **积分耗尽的 400 不再让整轮对话失败:改写成 402,让 magpie 换下一个账号**
@@ -32,6 +402,9 @@ hw.failed() == false
32
402
 
33
403
  账号于是永远留在轮换里,下一次请求继续撞同一个错误。
34
404
 
405
+ > 已把 magpie 侧根因与修法报到 https://github.com/yetone/magpie/issues/4095 ——
406
+ > 插件这一层是绕过,不是根修。
407
+
35
408
  ### 修法(插件层)
36
409
 
37
410
  `src/hooks/loader.mjs` 增加 `rewriteCreditRefusal()`:只在