magicrew-auth 0.0.0-stage → 0.3.6

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/api.mjs ADDED
@@ -0,0 +1,409 @@
1
+ // Magicrew / 超级麦吉 的 HTTP 层
2
+ //
3
+ // 一个产品两个站,后端不同、模型也不同:
4
+ //
5
+ // 国内 超级麦吉 www.letsmagic.cn i-magic-service.letsmagic.cn
6
+ // 国际 MagiCrew www.magicrew.ai 待定(客户端是同一个包,API host 还没摸到)
7
+ //
8
+ // 每个站点两个 base:
9
+ // 业务 API <host>/api/v1/... 要网页 JWT
10
+ // 模型网关 <host>/v1/... OpenAI 兼容,要 mgw_ api_key
11
+ //
12
+ // 三层凭证,有效期完全不同:
13
+ // ① 网页登录 JWT 30 天
14
+ // ② mgw_ api_key 2 小时
15
+ // ③ refresh_token 7 天(PUT 一次轮换一次,一次性)
16
+ //
17
+ // ⇒ 插件只需要 ① 在登录时拿一次,之后 30 天全靠 ③ 自动滚。
18
+
19
+ const UA =
20
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36"
21
+
22
+ // 一个包 = 两个 provider。
23
+ //
24
+ // 每个站点的 `id` 就是它在 magpie 里的**供应商 id** —— 不是内部代号。
25
+ // 所以两个站会各自占一行(供应商列表、用量概览、模型目录全分开),
26
+ // 和 @magpie-community/opencode-workbuddy-auth 承载 WorkBuddy / WorkBuddy AI、
27
+ // opencode-qoder-auth 承载 Qoder / Qoder CN / QwenWork 是同一个做法:
28
+ // export const MagicrewAuthPlugin = makePlugin(SITES.cn)
29
+ // export const MagicrewIntlAuthPlugin = makePlugin(SITES.intl)
30
+ //
31
+ // ⚠ `cn` 的 id 必须保持 `magicrew` —— 已装的用户靠这个 id 认领登录态。
32
+ export const SITES = {
33
+ cn: {
34
+ id: "magicrew",
35
+ label: "国内 · 超级麦吉",
36
+ web: "https://www.letsmagic.cn",
37
+ host: "https://i-magic-service.letsmagic.cn",
38
+ },
39
+ intl: {
40
+ id: "magicrew-intl",
41
+ label: "国际 · MagiCrew",
42
+ web: "https://www.magicrew.ai",
43
+ // 实测(2026-10-27):来源是 www.magicrew.ai 的运行时配置注入页
44
+ // GET https://www.magicrew.ai/workspace → HTML 里内嵌 window 配置
45
+ // "MAGIC_SERVICE_BASE_URL":"https://magic-service.magicrew.ai"
46
+ // "MAGIC_APP_ENV":"international-prod"
47
+ //
48
+ // ⚠ api.magicrew.ai 不是这套 API —— 它是 MAGIC_GATEWAY_ADDRESS(teamshare 网关),
49
+ // 对 /v1/models、/api/v1/* 一律回 "not match rule path"。
50
+ //
51
+ // 该网关与国内 i-magic-service.letsmagic.cn 逐字符同构(同一个 Go 服务):
52
+ // GET / → hello, magic-service!
53
+ // GET /v1/models → {"code":403,"message":"token无效"}
54
+ // GET /api/v1/flows/models → {"code":2179,"message":"auth_token 不存在"}
55
+ host: "https://magic-service.magicrew.ai",
56
+ // 国际站 /v1/chat/completions **强制**要求 organization-code:
57
+ // 不带 → HTTP 500 "Internal Server Error."(22 字节裸文本,不是 JSON)
58
+ // 带上 → 200 正常 chat.completion
59
+ // 国内站 /v1/* 不需要(README 里那套一直是两条 base 分开用的)。
60
+ needsOrgOnGateway: true,
61
+ },
62
+ }
63
+
64
+ export function site(id) {
65
+ return SITES[id] ?? SITES.cn
66
+ }
67
+
68
+ class MagicrewError extends Error {
69
+ constructor(message, { status, code, site: s } = {}) {
70
+ super(message)
71
+ this.name = "MagicrewError"
72
+ this.status = status
73
+ this.code = code
74
+ this.site = s
75
+ }
76
+ }
77
+
78
+ function headersFor(token, org, host) {
79
+ const h = {
80
+ authorization: `Bearer ${token}`,
81
+ "content-type": "application/json",
82
+ "user-agent": UA,
83
+ accept: "application/json",
84
+ // 网关会看 Origin,缺了偶发 403
85
+ origin: host ? new URL(host).origin : "https://www.letsmagic.cn",
86
+ }
87
+ if (org) h["organization-code"] = org
88
+ return h
89
+ }
90
+
91
+ async function read(res) {
92
+ const text = await res.text()
93
+ let body
94
+ try {
95
+ body = text ? JSON.parse(text) : {}
96
+ } catch {
97
+ body = { message: text.slice(0, 200) }
98
+ }
99
+ if (!res.ok) {
100
+ throw new MagicrewError(body?.message || `HTTP ${res.status}`, { status: res.status, code: body?.code })
101
+ }
102
+ // magic 的业务错误码:1000 = ok,其它都是错
103
+ if (typeof body?.code === "number" && body.code !== 1000) {
104
+ throw new MagicrewError(body.message || `code ${body.code}`, { status: res.status, code: body.code })
105
+ }
106
+ return body
107
+ }
108
+
109
+ // ── ① 登录 ───────────────────────────────────────────────────────────
110
+
111
+ /**
112
+ * 手机号 + 密码登录。
113
+ *
114
+ * 实测(2026-10-27 国内站):type 只认 "phone_password";"phone_code" 直接 500。
115
+ * 账号不存在时返回 {"code":33002,"message":"账号不存在"}。
116
+ *
117
+ * 国际站登录页有 Google / Password / Email / Private Service 四种,password 这条大概率同构。
118
+ */
119
+ export async function login({
120
+ phone,
121
+ password,
122
+ stateCode = "+86",
123
+ organizationCode = "",
124
+ host = SITES.cn.host,
125
+ inviteCode = "",
126
+ }) {
127
+ const res = await fetch(`${host}/api/v1/sessions`, {
128
+ method: "POST",
129
+ headers: headersFor("", organizationCode, host),
130
+ body: JSON.stringify({
131
+ type: "phone_password",
132
+ phone: String(phone || "").replace(/[^\d]/g, ""),
133
+ password: password || "",
134
+ state_code: stateCode || "+86",
135
+ organization_code: organizationCode || "",
136
+ email: "",
137
+ redirect: "",
138
+ // 注册邀请码。
139
+ //
140
+ // 字段名来自上游前端 dtyq/magic 的 `types/login.ts`(`invite_code?: string`),
141
+ // 消费时机写在 `LoginService.ts` 的注释里:
142
+ // // PS: 改到在无组织下创建组织的时候再消费邀请码
143
+ // // values.invite_code = consumeInvitationCookie()
144
+ // ⇒ 不是登录那一下生效,是「该账号还没有组织、这次登录顺手建个人组织」时生效。
145
+ // 已经是老账号、组织早就有了,传了也不会再记账。
146
+ //
147
+ // 不传就是空串,服务端忽略(登录本身不受影响)。
148
+ invite_code: inviteCode || "",
149
+ }),
150
+ })
151
+ const body = await read(res)
152
+ const d = body?.data ?? body
153
+
154
+ // 字段名官方文档没有;三种写法都兜住,别赌一个
155
+ const token = d?.token || d?.access_token || d?.accessToken || d?.jwt || ""
156
+ if (!token) throw new MagicrewError("登录成功但没拿到 token", { code: body?.code })
157
+
158
+ return {
159
+ token,
160
+ organizationCode: d?.organization_code || d?.organizationCode || organizationCode || "",
161
+ raw: d,
162
+ }
163
+ }
164
+
165
+ // ── ② 模型网关的 key ─────────────────────────────────────────────────
166
+
167
+ /** 用登录 JWT 签发一把 mgw_ key(2 小时)。 */
168
+ export async function issueGatewayKey(token, org, host = SITES.cn.host) {
169
+ const res = await fetch(`${host}/api/v1/model-gateway/tokens`, {
170
+ method: "POST",
171
+ headers: headersFor(token, org, host),
172
+ body: "{}",
173
+ })
174
+ const body = await read(res)
175
+ const d = body?.data ?? {}
176
+ if (!d.api_key) throw new MagicrewError("签发密钥失败:没有 api_key")
177
+ return {
178
+ apiKey: d.api_key,
179
+ refreshToken: d.refresh_token,
180
+ apiKeyExpiresAt: d.api_key_expires_at,
181
+ refreshTokenExpiresAt: d.refresh_token_expires_at,
182
+ }
183
+ }
184
+
185
+ /**
186
+ * 用 refresh_token 换新的 api_key。
187
+ * 不需要登录态;refresh_token 会**轮换**(一次性的),必须串行调用。
188
+ */
189
+ export async function refreshGatewayKey(refreshToken, host = SITES.cn.host) {
190
+ const res = await fetch(`${host}/api/v1/model-gateway/tokens`, {
191
+ method: "PUT",
192
+ headers: { "content-type": "application/json", "user-agent": UA },
193
+ body: JSON.stringify({ refresh_token: refreshToken }),
194
+ })
195
+ const body = await read(res)
196
+ const d = body?.data ?? {}
197
+ if (!d.api_key) throw new MagicrewError("续期失败:没有 api_key")
198
+ return {
199
+ apiKey: d.api_key,
200
+ refreshToken: d.refresh_token,
201
+ apiKeyExpiresAt: d.api_key_expires_at,
202
+ refreshTokenExpiresAt: d.refresh_token_expires_at,
203
+ }
204
+ }
205
+
206
+ // ── ③ 模型 ───────────────────────────────────────────────────────────
207
+
208
+ /**
209
+ * 账号**当前能选**的语言模型。
210
+ *
211
+ * 注意别用 /v1/models —— 国内站它返回 44 个,含 VIP 锁定、生图、embedding,
212
+ * 塞进 magpie 会让用户选到根本调不动的模型。可选模型只有这里这 27 个。
213
+ *
214
+ * 实测返回字段:value / label / icon / tags / configs / vision
215
+ */
216
+ export async function fetchModels(token, org, host = SITES.cn.host) {
217
+ const res = await fetch(`${host}/api/v1/flows/models`, { headers: headersFor(token, org, host) })
218
+ const body = await read(res)
219
+ const list = body?.data?.models ?? []
220
+ return list.map((m) => ({
221
+ id: m.value,
222
+ name: (m.label || m.value).trim(),
223
+ vision: !!m.vision,
224
+ }))
225
+ }
226
+
227
+ /** 网关上的全量模型 id(含生图/视频/embedding),用来做分类。 */
228
+ export async function fetchAllModels(apiKey, host = SITES.cn.host) {
229
+ const res = await fetch(`${host}/v1/models`, {
230
+ headers: { authorization: `Bearer ${apiKey}`, "user-agent": UA },
231
+ })
232
+ const body = await read(res)
233
+ return (body?.data ?? []).map((m) => m.id)
234
+ }
235
+
236
+ /**
237
+ * 每个模型的计费类型与积分单价。
238
+ *
239
+ * billing_type: TextTokens / Tokens → 语言或向量
240
+ * Times / ImageCount / Seedream5ProImage → 生图
241
+ * input_points / output_points 是**每百万 token 的积分**。
242
+ */
243
+ export async function fetchModelPoints(token, org, modelIds, host = SITES.cn.host) {
244
+ const res = await fetch(`${host}/api/v1/products/model-points`, {
245
+ method: "POST",
246
+ headers: headersFor(token, org, host),
247
+ body: JSON.stringify({ model_ids: modelIds }),
248
+ })
249
+ const body = await read(res)
250
+ const out = {}
251
+ for (const m of body?.data ?? []) {
252
+ out[m.model_id] = {
253
+ billingType: m.billing_type,
254
+ inputPoints: m.input_points,
255
+ outputPoints: m.output_points,
256
+ kind: classify(m.billing_type, m.model_id),
257
+ }
258
+ }
259
+ return out
260
+ }
261
+
262
+ const EMBEDDING_RE = /embedding|dmeta/i
263
+ const IMAGE_RE = /image|seedream|aes_general/i
264
+
265
+ function classify(billingType, modelId) {
266
+ if (EMBEDDING_RE.test(modelId)) return "embedding"
267
+ if (billingType === "Times" || billingType === "ImageCount" || /Image$/i.test(billingType || "")) return "image"
268
+ if (IMAGE_RE.test(modelId)) return "image"
269
+ if (billingType === "TextTokens" || billingType === "Tokens") return "language"
270
+ return "other"
271
+ }
272
+
273
+ // ── ③b 新手任务(onboarding)──────────────────────────────────────────
274
+ //
275
+ // 这条是前端 chunk `onboarding-CZBI0pQt.js` 里挖出来的,只有一行:
276
+ // GET /api/v1/onboarding/tasks
277
+ // PUT /api/v1/onboarding/tasks/{code}/completion
278
+ //
279
+ // 8 项任务里 7 项可以纯 HTTP 完成(PUT 即到账),只有 `first_ai_work`
280
+ // 返回 9303「该新人引导任务不支持手动完成」—— 那项是真的要跑一次 AI 任务。
281
+
282
+ export async function fetchOnboardingTasks(token, org, host = SITES.cn.host) {
283
+ const res = await fetch(`${host}/api/v1/onboarding/tasks`, { headers: headersFor(token, org, host) })
284
+ const body = await read(res)
285
+ return (body?.data?.tasks ?? []).map((t) => ({
286
+ code: t.code,
287
+ title: t.title,
288
+ points: Number(t.points ?? 0),
289
+ status: t.status,
290
+ rewardStatus: t.reward_status,
291
+ completedAt: t.completed_at,
292
+ }))
293
+ }
294
+
295
+ /**
296
+ * 标记一项任务完成。成功返回 { points },失败抛出带 code 的错误。
297
+ * 已知失败码:9303 = 该任务不支持手动完成。
298
+ */
299
+ export async function completeOnboardingTask(token, org, code, host = SITES.cn.host) {
300
+ const res = await fetch(`${host}/api/v1/onboarding/tasks/${encodeURIComponent(code)}/completion`, {
301
+ method: "PUT",
302
+ headers: headersFor(token, org, host),
303
+ body: "{}",
304
+ })
305
+ const body = await read(res)
306
+ return { points: Number(body?.data?.points ?? 0) }
307
+ }
308
+
309
+ // ── ④ 积分与任务 ─────────────────────────────────────────────────────
310
+
311
+ /**
312
+ * 积分余额。
313
+ *
314
+ * expiring_quota_details 每一段是一个会过期的额度包,列表顺序 = 先过期的先扣。
315
+ */
316
+ export async function fetchPoints(token, org, host = SITES.cn.host) {
317
+ const res = await fetch(`${host}/api/v1/quota/points/total`, { headers: headersFor(token, org, host) })
318
+ const body = await read(res)
319
+ const d = body?.data ?? {}
320
+ return {
321
+ total: Number(d.total_points ?? 0),
322
+ expiring: (d.expiring_quota_details ?? []).map((q) => ({
323
+ points: Number(q.remaining_points ?? 0),
324
+ expiresAt: q.expires_at,
325
+ })),
326
+ nextGrant: d.next_cycle_grant ?? null,
327
+ }
328
+ }
329
+
330
+ /** 积分流水(倒序)。签到和任务做完后可以用来确认到账。 */
331
+ export async function fetchPointRecords(token, org, { page = 1, pageSize = 20, host = SITES.cn.host } = {}) {
332
+ // ⚠ 是 POST 不是 GET(GET 回 405 Allow: POST)
333
+ const res = await fetch(`${host}/api/v1/quota/points/records/queries`, {
334
+ method: "POST",
335
+ headers: headersFor(token, org, host),
336
+ body: JSON.stringify({ page: String(page), page_size: String(pageSize) }),
337
+ })
338
+ const body = await read(res)
339
+ return body?.data?.list ?? body?.data ?? []
340
+ }
341
+
342
+ // ── ④b 自己的邀请码 ──────────────────────────────────────────────────
343
+ //
344
+ // 「Free Points Program」那套分享激励。页面上看到的现成链接长这样:
345
+ //
346
+ // https://www.magicrew.ai/?invitation=IV75F0ZKVKXG
347
+ // https://www.letsmagic.cn/?invitation=<code>
348
+ //
349
+ // 奖励口径两站不同(来自上游前端 constants,2026-10-27 读的):
350
+ //
351
+ // 每次注册 每次订阅 邀请注册封顶 总额封顶
352
+ // 国内 1,000 3,000 60,000 300,000
353
+ // 国际 200 500 10,000 30,000
354
+ //
355
+ // 接口(2026-10-27 实测,从客户端 Cache/Cache_Data/data_1 里读出来的):
356
+ //
357
+ // GET /api/v1/invitations/info → { invite_code, invite_count, invited_users }
358
+ // GET /api/v1/invitations/activity → 活动配置
359
+ // GET /api/v1/invitations/rewards/total-points → 已赚积分
360
+ //
361
+ // ⚠ 这三个接口只认**客户端登录**的那把 JWT(438 字符),网页版那把
362
+ // localstorage 里的 JWT(354 字符)调过去回 {"code":2155,"message":"用户不存在"}。
363
+ // 两把都是同一账号签的,差别在 payload 里的用户字段 —— 网页版不带。
364
+ // ⇒ 所以「用我自己的邀请码」只能拿**本插件上次登录成功时存下的**那把 JWT,
365
+ // 没登录过就没得用(报错,不猜)。
366
+ export const INVITE_CODE_PATH = "/api/v1/invitations/info"
367
+
368
+ /** 拿不到就抛错 —— 调用方要的是「真实结果」,不是静默降级 */
369
+ export async function fetchOwnInviteCode(token, org, host = SITES.cn.host) {
370
+ const res = await fetch(`${host}${INVITE_CODE_PATH}`, { headers: headersFor(token, org, host) })
371
+ const body = await read(res)
372
+ const d = body?.data ?? {}
373
+ const code = d.invite_code || d.inviteCode || d.code || d.invitation_code || ""
374
+ if (!code) throw new MagicrewError("接口通了但返回里没有 invite_code")
375
+ return {
376
+ code,
377
+ count: Number(d.invite_count ?? 0),
378
+ invited: Array.isArray(d.invited_users) ? d.invited_users.length : 0,
379
+ }
380
+ }
381
+
382
+ // ── ④c 这个登录态是谁 ────────────────────────────────────────────────
383
+ //
384
+ // 卡片上原来显示组织码(orgcode67890 / orgcode12345),那是随机串,用户认不出是谁。
385
+ //
386
+ // 优先级:**手机号 → 邮箱 → 昵称 → 真名**。
387
+ //
388
+ // ⚠ 昵称排在后面是有原因的:那是用户自己填的,新账号可以不填、也可以随便填
389
+ // (作者自己的账号填的是 "MYCF711",跟他是谁毫无关系)。手机号是登录凭据本身,
390
+ // 改不了、也漏不掉。
391
+ //
392
+ // ⚠ 服务端把手机号打码了(实测 `138****8000`,中间四位星号),邮箱常常是 null。
393
+ // 打码归打码,它仍然是唯一稳定的账号标识 —— 至少能看出是哪个号。
394
+ //
395
+ // 接口来自客户端缓存里出现过的清单(docs/internals.md 第九节的办法找到的)。
396
+ export async function fetchWho(token, org, host = SITES.cn.host) {
397
+ const res = await fetch(`${host}/api/v1/contact/accounts/me/users`, { headers: headersFor(token, org, host) })
398
+ const body = await read(res)
399
+ const u = body?.data?.items?.[0] ?? body?.data ?? {}
400
+ const name = String(u.phone || u.email || u.nickname || u.real_name || "").trim()
401
+ return {
402
+ name,
403
+ nickname: String(u.nickname || "").trim(),
404
+ phone: String(u.phone || ""),
405
+ email: String(u.email || ""),
406
+ }
407
+ }
408
+
409
+ export { MagicrewError, headersFor as _headers }