magicrew-auth 0.3.18 → 0.3.24

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
@@ -53,7 +53,7 @@ curl -s "https://usemagpie.ai/api/latest?channel=stable" | grep -o '"version":"[
53
53
  |---|---|---|---|
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
- | 3 | 换了 key 之后老 key 立刻失效、用户被要求重登 | **签新 key 会吊销旧 key**,别在热路径上调签发;续期走 `src/keys.mjs` 的唯一出口 | internals 十三 |
56
+ | 3 | 换了 key 之后老 key 立刻失效、用户被要求重登 | **「签新 key 会吊销旧 key」已被实测推翻(2026-10-28)** —— 见下方「最容易误信的旧说法」。仍然只留一把、只走 `src/keys.mjs` 的唯一出口,理由是**本地别分叉**,不是互相吊销 | internals 十四 |
57
57
  | 4 | 刷新 token 之后两处 refresh_token 不一致、用户莫名掉登录 | 只允许**一个**出口持有 refresh_token;`src/hooks/refresh.mjs` 绕过出口就会分叉 | internals 二 / 十四 |
58
58
  | 5 | agent 说「上下文放不下」,而实际模型能吃更长 | 上下文窗口**必须查 magpie 的模型库**,不能填保守值(早前一刀切 256K,实际 1M) | internals 十六 |
59
59
  | 6 | 流式回复拿到了,但 `usage` 永远是空、额度不扣 | 流式请求必须补 `stream_options.include_usage`,否则上游不回 usage | internals 十二 |
@@ -64,12 +64,24 @@ curl -s "https://usemagpie.ai/api/latest?channel=stable" | grep -o '"version":"[
64
64
  | 11 | Windows 上点登录,浏览器窗口拉不起来 | 浏览器可执行文件名**随版本变**(`msedge.exe` / Chrome 各版本目录不同),要逐个试候选,别写死一个名字 | internals 十一 |
65
65
  | 12 | 用户点「检查更新」显示有新提交,点了更新却没变化 | 本仓库是 **git 源**:magpie 比默认分支 HEAD 的 commit,只提示不自动更新,要用户点那一行的「更新」;更新后 `magpie plugin list` 的 commit 应变化 | 脚手架 UPDATING 一 |
66
66
  | 13 | 想让卡片标题 / 「源码」按钮对,去改了 magpie 的缓存 | 插件对 magpie 目录**只有读的份**;唯一载体是 `registry-entry.json`(提交给 `registry.json`) | internals 二 · README |
67
+ | 14 | **卡片上额度正常,路由页里这个号却是「剩余额度未知」** | 窗口**不能标 `aside`**:magpie 的 `allowanceOf()` 把 `Aside`/`Text` 的窗口整条跳过,一条不剩 ⇒ 该号 `Known=false`(官网文档 issue #3650 明写)。判据:`curl -s http://127.0.0.1:3425/v1/magpie/trace`,看这个 provider 的 `known` 是 `true` 还是**字段缺失** | HOOKS 三 · `src/hooks/usage.mjs` 头 |
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
+ | 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
+ | 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
+
72
+ **坑 14 的两个附带判据**(都实测过):
73
+
74
+ - **改了 `src/hooks/usage.mjs` 要重启 magpie 才生效** —— 插件跑在 `magpie-bun` 子进程里,
75
+ 模块缓存不随文件变化失效。改完先 `Stop-Process` 再 `Start-Process` 同一个 exe,
76
+ 然后 `curl /v1/magpie/quotas` 看窗口里的 `spans`:`[{}]` = 没标 aside,`[{"aside":true}]` = 还标着。
77
+ - **额度读取有 1 分钟缓存**,改完立刻看可能还是旧值。
67
78
 
68
79
  补充一条最容易误信的旧说法:
69
80
 
70
81
  | 症状 | 判据 | 出处 |
71
82
  |---|---|---|
72
83
  | 以为 checkin 会「顺带续 key」 | **从来没有**。`src/hooks/checkin.mjs` → `src/tasks.mjs` 全链路没 import 过 `ensureKey`;保活由 magpie 按 `refreshLead` 主动调 `auth.refresh` | internals 十四 · CHANGELOG 0.x |
84
+ | 以为「签一把顶一把」、`key-store` 里的 key 会「时间没到就被顶掉」 | **已实测推翻(2026-10-28)**:同账号两把相隔 67 秒签的 key 同时有效;12 把 key 逐把探活,403 **全部**对应 `expiresAt` 已过(剩 -173~-541 分),没有一个「时间没到就失效」的样本。仍只留一把、只走唯一出口的理由是「本地别分叉」,不是「互相吊销」。据此加「跨账号挑活 key」的兜底 = 把别人的 key 交出去 | internals 十四 · 十五 |
73
85
 
74
86
  ---
75
87
 
@@ -123,7 +135,7 @@ git tag v0.3.16 && git push origin v0.3.16
123
135
  | 文件 | 管什么 | 什么时候读 |
124
136
  |---|---|---|
125
137
  | [README](./README.md) | 用户看的门面:功能、模型、安装 | 改用户可见行为时 |
126
- | [docs/internals.md](./docs/internals.md) | 16 章实测:两站同构、三层凭证、接口清单、协议要点 | 改代码前按章查 |
138
+ | [docs/internals.md](./docs/internals.md) | 17 章实测:两站同构、三层凭证、接口清单、协议要点、工具 schema 兼容 | 改代码前按章查 |
127
139
  | [CHANGELOG.md](./CHANGELOG.md) | 每版一节 | 写新版本时 |
128
140
  | [CHECKLIST.md](./CHECKLIST.md) | 两张勾选表 | 两个交付门 |
129
141
  | 脚手架 [AGENTS.md](../magpie-plugin-scaffold/AGENTS.md) | 通用坑(九项清单、三条铁律、check 能查什么) | 第一次接触这个插件族时 |
package/CHANGELOG.md CHANGED
@@ -1,5 +1,223 @@
1
1
  # 更新记录
2
2
 
3
+ ## 0.3.24 — 2026-10-28
4
+
5
+ **修 `items: {}` 导致的整轮 400**
6
+
7
+ ### 症状
8
+
9
+ 用户的一次普通对话直接失败,界面只显示:
10
+
11
+ ```
12
+ 本轮运行失败 400: {"code":null,"message":"超级麦吉: HTTP 400 请求错误:
13
+ {\"error\":{\"code\":\"InvalidParameter\",\"message\":\"tools.40.function.parameters/
14
+ properties/functions/items: invalid JSON Schema; check the schema keywords
15
+ and references Request id: ...\",\"param\":\"\",\"type\":\"BadRequest\"}}",
16
+ "param":null,"type":"invalid_request_error"}
17
+ ```
18
+
19
+ ### 根因
20
+
21
+ 某个工具的参数 schema 里,数组的 `items` 是**空对象 `{}`**。上游的 JSON Schema
22
+ 校验器只拒这一种空 schema 形态;`items:{description:"x"}`、`items:{type:"string"}`、
23
+ 缺省 `items` 都是 200。国内站 `magicrew` 与国际站 `magicrew-intl` 的报错逐行一致。
24
+
25
+ 按 JSON Schema 规范 `items:{}` 是合法的("任意元素"),客户端没写错;这是上游
26
+ 兼容性不足。但插件是唯一能改请求体的地方。
27
+
28
+ ### 修法
29
+
30
+ `src/hooks/loader.mjs` 新增 `loosenEmptyItems()`,在 `rewriteRequestBody` 里递归扫
31
+ `tools[].function.parameters`:
32
+
33
+ ```
34
+ items: {} → 删除 // JSON Schema 缺省值就是 {},严格等价
35
+ items: true → 删除 // 同上;上游对它回的是另一种 400
36
+ items: false → 不动 // 「数组必须为空」,删掉就是放开
37
+ items: {...} → 不动 // 有任何一个键,上游都收
38
+ ```
39
+
40
+ 与 `additionalProperties`、`max_tokens`、`stream_options` 共用一次 `JSON.parse`;
41
+ 没改到时仍返回原始字符串。
42
+
43
+ ### 验证
44
+
45
+ - `tests/body-rewrite.mjs`:31 个断言(新增 11 个),`items:{}` / `true` 删除、
46
+ `{description}` / `{type}` / `false` 不动、`tools[40]` 命中、combined 改写、不误伤。
47
+ - 端到端(2026-10-28):41 个工具、第 41 个带 `properties.functions.items:{}`,
48
+ 原样请求 → `400 tools.40.../items`;过 `rewriteRequestBody` → `200`。
49
+ 国内站、国际站都跑过。
50
+
51
+ ## 0.3.23 — 2026-10-28
52
+
53
+ **修工具 schema 兼容 + 网关路径拿错 key,顺手把「签一把顶一把」测清楚了**
54
+
55
+ ### 一、上游不认 `additionalProperties: {}`
56
+
57
+ 接 ZCode / Claude Code 系客户端时,一次带 54 个工具定义的请求必回
58
+ `400 {"error":{"message":"模型响应失败","code":4017}}`。根因是这些客户端把
59
+ "允许任意属性"写成空对象,而上游适配层只认布尔值。
60
+
61
+ 同一把 key、同一份请求、只改这一个字段(2026-10-28 实测,glm-5.3-flash 与
62
+ deepseek-v4.1-flash **逐行一致**):
63
+
64
+ ```
65
+ additionalProperties: 缺省 → 200
66
+ additionalProperties: false → 200
67
+ additionalProperties: true → 200
68
+ additionalProperties: {} → 400 code 4017
69
+ additionalProperties: {type:"string"} → 400 code 4017
70
+ ```
71
+
72
+ **上游不是「只认布尔」,是根本不支持 schema 形态** —— 只处理空对象不够。这一点是拿
73
+ 改动端到端跑一遍才发现的:对照组 400 → 只改空对象**仍然 400** → 非布尔全换 true 才 200。
74
+
75
+ `rewriteRequestBody` 里加递归规范化,把**所有非布尔值**换成 `true`。
76
+ `{}` → `true` 是严格等价;`{type:"string"}` → `true` 是**降级放宽**、丢了约束,
77
+ 但上游无法表达它,留原值就是整个请求失败。方向安全(放宽不是收紧)。
78
+
79
+ 请求体三处改写(max_tokens / stream_options / additionalProperties)合并成**一次**
80
+ `JSON.parse`,没改到时返回原始字符串(54 个工具的请求体可达上百 KB)。
81
+
82
+ > 这处改动来自社区 PR #8(@baijiu666),按其最小复现验证后采纳。
83
+
84
+ ### 二、网关路径下拿不到正确账号的 key
85
+
86
+ agent 从 magpie 的 `/v1/chat/completions` 进来时,`getAuth()` 返回的 auth
87
+ **可能不带 org**,`accountOf()` 于是落到 `"default"` —— 读不到真实账号的 key 槽,
88
+ `ensureKey` 只能退回 `auth.access`;它过了 2 小时就 403,表现成
89
+ `502 {"message":"超级麦吉 did not stream: token无效"}`。
90
+
91
+ 新增 `accountFor()`:拿不到标识时,**只有该站点恰好一个真实账号**才退到它。
92
+ 0 个沿用原来的「首次接管」;**≥2 个不猜** —— 用错账号的 key 是拿别人的积分、
93
+ 别人的组织出话,本地看不出来,不是报错是错账。
94
+
95
+ ⚠ 多账号 + 网关路径这个组合仍然无解:需要 magpie 把 provider 实例 id 传给插件
96
+ (`plugin-auth.json` 里已经有 `magicrew#p4nwa7` 这种 key),插件侧拿不到。
97
+ 在那之前只能保守 —— 宁可这次请求失败,也不借身份。
98
+
99
+ ### 三、「签一把顶一把」实测推翻(不涉及代码,只更正文档)
100
+
101
+ `docs/internals.md` 第十四节一直挂着「要判定需要一次受控实验」。做完了:
102
+ 本机 12 把 key 逐把打 `GET /v1/models`(只读、不签发、不吊销),结果是
103
+
104
+ - 同账号两把相隔 **67 秒**签发的 key **同时有效**
105
+ - 所有 403 都对应 `expiresAt` 已过(剩 -173~-541 分),**没有**「时间没到就失效」的样本
106
+
107
+ ⇒ 后签的**不吊销**先签的;唯一失效条件是 2 小时到期。`AGENTS.md` 坑 3 与
108
+ internals 十三节据此更正。「只留一把、只走唯一出口」的设计保留,理由改为
109
+ 「本地别分叉」而不是「互相吊销」。
110
+
111
+ ### 测试
112
+
113
+ - `tests/body-rewrite.mjs` —— 20 个断言:只换空对象、非空 schema 不动、
114
+ 其余字段一字不动、没改就不重新序列化
115
+ - `tests/account-fallback.mjs` —— 10 个断言:单账号才兜、多账号不猜、空壳文件不算账号
116
+
117
+ ---
118
+
119
+ ## 0.3.22 — 2026-10-28
120
+
121
+ **余额窗口保留「今日 / 限时 / 永久」拆分展示**
122
+
123
+ 0.3.21 只发一条总余额,路由对了,但看不出每日 23:59 到期的额度。现在在
124
+ `积分余额` 余额窗口之后追加三条只展示的 `aside` 文本行:
125
+
126
+ - `今日积分`:`185 / 200 积分 · 今日 23:59 到期`
127
+ - `限时积分`:剩余限时批次 + 最早到期日
128
+ - `永久积分`:不过期部分
129
+
130
+ 它们不带 `used` / `limit` / `balance`,宿主把它们当 `aside` 文本,不参与
131
+ `Allowance.Left`,路由仍只按 `积分余额` 排序;总余额不会被拆分窗口压低。
132
+
133
+ ## 0.3.21 — 2026-10-28
134
+
135
+ **积分改为 magpie 的「余额型窗口」:不再编造百分比**
136
+
137
+ magpie 0.1.1790(issue #3950)起支持 `usage.windows[].balance`:只报余额和单位,
138
+ 不写 `used` / `limit` / `aside`。本版把原来「今日 / 限时 / 永久」三条假百分比窗口
139
+ 合并成一条 `积分余额`:
140
+
141
+ - `balance = total_points`、`unit = "积分"`;
142
+ - `runsOutAt` / `runsOut` 取最早过期的一批,让 smart 路由知道谁先失效;
143
+ - 余额 0 也报 `balance: 0`,由宿主判定为用完,不再合成 `used: 100`。
144
+
145
+ 宿主现在按同 provider、同单位的余额从多到少排;不再显示「总量 = 余额」的
146
+ 100% 假进度条。旧版三窗口写法是宿主支持余额前的过渡,已删除。
147
+
148
+ ## 0.3.20 — 2026-10-28
149
+
150
+ **积分按「什么时候失效」拆成三块:今日 / 限时 / 永久**
151
+
152
+ 0.3.19 把整池积分压成一条窗口,百分比是「总量 = 余额」的恒等式,没有信息量。
153
+ 现在拆成三块(数据都来自 `/api/v1/quota/points/total`):
154
+
155
+ | 窗口 | 是什么 | 百分比 | 参与路由 |
156
+ |---|---|---|---|
157
+ | **今日积分** | 今天 23:59:59 到期的批次(每日登录赠送,国内 200 / 国际 50) | **真的**(分母是每日额度) | 是 |
158
+ | **限时积分** | 其余会过期批次的剩余之和 | 无(服务端不给原始额度) | 是(带 `runsOutAt`) |
159
+ | **永久积分** | `total - Σ限时`,不过期 | — | 否(`aside` 只展示) |
160
+
161
+ 三块加起来正好等于 `total_points`,实测四个号都对得上。
162
+
163
+ **为什么「限时积分」条目不标 `aside`**:标了会被 `allowanceOf()` 整条跳过,
164
+ `runsOutAt` 就进不了路由 —— 这个号又会变回「未知」。所以它给 `used: 0` +
165
+ `limit = 当前余量`,让 magpie 收下它并读到 `runsOutAt`。百分比(恒 100%)无意义,
166
+ 但路由能按「谁的积分先过期」排。
167
+
168
+ **关于「7 个依次递减的到期时间」**:`runsOutAt` 是单值字段(`*time.Time`),
169
+ 语义就是「最早失效的那一批」,所以 7 个批次也只会出现一条提醒,不会堆成 7 条。
170
+ magpie 自己的 WorkBuddy 插件就是这么用的。magicrew 的每日登录积分是
171
+ 「当天 23:59:59 到期」(`expire_type: 1`,不累积),更不存在滚动多批。
172
+
173
+ **为什么还是要给 magpie 提 issue**:magpie 的契约把「有进度条」和「参与路由」
174
+ 焊在同一个窗口上 —— 要进度条就得给 `used`+`limit`,钱包没有分母就只能编;
175
+ 不编就判成纯文本 + `aside`,路由又当未知。三条不可能同时满足。
176
+ 真正的解是宿主支持「余额型窗口」:有读数、能参与路由,但不参与百分比。
177
+
178
+ ## 0.3.19 — 2026-10-28
179
+
180
+ **路由页不再说「剩余额度未知」:窗口不能标 `aside`**
181
+
182
+ 0.3.18 把积分窗口改成 `aside: true` + 只给 `display` —— 卡片好了,
183
+ **路由页还是「剩余额度未知」**。
184
+
185
+ 根因在 magpie 宿主侧,读源码可证:
186
+
187
+ ```
188
+ provider/routing.go allowanceOf() if (w.Aside || w.Text) { continue } ← 整条跳过
189
+ provider/routing.go routedAllowance() if len(a) == 0 { return nil, false } ← 一条不剩 ⇒ 不 ok
190
+ gateway/trace.go weighed() if l, ok := wg.lefts[key]; ok { w.Known = true }
191
+ routing/client.js w.known 为假且 kind == "account" ⇒ 「剩余额度未知」
192
+ ```
193
+
194
+ 官方文档也逐字写着(`docs/subsystems/provider-plugins.md`,issue #3650):
195
+
196
+ > An account whose windows all tell no share has no allowance, so routing counts
197
+ > it not known ... as one never read. A window that says `used: 0` is a reading
198
+ > at 0, as before.
199
+
200
+ ⇒ **`aside` 的代价不是「不参与排序」,是这个号在路由里整个消失**
201
+ (unknown 一律排在已知的之后)。
202
+
203
+ 现在改成钱包的恒等式:`limit = 余额`、`amount = 0`、`used = 0`,**不加 `aside`**。
204
+ `used` 仍然是「已用 0」而不是缺省 —— 缺省会被 magpie 判成「没有份额的文本行」,
205
+ 又回到未知。
206
+
207
+ 判据(2026-10-28 实测,两站四个号):
208
+
209
+ | | 修复前 | 修复后 |
210
+ |---|---|---|
211
+ | `/v1/magpie/trace` 的 `known` | **字段缺失** | `true`,`limit` = 余额 |
212
+ | 路由页那一行 | 剩余额度未知 | `1,363 / 1,363 积分 · 剩余 100%` |
213
+
214
+ A/B 都跑过:把 `aside: true` 加回去、重启 magpie,`known` 立刻变回缺失;
215
+ 换回来就恢复 `true`。
216
+
217
+ ⚠ 代价说清楚:这样算出来的百分比**没有信息量**(分母就是分子)——
218
+ magpie 的额度契约是为「周期配额」设计的,钱包型额度它没有专门表达。
219
+ 真正该修的是宿主侧,插件侧只能给出最接近的形态。
220
+
3
221
  ## 0.3.18 — 2026-10-28
4
222
 
5
223
  - **AGENTS 新增「零、magpie 升级时:只看稳定版」** —— magpie 有「更新渠道:每个版本 / 稳定版」,用户默认跑稳定版。两渠道实测差 **400 多个版本**(每版本 0.1.1763 / 稳定版 0.1.1327),但插件相关的契约字段**一条不差** ⇒ 追每版本几乎全是白跑。判断依据只有一条:**稳定版版本号变了**才需要重新同步
package/docs/internals.md CHANGED
@@ -760,15 +760,62 @@ mgw_8f9a…(18:14 又一次续期换的) → 网关请求 HTTP 200 正
760
760
 
761
761
  两把都活着。所以「一签就吊销」不成立。
762
762
 
763
- 那第十三节那次 `mgw_63d4…` 为什么 403?现在只能说**不确定**,可能的原因三种,没分辨开:
763
+ 那第十三节那次 `mgw_63d4…` 为什么 403?当时只能说**不确定**,三种可能没分辨开:
764
764
 
765
765
  1. 它真的过期了(2 小时),而本地 `expires` 字段显示还没到 —— 说明 `expires` 不可信
766
766
  2. `PUT`(用 refresh_token 续期)会轮换/吊销旧的 `api_key`,而 `POST`(签发)不会
767
767
  3. 别的吊销条件(重新登录、并发续期)
768
768
 
769
- **要判定需要一次受控实验**:同一账号 POST 签两把、隔着时间各测一次,再把 PUT 也测一遍。
770
- 在那之前,插件按「可能有吊销」来设计(只留一把、只走一个出口)是安全的,
771
- 但**不要把它当成已知事实往下传**。
769
+ ### ✅ 结论(2026-10-28 晚,受控实验做完了)
770
+
771
+ 上面说「要判定需要一次受控实验」。实验做了,**答案落在第 1 条:只有「真的超过 2 小时」会 403,
772
+ 「签一把顶一把」不成立,`expires` 也只是个理论值**。
773
+
774
+ 做法:把本机**所有** key(magpie 的 `plugin-auth.json` 的 access + key-store 每个账号每个槽)
775
+ 逐把打 `GET /v1/models`——只读、不签发、不吊销、不花钱。探针留在仓库外,
776
+ 结论是这一张表(`剩余` = `expiresAt - now`,单位分钟):
777
+
778
+ ```
779
+ 来源 站点 账号 槽 剩余(分) 判定
780
+ magpie magicrew-intl 67u4oe6fbenj access 116 HTTP 200 MODELS=65
781
+ magpie magicrew 7fli52t2tc2b access 115 HTTP 200 MODELS=44
782
+ magpie magicrew 66p0q56qo47j access 115 HTTP 200 MODELS=44
783
+ magpie magicrew h97gkbu8bruj access 115 HTTP 200 MODELS=44
784
+ store magicrew-intl 67u4oe6fbenj a* 116 HTTP 200 MODELS=65
785
+ store magicrew-intl 67u4oe6fbenj b -181 code=403 "token无效"
786
+ store magicrew 66p0q56qo47j a* -227 code=403 "token无效"
787
+ store magicrew 66p0q56qo47j b -541 code=403 "token无效"
788
+ store magicrew 7fli52t2tc2b a* -173 code=403 "token无效"
789
+ store magicrew 7fli52t2tc2b b -286 code=403 "token无效"
790
+ store magicrew h97gkbu8bruj a* 117 HTTP 200 MODELS=44
791
+ store magicrew h97gkbu8bruj b -228 code=403 "token无效"
792
+ ```
793
+
794
+ 两条判据:
795
+
796
+ **① 403 与「剩余分钟数」完全一致** —— 每一个 403 的剩余都是负数(-173 到 -541),
797
+ 每一个 200 的剩余都是正数。**没有一个「时间没到却不认」的样本。**
798
+ 所以「本地时间没到但服务端已经吊销」这个状态,本次实测里根本不存在。
799
+
800
+ **② 同一账号同时有两把活 key,签发时刻相差 67 秒:**
801
+
802
+ ```
803
+ magpie access expires 2026-10-28T14:39:56.898Z → 200
804
+ store 槽 a expires 2026-10-28T14:41:03.000Z → 200
805
+ ```
806
+
807
+ 两把都是 2 小时有效期,也就是一把签于 12:39:56、另一把签于 12:41:03 —— **后签的没有顶掉先签的**。
808
+
809
+ ⇒ **一条账号一把活 key 这个说法彻底作废**。`POST` 与 `PUT` 都不吊销已签出的 key,
810
+ 唯一的失效条件是 2 小时到期。
811
+
812
+ ### 那这条为什么必须写下来
813
+
814
+ 2026-10-28 的 PR #8 就是被「时间没到但已被顶掉」这个前提带偏的:它据此把
815
+ `ensureKey` 改成「优先用 `auth.access`」,并加了一个跨账号挑活 key 的兜底。
816
+ 前提不成立 ⇒ 改动没有依据;而那个兜底会**把别的账号的 key 交出去**
817
+ (实测:auth 无 org 时 PR 分支返回了 `h97gkbu8bruj` 账号的 key,
818
+ 而 main 返回调用方自己的)。教训不是「多加一层兜底」,是**先把前提测了再改代码**。
772
819
 
773
820
  ### 保活的正确说法
774
821
 
@@ -1005,3 +1052,131 @@ GET /v1/models → id / object / created_at / owner_by
1005
1052
 
1006
1053
  magicrew 既不返回 `maxInputTokens`(`flows/models` 的 `configs` 只有 temperature),
1007
1054
  `/v1/models` 也只有 `id / object / created_at / owner_by` —— 所以走模型库这条路。
1055
+
1056
+ ---
1057
+
1058
+ ## 十七、上游不认空 schema —— `additionalProperties: {}` 与 `items: {}`
1059
+
1060
+ > 两处是同一类兼容性问题的两个面:客户端按 JSON Schema 规范写出**空对象**,
1061
+ > 上游的校验器/适配层拒收。都只能在请求改写里兜。
1062
+
1063
+ ### 一、`additionalProperties: {}` → `400 code 4017`
1064
+
1065
+ **症状**:接 ZCode / Claude Code 系客户端时,一次带 54 个工具定义的请求必定失败:
1066
+
1067
+ ```
1068
+ POST /v1/chat/completions
1069
+ → HTTP 400 {"error":{"message":"模型响应失败","code":4017","request_id":"..."}}
1070
+ ```
1071
+
1072
+ 换成简单请求(一两个工具)就正常 —— 所以看起来像"某些模型不行"或"工具太多"。
1073
+
1074
+ ### 根因
1075
+
1076
+ 这些客户端生成工具 schema 时,会把"允许任意额外属性"写成**空对象**:
1077
+
1078
+ ```json
1079
+ "parameters": { "type": "object", "properties": {...}, "additionalProperties": {} }
1080
+ ```
1081
+
1082
+ 按 JSON Schema(draft-07)这是合法的 —— `additionalProperties` 的值是 schema 时,
1083
+ `{}` 表示"任意,不加约束",与 `true` 语义等价。但**上游的适配层只认布尔值**,
1084
+ 收到一个对象就整个请求拒掉,还只回一句"模型响应失败",不说是哪个字段。
1085
+
1086
+ ### 最小复现(2026-10-28,同一把 key、同一份请求、只改这一个字段)
1087
+
1088
+ | `additionalProperties` | glm-5.3-flash | deepseek-v4.1-flash |
1089
+ |---|---|---|
1090
+ | 缺省 | 200 | 200 |
1091
+ | `false` | 200 | 200 |
1092
+ | `true` | 200 | 200 |
1093
+ | `{}` | **400 code 4017** | **400 code 4017** |
1094
+ | `{type:"string"}` | **400 code 4017** | **400 code 4017** |
1095
+ | `{type:"string",description:"x"}` | **400 code 4017** | **400 code 4017** |
1096
+
1097
+ **两个模型逐行一致。结论比「上游只认布尔」更狠:它根本不支持 schema 形态** ——
1098
+ 不只是空对象,连 `{type:"string"}` 这种有约束的 schema 也一律拒。这一点很关键:
1099
+ 只处理空对象的改写在真实请求里仍会 400(实测过,见下方「修法」)。
1100
+
1101
+ ### 修法:请求改写里做等价替换
1102
+
1103
+ `src/hooks/loader.mjs` 的 `rewriteRequestBody` 里,`loosenAdditionalProperties()`
1104
+ 递归扫 `tools[].function.parameters`,把**所有非布尔值**换成 `true`:
1105
+
1106
+ ```js
1107
+ "additionalProperties": 缺省 → 不动 // 等价于 true
1108
+ "additionalProperties": true → 不动 // 上游认
1109
+ "additionalProperties": false → 不动 // 「不许额外属性」,换掉就是反向放开
1110
+ "additionalProperties": {} → true // 等价改写
1111
+ "additionalProperties": {type:"x"} → true // ⚠ 降级放宽,丢了约束
1112
+ ```
1113
+
1114
+ **为什么接受那个不等价**:`{}` → `true` 是严格等价(都表示「任意,不加约束」);
1115
+ `{type:"string"}` → `true` 丢掉了「额外属性必须是 string」这条约束。但上游无法表达它,
1116
+ 保留原值就是整个请求失败。两害相权,让工具调用能跑(可能多出未声明的字段)
1117
+ 比让 agent 完全不可用要好;方向也安全 —— 是放宽不是收紧,不会让原本合法的调用被拒。
1118
+
1119
+ 它与 `max_tokens`、`stream_options`、`items: {}` 四处改写共用**一次** `JSON.parse`;
1120
+ 没改到时返回原始字符串(省一次序列化 —— 54 工具的请求体可达上百 KB)。
1121
+
1122
+ 边界由 `tests/body-rewrite.mjs` 钉住(31 个断言):非布尔一律换、布尔一律不动、
1123
+ 其余字段一字不动、没改到就不重新序列化。
1124
+
1125
+ ### 二、`items: {}` → `400 invalid JSON Schema`
1126
+
1127
+ **症状**(2026-10-28,用户截图原文):
1128
+
1129
+ ```
1130
+ HTTP 400
1131
+ {"code":null,"message":"超级麦吉: HTTP 400 请求错误: {\"error\":{\"code\":\"InvalidParameter\",
1132
+ \"message\":\"tools.40.function.parameters/properties/functions/items: invalid JSON Schema;
1133
+ check the schema keywords and references Request id: ...\",\"param\":\"\",\"type\":\"BadRequest\"}}",
1134
+ "param":null,"type":"invalid_request_error"}
1135
+ ```
1136
+
1137
+ 与 `additionalProperties` 那条不同:这次上游**给了完整路径**(`tools.40.../items`),
1138
+ 而且国内站与 `magicrew-intl` 的报错逐行一致。
1139
+
1140
+ **最小复现**:41 个工具,第 41 个的参数是
1141
+
1142
+ ```json
1143
+ {"type":"object","properties":{"functions":{"type":"array","items":{}}},"required":["functions"]}
1144
+ ```
1145
+
1146
+ | `items` | 结果 |
1147
+ |---|---|
1148
+ | 缺省 | 200 |
1149
+ | `{}` | **400 invalid JSON Schema**(路径 `.../properties/functions/items`) |
1150
+ | `{description:"x"}` | 200 —— 空对象才炸,不是"缺 type" |
1151
+ | `{type:"string"}` | 200 |
1152
+ | `true` | 400 `模型响应失败`(另一条 400) |
1153
+ | `false` | 400 `模型响应失败` |
1154
+
1155
+ **根因**:客户端用 `z.array(z.unknown())` / 动态函数列表生成 schema 时,数组元素序列化成
1156
+ `{}`。JSON Schema 里 `items` 的缺省值本来就是 `{}` —— "任意元素",所以
1157
+ `items: {}` 删掉是**严格等价**,不是降级放宽;`items: true` 同理。
1158
+ `items: false`("数组必须为空")语义不同,不能删,原样留着。
1159
+
1160
+ **修法**:`loosenEmptyItems()` 递归删 `items: {}` 与 `items: true`,与
1161
+ `loosenAdditionalProperties()` 在同一个 `rewriteRequestBody` 里各跑一遍。
1162
+
1163
+ **端到端验证**(2026-10-28):同一份 41 工具请求
1164
+ `raw → 400 tools.40.../items`;过 `rewriteRequestBody` 后 `→ 200`。
1165
+ 国内站、国际站都验过。
1166
+
1167
+ ### 为什么算插件的事,不算客户端的事
1168
+
1169
+ 按规范 `{}` 与 `true` 等价,客户端没写错;**上游的兼容性不足**。
1170
+ 但插件是唯一能改请求体的地方(见 `src/hooks/loader.mjs` 顶部),
1171
+ 在这里兜住比让每个用户去改 ZCode 的源码现实。
1172
+
1173
+ ### 同一处这些改写的两个教训
1174
+
1175
+ 同一次排查里还确认了两件事,都值得记住:
1176
+
1177
+ 1. **上游对"token 无效"回的是 HTTP 200**,body `{"code":403,"message":"token无效"}`,
1178
+ 且 `error` 字段是空数组(falsy)。**只看 HTTP 状态码会把失败当成功** ——
1179
+ 判断得用 `j.code !== 200`。排查时在这上面绕了很久。
1180
+ 2. **插件宿主是运行时从 `~/.cache/magpie/bun/install-cache/` 加载的**,
1181
+ 不在 `plugin-host/host-*.js` 里;magpie 更新插件后会换目录(目录名带 commit hash),
1182
+ 要改当前实际加载的那个。
@@ -0,0 +1,90 @@
1
+ ## TL;DR
2
+
3
+ A provider that sells **prepaid credit** — a wallet — cannot be described by the current usage contract:
4
+
5
+ - **With windows**, `used` must be a share of a periodic allowance. A wallet has no such denominator, so any `used` we send is fabricated.
6
+ - **Without windows**, `routedAllowance` returns not-ok, `weighed()` leaves `Known` unset, and the Routing page says 「剩余额度未知」 — the account ranks after every known one (#3650).
7
+ - **`aside: true` does not help**: `allowanceOf()` skips those windows whole, with the same result.
8
+
9
+ What is missing is a **balance-shaped reading**: "this much is left", in a unit, with no percentage. Two fields would carry it — `QuotaWindow.Balance` and `routes.Seat.Left`.
10
+
11
+ ## The provider (real numbers, 2026-10-28)
12
+
13
+ SuperMagic (超级麦吉 / MagiCrew), community plugin `magpie-magicrew-auth`.
14
+
15
+ `GET /api/v1/quota/points/total` answers:
16
+
17
+ ```json
18
+ {"total_points": 6291,
19
+ "expiring_quota_details": [
20
+ {"remaining_points": 185, "expires_at": "2026-10-28 23:59:59"},
21
+ {"remaining_points": 100, "expires_at": "2026-11-26 16:45:13"},
22
+ {"remaining_points": 956, "expires_at": "2027-11-03 19:11:01"}],
23
+ "organization_limit_points": null,
24
+ "user_limit_points": null,
25
+ "next_cycle_grant": null}
26
+ ```
27
+
28
+ - No total, no consumed: `*_limit_points` are `null` on personal accounts.
29
+ - The ledger endpoint `POST /api/v1/quota/points/records/queries` answers **HTTP 500** on both sites (five body shapes tried), so "how much was spent" is not readable either.
30
+ - The only fixed number is the daily login gift — **200 points/day (CN), 50 (intl)** — which expires at 23:59:59 the same day.
31
+
32
+ ## What the plugin tried, and what magpie did
33
+
34
+ | plugin sends | magpie | result |
35
+ |---|---|---|
36
+ | `aside: true, used: 0, display: "6,291 积分"` | `allowanceOf()` skips Aside/Text whole; none left → `routedAllowance` not ok → `weighed()` leaves `Known` unset | Routing page 「剩余额度未知」; the account ranks after every known one |
37
+ | `used: 0, display: "6,291 积分"` (no `limit`) | known, but routing's `quota()` shows the count only when `limit > 0` | row reads `0% used`, no figure |
38
+ | `used: 0, amount: 0, limit: 6291` | known, figure shown | percentage is `0 / 6291` — a tautology (denominator = remainder); the "fake share" of #3353 |
39
+
40
+ ## What it costs the router
41
+
42
+ `routes.Seat` carries `Used float64` (a 0–100 share), `Soon []time.Time`, `Lapse`, `Pace`, … — **nothing that says how much is left**.
43
+
44
+ - **`pace`**: `(100 - used) / hours until resetsAt`. A wallet has no `resetsAt` and its `used` is always 0, so every account of this provider gets **the same pace**. 6,291 points and 750 points are indistinguishable.
45
+ - **`smart`**: the fine tier is ordered by `Soon` (`Allowance.Renewal`, which does fold `runsOutAt` in), then `Lapse` — it can say *whose points expire first*, never *who has more left*.
46
+
47
+ Measured on 4 accounts: 1,363 / 1,693 / 6,291 / 750 points; earliest expiries 178 / 29 / 29 / 372 days out. Under `smart` all four tie on `Soon` whenever the expiry days match; under `pace` they are **exactly** identical.
48
+
49
+ ## Suggested shape
50
+
51
+ **`QuotaWindow`**
52
+
53
+ ```go
54
+ // Balance is how much of this allowance is left, in Unit — a reading of a
55
+ // prepaid balance, with no share to tell (Used stays nil for it).
56
+ Balance *float64 `json:"balance,omitempty"`
57
+ ```
58
+
59
+ `allowanceOf` keeps such a window (`l.Balance = *w.Balance`), so `Known` is true, without inventing a `Used`.
60
+
61
+ **`routes.Seat`**
62
+
63
+ ```go
64
+ // Left is the absolute remainder in the provider's own unit: comparable
65
+ // within one provider, 0 when the vendor doesn't say.
66
+ Left float64
67
+ ```
68
+
69
+ Filled from the windows' `Balance` (or the fullest window's `Amount`/`Of` when the vendor does count).
70
+
71
+ **Ordering**: `smart` / `pace` fall back to `Left` (most first) when shares are unknown or equal. A prepaid provider then becomes rankable by both dimensions that matter: *who runs out first* (`runsOutAt`) and *who has the most left* (`balance`).
72
+
73
+ ## Today's plugin-side workaround (for reference)
74
+
75
+ Three windows, so the account is at least known and carries `runsOutAt`:
76
+
77
+ 1. **今日积分** — today's batch, **real percentage** (denominator = the 200/50 daily gift), `resetsAt` = next midnight.
78
+ 2. **限时积分** — the rest of the expiring batches, `used: 0`, `limit` = current remainder, `runsOutAt` = earliest.
79
+ 3. **永久积分** — `total − Σexpiring`, `aside`, display only.
80
+
81
+ That is the best the current contract allows — and it still cannot express "how much".
82
+
83
+ ## Related
84
+
85
+ - #3428 — the seat knows *when* but not *how much* is lost (`w.RunsOut` dropped in `allowanceOf`). This issue is the same gap one layer up: the seat also cannot see *how much is left at all*.
86
+ - #3352 — a `Windows: []` reading with a balance is dropped entirely, so the account is unknown.
87
+ - #3353 — a window that never moves reads as `used: 0` and distorts smart ordering.
88
+ - #3650 — a window without `used` becomes text + aside, no share.
89
+
90
+ Happy to send a PR for the two fields if the shape looks right — I can test it against this provider directly.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "magicrew-auth",
3
- "version": "0.3.18",
3
+ "version": "0.3.24",
4
4
  "description": "超级麦吉 / MagiCrew for magpie and OpenCode — one package, two providers: letsmagic.cn (国内) and magicrew.ai (international). Password or web-JWT sign-in, the account's own live model list, points balance, newcomer rewards, invite codes.",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",