creditdaddy 1.2.0 → 1.3.0

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.
@@ -0,0 +1,127 @@
1
+ /**
2
+ * MiniMax Code 浏览器登录 — 官方 OAuth 设备码授权(RFC 8628 + S256 PKCE),
3
+ * 与客户端「扫码登录」同一协议,任何浏览器 / 桌面版隐私窗口都能完成,无需 zcode:// 式本地回调。
4
+ *
5
+ * 1. POST https://account.minimax.cn/oauth2/device/code
6
+ * {client_id:mcode-public, scope:agent.default, audience:agent-backend,
7
+ * code_challenge:<S256>, code_challenge_method:S256}
8
+ * → {device_code, user_code, verification_uri_complete, expires_in:300, interval:3}
9
+ * 2. 用户在浏览器打开 verification_uri_complete(account.minimax.cn/oauth-authorize?user_code=…)登录授权
10
+ * 3. POST https://account.minimax.cn/oauth2/token
11
+ * {grant_type:urn:ietf:params:oauth:grant-type:device_code, device_code, client_id, code_verifier}
12
+ * → 未授权时 400 authorization_pending / slow_down(继续等);授权后 200
13
+ * {access_token, refresh_token, expires_in, …}
14
+ *
15
+ * 与「本机导入」的关键区别:设备码登录拿到的是**独立 loginEpoch** 的一条新 refresh token 链,
16
+ * 不与本机 ~/.minimax 客户端共用凭据,因此 CreditDaddy 自行刷新不会作废客户端的 token(消除轮换互斥)。
17
+ * 故本流程**绝不回写** auth.json,device 账号自持凭据链。
18
+ */
19
+
20
+ import crypto from 'node:crypto';
21
+ import { fetchJsonRace } from './zcodeClient.js';
22
+ import { FETCH_TIMEOUT_MS } from './constants.js';
23
+ import { MINIMAX_ACCOUNT_BASE, fetchMiniMaxProfile } from './minimaxClient.js';
24
+
25
+ const DEVICE_CODE_URL = `${MINIMAX_ACCOUNT_BASE}/oauth2/device/code`;
26
+ const TOKEN_URL = `${MINIMAX_ACCOUNT_BASE}/oauth2/token`;
27
+ const CLIENT_ID = 'mcode-public';
28
+ const SCOPE = 'agent.default';
29
+ const AUDIENCE = 'agent-backend';
30
+ const DEVICE_GRANT = 'urn:ietf:params:oauth:grant-type:device_code';
31
+
32
+ function base64Url(buf) {
33
+ return buf.toString('base64').replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
34
+ }
35
+
36
+ async function postForm(url, fields) {
37
+ const form = new URLSearchParams();
38
+ for (const [k, v] of Object.entries(fields)) form.set(k, v);
39
+ const res = await fetchJsonRace(url, {
40
+ method: 'POST',
41
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
42
+ body: form.toString(),
43
+ timeoutMs: FETCH_TIMEOUT_MS,
44
+ });
45
+ const text = await res.text().catch(() => '');
46
+ let body = null;
47
+ try { body = text ? JSON.parse(text) : null; } catch {}
48
+ return { res, body, text };
49
+ }
50
+
51
+ /** 发起设备码授权,返回浏览器授权地址与轮询参数 */
52
+ export async function startMiniMaxLogin() {
53
+ const verifier = base64Url(crypto.randomBytes(32));
54
+ const challenge = base64Url(crypto.createHash('sha256').update(verifier).digest());
55
+ const { res, body, text } = await postForm(DEVICE_CODE_URL, {
56
+ client_id: CLIENT_ID, scope: SCOPE, audience: AUDIENCE,
57
+ code_challenge: challenge, code_challenge_method: 'S256',
58
+ });
59
+ if (!res.ok || !body?.device_code || !body?.verification_uri_complete) {
60
+ throw new Error('MiniMax 设备码初始化失败:HTTP ' + res.status + (text ? ' ' + text.slice(0, 200) : ''));
61
+ }
62
+ const url = String(body.verification_uri_complete);
63
+ if (!url.startsWith('https://')) throw new Error('MiniMax 授权返回了非 https 地址');
64
+ return {
65
+ url,
66
+ expiresInMs: Math.max(60e3, (Number(body.expires_in) || 300) * 1000),
67
+ intervalMs: Math.max(2000, (Number(body.interval) || 3) * 1000),
68
+ data: { deviceCode: body.device_code, verifier, userCode: body.user_code || null },
69
+ };
70
+ }
71
+
72
+ /**
73
+ * 单次轮询:{status:'pending'} | {status:'ok', input}
74
+ * authorization_pending / slow_down 视为继续等待;其余错误抛出(终态失败)。
75
+ */
76
+ export async function pollMiniMaxLogin(data) {
77
+ const { res, body, text } = await postForm(TOKEN_URL, {
78
+ grant_type: DEVICE_GRANT,
79
+ device_code: data.deviceCode,
80
+ client_id: CLIENT_ID,
81
+ code_verifier: data.verifier,
82
+ });
83
+
84
+ const errCode = body?.error;
85
+ if (res.status === 400 && (errCode === 'authorization_pending' || errCode === 'slow_down')) {
86
+ return { status: 'pending' };
87
+ }
88
+ if (errCode === 'expired_token') throw new Error('MiniMax 授权已超时,请重新发起');
89
+ if (errCode === 'access_denied') throw new Error('MiniMax 授权被拒绝');
90
+ if (!res.ok || !body?.access_token) {
91
+ throw new Error('MiniMax 授权失败:HTTP ' + res.status + (errCode ? ' ' + errCode : '') + (text ? ' ' + text.slice(0, 200) : ''));
92
+ }
93
+
94
+ const accessToken = String(body.access_token);
95
+ const refreshToken = body.refresh_token ? String(body.refresh_token) : null;
96
+ const expiresIn = Number(body.expires_in) || 3600;
97
+ const expiresAt = new Date(Date.now() + expiresIn * 1000).toISOString();
98
+
99
+ // best-effort 取资料补全 uid / 昵称 / 邮箱;失败不阻断(uid 为空则按 token 去重)
100
+ let profile = null;
101
+ try { profile = await fetchMiniMaxProfile(accessToken); } catch {}
102
+
103
+ const uid = profile?.userId || null;
104
+ const name = profile?.name || (uid ? `MiniMax_${String(uid).slice(-6)}` : null);
105
+
106
+ return {
107
+ status: 'ok',
108
+ input: {
109
+ provider: 'minimax',
110
+ token: accessToken,
111
+ refreshToken,
112
+ expiresAt,
113
+ uid: uid ? String(uid) : null,
114
+ name: name || profile?.email || null,
115
+ email: profile?.email || null,
116
+ source: 'browser',
117
+ meta: {
118
+ clientId: CLIENT_ID,
119
+ scopes: [SCOPE],
120
+ audience: AUDIENCE,
121
+ loginVia: 'device-code',
122
+ userCode: data.userCode || null,
123
+ capturedAt: new Date().toISOString(),
124
+ },
125
+ },
126
+ };
127
+ }
@@ -0,0 +1,330 @@
1
+ /**
2
+ * MiniMax Code 服务端 API 客户端 — 用户信息、额度查询、每日签到与 Token 续期。
3
+ *
4
+ * 官方标准端点:
5
+ * - 用户资料:GET https://agent.minimax.cn/matrix/api/v1/user/profile
6
+ * - 额度查询:POST https://agent.minimax.cn/matrix/api/v1/commerce/get_membership_info
7
+ * - 签到状态:GET https://agent.minimax.cn/minimax-cloud/api/v1/signin/status?timezone_offset=28800&is_desktop=1&client=desktop
8
+ * - 签到领取:POST https://agent.minimax.cn/minimax-cloud/api/v1/signin/claim?timezone_offset=28800&is_desktop=1&client=desktop
9
+ * - Token 续期:POST https://account.minimax.cn/oauth2/token
10
+ *
11
+ * 补全模型网关端点:
12
+ * - Anthropic 兼容端点:POST https://agent.minimax.cn/mavis/api/v1/llm/v1/messages
13
+ */
14
+
15
+ import { fetchJsonRace } from './zcodeClient.js';
16
+ import { FETCH_TIMEOUT_MS } from './constants.js';
17
+
18
+ export const MINIMAX_AGENT_BASE = 'https://agent.minimax.cn';
19
+ export const MINIMAX_ACCOUNT_BASE = 'https://account.minimax.cn';
20
+ export const MINIMAX_MESSAGES_URL = `${MINIMAX_AGENT_BASE}/mavis/api/v1/llm/v1/messages`;
21
+
22
+ export const PROFILE_URL = `${MINIMAX_AGENT_BASE}/matrix/api/v1/user/profile`;
23
+ export const MEMBERSHIP_URL = `${MINIMAX_AGENT_BASE}/matrix/api/v1/commerce/get_membership_info`;
24
+ export const SIGNIN_STATUS_URL = `${MINIMAX_AGENT_BASE}/minimax-cloud/api/v1/signin/status?timezone_offset=28800&is_desktop=1&client=desktop`;
25
+ export const SIGNIN_CLAIM_URL = `${MINIMAX_AGENT_BASE}/minimax-cloud/api/v1/signin/claim?timezone_offset=28800&is_desktop=1&client=desktop`;
26
+ export const TOKEN_REFRESH_URL = `${MINIMAX_ACCOUNT_BASE}/oauth2/token`;
27
+
28
+ function authHeaders(token) {
29
+ const t = token?.startsWith('Bearer ') ? token : `Bearer ${String(token || '').trim()}`;
30
+ return {
31
+ Authorization: t,
32
+ Accept: 'application/json',
33
+ };
34
+ }
35
+
36
+ /**
37
+ * 刷新 MiniMax OAuth 凭据
38
+ * @param {string} refreshToken
39
+ * @returns {Promise<{accessToken: string, refreshToken: string, expiresIn: number}>}
40
+ */
41
+ export async function refreshMiniMaxToken(refreshToken) {
42
+ if (!refreshToken) throw new Error('缺少 refreshToken');
43
+ const form = new URLSearchParams();
44
+ form.set('grant_type', 'refresh_token');
45
+ form.set('refresh_token', refreshToken);
46
+ form.set('client_id', 'mcode-public');
47
+ form.set('scope', 'agent.default');
48
+ form.set('audience', 'agent-backend');
49
+
50
+ const res = await fetchJsonRace(TOKEN_REFRESH_URL, {
51
+ method: 'POST',
52
+ headers: {
53
+ 'Content-Type': 'application/x-www-form-urlencoded',
54
+ Accept: 'application/json',
55
+ },
56
+ body: form.toString(),
57
+ timeoutMs: FETCH_TIMEOUT_MS,
58
+ });
59
+
60
+ if (!res.ok) {
61
+ const text = await res.text().catch(() => '');
62
+ const err = new Error(`MiniMax token 刷新失败: HTTP ${res.status} ${text}`);
63
+ if (res.status === 400 || res.status === 401) err.auth = true;
64
+ throw err;
65
+ }
66
+
67
+ const data = await res.json().catch(() => null);
68
+ if (!data?.access_token) throw new Error('MiniMax token 刷新未返回 access_token');
69
+
70
+ return {
71
+ accessToken: data.access_token,
72
+ refreshToken: data.refresh_token || refreshToken,
73
+ expiresIn: Number(data.expires_in) || 3600,
74
+ };
75
+ }
76
+
77
+ /**
78
+ * 带自动刷新 401 重试的高阶调用。
79
+ *
80
+ * 刷新前若该账号来自本机导入(meta.authRecordKey),先从 ~/.minimax auth.json 重读该 record 的
81
+ * 最新 refreshToken——本机客户端会独立轮换刷新,账号库快照可能已被服务端作废(invalid_grant)。
82
+ * 刷新成功后经 ctx.onRefresh 回写账号库,并尝试安全回写 auth.json(见 minimaxLocal.writeMiniMaxAuth)。
83
+ */
84
+ export async function withMiniMaxAuth(account, fn, ctx = {}) {
85
+ try {
86
+ return await fn(account.token);
87
+ } catch (err) {
88
+ if (!err?.auth || !account.refreshToken) throw err;
89
+ const creds = await refreshAccountToken(account, ctx);
90
+ // 重试放在刷新之外:刷新成功但重试再 401 时,日志该说的是「重试仍被拒」,
91
+ // 而不是误导人的「自动刷新失败」(那时新 token 其实已经拿到并落库了)
92
+ return await fn(creds.token);
93
+ }
94
+ }
95
+
96
+ /**
97
+ * refreshToken 是一次性的、刷新即轮换:两个并发 401 拿同一个 refreshToken 去刷新,
98
+ * 必然一个成功一个 invalid_grant。签到轮(checkin)与面板的额度查询互不知情,
99
+ * 面板每 2 分钟刷一轮账号额度,很容易和 2 小时一次的签到轮撞上。
100
+ * 用 in-flight 表把同一账号的并发刷新合并成一次,后到者直接用刷新出来的新 token 重试。
101
+ */
102
+ const refreshInFlight = new Map();
103
+
104
+ function refreshAccountToken(account, ctx = {}) {
105
+ const key = account.id || account.token;
106
+ const inflight = refreshInFlight.get(key);
107
+ if (inflight) return inflight;
108
+ const p = (async () => {
109
+ // 本机导入账号:刷新前对齐客户端最新的 refreshToken,避免用陈旧快照刷新触发 invalid_grant
110
+ try {
111
+ const { alignMiniMaxFromLocal } = await import('./minimaxLocal.js');
112
+ alignMiniMaxFromLocal(account, ctx.log);
113
+ } catch {}
114
+ ctx.log?.('MiniMax token 已过期,正在自动刷新…');
115
+ try {
116
+ const refreshed = await refreshMiniMaxToken(account.refreshToken);
117
+ const expiresAt = new Date(Date.now() + refreshed.expiresIn * 1000).toISOString();
118
+ const creds = {
119
+ token: refreshed.accessToken,
120
+ refreshToken: refreshed.refreshToken,
121
+ expiresAt,
122
+ };
123
+ account.token = creds.token;
124
+ account.refreshToken = creds.refreshToken;
125
+ account.expiresAt = expiresAt;
126
+ await ctx.onRefresh?.(creds);
127
+ return creds;
128
+ } catch (refErr) {
129
+ ctx.log?.(`MiniMax token 自动刷新失败: ${refErr.message}`);
130
+ throw refErr;
131
+ }
132
+ })().finally(() => refreshInFlight.delete(key));
133
+ refreshInFlight.set(key, p);
134
+ return p;
135
+ }
136
+
137
+ /**
138
+ * 获取用户信息
139
+ */
140
+ export async function fetchMiniMaxProfile(token) {
141
+ const res = await fetchJsonRace(PROFILE_URL, {
142
+ headers: authHeaders(token),
143
+ timeoutMs: FETCH_TIMEOUT_MS,
144
+ });
145
+ if (res.status === 401) {
146
+ const err = new Error('MiniMax 登录凭据已失效 (401)');
147
+ err.auth = true;
148
+ throw err;
149
+ }
150
+ if (!res.ok) throw new Error(`获取 MiniMax 用户信息失败: HTTP ${res.status}`);
151
+ const data = await res.json().catch(() => null);
152
+ if (!data || data.base_resp?.status_code !== 0) {
153
+ const msg = data?.base_resp?.status_msg || '未知错误';
154
+ throw new Error(`MiniMax 用户信息错误: ${msg}`);
155
+ }
156
+ return {
157
+ userId: String(data.user_id || ''),
158
+ name: data.user_name || null,
159
+ email: data.email || null,
160
+ avatarUrl: data.avatar_url || data.default_avatar_url || null,
161
+ };
162
+ }
163
+
164
+ /**
165
+ * 校验 MiniMax 账号 (verify)
166
+ */
167
+ export async function verifyMiniMaxAccount(account) {
168
+ const p = await fetchMiniMaxProfile(account.token);
169
+ return {
170
+ name: p.name || `MiniMax_${p.userId.slice(-6)}`,
171
+ uid: p.userId,
172
+ email: p.email,
173
+ };
174
+ }
175
+
176
+ /**
177
+ * 查询额度 (quota)
178
+ */
179
+ export async function fetchMiniMaxQuota(account, ctx = {}) {
180
+ return withMiniMaxAuth(account, async (token) => {
181
+ const res = await fetchJsonRace(MEMBERSHIP_URL, {
182
+ method: 'POST',
183
+ headers: {
184
+ ...authHeaders(token),
185
+ 'Content-Type': 'application/json',
186
+ },
187
+ body: JSON.stringify({}),
188
+ timeoutMs: FETCH_TIMEOUT_MS,
189
+ });
190
+ if (res.status === 401) {
191
+ const err = new Error('MiniMax 凭据已失效 (401)');
192
+ err.auth = true;
193
+ throw err;
194
+ }
195
+ if (!res.ok) throw new Error(`获取 MiniMax 额度失败: HTTP ${res.status}`);
196
+ const data = await res.json().catch(() => null);
197
+ if (!data || data.base_resp?.status_code !== 0) {
198
+ const msg = data?.base_resp?.status_msg || '未知错误';
199
+ throw new Error(`MiniMax 额度错误: ${msg}`);
200
+ }
201
+ return normalizeMiniMaxQuota(data);
202
+ }, ctx);
203
+ }
204
+
205
+ /**
206
+ * 解析并归一化 MiniMax 额度
207
+ */
208
+ export function normalizeMiniMaxQuota(data) {
209
+ const summary = data.op_credit_summary || {};
210
+ const total = Number(summary.total_remaining_amount ?? data.total_remains_credit) || 0;
211
+ const free = Number(summary.free_remaining_amount) || 0;
212
+ const purchased = Number(summary.purchased_remaining_amount) || 0;
213
+
214
+ const parts = [];
215
+ if (free > 0) {
216
+ parts.push({
217
+ name: '免费/活动积分',
218
+ total: free,
219
+ used: 0,
220
+ remaining: free,
221
+ recurring: true,
222
+ unit: '积分',
223
+ });
224
+ }
225
+ if (purchased > 0) {
226
+ parts.push({
227
+ name: '购买积分',
228
+ total: purchased,
229
+ used: 0,
230
+ remaining: purchased,
231
+ recurring: false,
232
+ unit: '积分',
233
+ });
234
+ }
235
+ if (!parts.length) {
236
+ parts.push({
237
+ name: '积分',
238
+ total,
239
+ used: 0,
240
+ remaining: total,
241
+ recurring: true,
242
+ unit: '积分',
243
+ });
244
+ }
245
+
246
+ return {
247
+ total: Math.round(total * 100) / 100,
248
+ used: 0,
249
+ remaining: Math.round(total * 100) / 100,
250
+ // 顶层不设 unit:面板 creditSum 用「有 unit 即异单位」来排除合计,
251
+ // MiniMax 与 Qoder / Trae 同为「积分」口径,必须计入剩余积分合计。
252
+ plan: data.plan_name || (data.is_pro_builder ? 'Pro' : '免费版'),
253
+ planExpiresAt: data.expires_at ? new Date(data.expires_at * 1000).toISOString() : null,
254
+ parts,
255
+ empty: total <= 0,
256
+ };
257
+ }
258
+
259
+ /**
260
+ * 每日自动签到 (checkin)
261
+ */
262
+ export async function checkinMiniMax(account, ctx = {}) {
263
+ return withMiniMaxAuth(account, async (token) => {
264
+ // 1. 查询签到状态
265
+ const statusRes = await fetchJsonRace(SIGNIN_STATUS_URL, {
266
+ headers: authHeaders(token),
267
+ timeoutMs: FETCH_TIMEOUT_MS,
268
+ });
269
+ if (statusRes.status === 401) {
270
+ const err = new Error('MiniMax 凭据已失效 (401)');
271
+ err.auth = true;
272
+ throw err;
273
+ }
274
+ if (!statusRes.ok) throw new Error(`查询签到状态失败: HTTP ${statusRes.status}`);
275
+ const statusData = await statusRes.json().catch(() => null);
276
+ if (!statusData || statusData.base_resp?.status_code !== 0) {
277
+ throw new Error(`查询签到状态错误: ${statusData?.base_resp?.status_msg || '未知'}`);
278
+ }
279
+
280
+ const days = statusData.data?.days || [];
281
+ const today = days.find((d) => d.is_today);
282
+
283
+ // status: 3 = 已领取,1 = 未领取/待领取
284
+ if (today && today.status === 3) {
285
+ return {
286
+ status: 'already',
287
+ message: `今日第 ${today.day_no} 天已签到(${today.points} + ${today.bonus_points} 积分)`,
288
+ amount: (today.points || 0) + (today.bonus_points || 0),
289
+ streakDays: today.day_no || 0,
290
+ };
291
+ }
292
+
293
+ // 2. 发起签到 claim
294
+ const claimRes = await fetchJsonRace(SIGNIN_CLAIM_URL, {
295
+ method: 'POST',
296
+ headers: authHeaders(token),
297
+ timeoutMs: FETCH_TIMEOUT_MS,
298
+ });
299
+ if (claimRes.status === 401) {
300
+ const err = new Error('MiniMax 凭据已失效 (401)');
301
+ err.auth = true;
302
+ throw err;
303
+ }
304
+ if (!claimRes.ok) throw new Error(`签到领取失败: HTTP ${claimRes.status}`);
305
+ const claimData = await claimRes.json().catch(() => null);
306
+ if (!claimData || claimData.base_resp?.status_code !== 0) {
307
+ throw new Error(`签到领取错误: ${claimData?.base_resp?.status_msg || '未知'}`);
308
+ }
309
+
310
+ const cdata = claimData.data || {};
311
+ // claim_result: 1 = 新签到成功, 2 = 重复签到已领
312
+ const pts = Number(cdata.points) || (today ? (today.points || 0) + (today.bonus_points || 0) : 0);
313
+ if (cdata.claim_result === 2) {
314
+ return {
315
+ status: 'already',
316
+ message: `今日已领取(+${pts} 积分)`,
317
+ amount: pts,
318
+ streakDays: cdata.day_no || today?.day_no || 0,
319
+ };
320
+ }
321
+
322
+ return {
323
+ status: 'checked-in',
324
+ message: `签到成功第 ${cdata.day_no || today?.day_no || 1} 天(+${pts} 积分)`,
325
+ claimedAmount: pts,
326
+ amount: pts,
327
+ streakDays: cdata.day_no || today?.day_no || 0,
328
+ };
329
+ }, ctx);
330
+ }