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/src/keys.mjs CHANGED
@@ -2,21 +2,16 @@
2
2
  //
3
3
  // 登录成功 → issueAndStore() 签一把放进「另一个槽」,切过去
4
4
  // loader → ensureKey() 快过期就把新的签到另一个槽,切过去;旧的留着当回退
5
- // refresh → ⚠ **不经过本模块**。src/hooks/refresh.mjs 自己调
6
- // api.refreshGatewayKey(auth.refresh),magpie 拿到的不是这一把。
5
+ // refresh → renewViaStore() 续期、写回 store;magpie 拿到的就是 store 里这一把
7
6
  //
8
7
  // **每个账号一套槽,互不干扰** —— 同站点登两个账号时,第二个人登录不会碰第一个人的 key。
9
8
  // 账号标识用 `org`(组织码),拿不到才退回 accountId。
10
9
  //
11
- // ⚠ 上面那条 refresh 是「唯一出口」这个设计里**唯一没落地的口子**(2026-10-27 核对):
12
- // refresh_token 是一次性轮换的,两条路径各用各的那份会分叉 ——
13
- // ensureKey 轮换后 store 里是新值,refresh.mjs 手里 magpie 的还是旧值,
14
- // 再调就是 401 → magpie 判定「登录已失效」→ 让用户重新登录。
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 res = await fetch(`${base}/chat/completions`, {
132
- method: "POST",
133
- headers: { "content-type": "application/json" },
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
- body: JSON.stringify({
137
- model,
138
- max_tokens: 400,
139
- temperature: 0,
140
- messages: [
141
- {
142
- role: "user",
143
- content: [
144
- { type: "text", text: CLOCK_PROMPT(variant) },
145
- { type: "image_url", image_url: { url: imageDataUrl } },
146
- ],
147
- },
148
- ],
149
- }),
150
- signal: AbortSignal.timeout(45_000),
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 text = j?.choices?.[0]?.message?.content ?? ""
155
- return { clock: lastClock(text), text: String(text).trim().slice(0, 200) }
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
- const a = await askClock(base, model, img, 0).catch((e) => ({ clock: null, text: String(e?.message ?? e) }))
173
- const b = await askClock(base, model, img, 1).catch(() => ({ clock: null, text: "" }))
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}` }