magicrew-auth 0.3.18 → 0.3.24

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)