magicrew-auth 0.3.16 → 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.
@@ -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)
package/src/vision.mjs ADDED
@@ -0,0 +1,193 @@
1
+ // 识图判角度 —— 用 magpie 里**用户自己配的那个识图模型**
2
+ //
3
+ // 为什么需要它:旋转验证的答案只存在于图片里。协议(见 captcha.mjs)能把
4
+ // 「怎么提交」做到位,但「该提交多少度」必须有人看图。
5
+ //
6
+ // 三条实测结论决定了这里怎么写:
7
+ //
8
+ // ① **不能问「多少度」,要问「主体的上方向指向钟面几点」。**
9
+ // 实测:直接问角度(填空)6 次错 5 次;改成钟点(相对描述)3 轮中 2。
10
+ // 让模型「在脑子里把图转过来」它做不好,问「现在朝哪」它答得稳。
11
+ //
12
+ // ② **必须问两次并比分歧。**
13
+ // 实测的失败案例是两个判官给出 1.7 和 7.5 钟点(相差 174°)——
14
+ // 其中一个把方向看反了。这种情况取平均毫无意义,只能判为「不可信」重来。
15
+ //
16
+ // ③ **模型是用户在 magpie 设置里选的那个**(`settings.json` 的 `vision`),
17
+ // 不额外要求他配 key —— magpie 自己就监听着一个 OpenAI 兼容网关,
18
+ // 直接打本地端口就行。
19
+ //
20
+ // ⚠ 模型回什么形状的答案都可能(带解释、带思考过程、结尾才有数字),
21
+ // 所以解析一律「从后往前找最后一个合法数字」,找不到就算这次不可信。
22
+
23
+ import { readFileSync } from "node:fs"
24
+ import { homedir } from "node:os"
25
+ import { join } from "node:path"
26
+
27
+ import { CLOCK_MAX_SPREAD, clockSpread, clockToDeg } from "./captcha.mjs"
28
+
29
+ /** magpie 的配置目录;Windows 上是 ~/.config/magpie,和别的系统一致 */
30
+ export function magpieConfigDir() {
31
+ return join(homedir(), ".config", "magpie")
32
+ }
33
+
34
+ /**
35
+ * 用户选的识图模型。
36
+ * @returns {string|null} 形如 `workbuddy/deepseek-v4.1-flash`;没配 / 关掉 / 读不到 → null
37
+ */
38
+ export function readVisionModel() {
39
+ try {
40
+ const raw = readFileSync(join(magpieConfigDir(), "settings.json"), "utf8")
41
+ const j = JSON.parse(raw)
42
+ const v = String(j?.vision ?? "").trim()
43
+ if (!v || v === "off" || v === "none" || v === "0") return null
44
+ return v
45
+ } catch {
46
+ return null
47
+ }
48
+ }
49
+
50
+ /**
51
+ * magpie 自己的网关端口。
52
+ *
53
+ * 默认 3425(本机实测 `magpie-windows-amd64.exe` 就监听这个)。
54
+ * 用户能通过 `magpie web --addr host:port` / `magpie serve` 改,所以:
55
+ * · 环境变量优先(万一 magpie 把它传进插件进程)
56
+ * · 否则按常见端口列表逐个试 —— **只看谁能以 200 回 /v1/models**
57
+ */
58
+ export function gatewayCandidates() {
59
+ const fromEnv = [process.env.MAGPIE_GATEWAY_PORT, process.env.MAGPIE_PORT]
60
+ .map((x) => Number(x))
61
+ .filter((n) => Number.isFinite(n) && n > 0)
62
+ const common = [3425, 3426, 8080]
63
+ return [...new Set([...fromEnv, ...common])]
64
+ }
65
+
66
+ let cachedBase = null
67
+
68
+ /** 探一个能用的网关基址;找到就记住,找不到返回 null */
69
+ export async function resolveGatewayBase() {
70
+ if (cachedBase) return cachedBase
71
+ for (const port of gatewayCandidates()) {
72
+ const base = `http://127.0.0.1:${port}/v1`
73
+ try {
74
+ const res = await fetch(`${base}/models`, { signal: AbortSignal.timeout(1500) })
75
+ if (res.ok) {
76
+ cachedBase = base
77
+ return base
78
+ }
79
+ } catch {
80
+ /* 这个端口没在听,试下一个 */
81
+ }
82
+ }
83
+ return null
84
+ }
85
+
86
+ /**
87
+ * webp → png 的 data URL。
88
+ *
89
+ * 为什么要转:验证码图是 `data:image/webp`,不是每个视觉模型都吃 webp。
90
+ * magpie 自带的 bun 有 `Bun.Image`(实测能解 webp),在没有它的环境里就原样返回。
91
+ */
92
+ export async function toPngDataUrl(imageDataUrl) {
93
+ if (!/^data:image\/webp/i.test(imageDataUrl)) return imageDataUrl
94
+ try {
95
+ if (typeof Bun === "undefined" || typeof Bun.Image !== "function") return imageDataUrl
96
+ const b64 = imageDataUrl.split(",")[1] || ""
97
+ const img = new Bun.Image(Buffer.from(b64, "base64"))
98
+ await img.metadata()
99
+ img.png()
100
+ const out = await img.toBuffer()
101
+ return "data:image/png;base64," + Buffer.from(out).toString("base64")
102
+ } catch {
103
+ return imageDataUrl
104
+ }
105
+ }
106
+
107
+ const CLOCK_PROMPT = (variant) => {
108
+ const frame =
109
+ variant === 0
110
+ ? "想象一个钟面盖在图上:12 = 正上方,3 = 正右方,6 = 正下方,9 = 正左方。"
111
+ : "把这张图当成表盘:最上方是 12 点,顺时针数到 3 点在右、6 点在正下、9 点在左。"
112
+ return `这张图片被随机旋转过,是圆形裁切的照片,里面有一个主体。
113
+ ${frame}
114
+ 判断:主体自己的"上方向"(人的头顶、动物的头顶、瓶子口、相机顶部那一侧)现在指向钟面的几点?
115
+ 要精确,允许一位小数(例如 7.3)。
116
+ 最后一行只输出这个数字,不要写别的。`
117
+ }
118
+
119
+ /** 从一段话里取「最后出现的、落在 0…12 的数」—— 模型常在前面铺垫一堆 */
120
+ function lastClock(text) {
121
+ const nums = String(text || "").match(/\d+(?:\.\d+)?/g)
122
+ if (!nums) return null
123
+ for (let i = nums.length - 1; i >= 0; i--) {
124
+ const v = Number(nums[i])
125
+ if (Number.isFinite(v) && v >= 0 && v <= 12) return v
126
+ }
127
+ return null
128
+ }
129
+
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" },
134
+ // temperature 0 是为了同一张图两次问法之外的噪声尽量小;
135
+ // 两次用不同措辞(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
+ })
152
+ if (!res.ok) throw new Error(`识图模型回 HTTP ${res.status}`)
153
+ 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) }
156
+ }
157
+
158
+ /**
159
+ * 让识图模型判断「该顺时针转多少度」。
160
+ *
161
+ * @returns {Promise<{ok:true, deg:number, clock:number, spread:number, model:string}
162
+ * |{ok:false, reason:string, detail?:string}>}
163
+ */
164
+ export async function decideDegFromImage(imageDataUrl) {
165
+ const model = readVisionModel()
166
+ if (!model) return { ok: false, reason: "no-vision-model" }
167
+
168
+ const base = await resolveGatewayBase()
169
+ if (!base) return { ok: false, reason: "no-gateway", detail: "连不上 magpie 自己的网关(试过 3425/3426/8080)" }
170
+
171
+ 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: "" }))
174
+
175
+ if (a.clock == null && b.clock == null) {
176
+ return { ok: false, reason: "unparsable", detail: `${a.text} / ${b.text}` }
177
+ }
178
+ // 只拿到一个也能用(另一个多半是被截断或跑题了)
179
+ if (a.clock == null || b.clock == null) {
180
+ const one = a.clock ?? b.clock
181
+ return { ok: true, deg: clockToDeg(one), clock: one, spread: 0, model, single: true }
182
+ }
183
+
184
+ const spread = clockSpread(a.clock, b.clock)
185
+ if (spread > CLOCK_MAX_SPREAD) {
186
+ // 相差超过 1.5 钟点(45°)基本就是「有一个人把方向看反了」,
187
+ // 这时候取平均只会得到一个两边都不对的中间值 —— 宁可换一张图重来
188
+ return { ok: false, reason: "disagree", detail: `${a.clock} vs ${b.clock}(相差 ${spread.toFixed(1)} 钟点)`, spread }
189
+ }
190
+
191
+ const clock = (a.clock + b.clock) / 2
192
+ return { ok: true, deg: clockToDeg(clock), clock, spread, model }
193
+ }