magicrew-auth 0.3.28 → 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 +2 -1
- package/CHANGELOG.md +93 -0
- package/docs/internals.md +17 -3
- package/package.json +1 -1
- package/src/hooks/loader.mjs +95 -7
- package/src/hooks/refresh.mjs +14 -7
- package/src/keys.mjs +150 -11
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`
|
|
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 十一之二 |
|
|
@@ -72,6 +72,7 @@ curl -s "https://usemagpie.ai/api/latest?channel=stable" | grep -o '"version":"[
|
|
|
72
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
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
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` |
|
|
75
76
|
|
|
76
77
|
**坑 14 的两个附带判据**(都实测过):
|
|
77
78
|
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,98 @@
|
|
|
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
|
+
|
|
3
96
|
## 0.3.28 — 2026-10-29
|
|
4
97
|
|
|
5
98
|
**邮箱验证码登录(内置)· 已有账号不再过旋转图 · 识图模型被 `max_tokens` 饿死的修复**
|
package/docs/internals.md
CHANGED
|
@@ -822,7 +822,7 @@ store 槽 a expires 2026-10-28T14:41:03.000Z → 200
|
|
|
822
822
|
不管吊销机制是什么,**只留一把、只有一个出口**这个设计依然成立 ——
|
|
823
823
|
理由从「否则互相吊销」改成「否则本地会分叉,说不清手里那把是不是服务端认的」。
|
|
824
824
|
|
|
825
|
-
### 保活链路(2026-10-
|
|
825
|
+
### 保活链路(0.3.29 起;2026-10-29 逐版本核对)
|
|
826
826
|
|
|
827
827
|
```
|
|
828
828
|
每次请求前 ensureKey(c, getAuth) ← src/hooks/loader.mjs
|
|
@@ -831,13 +831,27 @@ store 槽 a expires 2026-10-28T14:41:03.000Z → 200
|
|
|
831
831
|
├─ 有 refresh_token → PUT 换新的 → 覆盖落盘
|
|
832
832
|
└─ 没有 / 失败 → 退回 magpie 存的那把
|
|
833
833
|
|
|
834
|
+
keyCandidates(c, auth) ← 同一账号的 store 两槽 + auth.access
|
|
835
|
+
└─ 逐把打 /v1/chat/completions;被拒换下一把,命中写回 store(adoptKey)
|
|
836
|
+
|
|
834
837
|
到期前 10 分钟 auth.refresh(auth) ← magpie 按 refreshLead 主动调度
|
|
835
|
-
└─ src/hooks/refresh.mjs
|
|
836
|
-
|
|
838
|
+
└─ src/hooks/refresh.mjs → keys.mjs 的 renewViaStore()
|
|
839
|
+
与 ensureKey 共用 renewOnce 锁和同一个 key-store;
|
|
840
|
+
refresh_token 先试 store 那份、再试 magpie 那份(升级兼容)
|
|
837
841
|
|
|
838
842
|
每天一次 checkin → runCheckin() ← 领积分 / 补首任务 / 新手福利,**不碰 key**
|
|
839
843
|
```
|
|
840
844
|
|
|
845
|
+
⚠ 0.3.29 之前 refresh.mjs 自己调 `api.refreshGatewayKey(auth.refresh)`、不写 key-store:
|
|
846
|
+
两条签发路径各存一把 key,而 key 只活 2 小时,两条路各自续到哪把、哪把已经过期,
|
|
847
|
+
本地并不一致。2026-10-29 实测 5 个国际站账号里 3 个「静态挑一把」踩空,表现是
|
|
848
|
+
403 `token无效` 且 `retryable=false`(magpie 不换号,整轮直接抛给客户端)。
|
|
849
|
+
`keyCandidates()` 的逐把试与 `renewViaStore()` 的收敛就是为这两条路分别兜底。
|
|
850
|
+
|
|
851
|
+
⚠ 别把这里读成「服务端同一账号只认最新签发的那把」—— 2026-10-28 的受控实验
|
|
852
|
+
已经推翻过:同账号相隔 67 秒签的两把同时有效;12 把逐把探活,403 全部对应
|
|
853
|
+
`expiresAt` 已过。真正失效的是**手里那把过期了**,不是后来签的把它顶掉了。
|
|
854
|
+
|
|
841
855
|
### checkin 现在做四件事(0.3.11 起)
|
|
842
856
|
|
|
843
857
|
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "magicrew-auth",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.29",
|
|
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/hooks/loader.mjs
CHANGED
|
@@ -14,7 +14,45 @@
|
|
|
14
14
|
// ⚠ 不要在这里读流、不要手拼 x-api-key ——
|
|
15
15
|
// 流由 magpie 处理,头由 SDK 包自动加。见 README「最小交付面」。
|
|
16
16
|
|
|
17
|
-
import { ensureKey, accountOf } from "../keys.mjs"
|
|
17
|
+
import { ensureKey, accountOf, keyCandidates, adoptKey } from "../keys.mjs"
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* 上游「这个账号不行」的两种形态(2026-10-29 实测):
|
|
21
|
+
*
|
|
22
|
+
* · HTTP 403
|
|
23
|
+
* · **HTTP 200 + `{"code":403,"message":"token无效"}`** ← 绝大多数是这种
|
|
24
|
+
*
|
|
25
|
+
* 所以光看状态码会漏。只解析 JSON(SSE 一律不碰,读流会破坏它)。
|
|
26
|
+
*/
|
|
27
|
+
async function isKeyRejected(res) {
|
|
28
|
+
if (!res) return false
|
|
29
|
+
if (res.status === 401 || res.status === 403) return true
|
|
30
|
+
const ct = res.headers.get("content-type") || ""
|
|
31
|
+
if (res.status !== 200 || !ct.includes("application/json")) return false
|
|
32
|
+
let text = ""
|
|
33
|
+
try {
|
|
34
|
+
text = await res.clone().text()
|
|
35
|
+
} catch {
|
|
36
|
+
return false
|
|
37
|
+
}
|
|
38
|
+
if (text.length > 8192) return false
|
|
39
|
+
let body
|
|
40
|
+
try {
|
|
41
|
+
body = JSON.parse(text)
|
|
42
|
+
} catch {
|
|
43
|
+
return false
|
|
44
|
+
}
|
|
45
|
+
// 只认**业务错误码**。正常 chat 完成的 JSON 是 {choices:[…]},没有顶层 code;
|
|
46
|
+
// 用文本正则会把模型回复里恰好出现的 "unauthorized" 当成 key 拒绝 ——
|
|
47
|
+
// 那会白白重发请求(可能重复计费),甚至最后把正常响应丢掉换成 402。
|
|
48
|
+
const code = Number(body?.code ?? body?.error?.code)
|
|
49
|
+
if (code === 401 || code === 403) return true
|
|
50
|
+
const msg = String(body?.message ?? body?.error?.message ?? "")
|
|
51
|
+
if (!msg) return false
|
|
52
|
+
if (/token无效|token 无效|invalid token/i.test(msg)) return true
|
|
53
|
+
// 兜底:只有 message、没有 code 的少量形态;有 choices 的正常响应绝不判
|
|
54
|
+
return !Array.isArray(body?.choices) && /unauthorized/i.test(msg)
|
|
55
|
+
}
|
|
18
56
|
|
|
19
57
|
/**
|
|
20
58
|
* 把 JSON Schema 里的 `additionalProperties` **非布尔值**原地换成 `true`。
|
|
@@ -447,6 +485,8 @@ export async function rewriteCreditRefusal(res) {
|
|
|
447
485
|
}
|
|
448
486
|
|
|
449
487
|
export function makeLoader(c) {
|
|
488
|
+
/** 已经写回 store 的 key —— 避免每个请求都去读一次 store */
|
|
489
|
+
const adopted = new Set()
|
|
450
490
|
return async (getAuth) => {
|
|
451
491
|
const auth = await getAuth()
|
|
452
492
|
if (!auth) return {}
|
|
@@ -489,12 +529,60 @@ export function makeLoader(c) {
|
|
|
489
529
|
// 四处请求体改写,全在 rewriteRequestBody 里(那里有实测出处)
|
|
490
530
|
body = rewriteRequestBody(body)
|
|
491
531
|
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
532
|
+
const signal = init?.signal ?? req?.signal ?? AbortSignal.timeout(300_000)
|
|
533
|
+
const sendWith = (apiKey) =>
|
|
534
|
+
fetch(url, {
|
|
535
|
+
method: init?.method ?? "POST",
|
|
536
|
+
headers: { ...headers, authorization: `Bearer ${apiKey}` },
|
|
537
|
+
body,
|
|
538
|
+
signal,
|
|
539
|
+
})
|
|
540
|
+
|
|
541
|
+
// 同一账号可能握着两把 key(magpie 的 refresh 一条、插件 store 一条),
|
|
542
|
+
// 哪把还有效本地无法预判 —— 静态挑必然有一半踩空,只能逐把试。
|
|
543
|
+
// 详见 src/keys.mjs 的 keyCandidates 注释(2026-10-29 五个账号的实测表)。
|
|
544
|
+
// 传 fresh 而不是 getAuth:候选与下面 adoptKey 必须落在同一个账号上,
|
|
545
|
+
// 不能让两次 getAuth() 返回不同结果。
|
|
546
|
+
const cands = await keyCandidates(c, fresh)
|
|
547
|
+
if (!cands.length) throw new Error(`${c.SHORT} 未登录(拿不到可用的 key)`)
|
|
548
|
+
|
|
549
|
+
for (const cand of cands) {
|
|
550
|
+
const res = await sendWith(cand.apiKey)
|
|
551
|
+
if (!(await isKeyRejected(res))) {
|
|
552
|
+
// 这把能用 —— 写回 store,让 ensureKey / 额度那几路也用同一把
|
|
553
|
+
if (!adopted.has(cand.apiKey)) {
|
|
554
|
+
adopted.add(cand.apiKey)
|
|
555
|
+
adoptKey(c, fresh, cand.apiKey, cand.expiresAt)
|
|
556
|
+
}
|
|
557
|
+
return rewriteCreditRefusal(res)
|
|
558
|
+
}
|
|
559
|
+
// 这把被拒:响应不再使用,显式取消 body —— 否则逐把试时
|
|
560
|
+
// 每条死连接的响应体都挂着,等 GC 才释放。
|
|
561
|
+
try {
|
|
562
|
+
await res.body?.cancel()
|
|
563
|
+
} catch {}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// 这个账号的 key 全被拒。上游给的是 403「token无效」,magpie 会判成
|
|
567
|
+
// 「不可重试」直接抛给客户端(ZCode 显示 auth_failed / token无效)。
|
|
568
|
+
// 改写成 402:magpie 的 failure(402) 是 failCredit → 把这个账号 rest 掉、
|
|
569
|
+
// 去问下一个账号 / 组的下一个成员。与 0.3.26 的「积分不足」同一条通路。
|
|
570
|
+
return new Response(
|
|
571
|
+
JSON.stringify({
|
|
572
|
+
error: {
|
|
573
|
+
message:
|
|
574
|
+
`${c.SHORT}:本账号的 ${cands.length} 把密钥均已被上游拒绝,已交给 magpie 换下一个账号。` +
|
|
575
|
+
`(常见于登录 / 续期分叉;请重新登录这个账号)`,
|
|
576
|
+
type: "account_unavailable",
|
|
577
|
+
code: 402,
|
|
578
|
+
},
|
|
579
|
+
}),
|
|
580
|
+
{
|
|
581
|
+
status: 402,
|
|
582
|
+
statusText: "Payment Required",
|
|
583
|
+
headers: { "content-type": "application/json" },
|
|
584
|
+
},
|
|
585
|
+
)
|
|
498
586
|
},
|
|
499
587
|
}
|
|
500
588
|
}
|
package/src/hooks/refresh.mjs
CHANGED
|
@@ -3,24 +3,31 @@
|
|
|
3
3
|
// magpie 在 api_key 到期前(refreshLead)调一次,**返回的字段合并进旧账号**,
|
|
4
4
|
// 不是整体替换(client.auth.set 才是替换,两者不同)。
|
|
5
5
|
//
|
|
6
|
-
// ⚠ 一次性 refresh_token 必须串行 —— magpie
|
|
6
|
+
// ⚠ 一次性 refresh_token 必须串行 —— magpie 已按账号串行化;插件里
|
|
7
|
+
// renewViaStore 与 ensureKey 共用 renewOnce 的同一把锁,也不会并发。
|
|
7
8
|
// 服务端每次换完会轮换,必须用返回的新值。
|
|
8
9
|
|
|
9
|
-
import {
|
|
10
|
+
import { renewViaStore } from "../keys.mjs"
|
|
10
11
|
|
|
11
12
|
export function makeRefresh(c) {
|
|
12
13
|
return async (auth) => {
|
|
13
|
-
|
|
14
|
+
// 不要求 auth.refresh 存在:refresh_token 可能只躺在 key-store 里
|
|
15
|
+
//(0.3.29 之前 ensureKey 续过、magpie 那份已经轮换废了)。由 renewViaStore
|
|
16
|
+
// 两处都试,这里先别把它挡掉。
|
|
17
|
+
if (!auth) return undefined
|
|
14
18
|
try {
|
|
15
|
-
|
|
19
|
+
// ⚠ 必须走 store —— 由它统一持有 refresh_token 并把新 key 写回。
|
|
20
|
+
// 自己再调一次 refreshGatewayKey 会让两条路径各存一把,
|
|
21
|
+
// 哪把还有效本地无法预判,请求侧就会 403「token无效」(2026-10-29 实测)。
|
|
22
|
+
const k = await renewViaStore(c, auth)
|
|
16
23
|
return {
|
|
17
24
|
access: k.apiKey,
|
|
18
|
-
//
|
|
25
|
+
// 服务端每次换完都轮换 refresh_token,必须用返回的新值
|
|
19
26
|
refresh: k.refreshToken ?? auth.refresh,
|
|
20
|
-
expires: Date.now() + 2 * 3600_000,
|
|
27
|
+
expires: k.expiresAt || Date.now() + 2 * 3600_000,
|
|
21
28
|
}
|
|
22
29
|
} catch (e) {
|
|
23
|
-
if (/invalid|expired|401|403/i.test(String(e?.message))) {
|
|
30
|
+
if (/invalid|expired|401|403|token/i.test(String(e?.message))) {
|
|
24
31
|
throw Object.assign(new Error("登录已失效,请重新登录"), { signIn: "expired" })
|
|
25
32
|
}
|
|
26
33
|
throw e
|
package/src/keys.mjs
CHANGED
|
@@ -2,21 +2,16 @@
|
|
|
2
2
|
//
|
|
3
3
|
// 登录成功 → issueAndStore() 签一把放进「另一个槽」,切过去
|
|
4
4
|
// loader → ensureKey() 快过期就把新的签到另一个槽,切过去;旧的留着当回退
|
|
5
|
-
// refresh →
|
|
6
|
-
// api.refreshGatewayKey(auth.refresh),magpie 拿到的不是这一把。
|
|
5
|
+
// refresh → renewViaStore() 续期、写回 store;magpie 拿到的就是 store 里这一把
|
|
7
6
|
//
|
|
8
7
|
// **每个账号一套槽,互不干扰** —— 同站点登两个账号时,第二个人登录不会碰第一个人的 key。
|
|
9
8
|
// 账号标识用 `org`(组织码),拿不到才退回 accountId。
|
|
10
9
|
//
|
|
11
|
-
// ⚠
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
// 目前不会触发,因为 **store 从来没被写过**(本机 magicrew-key-*.json 一个都没有,
|
|
17
|
-
// 说明 ensureKey 从没执行过 —— 没发过模型请求)。但这意味着:
|
|
18
|
-
// **若将来要给 checkin 补 ensureKey,必须先把这个钩子也收敛进来**,
|
|
19
|
-
// 否则就是亲手把「一条路」掰成两条,用「多写一句文档」换「用户莫名掉登录」。
|
|
10
|
+
// ⚠ refresh 这条口子曾经是「唯一出口」设计里唯一没落地的地方(2026-10-27 核对):
|
|
11
|
+
// refresh.mjs 自己调 api.refreshGatewayKey(auth.refresh),拿到的 key 只交给 magpie,
|
|
12
|
+
// 与 store 各存一份 —— refresh_token 一次性轮换,两条路径必然分叉。
|
|
13
|
+
// 0.3.29(PR #12)起已收敛到 renewViaStore():与 ensureKey 共用 renewOnce 锁和
|
|
14
|
+
// 同一个 key-store,refresh_token 也只从这里轮换出去。
|
|
20
15
|
|
|
21
16
|
import { issueGatewayKey, refreshGatewayKey } from "./api.mjs"
|
|
22
17
|
import {
|
|
@@ -84,6 +79,150 @@ export function accountFor(c, auth) {
|
|
|
84
79
|
return direct
|
|
85
80
|
}
|
|
86
81
|
|
|
82
|
+
/**
|
|
83
|
+
* 这个账号**所有**可能能用的 key,按到期时间从晚到早排。
|
|
84
|
+
*
|
|
85
|
+
* 为什么需要「多把」:key 有两条签发路径 —— magpie 的 `auth.refresh` 一条
|
|
86
|
+
* (src/hooks/refresh.mjs)、本模块的 `ensureKey` 一条,两边各写各的。
|
|
87
|
+
* api_key 只活 2 小时,两条路各自什么时候续、续到哪把,本地并不一致 ——
|
|
88
|
+
* 静态「只挑一把」必然有一半概率踩空。
|
|
89
|
+
*
|
|
90
|
+
* 2026-10-29 实测(5 个国际站账号,逐把打 /v1/chat/completions):
|
|
91
|
+
*
|
|
92
|
+
* magicrew-intl auth.access ✅ store 槽 b ❌
|
|
93
|
+
* magicrew-intl#z983c8 auth.access ✅ store 槽 b ❌
|
|
94
|
+
* magicrew-intl#3oqx5z auth.access ❌ store 槽 a ✅
|
|
95
|
+
* magicrew-intl#h2mcxi auth.access ❌ store 槽 b ✅
|
|
96
|
+
* magicrew-intl#8amjca auth.access ❌ store 槽 a ❌ ← 两把都死
|
|
97
|
+
*
|
|
98
|
+
* **5 个里 3 个两路状态不一致,方向还各不相同** —— 先 store 或先 auth 都救不了。
|
|
99
|
+
*
|
|
100
|
+
* ⚠ 不要读成「服务端同一账号只认最新签发的那把」。2026-10-28 的受控实验已经
|
|
101
|
+
* 推翻过这个说法:同账号相隔 67 秒签的两把同时有效;12 把逐把探活,403 全部
|
|
102
|
+
* 对应 `expiresAt` 已过。真正的原因是**手里那把过期了**,而两条路径让本地
|
|
103
|
+
* 不知道自己手里的是哪把。逐把试解决的就是这个「不知道」。
|
|
104
|
+
*
|
|
105
|
+
* 到期越晚 = 签发越晚 = 越可能是活着的那把,所以按 expiresAt 倒序试。
|
|
106
|
+
*
|
|
107
|
+
* @param {object} c 站点上下文
|
|
108
|
+
* @param {Function|object} getAuth magpie 的取账号函数,或直接给 auth 对象
|
|
109
|
+
*/
|
|
110
|
+
export async function keyCandidates(c, getAuth) {
|
|
111
|
+
let auth = null
|
|
112
|
+
try {
|
|
113
|
+
auth = typeof getAuth === "function" ? await getAuth() : getAuth
|
|
114
|
+
} catch {}
|
|
115
|
+
const account = accountFor(c, auth)
|
|
116
|
+
const st = readStore(c.id, account)
|
|
117
|
+
|
|
118
|
+
const out = []
|
|
119
|
+
const byKey = new Map()
|
|
120
|
+
const add = (k) => {
|
|
121
|
+
if (!k?.apiKey) return
|
|
122
|
+
const rec = { apiKey: k.apiKey, expiresAt: Number(k.expiresAt) || 0 }
|
|
123
|
+
const prev = byKey.get(rec.apiKey)
|
|
124
|
+
if (prev) {
|
|
125
|
+
// 同一把 key 从两路拿到时,保留更晚的到期时间 —— 否则可能被排到死 key 后面
|
|
126
|
+
if (rec.expiresAt > prev.expiresAt) prev.expiresAt = rec.expiresAt
|
|
127
|
+
return
|
|
128
|
+
}
|
|
129
|
+
byKey.set(rec.apiKey, rec)
|
|
130
|
+
out.push(rec)
|
|
131
|
+
}
|
|
132
|
+
add(currentKey(st))
|
|
133
|
+
add(st?.keys?.[otherSlot(st.current)])
|
|
134
|
+
if (auth?.access) add({ apiKey: auth.access, expiresAt: Number(auth.expires) || 0 })
|
|
135
|
+
|
|
136
|
+
return out.sort((a, b) => Number(b.expiresAt || 0) - Number(a.expiresAt || 0))
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** 把实测能用的那把写回 store —— 下次直接命中,不再逐把试错 */
|
|
140
|
+
export function adoptKey(c, auth, apiKey, expiresAt) {
|
|
141
|
+
try {
|
|
142
|
+
const account = accountFor(c, auth)
|
|
143
|
+
const st = readStore(c.id, account)
|
|
144
|
+
if (currentKey(st)?.apiKey === apiKey) return
|
|
145
|
+
const slot = otherSlot(st.current)
|
|
146
|
+
writeStore(c.id, account, {
|
|
147
|
+
current: slot,
|
|
148
|
+
// 空 store 首次接管时 refresh_token 只在 magpie 手里 —— 这里必须接住,
|
|
149
|
+
// 否则 2 小时后没人能续,用户被要求重新登录。
|
|
150
|
+
refreshToken: st.refreshToken || auth?.refresh || "",
|
|
151
|
+
keys: { ...st.keys, [slot]: { apiKey, expiresAt: Number(expiresAt) || 0 } },
|
|
152
|
+
at: st.at,
|
|
153
|
+
})
|
|
154
|
+
} catch {}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* 续期并**写回 store** —— 给 src/hooks/refresh.mjs 用。
|
|
159
|
+
*
|
|
160
|
+
* 收敛成一条路径的原因:refresh.mjs 原来自己调 `refreshGatewayKey`,
|
|
161
|
+
* 拿到的 key 只交给 magpie,插件 store 里还是旧的。两条路径各存一份,
|
|
162
|
+
* 哪把是新签的、哪把已经过期,本地说不清 —— 2026-10-29 实测 5 个账号里
|
|
163
|
+
* 3 个两路状态不一致,就是这么来的。
|
|
164
|
+
*
|
|
165
|
+
* ⚠ refresh_token 是一次性的:谁续谁负责写回,不能让两边各存各的。
|
|
166
|
+
*/
|
|
167
|
+
export async function renewViaStore(c, auth) {
|
|
168
|
+
const account = accountFor(c, auth)
|
|
169
|
+
|
|
170
|
+
// 与 ensureKey 共用同一把锁(renewOnce 按 siteId+account 去重)——
|
|
171
|
+
// refresh_token 一次性,两条路径同时续期会让后到的那次用废 token → 误判掉登录。
|
|
172
|
+
let err = null
|
|
173
|
+
const rec = await renewOnce(c.id, account, async () => {
|
|
174
|
+
try {
|
|
175
|
+
const st = readStore(c.id, account)
|
|
176
|
+
|
|
177
|
+
// refresh_token 也可能有两份:store 里的(ensureKey 续过)与 magpie 手里的
|
|
178
|
+
// (0.3.29 之前的 refresh.mjs 续过)。两把都是一次性,只有一把还活着 ——
|
|
179
|
+
// 升级上来的第一次尤其:store 那份可能早被轮换废了,auth.refresh 才是活的。
|
|
180
|
+
const tokens = []
|
|
181
|
+
const seen = new Set()
|
|
182
|
+
for (const t of [st.refreshToken, auth?.refresh]) {
|
|
183
|
+
if (!t || seen.has(t)) continue
|
|
184
|
+
seen.add(t)
|
|
185
|
+
tokens.push(t)
|
|
186
|
+
}
|
|
187
|
+
if (!tokens.length) throw new Error("没有 refresh_token")
|
|
188
|
+
|
|
189
|
+
let lastErr = null
|
|
190
|
+
for (const refreshToken of tokens) {
|
|
191
|
+
try {
|
|
192
|
+
const k = await refreshGatewayKey(refreshToken, c.host)
|
|
193
|
+
const next = { apiKey: k.apiKey, expiresAt: toEpochMs(k.apiKeyExpiresAt) }
|
|
194
|
+
const slot = otherSlot(st.current)
|
|
195
|
+
const nextRefresh = k.refreshToken || refreshToken
|
|
196
|
+
writeStore(c.id, account, {
|
|
197
|
+
current: slot,
|
|
198
|
+
refreshToken: nextRefresh,
|
|
199
|
+
keys: { ...st.keys, [slot]: next },
|
|
200
|
+
at: st.at,
|
|
201
|
+
})
|
|
202
|
+
return { ...next, refreshToken: nextRefresh }
|
|
203
|
+
} catch (e) {
|
|
204
|
+
lastErr = e
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
throw lastErr ?? new Error("续期失败")
|
|
208
|
+
} catch (e) {
|
|
209
|
+
err = e
|
|
210
|
+
throw e
|
|
211
|
+
}
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
if (!rec) throw err ?? new Error("续期失败")
|
|
215
|
+
// 并发时可能复用 ensureKey 的 rec(它只返回 apiKey/expiresAt)——
|
|
216
|
+
// 从 store 补上最新 refresh_token,免得 magpie 那份落后一轮。
|
|
217
|
+
if (!rec.refreshToken) {
|
|
218
|
+
try {
|
|
219
|
+
const st2 = readStore(c.id, account)
|
|
220
|
+
if (st2.refreshToken) rec.refreshToken = st2.refreshToken
|
|
221
|
+
} catch {}
|
|
222
|
+
}
|
|
223
|
+
return rec
|
|
224
|
+
}
|
|
225
|
+
|
|
87
226
|
/**
|
|
88
227
|
* 拿到这个账号一把能用的 key。**永不抛** —— 续不上就交出手里那把(哪怕不新鲜),
|
|
89
228
|
* 让请求自己去撞上游的错误,比在这里抛更能说明问题。
|