magicrew-auth 0.3.26 → 0.3.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +6 -2
- package/CHANGELOG.md +373 -0
- package/README.md +133 -59
- package/docs/internals.md +139 -3
- package/package.json +1 -1
- package/src/captcha.mjs +146 -43
- package/src/headless.mjs +79 -29
- package/src/hooks/loader.mjs +258 -7
- package/src/hooks/methods.mjs +238 -147
- package/src/hooks/refresh.mjs +14 -7
- package/src/keys.mjs +150 -11
- package/src/vision.mjs +83 -25
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
|
* 让请求自己去撞上游的错误,比在这里抛更能说明问题。
|
package/src/vision.mjs
CHANGED
|
@@ -128,49 +128,107 @@ function lastClock(text) {
|
|
|
128
128
|
}
|
|
129
129
|
|
|
130
130
|
async function askClock(base, model, imageDataUrl, variant) {
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
|
|
131
|
+
const body = {
|
|
132
|
+
model,
|
|
133
|
+
// ⚠ 这个上限是**给推理 token 留的**,不是给答案留的。
|
|
134
|
+
//
|
|
135
|
+
// 实测(2026-10-28,`workbuddy/deepseek-v4.1-flash`):max_tokens=400 时
|
|
136
|
+
// `finish_reason:"length"`、`reasoning_tokens:400`、**`content` 是 null** ——
|
|
137
|
+
// 推理过程把额度吃干净,答案一个字都没输出。判官拿到空串 ⇒ 判成
|
|
138
|
+
// 「不可信」⇒ 整条无头登录挂掉。给到 6000 时 reasoning 用 899 就收尾,
|
|
139
|
+
// `content` 正常回 "10"。
|
|
140
|
+
// ⇒ 别按「答案就一个数字」去估这个值;它买的是模型的思考空间。
|
|
141
|
+
max_tokens: 6000,
|
|
142
|
+
// ⚠ `reasoning_effort: "low"` —— 实测有据的省法(2026-10-29,同一模型同一批图,
|
|
143
|
+
// 每个配置各跑 6 轮):
|
|
144
|
+
//
|
|
145
|
+
// | | 出数字 | 平均耗时 | 平均 tokens | 平均 reasoning |
|
|
146
|
+
// | 不传 | 5/6 | 20.6 s | 2343 | 3581 字 |
|
|
147
|
+
// | low | 6/6 | 8.4 s | 1007 | 1646 字 |
|
|
148
|
+
//
|
|
149
|
+
// 更快、更省、而且**更稳**(不传的那一组有一次把 6000 token 全烧在思考上、
|
|
150
|
+
// `content` 交白卷)。判出来的角度两者一致。
|
|
151
|
+
//
|
|
152
|
+
// ⚠ 别指望「彻底不思考」——这个模型在网关侧关不掉,实测 12 组参数:
|
|
153
|
+
// `enable_thinking:false` / `thinking_budget:0` → 思考**更长**且 content 空;
|
|
154
|
+
// `reasoning_effort:"none"/"minimal"` → 比不传还长;
|
|
155
|
+
// `thinking:{type:"disabled"}` / `chat_template_kwargs` / `extra_body` /
|
|
156
|
+
// `reasoning:{enabled:false}` → 都只是小幅缩短,没有一个真的关掉。
|
|
157
|
+
// 要真不思考,只能换一个**非推理**的视觉模型(那由用户在 magpie 设置里选)。
|
|
158
|
+
//
|
|
159
|
+
// ⚠ 它**不是** OpenAI 规范字段:下面 send() 里有一层兜底,被 400 就退一步重发。
|
|
160
|
+
reasoning_effort: "low",
|
|
134
161
|
// temperature 0 是为了同一张图两次问法之外的噪声尽量小;
|
|
135
162
|
// 两次用不同措辞(variant)而不是靠随机温度,好复现
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
|
|
163
|
+
temperature: 0,
|
|
164
|
+
messages: [
|
|
165
|
+
{
|
|
166
|
+
role: "user",
|
|
167
|
+
content: [
|
|
168
|
+
{ type: "text", text: CLOCK_PROMPT(variant) },
|
|
169
|
+
{ type: "image_url", image_url: { url: imageDataUrl } },
|
|
170
|
+
],
|
|
171
|
+
},
|
|
172
|
+
],
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const send = (b) =>
|
|
176
|
+
fetch(`${base}/chat/completions`, {
|
|
177
|
+
method: "POST",
|
|
178
|
+
headers: { "content-type": "application/json" },
|
|
179
|
+
body: JSON.stringify(b),
|
|
180
|
+
signal: AbortSignal.timeout(90_000),
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
let res = await send(body)
|
|
184
|
+
if (res.status === 400 && Object.hasOwn(body, "reasoning_effort")) {
|
|
185
|
+
// 兼容兜底:识图模型是**用户自己选的**,不是我们能假设的(插件声明的模型目录里
|
|
186
|
+
// 什么都可能有,magpie 也允许自定义 provider)。有的 OpenAI 兼容端认出这个字段、
|
|
187
|
+
// 有的直接为未知字段回 400。宁可少省那几秒,也不要因为一个可选的优化把整条
|
|
188
|
+
// 无头登录打死 —— 去掉它重发一次,行为退回「不传」的那一档。
|
|
189
|
+
delete body.reasoning_effort
|
|
190
|
+
res = await send(body)
|
|
191
|
+
}
|
|
152
192
|
if (!res.ok) throw new Error(`识图模型回 HTTP ${res.status}`)
|
|
153
193
|
const j = await res.json()
|
|
154
|
-
const
|
|
155
|
-
|
|
194
|
+
const m = j?.choices?.[0]?.message ?? {}
|
|
195
|
+
const content = String(m.content ?? "").trim()
|
|
196
|
+
const reasoning = String(m.reasoning_content ?? "")
|
|
197
|
+
// ① 正常情况:答案在 content 里
|
|
198
|
+
if (content) return { clock: lastClock(content), text: content.slice(-200), from: "content" }
|
|
199
|
+
// ② 兜底:推理模型被截断、content 为空 —— 答案在 reasoning 的最后一段里
|
|
200
|
+
// (实测尾巴就是「最终决定:10。」这种形状)。只看尾巴 300 字,
|
|
201
|
+
// 别把前面推理中间提到的钟点当答案。
|
|
202
|
+
if (reasoning) {
|
|
203
|
+
const tail = reasoning.slice(-300)
|
|
204
|
+
return { clock: lastClock(tail), text: tail.slice(-200), from: "reasoning" }
|
|
205
|
+
}
|
|
206
|
+
return { clock: null, text: "", from: "empty" }
|
|
156
207
|
}
|
|
157
208
|
|
|
158
209
|
/**
|
|
159
210
|
* 让识图模型判断「该顺时针转多少度」。
|
|
160
211
|
*
|
|
212
|
+
* @param {string} imageDataUrl 验证码图的 data URL
|
|
213
|
+
* @param {{model?:string}} [opts] `model` 可覆盖设置里那个(给对比脚本 / 测试用;
|
|
214
|
+
* 正常运行不传,读用户选的)
|
|
161
215
|
* @returns {Promise<{ok:true, deg:number, clock:number, spread:number, model:string}
|
|
162
216
|
* |{ok:false, reason:string, detail?:string}>}
|
|
163
217
|
*/
|
|
164
|
-
export async function decideDegFromImage(imageDataUrl) {
|
|
165
|
-
const model = readVisionModel()
|
|
218
|
+
export async function decideDegFromImage(imageDataUrl, { model: overrideModel } = {}) {
|
|
219
|
+
const model = overrideModel || readVisionModel()
|
|
166
220
|
if (!model) return { ok: false, reason: "no-vision-model" }
|
|
167
221
|
|
|
168
222
|
const base = await resolveGatewayBase()
|
|
169
223
|
if (!base) return { ok: false, reason: "no-gateway", detail: "连不上 magpie 自己的网关(试过 3425/3426/8080)" }
|
|
170
224
|
|
|
171
225
|
const img = await toPngDataUrl(imageDataUrl)
|
|
172
|
-
|
|
173
|
-
|
|
226
|
+
// ⚠ **两个判官并行**:串行等两次就把延迟叠成两倍(实测单次 5~20 秒),
|
|
227
|
+
// 而它们互不依赖 —— 一起发,耗时等于较慢的那一次。
|
|
228
|
+
const [a, b] = await Promise.all([
|
|
229
|
+
askClock(base, model, img, 0).catch((e) => ({ clock: null, text: String(e?.message ?? e) })),
|
|
230
|
+
askClock(base, model, img, 1).catch(() => ({ clock: null, text: "" })),
|
|
231
|
+
])
|
|
174
232
|
|
|
175
233
|
if (a.clock == null && b.clock == null) {
|
|
176
234
|
return { ok: false, reason: "unparsable", detail: `${a.text} / ${b.text}` }
|