magicrew-auth 0.3.13 → 0.3.14

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/clipboard.mjs CHANGED
@@ -1,108 +1,108 @@
1
- // 剪贴板 —— 浏览器登录的降级通道
2
- //
3
- // 场景:本机没有 Chrome / Edge,或者自己拉起来的那个没读出凭据。
4
- // 这时让用户在官方登录页的 Console 里粘一行,把 jwt + org 送进剪贴板,
5
- // 插件读回来 —— 不用他去 DevTools 里翻两个字段。
6
- //
7
- // ⚠ authorize() 里不能 await 读剪贴板(起 powershell 要几百毫秒,它必须立刻返回);
8
- // 把 promise 递到 callback 才落地。
9
-
10
- import { execFile as execFileCb } from "node:child_process"
11
- import { promisify } from "node:util"
12
-
13
- const execFile = promisify(execFileCb)
14
-
15
- // ═══ 浏览器登录:为什么只能「开页面 + 接剪贴板」═══════════════════════
16
- //
17
- // 想要的那种「点一下 → 跳官网 → 用 Google / 邮箱 / 手机登 → 插件自己拿到凭证」
18
- // magicrew **没有这条路**,不是没找到实现方法,是接口不存在:
19
- //
20
- // ① 它的登录类型只有 13 种,全是「拿凭据换 token」的:
21
- // phone_password / phone_captcha / email / DingTalk / DingTalkAvoid /
22
- // wecom / Lark / LarkRedirect / wechat_official_account / wechat_app /
23
- // dingtalk_app / redirect / apple_login / google_login / feishu_oauth
24
- // (dtyq/magic `frontend/magic-web/src/types/login.ts` 的 LoginType 枚举,
25
- // 注释写明「登录方式(与API强绑定,禁止修改)」)
26
- // —— 没有设备码、没有插件授权、没有一次性 ticket。
27
- //
28
- // ② WorkBuddy 那种做法在它这里是空的:它没有
29
- // POST /v2/plugin/auth/state (开登录会话)
30
- // GET /v2/plugin/auth/token (轮询拿 token)
31
- // 这套 `/plugin/auth/*` 协议。
32
- //
33
- // ③ 也试过 `/api/v1/auth/status`、`/api/v1/auth/environment`、
34
- // `/api/v1/aggregation/login-data` —— status 要 authorization(3103),
35
- // environment 回 `{"login_code":null}` 且没有配套的兑现接口。
36
- //
37
- // ④ 网页登录成功后 token 只在 www.magicrew.ai 的 localStorage 里,
38
- // 插件读不到别的源的存储;`redirect` 字段也不能收任意回调地址。
39
- //
40
- // ⇒ 所以能做的最短路径是:**用系统浏览器打开官方登录页(官方登录方式一样不少),
41
- // 登录完粘一行 Console 命令把两个值送进剪贴板,插件自己读回来。**
42
- // 用户不用在 magpie 里输手机号密码,也不用去 DevTools 里翻两个字段。
43
- //
44
- // 剪贴板读法(三平台):
45
- // win32 powershell Get-Clipboard -Raw
46
- // darwin pbpaste
47
- // linux xclip -selection clipboard -o
48
- const CLIP_HINT = [
49
- "浏览器已打开官方登录页 —— 用你平时的方式登进去(Google / 邮箱 / 手机号都行)。",
50
- "登录完成后回到那个页面按 F12 → Console,粘这一行回车:",
51
- `copy(JSON.stringify({jwt:(()=>{try{return JSON.parse(localStorage["MAGIC:user:authorization"]).value}catch{return localStorage["MAGIC:user:authorization"]}})(),org:(()=>{try{return JSON.parse(localStorage["MAGIC:user:organizationCode"]).value}catch{return localStorage["MAGIC:user:organizationCode"]}})()}))`,
52
- "插件会自己从剪贴板读,读到就完成 —— 不用回来点任何按钮。",
53
- ].join("\n")
54
-
55
- async function clipboardText() {
56
- try {
57
- const { stdout } = await execFile(CLIP_CMD[process.platform]?.[0] ?? "true", CLIP_CMD[process.platform]?.slice(1) ?? [], {
58
- timeout: 6000,
59
- windowsHide: true,
60
- maxBuffer: 1 << 20,
61
- })
62
- return String(stdout ?? "")
63
- } catch {
64
- return ""
65
- }
66
- }
67
-
68
- const CLIP_CMD = {
69
- win32: ["powershell", "-NoProfile", "-NonInteractive", "-Command", "Get-Clipboard -Raw"],
70
- darwin: ["pbpaste"],
71
- linux: ["xclip", "-selection", "clipboard", "-o"],
72
- }
73
-
74
- /** 剪贴板里的内容 → { jwt, org },认不出返回 null */
75
- function parseCreds(txt) {
76
- const s = String(txt || "").trim()
77
- if (!s) return null
78
- // ① Console 那行给的 JSON
79
- try {
80
- const o = JSON.parse(s)
81
- if (typeof o?.jwt === "string" && o.jwt.startsWith("eyJ")) {
82
- return { jwt: o.jwt, org: String(o?.org || "").trim() }
83
- }
84
- } catch {}
85
- // ② 只粘了 JWT(组织码空着,后面会报错让人补)
86
- if (s.startsWith("eyJ") && s.split(".").length === 3) return { jwt: s, org: "" }
87
- return null
88
- }
89
-
90
- /**
91
- * 盯着剪贴板,直到出现能认的凭证。
92
- * @param skip 打开登录页之前剪贴板里就有的内容 —— 不能拿它当结果
93
- */
94
- async function pollClipboard({ skip = "", timeout = 300_000, interval = 1500 } = {}) {
95
- const deadline = Date.now() + timeout
96
- while (Date.now() < deadline) {
97
- const txt = (await clipboardText()).trim()
98
- if (txt && txt !== skip) {
99
- const got = parseCreds(txt)
100
- if (got) return got
101
- }
102
- await new Promise((r) => setTimeout(r, interval))
103
- }
104
- return null
105
- }
106
-
1
+ // 剪贴板 —— 浏览器登录的降级通道
2
+ //
3
+ // 场景:本机没有 Chrome / Edge,或者自己拉起来的那个没读出凭据。
4
+ // 这时让用户在官方登录页的 Console 里粘一行,把 jwt + org 送进剪贴板,
5
+ // 插件读回来 —— 不用他去 DevTools 里翻两个字段。
6
+ //
7
+ // ⚠ authorize() 里不能 await 读剪贴板(起 powershell 要几百毫秒,它必须立刻返回);
8
+ // 把 promise 递到 callback 才落地。
9
+
10
+ import { execFile as execFileCb } from "node:child_process"
11
+ import { promisify } from "node:util"
12
+
13
+ const execFile = promisify(execFileCb)
14
+
15
+ // ═══ 浏览器登录:为什么只能「开页面 + 接剪贴板」═══════════════════════
16
+ //
17
+ // 想要的那种「点一下 → 跳官网 → 用 Google / 邮箱 / 手机登 → 插件自己拿到凭证」
18
+ // magicrew **没有这条路**,不是没找到实现方法,是接口不存在:
19
+ //
20
+ // ① 它的登录类型只有 13 种,全是「拿凭据换 token」的:
21
+ // phone_password / phone_captcha / email / DingTalk / DingTalkAvoid /
22
+ // wecom / Lark / LarkRedirect / wechat_official_account / wechat_app /
23
+ // dingtalk_app / redirect / apple_login / google_login / feishu_oauth
24
+ // (dtyq/magic `frontend/magic-web/src/types/login.ts` 的 LoginType 枚举,
25
+ // 注释写明「登录方式(与API强绑定,禁止修改)」)
26
+ // —— 没有设备码、没有插件授权、没有一次性 ticket。
27
+ //
28
+ // ② WorkBuddy 那种做法在它这里是空的:它没有
29
+ // POST /v2/plugin/auth/state (开登录会话)
30
+ // GET /v2/plugin/auth/token (轮询拿 token)
31
+ // 这套 `/plugin/auth/*` 协议。
32
+ //
33
+ // ③ 也试过 `/api/v1/auth/status`、`/api/v1/auth/environment`、
34
+ // `/api/v1/aggregation/login-data` —— status 要 authorization(3103),
35
+ // environment 回 `{"login_code":null}` 且没有配套的兑现接口。
36
+ //
37
+ // ④ 网页登录成功后 token 只在 www.magicrew.ai 的 localStorage 里,
38
+ // 插件读不到别的源的存储;`redirect` 字段也不能收任意回调地址。
39
+ //
40
+ // ⇒ 所以能做的最短路径是:**用系统浏览器打开官方登录页(官方登录方式一样不少),
41
+ // 登录完粘一行 Console 命令把两个值送进剪贴板,插件自己读回来。**
42
+ // 用户不用在 magpie 里输手机号密码,也不用去 DevTools 里翻两个字段。
43
+ //
44
+ // 剪贴板读法(三平台):
45
+ // win32 powershell Get-Clipboard -Raw
46
+ // darwin pbpaste
47
+ // linux xclip -selection clipboard -o
48
+ const CLIP_HINT = [
49
+ "浏览器已打开官方登录页 —— 用你平时的方式登进去(Google / 邮箱 / 手机号都行)。",
50
+ "登录完成后回到那个页面按 F12 → Console,粘这一行回车:",
51
+ `copy(JSON.stringify({jwt:(()=>{try{return JSON.parse(localStorage["MAGIC:user:authorization"]).value}catch{return localStorage["MAGIC:user:authorization"]}})(),org:(()=>{try{return JSON.parse(localStorage["MAGIC:user:organizationCode"]).value}catch{return localStorage["MAGIC:user:organizationCode"]}})()}))`,
52
+ "插件会自己从剪贴板读,读到就完成 —— 不用回来点任何按钮。",
53
+ ].join("\n")
54
+
55
+ async function clipboardText() {
56
+ try {
57
+ const { stdout } = await execFile(CLIP_CMD[process.platform]?.[0] ?? "true", CLIP_CMD[process.platform]?.slice(1) ?? [], {
58
+ timeout: 6000,
59
+ windowsHide: true,
60
+ maxBuffer: 1 << 20,
61
+ })
62
+ return String(stdout ?? "")
63
+ } catch {
64
+ return ""
65
+ }
66
+ }
67
+
68
+ const CLIP_CMD = {
69
+ win32: ["powershell", "-NoProfile", "-NonInteractive", "-Command", "Get-Clipboard -Raw"],
70
+ darwin: ["pbpaste"],
71
+ linux: ["xclip", "-selection", "clipboard", "-o"],
72
+ }
73
+
74
+ /** 剪贴板里的内容 → { jwt, org },认不出返回 null */
75
+ function parseCreds(txt) {
76
+ const s = String(txt || "").trim()
77
+ if (!s) return null
78
+ // ① Console 那行给的 JSON
79
+ try {
80
+ const o = JSON.parse(s)
81
+ if (typeof o?.jwt === "string" && o.jwt.startsWith("eyJ")) {
82
+ return { jwt: o.jwt, org: String(o?.org || "").trim() }
83
+ }
84
+ } catch {}
85
+ // ② 只粘了 JWT(组织码空着,后面会报错让人补)
86
+ if (s.startsWith("eyJ") && s.split(".").length === 3) return { jwt: s, org: "" }
87
+ return null
88
+ }
89
+
90
+ /**
91
+ * 盯着剪贴板,直到出现能认的凭证。
92
+ * @param skip 打开登录页之前剪贴板里就有的内容 —— 不能拿它当结果
93
+ */
94
+ async function pollClipboard({ skip = "", timeout = 300_000, interval = 1500 } = {}) {
95
+ const deadline = Date.now() + timeout
96
+ while (Date.now() < deadline) {
97
+ const txt = (await clipboardText()).trim()
98
+ if (txt && txt !== skip) {
99
+ const got = parseCreds(txt)
100
+ if (got) return got
101
+ }
102
+ await new Promise((r) => setTimeout(r, interval))
103
+ }
104
+ return null
105
+ }
106
+
107
107
 
108
108
  export { CLIP_HINT, clipboardText, parseCreds, pollClipboard }
@@ -1,93 +1,93 @@
1
- // ④ auth.loader(getAuth) —— 请求接管
2
- //
3
- // 返回 baseURL / apiKey / headers / fetch(a,b),magpie 拿它发真实请求。
4
- // 这是唯一能改请求体的地方。本项目用它干两件事:
5
- //
6
- // · 国际站网关强制要 organization-code(国内站不要),不带就是
7
- // HTTP 500 "Internal Server Error."(22 字节纯文本,不是 JSON)
8
- // · 推理模型低于 512 的 max_tokens 会被 reasoning 吃光,content 返回空串
9
- //
10
- // ⚠ 不要在这里读流、不要重试换号、不要手拼 x-api-key ——
11
- // 前两件 magpie 做了,第三件看 SDK 包自动加。见 README「最小交付面」。
12
-
13
- import { ensureKey, accountOf } from "../keys.mjs"
14
-
15
- export function makeLoader(c) {
16
- return async (getAuth) => {
17
- const auth = await getAuth()
18
- if (!auth) return {}
19
- return {
20
- baseURL: c.BASE,
21
- apiKey: auth.access,
22
- headers: { "user-agent": "MagicrewClient/1.0" },
23
- async fetch(input, init) {
24
- const fresh = await getAuth()
25
- if (!fresh) throw new Error(`${c.SHORT} 未登录`)
26
-
27
- // key 只有一个来源(src/keys.mjs)—— 服务端签一把顶一把,多入口必互相吊销
28
- const key = await ensureKey(c, getAuth)
29
- if (!key) throw new Error(`${c.SHORT} 未登录(拿不到可用的 key)`)
30
- const account = accountOf(fresh)
31
-
32
- const req = input instanceof Request ? input : null
33
- const url =
34
- typeof input === "string"
35
- ? input
36
- : input instanceof URL
37
- ? input.toString()
38
- : (req?.url ?? "")
39
-
40
- const incoming = new Headers(init?.headers ?? req?.headers ?? {})
41
- const headers = {
42
- authorization: `Bearer ${key.apiKey}`,
43
- "content-type": "application/json",
44
- }
45
- if (c.needsOrgOnGateway && fresh.org) headers["organization-code"] = fresh.org
46
-
47
- for (const [k, v] of incoming) {
48
- if (["authorization", "host", "content-length", "user-agent"].includes(k.toLowerCase())) continue
49
- headers[k] = v
50
- }
51
-
52
- let body = init?.body
53
- if (body === undefined && req) body = await req.text()
54
-
55
- // 改写两处请求体,都为实测出来的:
56
- //
57
- // ① max_tokens 抬到 512
58
- // 实测 glm-5.3-flash:64 → finish_reason:"length" 且 content:""
59
- // 512 → 正常出话(reasoning 208 / total 233)
60
- //
61
- // ② 流式请求补 stream_options.include_usage
62
- // magicrew 在 stream:true 下**默认不返回 usage**;补上之后,[DONE]
63
- // 之前会多一条 {"choices":[],"usage":{...}} 的收尾 chunk。
64
- // magpie 新版靠这条收尾 chunk 判定「流正常结束了」——缺了它就报
65
- // 「Stream ended without finish_reason」然后重试(实测 2026-10-27)。
66
- // 控制台里看到的就是:模型请求重试 (3/5) · Stream ended without finish_reason
67
- if (typeof body === "string" && body.includes('"stream"')) {
68
- try {
69
- const j = JSON.parse(body)
70
- if (Number(j.max_tokens) > 0 && Number(j.max_tokens) < 512) j.max_tokens = 512
71
- if (j.stream === true && !j.stream_options) {
72
- j.stream_options = { include_usage: true }
73
- }
74
- body = JSON.stringify(j)
75
- } catch {}
76
- } else if (typeof body === "string" && body.includes('"max_tokens"')) {
77
- try {
78
- const j = JSON.parse(body)
79
- if (Number(j.max_tokens) > 0 && Number(j.max_tokens) < 512) j.max_tokens = 512
80
- body = JSON.stringify(j)
81
- } catch {}
82
- }
83
-
84
- return fetch(url, {
85
- method: init?.method ?? "POST",
86
- headers,
87
- body,
88
- signal: init?.signal ?? req?.signal ?? AbortSignal.timeout(300_000),
89
- })
90
- },
91
- }
92
- }
93
- }
1
+ // ④ auth.loader(getAuth) —— 请求接管
2
+ //
3
+ // 返回 baseURL / apiKey / headers / fetch(a,b),magpie 拿它发真实请求。
4
+ // 这是唯一能改请求体的地方。本项目用它干两件事:
5
+ //
6
+ // · 国际站网关强制要 organization-code(国内站不要),不带就是
7
+ // HTTP 500 "Internal Server Error."(22 字节纯文本,不是 JSON)
8
+ // · 推理模型低于 512 的 max_tokens 会被 reasoning 吃光,content 返回空串
9
+ //
10
+ // ⚠ 不要在这里读流、不要重试换号、不要手拼 x-api-key ——
11
+ // 前两件 magpie 做了,第三件看 SDK 包自动加。见 README「最小交付面」。
12
+
13
+ import { ensureKey, accountOf } from "../keys.mjs"
14
+
15
+ export function makeLoader(c) {
16
+ return async (getAuth) => {
17
+ const auth = await getAuth()
18
+ if (!auth) return {}
19
+ return {
20
+ baseURL: c.BASE,
21
+ apiKey: auth.access,
22
+ headers: { "user-agent": "MagicrewClient/1.0" },
23
+ async fetch(input, init) {
24
+ const fresh = await getAuth()
25
+ if (!fresh) throw new Error(`${c.SHORT} 未登录`)
26
+
27
+ // key 只有一个来源(src/keys.mjs)—— 服务端签一把顶一把,多入口必互相吊销
28
+ const key = await ensureKey(c, getAuth)
29
+ if (!key) throw new Error(`${c.SHORT} 未登录(拿不到可用的 key)`)
30
+ const account = accountOf(fresh)
31
+
32
+ const req = input instanceof Request ? input : null
33
+ const url =
34
+ typeof input === "string"
35
+ ? input
36
+ : input instanceof URL
37
+ ? input.toString()
38
+ : (req?.url ?? "")
39
+
40
+ const incoming = new Headers(init?.headers ?? req?.headers ?? {})
41
+ const headers = {
42
+ authorization: `Bearer ${key.apiKey}`,
43
+ "content-type": "application/json",
44
+ }
45
+ if (c.needsOrgOnGateway && fresh.org) headers["organization-code"] = fresh.org
46
+
47
+ for (const [k, v] of incoming) {
48
+ if (["authorization", "host", "content-length", "user-agent"].includes(k.toLowerCase())) continue
49
+ headers[k] = v
50
+ }
51
+
52
+ let body = init?.body
53
+ if (body === undefined && req) body = await req.text()
54
+
55
+ // 改写两处请求体,都为实测出来的:
56
+ //
57
+ // ① max_tokens 抬到 512
58
+ // 实测 glm-5.3-flash:64 → finish_reason:"length" 且 content:""
59
+ // 512 → 正常出话(reasoning 208 / total 233)
60
+ //
61
+ // ② 流式请求补 stream_options.include_usage
62
+ // magicrew 在 stream:true 下**默认不返回 usage**;补上之后,[DONE]
63
+ // 之前会多一条 {"choices":[],"usage":{...}} 的收尾 chunk。
64
+ // magpie 新版靠这条收尾 chunk 判定「流正常结束了」——缺了它就报
65
+ // 「Stream ended without finish_reason」然后重试(实测 2026-10-27)。
66
+ // 控制台里看到的就是:模型请求重试 (3/5) · Stream ended without finish_reason
67
+ if (typeof body === "string" && body.includes('"stream"')) {
68
+ try {
69
+ const j = JSON.parse(body)
70
+ if (Number(j.max_tokens) > 0 && Number(j.max_tokens) < 512) j.max_tokens = 512
71
+ if (j.stream === true && !j.stream_options) {
72
+ j.stream_options = { include_usage: true }
73
+ }
74
+ body = JSON.stringify(j)
75
+ } catch {}
76
+ } else if (typeof body === "string" && body.includes('"max_tokens"')) {
77
+ try {
78
+ const j = JSON.parse(body)
79
+ if (Number(j.max_tokens) > 0 && Number(j.max_tokens) < 512) j.max_tokens = 512
80
+ body = JSON.stringify(j)
81
+ } catch {}
82
+ }
83
+
84
+ return fetch(url, {
85
+ method: init?.method ?? "POST",
86
+ headers,
87
+ body,
88
+ signal: init?.signal ?? req?.signal ?? AbortSignal.timeout(300_000),
89
+ })
90
+ },
91
+ }
92
+ }
93
+ }
@@ -42,17 +42,28 @@ export function makeModels(c) {
42
42
  if (locked) continue
43
43
 
44
44
  const p = points[m.id] || {}
45
- const hasPoints = p.inputPoints != null && Number(p.inputPoints) > 0
45
+ // 三分支 —— 按 HOOKS.md 十一之二:`free` 的判据是「花不花这份订阅的
46
+ // 额度」,**不能从 rate 反推**(实测 rate===0 的 51 条里,38 条是
47
+ // 「套餐内不按次计费」,照样吃配额;标成免费会让 Free first 一直派到它们身上)。
48
+ //
49
+ // input_points > 0 → 有倍率,free 不设
50
+ // input_points === 0 → 厂商声明免费 → free: true,且不给 rate
51
+ // input_points 缺失 → 价格未知 → 不列(不猜,也不谎标免费)
52
+ //
53
+ // 「已知」要挡住空串与非数字:`Number("") === 0` 会把没填的价格当成免费。
54
+ const raw = p.inputPoints
55
+ const known = raw != null && String(raw).trim() !== "" && Number.isFinite(Number(raw))
56
+ const isFree = known && Number(raw) === 0
46
57
  // 计费接口整体成功、但这条没价格 → 不列。
47
58
  // 不填 rate 就不会画倍率胶囊(正确),但也别让它出现在列表里 ——
48
59
  // 用户选了不知道花多少。接口整体失败时不过滤,免得列表空掉。
49
- if (pointsOk && !hasPoints) continue
60
+ if (pointsOk && !known) continue
50
61
 
51
- // name 保持干净,倍率走 magpie 自己的 `rate` 字段 → 渲染成灰色胶囊。
52
- // (之前试过拼进 name、也试过塞进 cost,都不对:抓 codearts 插件的
53
- // modelOf 才看到宿主认的是 rate / free 这两个字段)
62
+ // name 保持干净,倍率走 magpie 自己的 `rate` 字段 → 渲染成灰色胶囊,
63
+ // 免费走 `free` → 进 Free first 那一档(codearts 的 modelOf 认的就是这两个)。
54
64
  out[m.id] = modelEntry(c, m.id, m.name, m.vision, {
55
- rate: hasPoints ? Number(p.inputPoints) / MULT_BASE : 0,
65
+ rate: known ? Number(raw) / MULT_BASE : 0,
66
+ free: isFree,
56
67
  })
57
68
  }
58
69
  writeSnapshot(c.id, out)