magicrew-auth 0.3.18 → 0.3.23

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,23 @@ 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
+
71
+ **坑 14 的两个附带判据**(都实测过):
72
+
73
+ - **改了 `src/hooks/usage.mjs` 要重启 magpie 才生效** —— 插件跑在 `magpie-bun` 子进程里,
74
+ 模块缓存不随文件变化失效。改完先 `Stop-Process` 再 `Start-Process` 同一个 exe,
75
+ 然后 `curl /v1/magpie/quotas` 看窗口里的 `spans`:`[{}]` = 没标 aside,`[{"aside":true}]` = 还标着。
76
+ - **额度读取有 1 分钟缓存**,改完立刻看可能还是旧值。
67
77
 
68
78
  补充一条最容易误信的旧说法:
69
79
 
70
80
  | 症状 | 判据 | 出处 |
71
81
  |---|---|---|
72
82
  | 以为 checkin 会「顺带续 key」 | **从来没有**。`src/hooks/checkin.mjs` → `src/tasks.mjs` 全链路没 import 过 `ensureKey`;保活由 magpie 按 `refreshLead` 主动调 `auth.refresh` | internals 十四 · CHANGELOG 0.x |
83
+ | 以为「签一把顶一把」、`key-store` 里的 key 会「时间没到就被顶掉」 | **已实测推翻(2026-10-28)**:同账号两把相隔 67 秒签的 key 同时有效;12 把 key 逐把探活,403 **全部**对应 `expiresAt` 已过(剩 -173~-541 分),没有一个「时间没到就失效」的样本。仍只留一把、只走唯一出口的理由是「本地别分叉」,不是「互相吊销」。据此加「跨账号挑活 key」的兜底 = 把别人的 key 交出去 | internals 十四 · 十五 |
73
84
 
74
85
  ---
75
86
 
@@ -123,7 +134,7 @@ git tag v0.3.16 && git push origin v0.3.16
123
134
  | 文件 | 管什么 | 什么时候读 |
124
135
  |---|---|---|
125
136
  | [README](./README.md) | 用户看的门面:功能、模型、安装 | 改用户可见行为时 |
126
- | [docs/internals.md](./docs/internals.md) | 16 章实测:两站同构、三层凭证、接口清单、协议要点 | 改代码前按章查 |
137
+ | [docs/internals.md](./docs/internals.md) | 17 章实测:两站同构、三层凭证、接口清单、协议要点、工具 schema 兼容 | 改代码前按章查 |
127
138
  | [CHANGELOG.md](./CHANGELOG.md) | 每版一节 | 写新版本时 |
128
139
  | [CHECKLIST.md](./CHECKLIST.md) | 两张勾选表 | 两个交付门 |
129
140
  | 脚手架 [AGENTS.md](../magpie-plugin-scaffold/AGENTS.md) | 通用坑(九项清单、三条铁律、check 能查什么) | 第一次接触这个插件族时 |
package/CHANGELOG.md CHANGED
@@ -1,5 +1,175 @@
1
1
  # 更新记录
2
2
 
3
+ ## 0.3.23 — 2026-10-28
4
+
5
+ **修工具 schema 兼容 + 网关路径拿错 key,顺手把「签一把顶一把」测清楚了**
6
+
7
+ ### 一、上游不认 `additionalProperties: {}`
8
+
9
+ 接 ZCode / Claude Code 系客户端时,一次带 54 个工具定义的请求必回
10
+ `400 {"error":{"message":"模型响应失败","code":4017}}`。根因是这些客户端把
11
+ "允许任意属性"写成空对象,而上游适配层只认布尔值。
12
+
13
+ 同一把 key、同一份请求、只改这一个字段(2026-10-28 实测,glm-5.3-flash 与
14
+ deepseek-v4.1-flash **逐行一致**):
15
+
16
+ ```
17
+ additionalProperties: 缺省 → 200
18
+ additionalProperties: false → 200
19
+ additionalProperties: true → 200
20
+ additionalProperties: {} → 400 code 4017
21
+ additionalProperties: {type:"string"} → 400 code 4017
22
+ ```
23
+
24
+ **上游不是「只认布尔」,是根本不支持 schema 形态** —— 只处理空对象不够。这一点是拿
25
+ 改动端到端跑一遍才发现的:对照组 400 → 只改空对象**仍然 400** → 非布尔全换 true 才 200。
26
+
27
+ `rewriteRequestBody` 里加递归规范化,把**所有非布尔值**换成 `true`。
28
+ `{}` → `true` 是严格等价;`{type:"string"}` → `true` 是**降级放宽**、丢了约束,
29
+ 但上游无法表达它,留原值就是整个请求失败。方向安全(放宽不是收紧)。
30
+
31
+ 请求体三处改写(max_tokens / stream_options / additionalProperties)合并成**一次**
32
+ `JSON.parse`,没改到时返回原始字符串(54 个工具的请求体可达上百 KB)。
33
+
34
+ > 这处改动来自社区 PR #8(@baijiu666),按其最小复现验证后采纳。
35
+
36
+ ### 二、网关路径下拿不到正确账号的 key
37
+
38
+ agent 从 magpie 的 `/v1/chat/completions` 进来时,`getAuth()` 返回的 auth
39
+ **可能不带 org**,`accountOf()` 于是落到 `"default"` —— 读不到真实账号的 key 槽,
40
+ `ensureKey` 只能退回 `auth.access`;它过了 2 小时就 403,表现成
41
+ `502 {"message":"超级麦吉 did not stream: token无效"}`。
42
+
43
+ 新增 `accountFor()`:拿不到标识时,**只有该站点恰好一个真实账号**才退到它。
44
+ 0 个沿用原来的「首次接管」;**≥2 个不猜** —— 用错账号的 key 是拿别人的积分、
45
+ 别人的组织出话,本地看不出来,不是报错是错账。
46
+
47
+ ⚠ 多账号 + 网关路径这个组合仍然无解:需要 magpie 把 provider 实例 id 传给插件
48
+ (`plugin-auth.json` 里已经有 `magicrew#p4nwa7` 这种 key),插件侧拿不到。
49
+ 在那之前只能保守 —— 宁可这次请求失败,也不借身份。
50
+
51
+ ### 三、「签一把顶一把」实测推翻(不涉及代码,只更正文档)
52
+
53
+ `docs/internals.md` 第十四节一直挂着「要判定需要一次受控实验」。做完了:
54
+ 本机 12 把 key 逐把打 `GET /v1/models`(只读、不签发、不吊销),结果是
55
+
56
+ - 同账号两把相隔 **67 秒**签发的 key **同时有效**
57
+ - 所有 403 都对应 `expiresAt` 已过(剩 -173~-541 分),**没有**「时间没到就失效」的样本
58
+
59
+ ⇒ 后签的**不吊销**先签的;唯一失效条件是 2 小时到期。`AGENTS.md` 坑 3 与
60
+ internals 十三节据此更正。「只留一把、只走唯一出口」的设计保留,理由改为
61
+ 「本地别分叉」而不是「互相吊销」。
62
+
63
+ ### 测试
64
+
65
+ - `tests/body-rewrite.mjs` —— 20 个断言:只换空对象、非空 schema 不动、
66
+ 其余字段一字不动、没改就不重新序列化
67
+ - `tests/account-fallback.mjs` —— 10 个断言:单账号才兜、多账号不猜、空壳文件不算账号
68
+
69
+ ---
70
+
71
+ ## 0.3.22 — 2026-10-28
72
+
73
+ **余额窗口保留「今日 / 限时 / 永久」拆分展示**
74
+
75
+ 0.3.21 只发一条总余额,路由对了,但看不出每日 23:59 到期的额度。现在在
76
+ `积分余额` 余额窗口之后追加三条只展示的 `aside` 文本行:
77
+
78
+ - `今日积分`:`185 / 200 积分 · 今日 23:59 到期`
79
+ - `限时积分`:剩余限时批次 + 最早到期日
80
+ - `永久积分`:不过期部分
81
+
82
+ 它们不带 `used` / `limit` / `balance`,宿主把它们当 `aside` 文本,不参与
83
+ `Allowance.Left`,路由仍只按 `积分余额` 排序;总余额不会被拆分窗口压低。
84
+
85
+ ## 0.3.21 — 2026-10-28
86
+
87
+ **积分改为 magpie 的「余额型窗口」:不再编造百分比**
88
+
89
+ magpie 0.1.1790(issue #3950)起支持 `usage.windows[].balance`:只报余额和单位,
90
+ 不写 `used` / `limit` / `aside`。本版把原来「今日 / 限时 / 永久」三条假百分比窗口
91
+ 合并成一条 `积分余额`:
92
+
93
+ - `balance = total_points`、`unit = "积分"`;
94
+ - `runsOutAt` / `runsOut` 取最早过期的一批,让 smart 路由知道谁先失效;
95
+ - 余额 0 也报 `balance: 0`,由宿主判定为用完,不再合成 `used: 100`。
96
+
97
+ 宿主现在按同 provider、同单位的余额从多到少排;不再显示「总量 = 余额」的
98
+ 100% 假进度条。旧版三窗口写法是宿主支持余额前的过渡,已删除。
99
+
100
+ ## 0.3.20 — 2026-10-28
101
+
102
+ **积分按「什么时候失效」拆成三块:今日 / 限时 / 永久**
103
+
104
+ 0.3.19 把整池积分压成一条窗口,百分比是「总量 = 余额」的恒等式,没有信息量。
105
+ 现在拆成三块(数据都来自 `/api/v1/quota/points/total`):
106
+
107
+ | 窗口 | 是什么 | 百分比 | 参与路由 |
108
+ |---|---|---|---|
109
+ | **今日积分** | 今天 23:59:59 到期的批次(每日登录赠送,国内 200 / 国际 50) | **真的**(分母是每日额度) | 是 |
110
+ | **限时积分** | 其余会过期批次的剩余之和 | 无(服务端不给原始额度) | 是(带 `runsOutAt`) |
111
+ | **永久积分** | `total - Σ限时`,不过期 | — | 否(`aside` 只展示) |
112
+
113
+ 三块加起来正好等于 `total_points`,实测四个号都对得上。
114
+
115
+ **为什么「限时积分」条目不标 `aside`**:标了会被 `allowanceOf()` 整条跳过,
116
+ `runsOutAt` 就进不了路由 —— 这个号又会变回「未知」。所以它给 `used: 0` +
117
+ `limit = 当前余量`,让 magpie 收下它并读到 `runsOutAt`。百分比(恒 100%)无意义,
118
+ 但路由能按「谁的积分先过期」排。
119
+
120
+ **关于「7 个依次递减的到期时间」**:`runsOutAt` 是单值字段(`*time.Time`),
121
+ 语义就是「最早失效的那一批」,所以 7 个批次也只会出现一条提醒,不会堆成 7 条。
122
+ magpie 自己的 WorkBuddy 插件就是这么用的。magicrew 的每日登录积分是
123
+ 「当天 23:59:59 到期」(`expire_type: 1`,不累积),更不存在滚动多批。
124
+
125
+ **为什么还是要给 magpie 提 issue**:magpie 的契约把「有进度条」和「参与路由」
126
+ 焊在同一个窗口上 —— 要进度条就得给 `used`+`limit`,钱包没有分母就只能编;
127
+ 不编就判成纯文本 + `aside`,路由又当未知。三条不可能同时满足。
128
+ 真正的解是宿主支持「余额型窗口」:有读数、能参与路由,但不参与百分比。
129
+
130
+ ## 0.3.19 — 2026-10-28
131
+
132
+ **路由页不再说「剩余额度未知」:窗口不能标 `aside`**
133
+
134
+ 0.3.18 把积分窗口改成 `aside: true` + 只给 `display` —— 卡片好了,
135
+ **路由页还是「剩余额度未知」**。
136
+
137
+ 根因在 magpie 宿主侧,读源码可证:
138
+
139
+ ```
140
+ provider/routing.go allowanceOf() if (w.Aside || w.Text) { continue } ← 整条跳过
141
+ provider/routing.go routedAllowance() if len(a) == 0 { return nil, false } ← 一条不剩 ⇒ 不 ok
142
+ gateway/trace.go weighed() if l, ok := wg.lefts[key]; ok { w.Known = true }
143
+ routing/client.js w.known 为假且 kind == "account" ⇒ 「剩余额度未知」
144
+ ```
145
+
146
+ 官方文档也逐字写着(`docs/subsystems/provider-plugins.md`,issue #3650):
147
+
148
+ > An account whose windows all tell no share has no allowance, so routing counts
149
+ > it not known ... as one never read. A window that says `used: 0` is a reading
150
+ > at 0, as before.
151
+
152
+ ⇒ **`aside` 的代价不是「不参与排序」,是这个号在路由里整个消失**
153
+ (unknown 一律排在已知的之后)。
154
+
155
+ 现在改成钱包的恒等式:`limit = 余额`、`amount = 0`、`used = 0`,**不加 `aside`**。
156
+ `used` 仍然是「已用 0」而不是缺省 —— 缺省会被 magpie 判成「没有份额的文本行」,
157
+ 又回到未知。
158
+
159
+ 判据(2026-10-28 实测,两站四个号):
160
+
161
+ | | 修复前 | 修复后 |
162
+ |---|---|---|
163
+ | `/v1/magpie/trace` 的 `known` | **字段缺失** | `true`,`limit` = 余额 |
164
+ | 路由页那一行 | 剩余额度未知 | `1,363 / 1,363 积分 · 剩余 100%` |
165
+
166
+ A/B 都跑过:把 `aside: true` 加回去、重启 magpie,`known` 立刻变回缺失;
167
+ 换回来就恢复 `true`。
168
+
169
+ ⚠ 代价说清楚:这样算出来的百分比**没有信息量**(分母就是分子)——
170
+ magpie 的额度契约是为「周期配额」设计的,钱包型额度它没有专门表达。
171
+ 真正该修的是宿主侧,插件侧只能给出最接近的形态。
172
+
3
173
  ## 0.3.18 — 2026-10-28
4
174
 
5
175
  - **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,84 @@ 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
+ ## 十七、上游不认 `additionalProperties: {}` —— 工具 schema 要在请求里兜住
1059
+
1060
+ **症状**:接 ZCode / Claude Code 系客户端时,一次带 54 个工具定义的请求必定失败:
1061
+
1062
+ ```
1063
+ POST /v1/chat/completions
1064
+ → HTTP 400 {"error":{"message":"模型响应失败","code":4017","request_id":"..."}}
1065
+ ```
1066
+
1067
+ 换成简单请求(一两个工具)就正常 —— 所以看起来像"某些模型不行"或"工具太多"。
1068
+
1069
+ ### 根因
1070
+
1071
+ 这些客户端生成工具 schema 时,会把"允许任意额外属性"写成**空对象**:
1072
+
1073
+ ```json
1074
+ "parameters": { "type": "object", "properties": {...}, "additionalProperties": {} }
1075
+ ```
1076
+
1077
+ 按 JSON Schema(draft-07)这是合法的 —— `additionalProperties` 的值是 schema 时,
1078
+ `{}` 表示"任意,不加约束",与 `true` 语义等价。但**上游的适配层只认布尔值**,
1079
+ 收到一个对象就整个请求拒掉,还只回一句"模型响应失败",不说是哪个字段。
1080
+
1081
+ ### 最小复现(2026-10-28,同一把 key、同一份请求、只改这一个字段)
1082
+
1083
+ | `additionalProperties` | glm-5.3-flash | deepseek-v4.1-flash |
1084
+ |---|---|---|
1085
+ | 缺省 | 200 | 200 |
1086
+ | `false` | 200 | 200 |
1087
+ | `true` | 200 | 200 |
1088
+ | `{}` | **400 code 4017** | **400 code 4017** |
1089
+ | `{type:"string"}` | **400 code 4017** | **400 code 4017** |
1090
+ | `{type:"string",description:"x"}` | **400 code 4017** | **400 code 4017** |
1091
+
1092
+ **两个模型逐行一致。结论比「上游只认布尔」更狠:它根本不支持 schema 形态** ——
1093
+ 不只是空对象,连 `{type:"string"}` 这种有约束的 schema 也一律拒。这一点很关键:
1094
+ 只处理空对象的改写在真实请求里仍会 400(实测过,见下方「修法」)。
1095
+
1096
+ ### 修法:请求改写里做等价替换
1097
+
1098
+ `src/hooks/loader.mjs` 的 `rewriteRequestBody` 里,`loosenAdditionalProperties()`
1099
+ 递归扫 `tools[].function.parameters`,把**所有非布尔值**换成 `true`:
1100
+
1101
+ ```js
1102
+ "additionalProperties": 缺省 → 不动 // 等价于 true
1103
+ "additionalProperties": true → 不动 // 上游认
1104
+ "additionalProperties": false → 不动 // 「不许额外属性」,换掉就是反向放开
1105
+ "additionalProperties": {} → true // 等价改写
1106
+ "additionalProperties": {type:"x"} → true // ⚠ 降级放宽,丢了约束
1107
+ ```
1108
+
1109
+ **为什么接受那个不等价**:`{}` → `true` 是严格等价(都表示「任意,不加约束」);
1110
+ `{type:"string"}` → `true` 丢掉了「额外属性必须是 string」这条约束。但上游无法表达它,
1111
+ 保留原值就是整个请求失败。两害相权,让工具调用能跑(可能多出未声明的字段)
1112
+ 比让 agent 完全不可用要好;方向也安全 —— 是放宽不是收紧,不会让原本合法的调用被拒。
1113
+
1114
+ 它与 `max_tokens`、`stream_options` 三处改写共用**一次** `JSON.parse`;
1115
+ 没改到时返回原始字符串(省一次序列化 —— 54 工具的请求体可达上百 KB)。
1116
+
1117
+ 边界由 `tests/body-rewrite.mjs` 钉住(20 个断言):非布尔一律换、布尔一律不动、
1118
+ 其余字段一字不动、没改到就不重新序列化。
1119
+
1120
+ ### 为什么算插件的事,不算客户端的事
1121
+
1122
+ 按规范 `{}` 与 `true` 等价,客户端没写错;**上游的兼容性不足**。
1123
+ 但插件是唯一能改请求体的地方(见 `src/hooks/loader.mjs` 顶部),
1124
+ 在这里兜住比让每个用户去改 ZCode 的源码现实。
1125
+
1126
+ ### 同一处的第三个改写,以及它的教训
1127
+
1128
+ 同一次排查里还确认了两件事,都值得记住:
1129
+
1130
+ 1. **上游对"token 无效"回的是 HTTP 200**,body `{"code":403,"message":"token无效"}`,
1131
+ 且 `error` 字段是空数组(falsy)。**只看 HTTP 状态码会把失败当成功** ——
1132
+ 判断得用 `j.code !== 200`。排查时在这上面绕了很久。
1133
+ 2. **插件宿主是运行时从 `~/.cache/magpie/bun/install-cache/` 加载的**,
1134
+ 不在 `plugin-host/host-*.js` 里;magpie 更新插件后会换目录(目录名带 commit hash),
1135
+ 要改当前实际加载的那个。
@@ -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.23",
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",
package/src/headless.mjs CHANGED
@@ -118,24 +118,73 @@ export async function finishSmsLogin(site, { mobile, stateCode = "+86", code, de
118
118
 
119
119
  const { jwt, org } = extractLoginCreds(json)
120
120
  if (!jwt) {
121
- throw Object.assign(new Error("登录成功但响应里没找到 JWT —— 请把这条原始响应反馈给插件作者"), {
122
- raw: json,
123
- })
121
+ throw Object.assign(
122
+ new Error(`登录成功但响应里没找到可用令牌。响应结构:${describeShape(json)}`),
123
+ { raw: json },
124
+ )
124
125
  }
125
126
  // 响应里给了组织码就用它;没给就把该账号的组织**全列出来**,
126
- // 由调用方逐个拿 fetchPoints 验(见 methods.mjs 的 completeSignIn)——
127
- // 只取第一个是不够的:多组织账号会落到「列表第一个」而不是用户平时用的那个。
127
+ // 由调用方逐个拿 fetchPoints 验(见 methods.mjs 的 completeSignIn)。
128
128
  const orgs = org ? [org] : await fetchOrgs(site, jwt)
129
129
  if (!orgs.length) {
130
- throw Object.assign(new Error("登录成功但拿不到组织码(该账号没有任何组织?)"), { raw: json })
130
+ throw Object.assign(
131
+ new Error(
132
+ `登录成功但拿不到组织码。\n` +
133
+ ` · 登录响应结构:${describeShape(json)}\n` +
134
+ ` · 用该令牌调 login-data 的探测结果:${orgProbeErrors().join(" / ") || "(没探)"}\n` +
135
+ ` (前端拿 access_token 直接 setAuthorization 后就用它调这个接口)`,
136
+ ),
137
+ { raw: json },
138
+ )
131
139
  }
132
140
  return { jwt, org: orgs[0], orgs, raw: json }
133
141
  }
134
142
 
143
+ /**
144
+ * 把一段 JSON 的形状描述出来:**只有键名和类型,没有任何值**。
145
+ *
146
+ * 用来让真机上的报错自带诊断信息 —— 用户截一张图,就能看出服务端到底回了什么结构,
147
+ * 不用反复试。token 之类的东西一律不会出现在这里。
148
+ */
149
+ export function describeShape(o, depth = 0) {
150
+ if (o === null) return "null"
151
+ if (o === undefined) return "undefined"
152
+ const t = typeof o
153
+ if (t !== "object") return t === "string" ? `str(${String(o).length})` : t
154
+ if (depth >= 3) return "…"
155
+ if (Array.isArray(o)) {
156
+ return `[${o.length}]` + (o.length ? describeShape(o[0], depth + 1) : "")
157
+ }
158
+ const keys = Object.keys(o).slice(0, 24)
159
+ return "{" + keys.map((k) => `${k}:${describeShape(o[k], depth + 1)}`).join(", ") + "}"
160
+ }
161
+
162
+ /**
163
+ * 从登录响应里取「授权令牌」和「组织码」。
164
+ *
165
+ * ⚠ 登录返回的**不是 JWT,是 `access_token`** —— 前端拿到之后立刻
166
+ * `setAuthorization(access_token)` 并用它调 `getUserProfile`。
167
+ * 早先这里只按 `eyJ` 前缀找 JWT,结果在响应里挑到了别的字段,
168
+ * 拿去调 `login-data` 自然拿不到组织(真机表现就是
169
+ * 「登录成功但拿不到组织码」)。
170
+ */
135
171
  function extractLoginCreds(json) {
136
- // 只认 eyJ 三段式当 JWT;组织码优先取键名带 org 的那个
137
- const { jwt, org } = extractCreds(json)
138
- return { jwt, org }
172
+ const d = json?.data ?? json ?? {}
173
+ const pick = (...keys) => {
174
+ for (const k of keys) {
175
+ const v = d?.[k]
176
+ if (typeof v === "string" && v.trim()) return v.trim()
177
+ }
178
+ return null
179
+ }
180
+ // 优先认登录接口自己给的字段名
181
+ const token =
182
+ pick("access_token", "accessToken", "token", "authorization", "teamshare_token") ??
183
+ extractCreds(json).jwt
184
+ const org =
185
+ pick("organization_code", "organizationCode", "magic_organization_code") ??
186
+ extractCreds(json).org
187
+ return { jwt: token, org }
139
188
  }
140
189
 
141
190
  /**
@@ -143,22 +192,80 @@ function extractLoginCreds(json) {
143
192
  *
144
193
  * ⚠ 浏览器登录能直接读到用户**当前选中**的组织(localStorage 里的
145
194
  * `MAGIC:user:organizationCode`),无头这条路没有那个信息 —— 所以只能把候选
146
- * 全拿回来,让调用方逐个验,验不过再换下一个。单组织账号没有区别;
147
- * 多组织账号靠这一步才不会落错地方。
195
+ * 全拿回来,让调用方逐个验,验不过再换下一个。
196
+ *
197
+ * ⚠ `authorization` 头**两种写法都要试**:前端这个接口是
198
+ * `h.set("authorization", authorization)` —— 直接放 token,**没有 `Bearer ` 前缀**;
199
+ * 但平台其它接口(`/api/v1/flows/models` 等)走的是 `Bearer <jwt>` 并且能通。
200
+ * 登录拿到的是 438 字符的 `access_token`,和网页 localStorage 里那把 354 字符的不是
201
+ * 同一个东西,所以不能想当然 —— 两种都打,先成的算。
202
+ * 全失败时把每一步的真实原因记在模块变量里,供上层错误信息引用。
148
203
  */
204
+ let lastOrgProbeErrors = []
205
+ export function orgProbeErrors() {
206
+ return lastOrgProbeErrors.slice()
207
+ }
208
+
149
209
  export async function fetchOrgs(site, jwt) {
150
- const res = await fetch(`${site.host}/api/v1/aggregation/login-data?teamshare_login_code=`, {
151
- headers: {
152
- authorization: `Bearer ${jwt}`,
153
- "user-agent": "magpie-magicrew-auth",
154
- },
155
- signal: AbortSignal.timeout(20_000),
156
- }).catch(() => null)
157
- if (!res?.ok) return []
158
- const j = await res.json().catch(() => null)
159
- const list = j?.data?.auth_status ?? j?.auth_status ?? []
160
- if (!Array.isArray(list)) return []
210
+ lastOrgProbeErrors = []
211
+
212
+ // 三个端点 × 两种 authorization 写法,全试一遍 —— 它们对令牌形态的宽容度不一样:
213
+ // · login-data 前端是 h.set("authorization", token)(无 Bearer),但它同时
214
+ // 接受 `Bearer <网页版 JWT>`(用本机 354 字符 JWT 实测通过)
215
+ // · me/organizations 实测连 organization-code 头都不要,只认令牌
216
+ // · auth/status 前端 bindMagicAuthorization 用它,返回 Array<MagicOrganization>
217
+ // 登录拿到的是 438 字符的 access_token,和网页 localStorage 那把 354 的不同,
218
+ // 所以不能想当然只打一个。
219
+ const probes = [
220
+ { tag: "login-data/Bearer", url: `${site.host}/api/v1/aggregation/login-data?teamshare_login_code=`, auth: `Bearer ${jwt}`, pick: (j) => j?.data?.auth_status ?? j?.auth_status },
221
+ { tag: "login-data/裸", url: `${site.host}/api/v1/aggregation/login-data?teamshare_login_code=`, auth: jwt, pick: (j) => j?.data?.auth_status ?? j?.auth_status },
222
+ { tag: "auth-status/Bearer", url: `${site.host}/api/v1/auth/status?login_code=`, auth: `Bearer ${jwt}`, pick: (j) => j?.data ?? j },
223
+ { tag: "auth-status/裸", url: `${site.host}/api/v1/auth/status?login_code=`, auth: jwt, pick: (j) => j?.data ?? j },
224
+ { tag: "me-organizations/Bearer", url: `${site.host}/api/v1/contact/accounts/me/organizations`, auth: `Bearer ${jwt}`, pick: (j) => j?.data?.items ?? j?.items },
225
+ { tag: "me-organizations/裸", url: `${site.host}/api/v1/contact/accounts/me/organizations`, auth: jwt, pick: (j) => j?.data?.items ?? j?.items },
226
+ ]
227
+
228
+ for (const p of probes) {
229
+ let res = null
230
+ try {
231
+ res = await fetch(p.url, {
232
+ headers: { authorization: p.auth, "user-agent": "magpie-magicrew-auth" },
233
+ signal: AbortSignal.timeout(20_000),
234
+ })
235
+ } catch (e) {
236
+ lastOrgProbeErrors.push(`${p.tag}:网络失败 ${String(e?.message ?? e).slice(0, 60)}`)
237
+ continue
238
+ }
239
+
240
+ const text = await res.text().catch(() => "")
241
+ if (!res.ok) {
242
+ lastOrgProbeErrors.push(`${p.tag}:HTTP ${res.status} ${text.slice(0, 70)}`)
243
+ continue
244
+ }
245
+ let j = null
246
+ try {
247
+ j = JSON.parse(text)
248
+ } catch {
249
+ lastOrgProbeErrors.push(`${p.tag}:非 JSON ${text.slice(0, 70)}`)
250
+ continue
251
+ }
252
+ if (j?.code !== 1000) {
253
+ lastOrgProbeErrors.push(`${p.tag}:code ${j?.code} ${j?.message ?? ""}`.trim())
254
+ continue
255
+ }
256
+ const list = p.pick(j)
257
+ if (!Array.isArray(list) || !list.length) {
258
+ lastOrgProbeErrors.push(`${p.tag}:200 但列表为空`)
259
+ continue
260
+ }
261
+ const orgs = parseOrgs(list)
262
+ if (orgs.length) return orgs
263
+ lastOrgProbeErrors.push(`${p.tag}:有 ${list.length} 项但取不到组织码`)
264
+ }
265
+ return []
266
+ }
161
267
 
268
+ function parseOrgs(list) {
162
269
  // ⚠ 字段名是 `magic_organization_code`(实测)。`organization_code` 只在**外层**存在,
163
270
  // 而且是空串 —— 认它就会拿到 ""。每项里另有 third_platform_ / teamshare_ 两份同值码。
164
271
  const KEYS = [
@@ -1,93 +1,178 @@
1
- // ④ auth.loader(getAuth) —— 请求接管
2
- //
3
- // 返回 baseURL / apiKey / headers / fetch(a,b),magpie 拿它发真实请求。
4
- // 这是唯一能改请求体的地方。本项目用它干两件事:
5
- //
6
- // · 国际站网关强制要 organization-code(国内站不要),不带就是
7
- // HTTP 500 "Internal Server Error."(22 字节纯文本,不是 JSON)
8
- // · 推理模型低于 512 的 max_tokens 会被 reasoning 吃光,content 返回空串
9
- //
10
- // ⚠ 不要在这里读流、不要重试换号、不要手拼 x-api-key ——
11
- // 前两件 magpie 做了,第三件看 SDK 包自动加。见 README「最小交付面」。
12
-
13
- import { ensureKey, accountOf } from "../keys.mjs"
14
-
15
- export function makeLoader(c) {
16
- return async (getAuth) => {
17
- const auth = await getAuth()
18
- if (!auth) return {}
19
- return {
20
- baseURL: c.BASE,
21
- apiKey: auth.access,
22
- headers: { "user-agent": "MagicrewClient/1.0" },
23
- async fetch(input, init) {
24
- const fresh = await getAuth()
25
- if (!fresh) throw new Error(`${c.SHORT} 未登录`)
26
-
27
- // key 只有一个来源(src/keys.mjs)—— 服务端签一把顶一把,多入口必互相吊销
28
- const key = await ensureKey(c, getAuth)
29
- if (!key) throw new Error(`${c.SHORT} 未登录(拿不到可用的 key)`)
30
- const account = accountOf(fresh)
31
-
32
- const req = input instanceof Request ? input : null
33
- const url =
34
- typeof input === "string"
35
- ? input
36
- : input instanceof URL
37
- ? input.toString()
38
- : (req?.url ?? "")
39
-
40
- const incoming = new Headers(init?.headers ?? req?.headers ?? {})
41
- const headers = {
42
- authorization: `Bearer ${key.apiKey}`,
43
- "content-type": "application/json",
44
- }
45
- if (c.needsOrgOnGateway && fresh.org) headers["organization-code"] = fresh.org
46
-
47
- for (const [k, v] of incoming) {
48
- if (["authorization", "host", "content-length", "user-agent"].includes(k.toLowerCase())) continue
49
- headers[k] = v
50
- }
51
-
52
- let body = init?.body
53
- if (body === undefined && req) body = await req.text()
54
-
55
- // 改写两处请求体,都为实测出来的:
56
- //
57
- // ① max_tokens 抬到 512
58
- // 实测 glm-5.3-flash:64 → finish_reason:"length" 且 content:""
59
- // 512 → 正常出话(reasoning 208 / total 233)
60
- //
61
- // ② 流式请求补 stream_options.include_usage
62
- // magicrew 在 stream:true 下**默认不返回 usage**;补上之后,[DONE]
63
- // 之前会多一条 {"choices":[],"usage":{...}} 的收尾 chunk。
64
- // magpie 新版靠这条收尾 chunk 判定「流正常结束了」——缺了它就报
65
- // 「Stream ended without finish_reason」然后重试(实测 2026-10-27)。
66
- // 控制台里看到的就是:模型请求重试 (3/5) · Stream ended without finish_reason
67
- if (typeof body === "string" && body.includes('"stream"')) {
68
- try {
69
- const j = JSON.parse(body)
70
- if (Number(j.max_tokens) > 0 && Number(j.max_tokens) < 512) j.max_tokens = 512
71
- if (j.stream === true && !j.stream_options) {
72
- j.stream_options = { include_usage: true }
73
- }
74
- body = JSON.stringify(j)
75
- } catch {}
76
- } else if (typeof body === "string" && body.includes('"max_tokens"')) {
77
- try {
78
- const j = JSON.parse(body)
79
- if (Number(j.max_tokens) > 0 && Number(j.max_tokens) < 512) j.max_tokens = 512
80
- body = JSON.stringify(j)
81
- } catch {}
82
- }
83
-
84
- return fetch(url, {
85
- method: init?.method ?? "POST",
86
- headers,
87
- body,
88
- signal: init?.signal ?? req?.signal ?? AbortSignal.timeout(300_000),
89
- })
90
- },
91
- }
92
- }
93
- }
1
+ // ④ auth.loader(getAuth) —— 请求接管
2
+ //
3
+ // 返回 baseURL / apiKey / headers / fetch(a,b),magpie 拿它发真实请求。
4
+ // 这是唯一能改请求体的地方。本项目用它干两件事:
5
+ //
6
+ // · 国际站网关强制要 organization-code(国内站不要),不带就是
7
+ // HTTP 500 "Internal Server Error."(22 字节纯文本,不是 JSON)
8
+ // · 推理模型低于 512 的 max_tokens 会被 reasoning 吃光,content 返回空串
9
+ //
10
+ // ⚠ 不要在这里读流、不要重试换号、不要手拼 x-api-key ——
11
+ // 前两件 magpie 做了,第三件看 SDK 包自动加。见 README「最小交付面」。
12
+
13
+ import { ensureKey, accountOf } from "../keys.mjs"
14
+
15
+ /**
16
+ * 把 JSON Schema 里的 `additionalProperties` **非布尔值**原地换成 `true`。
17
+ *
18
+ * 上游只认布尔值,收到任何对象都回 HTTP 400
19
+ * `{"error":{"message":"模型响应失败","code":4017}}`,整个请求被拒。
20
+ * 实测(2026-10-28,glm-5.3-flash 与 deepseek-v4.1-flash **两个模型完全一致**,
21
+ * 同一把 key、同一份请求、只改这一个字段):
22
+ *
23
+ * additionalProperties: 缺省 → 200
24
+ * additionalProperties: false → 200
25
+ * additionalProperties: true → 200
26
+ * additionalProperties: {} → 400 code 4017
27
+ * additionalProperties: {type:"string"} → 400 code 4017
28
+ * additionalProperties: {type:"string",description:"x"} → 400 code 4017
29
+ *
30
+ * ⚠ 注意**不只是空对象**。有约束的 schema 形态(`{type:"string"}`)一样被拒 ——
31
+ * 这一点比"上游只认 true/false"更进一步:它根本不支持 schema 形态。
32
+ * 所以这里一律换成 `true`,属于**降级放宽**:
33
+ *
34
+ * · `{}` → `true` 语义等价(都是"任意,不加约束")
35
+ * · `{type:"string"}` → `true` **不等价** —— 丢掉了"额外属性必须是 string"的约束
36
+ *
37
+ * 为什么接受这个不等价:上游无法表达它,保留原值就是整个请求失败。两害相权,
38
+ * 让工具调用能跑(可能多出未声明的字段)比让 agent 完全不可用要好。方向也安全 ——
39
+ * 是放宽不是收紧,不会让原本合法的调用被拒。
40
+ *
41
+ * 命中来源:ZCode / Claude Code 系客户端生成 schema 时爱写 `{}`;
42
+ * 一次真实请求里 54 个工具中的 `ExitPlanMode` 与 `CreateWorkflow` 就这么写。
43
+ * 客户端没写错(按规范 `{}` 与 `true` 等价),是上游兼容性不足,插件这边兜住。
44
+ *
45
+ * @returns {boolean} 有没有改到
46
+ */
47
+ export function loosenAdditionalProperties(node) {
48
+ if (!node || typeof node !== "object") return false
49
+ if (Array.isArray(node)) {
50
+ let any = false
51
+ for (const n of node) if (loosenAdditionalProperties(n)) any = true
52
+ return any
53
+ }
54
+ let changed = false
55
+ for (const [k, v] of Object.entries(node)) {
56
+ if (k === "additionalProperties") {
57
+ // 布尔值上游认,原样留着(false 是"不许额外属性",语义相反,绝对不能动)
58
+ if (typeof v !== "boolean") {
59
+ node[k] = true
60
+ changed = true
61
+ }
62
+ continue
63
+ }
64
+ if (v && typeof v === "object" && loosenAdditionalProperties(v)) changed = true
65
+ }
66
+ return changed
67
+ }
68
+
69
+ /**
70
+ * 改写发往上游的请求体。**一次 parse,三处改写都在这**。
71
+ *
72
+ * 三处都是实测出来的,不是猜的:
73
+ *
74
+ * ① max_tokens 抬到 512
75
+ * 实测 glm-5.3-flash:64 → finish_reason:"length" 且 content:""
76
+ * 512 → 正常出话(reasoning 208 / total 233)
77
+ *
78
+ * ② 流式请求补 stream_options.include_usage
79
+ * magicrew 在 stream:true 下**默认不返回 usage**;补上之后,[DONE]
80
+ * 之前会多一条 {"choices":[],"usage":{...}} 的收尾 chunk。
81
+ * magpie 新版靠这条收尾 chunk 判定「流正常结束了」——缺了它就报
82
+ * 「Stream ended without finish_reason」然后重试(实测 2026-10-27)。
83
+ *
84
+ * ③ tools 里的 additionalProperties: {} → true(见 looser 的注释)
85
+ *
86
+ * 没改到时返回原始字符串(省一次序列化 —— 54 个工具的请求体不小)。
87
+ *
88
+ * @param {unknown} raw
89
+ * @returns {unknown} 改了就是新字符串,没改就原样返回
90
+ */
91
+ export function rewriteRequestBody(raw) {
92
+ if (typeof raw !== "string") return raw
93
+ // 粗筛:三个关键字一个都没有,就不必 parse
94
+ if (!/"stream"|"max_tokens"|"tools"/.test(raw)) return raw
95
+
96
+ let j
97
+ try {
98
+ j = JSON.parse(raw)
99
+ } catch {
100
+ return raw
101
+ }
102
+ if (!j || typeof j !== "object") return raw
103
+
104
+ let changed = false
105
+
106
+ if (Number(j.max_tokens) > 0 && Number(j.max_tokens) < 512) {
107
+ j.max_tokens = 512
108
+ changed = true
109
+ }
110
+
111
+ if (j.stream === true && !j.stream_options) {
112
+ j.stream_options = { include_usage: true }
113
+ changed = true
114
+ }
115
+
116
+ if (Array.isArray(j.tools)) {
117
+ for (const t of j.tools) {
118
+ const p = t?.function?.parameters
119
+ if (p && typeof p === "object" && loosenAdditionalProperties(p)) changed = true
120
+ }
121
+ }
122
+
123
+ return changed ? JSON.stringify(j) : raw
124
+ }
125
+
126
+ export function makeLoader(c) {
127
+ return async (getAuth) => {
128
+ const auth = await getAuth()
129
+ if (!auth) return {}
130
+ return {
131
+ baseURL: c.BASE,
132
+ apiKey: auth.access,
133
+ headers: { "user-agent": "MagicrewClient/1.0" },
134
+ async fetch(input, init) {
135
+ const fresh = await getAuth()
136
+ if (!fresh) throw new Error(`${c.SHORT} 未登录`)
137
+
138
+ // key 只有一个来源(src/keys.mjs)—— 服务端签一把顶一把,多入口必互相吊销
139
+ const key = await ensureKey(c, getAuth)
140
+ if (!key) throw new Error(`${c.SHORT} 未登录(拿不到可用的 key)`)
141
+ const account = accountOf(fresh)
142
+
143
+ const req = input instanceof Request ? input : null
144
+ const url =
145
+ typeof input === "string"
146
+ ? input
147
+ : input instanceof URL
148
+ ? input.toString()
149
+ : (req?.url ?? "")
150
+
151
+ const incoming = new Headers(init?.headers ?? req?.headers ?? {})
152
+ const headers = {
153
+ authorization: `Bearer ${key.apiKey}`,
154
+ "content-type": "application/json",
155
+ }
156
+ if (c.needsOrgOnGateway && fresh.org) headers["organization-code"] = fresh.org
157
+
158
+ for (const [k, v] of incoming) {
159
+ if (["authorization", "host", "content-length", "user-agent"].includes(k.toLowerCase())) continue
160
+ headers[k] = v
161
+ }
162
+
163
+ let body = init?.body
164
+ if (body === undefined && req) body = await req.text()
165
+
166
+ // 三处请求体改写,全在 rewriteRequestBody 里(那里有实测出处)
167
+ body = rewriteRequestBody(body)
168
+
169
+ return fetch(url, {
170
+ method: init?.method ?? "POST",
171
+ headers,
172
+ body,
173
+ signal: init?.signal ?? req?.signal ?? AbortSignal.timeout(300_000),
174
+ })
175
+ },
176
+ }
177
+ }
178
+ }
@@ -44,10 +44,17 @@ function kickoffFirstTask(c, jwt, org) {
44
44
  * 记组织 / 滚邀请码 / 补首任务 / 取昵称」这一串只有一份实现。
45
45
  *
46
46
  * `orgs` 是候选组织列表(无头那条路拿不到「用户当前选中的组织」,只能猜)。
47
- * 按顺序用 `fetchPoints` 真验一遍,第一个验得过才算 —— 多组织账号靠这个
48
- * 才不会落错地方。浏览器登录只有一个候选,行为不变。
47
+ * 按顺序用 `fetchPoints` 真验一遍,第一个验得过才算。
48
+ *
49
+ * ⚠ 别说这解决了「多组织选哪个」—— **它没解决,只是容错**:
50
+ * 账号所属的任何一个组织都能通过 `fetchPoints`(只要 JWT 有效),
51
+ * 所以「第一个验得过的」实际就是「列表第一个」。
52
+ * 真正的区别在浏览器登录那条路:它直接读 localStorage 里用户**当前选中**的那个。
53
+ * 服务端虽然给了 `is_current` 字段,但实测恒为 false(带不带 organization-code
54
+ * 头都一样),不能拿来判断。⇒ 多组织账号在无头这条路上会落到列表第一个组织,
55
+ * 这是已知限制,不是 bug。
49
56
  */
50
- async function completeSignIn(c, jwt, org, orgs = []) {
57
+ async function completeSignIn(c, jwt, org, orgs = [], preferredAccountId = null) {
51
58
  const candidates = [org, ...orgs.filter((o) => o && o !== org)]
52
59
  let lastErr = null
53
60
  let picked = null
@@ -71,12 +78,23 @@ async function completeSignIn(c, jwt, org, orgs = []) {
71
78
  await rollInvite(c.id, jwt, picked, c.host)
72
79
  kickoffFirstTask(c, jwt, picked)
73
80
  const who = await fetchWho(jwt, picked, c.host).catch(() => null)
81
+
82
+ // ⚠ `accountId` 决定 magpie 是「替换」还是「新增」—— 同一个账号在两条登录路上
83
+ // 必须算出同一个字符串,否则就会出现「同一个号被加了两遍」。
84
+ //
85
+ // 真机踩过:浏览器登录拿到的是打码手机号 `189****051`(fetchWho 成功,
86
+ // 服务端只给打码号);无头那条路 fetchWho 挂了(438 字符的 access_token
87
+ // 配 Bearer 头不通),于是兜底用了组织码 `h97gkbu8bruj` ⇒ 两条并存。
88
+ //
89
+ // 所以优先用**调用方明确给的** accountId(无头路径里就是它自己算出来的打码手机号),
90
+ // `who.name` 只作为补充,组织码是最后一档 —— 它绝不该当账号名。
91
+ const accountId = preferredAccountId || who?.name || picked
74
92
  return {
75
93
  type: "success",
76
94
  access: key.apiKey,
77
95
  refresh: key.refreshToken,
78
96
  expires: Date.now() + 2 * 3600_000,
79
- accountId: who?.name || picked,
97
+ accountId,
80
98
  uid: picked,
81
99
  jwt,
82
100
  org: picked,
@@ -109,7 +127,13 @@ export function makeMethods(c) {
109
127
  ],
110
128
  async authorize(inputs) {
111
129
  const phone = String(inputs?.phone || "").replace(/[\s-]/g, "")
112
- if (!/^1\d{10}$/.test(phone)) return { type: "failed", error: "手机号不完整" }
130
+ // ⚠ authorize() 里**不能**返回 { type: "failed" }:magpie 把这里的返回值当作
131
+ // 「登录流程对象」,会去调它的 `.callback()` —— 少了这个函数就是一句
132
+ // `callback is not a function`,用户只看到莫名其妙的报错。
133
+ // 实测触发路径:callback 里失败 → magpie 显示「重试」 → 点重试会**重新调
134
+ // authorize** → 这次走到这条失败分支 → 返回没有 callback 的对象 → 崩。
135
+ // ⇒ authorize 里失败一律抛异常。
136
+ if (!/^1\d{10}$/.test(phone)) throw new Error("手机号不完整")
113
137
 
114
138
  const vision = readVisionModel()
115
139
  const masked = phone.replace(/^(\d{3})\d{4}(\d{4})$/, "$1****$2")
@@ -195,7 +219,9 @@ export function makeMethods(c) {
195
219
  try {
196
220
  sent = await startSmsLogin(c, { mobile: phone })
197
221
  } catch (e) {
198
- return { type: "failed", error: String(e?.message ?? e).slice(0, 500) }
222
+ // 同上:这里也不能 return failed —— 点「重试」会重新走 authorize,
223
+ // 返回没有 callback 的对象会让 magpie 崩在 `.callback()`
224
+ throw new Error(String(e?.message ?? e).slice(0, 500))
199
225
  }
200
226
 
201
227
  return {
@@ -220,7 +246,9 @@ export function makeMethods(c) {
220
246
  return { type: "failed", error: String(e?.message ?? e).slice(0, 500) }
221
247
  }
222
248
  try {
223
- return await completeSignIn(c, creds.jwt, creds.org, creds.orgs)
249
+ // 传 masked(打码手机号)当 accountId —— 浏览器登录那条路拿到的也是
250
+ // 这个字符串,两边一致才不会把同一个号加成两条
251
+ return await completeSignIn(c, creds.jwt, creds.org, creds.orgs, masked)
224
252
  } catch (e) {
225
253
  return { type: "failed", error: `登录成功了,但换 key 失败:${String(e?.message ?? e).slice(0, 300)}` }
226
254
  }
@@ -407,8 +435,9 @@ export function makeMethods(c) {
407
435
  async authorize(inputs) {
408
436
  const jwt = String(inputs?.jwt || "").trim().replace(/^Bearer\s+/i, "")
409
437
  const org = String(inputs?.org || "").trim()
410
- if (!jwt.startsWith("eyJ")) return { type: "failed", error: "不是 JWT" }
411
- if (!org) return { type: "failed", error: "没填组织码" }
438
+ // 同一条规矩:authorize 里失败只抛异常,不返回 { type: "failed" }
439
+ if (!jwt.startsWith("eyJ")) throw new Error("不是 JWT")
440
+ if (!org) throw new Error("没填组织码")
412
441
 
413
442
  const result = (async () => {
414
443
  await fetchPoints(jwt, org, c.host)
@@ -1,12 +1,34 @@
1
1
  // ⑤ auth.usage(getAuth) —— 额度
2
2
  //
3
- // 画进度条,**同时是路由排序输入**:`used` 缺了会被当成 0% = 满额,
4
- // 空号反而排到队首。
3
+ // 超级麦吉的积分是「预付钱包」:`/api/v1/quota/points/total` 只回当前余额
4
+ // `total_points` 和一批会过期的明细(`expiring_quota_details`),没有总量、
5
+ // 没有已消耗;积分流水接口恒 500。钱包没有分母,不能编造 used/limit 百分比。
5
6
  //
6
- // ⚠ 预充值积分制放 aside:只显示余额、不参与排序;余额 0 时 used 置 100,
7
- // 让它老老实实排到最后。
7
+ // magpie 0.1.1790(issue #3950)起,窗口可以直接报余额:
8
+ //
9
+ // windows: [{
10
+ // name: "积分余额",
11
+ // balance: 6291, // 还剩多少,单位见 unit
12
+ // unit: "积分",
13
+ // // 可选:runsOutAt / runsOut / display / model / models / notModels
14
+ // }]
15
+ //
16
+ // 不写 used / limit / aside —— 宿主把余额当读数额度,不画百分比;同 provider、
17
+ // 同单位的账号按余额从多到少排,余额 0 视为用完。`runsOutAt` 取最早过期的
18
+ // 一批,让 smart 路由知道谁的积分先失效。
19
+ //
20
+ // 钱包只有一个总数,但过期批次有先后:今日 23:59 到期的每日积分、其他限时
21
+ // 批次、永久积分要看得见。它们在余额窗口之后作为 `aside` 文本行显示(只展示,
22
+ // 不进 Allowance.Left —— 否则最小的那一条会把总余额压低)。
23
+ //
24
+ // 不要再把「今日 / 限时 / 永久」做成带 used/limit 的假百分比窗口;旧写法是
25
+ // 宿主支持余额之前的临时协议,见 CHANGELOG 0.3.20。
26
+
27
+ import { fetchPoints, SITES } from "../api.mjs"
8
28
 
9
- import { fetchPoints } from "../api.mjs"
29
+ // 每日登录积分:国内 +200/天、国际 +50/天(实测,见 docs/internals.md)。
30
+ // 今日批次的分母,只用于展示「今日还剩 / 今日额度」。
31
+ const DAILY_LOGIN_GIFT = { cn: 200, intl: 50 }
10
32
 
11
33
  export function makeUsage(c) {
12
34
  return async (getAuth) => {
@@ -14,27 +36,107 @@ export function makeUsage(c) {
14
36
  if (!auth) return { error: "未登录", windows: [] }
15
37
  try {
16
38
  const p = await fetchPoints(auth.jwt, auth.org, c.host)
17
- const nextExpiry = p.expiring?.[0]?.expiresAt
39
+ const total = Number(p?.total ?? 0)
40
+ if (!Number.isFinite(total)) return { error: "积分接口没有返回可用余额", windows: [] }
41
+ const batches = Array.isArray(p?.expiring) ? p.expiring : []
42
+ const windows = [{
43
+ name: "积分余额",
44
+ balance: total,
45
+ unit: "积分",
46
+ display: total > 0 ? `${fmt(total)} 积分` : "已耗尽",
47
+ ...(total > 0 ? runsOutOf(batches) : {}),
48
+ }]
49
+
50
+ if (total > 0) {
51
+ const today = beijingDay(Date.now())
52
+ const todayBatches = batches.filter((b) => b.points > 0 && beijingDay(parseServerTime(b.expiresAt)) === today)
53
+ const todayLeft = sum(todayBatches)
54
+ const timedLeft = sum(batches)
55
+ const timedRest = Math.max(0, timedLeft - todayLeft)
56
+ const permanent = Math.max(0, total - timedLeft)
57
+ const dailyCap = DAILY_LOGIN_GIFT[c.host === SITES.cn.host ? "cn" : "intl"]
58
+
59
+ // 只展示的批次行:没有 used/limit/balance,宿主把它们当 aside 文本,
60
+ // 不参与 Allowance.Left;路由仍只看上面的总余额。
61
+ if (todayLeft > 0) {
62
+ windows.push({
63
+ name: "今日积分",
64
+ display: `${fmt(todayLeft)} / ${fmt(dailyCap)} 积分 · 今日 23:59 到期`,
65
+ unit: "积分",
66
+ aside: true,
67
+ })
68
+ }
69
+ if (timedRest > 0) {
70
+ const next = pickEarliest(batches.filter((b) => !todayBatches.includes(b)))
71
+ const when = next ? ` · 最早 ${beijingDate(next.ms)} 到期` : ""
72
+ windows.push({
73
+ name: "限时积分",
74
+ display: `${fmt(timedRest)} 积分${when}`,
75
+ unit: "积分",
76
+ aside: true,
77
+ })
78
+ }
79
+ if (permanent > 0) {
80
+ windows.push({
81
+ name: "永久积分",
82
+ display: `${fmt(permanent)} 积分`,
83
+ unit: "积分",
84
+ aside: true,
85
+ })
86
+ }
87
+ }
88
+
18
89
  return {
19
- plan: p.total > 0 ? "积分制" : "已耗尽",
20
- balance: `${p.total.toLocaleString("zh-CN")} 积分`,
21
- windows: [
22
- p.total > 0
23
- ? {
24
- name: "积分余额",
25
- used: 0,
26
- display: `${p.total.toLocaleString("zh-CN")} 积分`,
27
- amount: 0,
28
- limit: p.total,
29
- unit: "积分",
30
- resetsAt: nextExpiry,
31
- aside: true,
32
- }
33
- : { name: "积分余额", used: 100, display: "已耗尽" },
34
- ],
90
+ plan: total > 0 ? "积分制" : "已耗尽",
91
+ balance: `${fmt(total)} 积分`,
92
+ windows,
35
93
  }
36
94
  } catch (e) {
37
95
  return { error: String(e?.message ?? e), windows: [] }
38
96
  }
39
97
  }
40
98
  }
99
+
100
+ /** 最早失效的那批 → magpie 的 `runsOutAt` / `runsOut`。 */
101
+ function runsOutOf(expiring) {
102
+ const next = pickEarliest(expiring)
103
+ if (!next) return {}
104
+ return {
105
+ runsOutAt: new Date(next.ms).toISOString(),
106
+ runsOut: next.points,
107
+ }
108
+ }
109
+
110
+ function pickEarliest(expiring) {
111
+ const list = Array.isArray(expiring) ? expiring : []
112
+ let best = null
113
+ for (const e of list) {
114
+ const pts = Number(e?.points ?? 0)
115
+ const ms = parseServerTime(e?.expiresAt)
116
+ if (!(pts > 0) || ms === null) continue
117
+ if (!best || ms < best.ms) best = { points: pts, ms }
118
+ }
119
+ return best
120
+ }
121
+
122
+ /** `"2026-10-28 23:59:59"` → 毫秒;解析不了返回 null */
123
+ function parseServerTime(s) {
124
+ const m = /^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})/.exec(String(s ?? ""))
125
+ if (!m) return null
126
+ const [, y, mo, d, h, mi, se] = m
127
+ const ms = new Date(Number(y), Number(mo) - 1, Number(d), Number(h), Number(mi), Number(se)).getTime()
128
+ return Number.isFinite(ms) ? ms : null
129
+ }
130
+
131
+ /** 北京时间的日期键 `YYYY-MM-DD`(服务端的时间都是 UTC+8) */
132
+ function beijingDay(ms) {
133
+ if (ms === null) return ""
134
+ return new Date(ms + 8 * 3600e3).toISOString().slice(0, 10)
135
+ }
136
+
137
+ function beijingDate(ms) {
138
+ return beijingDay(ms)
139
+ }
140
+
141
+ const sum = (list) => list.reduce((n, b) => n + Number(b?.points ?? 0), 0)
142
+ const fmt = (n) => Number(n).toLocaleString("zh-CN")
package/src/keys.mjs CHANGED
@@ -28,6 +28,7 @@ import {
28
28
  otherSlot,
29
29
  currentKey,
30
30
  safeAccount,
31
+ listAccounts,
31
32
  } from "./key-store.mjs"
32
33
 
33
34
  /**
@@ -37,6 +38,52 @@ export function accountOf(auth) {
37
38
  return safeAccount(auth?.org || auth?.accountId || auth?.uid || "default")
38
39
  }
39
40
 
41
+ /** 这个账号的 store 里到底有没有东西(登录过 / 接管过 / 续过期都算) */
42
+ function storeHasIdentity(siteId, account) {
43
+ const st = readStore(siteId, account)
44
+ return !!(
45
+ st?.refreshToken ||
46
+ st?.keys?.a?.apiKey ||
47
+ st?.keys?.b?.apiKey
48
+ )
49
+ }
50
+
51
+ /**
52
+ * 这条请求属于哪个账号的 key 槽 —— 比 `accountOf` 多一层兜底。
53
+ *
54
+ * 为什么要多这一层(2026-10-28 实测的链路):
55
+ *
56
+ * 网关路径下(ZCode 等 agent 从 magpie 的 `/v1/chat/completions` 进来),
57
+ * `getAuth()` 返回的 auth **可能不带 org / accountId / uid** ——
58
+ * 于是 `accountOf()` 落到 `"default"`,`readStore(c.id, "default")` 读不到
59
+ * 真实账号的槽,`ensureKey` 只能退到「从 magpie 存的凭据接管一次」用
60
+ * `auth.access`;而它过了 2 小时就 403,表现成
61
+ * `502 {"message":"超级麦吉 did not stream: token无效"}`。
62
+ *
63
+ * 兜底只做**一半**,这是关键:
64
+ *
65
+ * · 该站点**恰好一个账号**(绝大多数用户)→ 那就是它,安全
66
+ * · 0 个 → 交给原来的「首次接管」
67
+ * · **≥2 个 → 不猜**,退回 `"default"`
68
+ *
69
+ * 为什么不猜:key 与账号一一对应,用错账号的 key 就是拿别人的积分、别人的
70
+ * 组织出话,而且本地看不出来(不是报错,是错账)。多账号场景宁可让这次请求
71
+ * 失败,也不要借身份 —— 判据见 docs/internals.md 第十五节。
72
+ *
73
+ * ⚠ 多账号 + 网关路径的组合目前仍然无解:需要 magpie 把 provider 实例 id
74
+ * 传进来(`plugin-auth.json` 里已经有 `magicrew#p4nwa7` 这种带后缀的 key),
75
+ * 插件侧拿不到。在那之前这里只能保守。
76
+ */
77
+ export function accountFor(c, auth) {
78
+ const direct = accountOf(auth)
79
+ if (direct !== "default") return direct
80
+ try {
81
+ const live = listAccounts(c.id).filter((acc) => storeHasIdentity(c.id, acc))
82
+ if (live.length === 1) return live[0]
83
+ } catch {}
84
+ return direct
85
+ }
86
+
40
87
  /**
41
88
  * 拿到这个账号一把能用的 key。**永不抛** —— 续不上就交出手里那把(哪怕不新鲜),
42
89
  * 让请求自己去撞上游的错误,比在这里抛更能说明问题。
@@ -50,7 +97,7 @@ export async function ensureKey(c, getAuth, { leadMs } = {}) {
50
97
  try {
51
98
  auth = typeof getAuth === "function" ? await getAuth() : getAuth
52
99
  } catch {}
53
- const account = accountOf(auth)
100
+ const account = accountFor(c, auth)
54
101
 
55
102
  const st = readStore(c.id, account)
56
103
  const cur = currentKey(st)