dsh-connect-minimaxcode 0.4.2 → 0.5.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.
- package/README.en.md +421 -0
- package/README.md +426 -350
- package/lib/client.js +165 -8
- package/lib/index.d.ts +304 -1
- package/lib/index.js +968 -26
- package/package.json +94 -94
package/lib/index.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
|
-
import { readFile, rename, writeFile } from "node:fs/promises";
|
|
2
|
+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
|
-
import { join } from "node:path";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
5
|
import { createProvider } from "@earendil-works/pi-ai";
|
|
6
6
|
import { anthropicMessagesApi } from "@earendil-works/pi-ai/api/anthropic-messages.lazy";
|
|
7
7
|
import { LlmAdapter, resolveRetryPolicy } from "@deepseek-ai/dsh-llm";
|
|
8
8
|
import { PiAiAdapter } from "@deepseek-ai/dsh-llm-pi-ai";
|
|
9
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
9
10
|
//#region src/auth.ts
|
|
10
11
|
/**
|
|
11
12
|
* Reading the MiniMax Code desktop app's own sign-in.
|
|
@@ -625,6 +626,8 @@ function createMinimaxCodeAdapter(options) {
|
|
|
625
626
|
//#region src/status-route.ts
|
|
626
627
|
/** Route this plugin mounts, matching its settings card. */
|
|
627
628
|
const MINIMAX_STATUS_PATH = "/plugins/dsh-connect-minimaxcode/status";
|
|
629
|
+
/** Route the card posts to when it wants something done rather than read. */
|
|
630
|
+
const MINIMAX_ACTION_PATH = "/plugins/dsh-connect-minimaxcode/action";
|
|
628
631
|
const HINTS = {
|
|
629
632
|
"app-missing": "未找到 MiniMax Code 的登录信息。请先安装并登录 MiniMax Code 桌面 App,然后重启 DSH。",
|
|
630
633
|
"not-signed-in": "MiniMax Code 尚未登录。请在 MiniMax Code 桌面 App 中登录后重启 DSH。",
|
|
@@ -648,7 +651,9 @@ function statusDocument(deps) {
|
|
|
648
651
|
models: deps.models,
|
|
649
652
|
refreshing: deps.refreshing,
|
|
650
653
|
...deps.credits === void 0 ? {} : { credits: deps.credits },
|
|
651
|
-
...deps.usage === void 0 ? {} : { usage: deps.usage }
|
|
654
|
+
...deps.usage === void 0 ? {} : { usage: deps.usage },
|
|
655
|
+
...deps.signin === void 0 ? {} : { signin: deps.signin },
|
|
656
|
+
...deps.login === void 0 ? {} : { login: deps.login }
|
|
652
657
|
};
|
|
653
658
|
if (auth.state === "expired") return {
|
|
654
659
|
...base,
|
|
@@ -719,7 +724,7 @@ const MEMBERSHIP_PATH = "/matrix/api/v1/commerce/get_membership_info";
|
|
|
719
724
|
/** Credit ledger endpoint: per-grant amounts and expiry. */
|
|
720
725
|
const CREDIT_DETAILS_PATH = "/minimax-cloud/api/v1/credit/details";
|
|
721
726
|
/** OAuth token endpoint. */
|
|
722
|
-
const TOKEN_PATH = "/oauth2/token";
|
|
727
|
+
const TOKEN_PATH$1 = "/oauth2/token";
|
|
723
728
|
/** Absolute path of the app's credential file for a region. */
|
|
724
729
|
function oauthAuthPath(region, env = process.env) {
|
|
725
730
|
return join(dataDir(env), "auth", "prod", region, "mcode-public", "auth.json");
|
|
@@ -823,7 +828,7 @@ async function refreshOAuthCredential(region, credential, env = process.env, now
|
|
|
823
828
|
scope: credential.scopes.join(" "),
|
|
824
829
|
audience: credential.audience
|
|
825
830
|
});
|
|
826
|
-
const response = await fetchImpl(`${MINIMAX_AUTH_HOSTS[region]}${TOKEN_PATH}`, {
|
|
831
|
+
const response = await fetchImpl(`${MINIMAX_AUTH_HOSTS[region]}${TOKEN_PATH$1}`, {
|
|
827
832
|
method: "POST",
|
|
828
833
|
headers: {
|
|
829
834
|
accept: "application/json",
|
|
@@ -920,17 +925,17 @@ function parseCreditSummary(document) {
|
|
|
920
925
|
const summary = record.op_credit_summary;
|
|
921
926
|
if (typeof summary === "object" && summary !== null) {
|
|
922
927
|
const entry = summary;
|
|
923
|
-
const total = toNumber(entry.total_remaining_amount);
|
|
928
|
+
const total = toNumber$1(entry.total_remaining_amount);
|
|
924
929
|
if (total !== void 0) {
|
|
925
|
-
const purchased = toNumber(entry.purchased_remaining_amount) ?? 0;
|
|
930
|
+
const purchased = toNumber$1(entry.purchased_remaining_amount) ?? 0;
|
|
926
931
|
return {
|
|
927
932
|
total,
|
|
928
933
|
purchased,
|
|
929
|
-
free: toNumber(entry.free_remaining_amount) ?? total - purchased
|
|
934
|
+
free: toNumber$1(entry.free_remaining_amount) ?? total - purchased
|
|
930
935
|
};
|
|
931
936
|
}
|
|
932
937
|
}
|
|
933
|
-
const flat = toNumber(record.opcredit_balance) ?? toNumber(record.total_remains_credit);
|
|
938
|
+
const flat = toNumber$1(record.opcredit_balance) ?? toNumber$1(record.total_remains_credit);
|
|
934
939
|
return flat === void 0 ? void 0 : {
|
|
935
940
|
total: flat,
|
|
936
941
|
purchased: 0,
|
|
@@ -938,7 +943,7 @@ function parseCreditSummary(document) {
|
|
|
938
943
|
};
|
|
939
944
|
}
|
|
940
945
|
/** Read a numeric field the upstream may send as a string. */
|
|
941
|
-
function toNumber(value) {
|
|
946
|
+
function toNumber$1(value) {
|
|
942
947
|
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
943
948
|
if (typeof value === "string" && value.trim() !== "") {
|
|
944
949
|
const parsed = Number(value);
|
|
@@ -976,17 +981,17 @@ function parseCreditLots(document) {
|
|
|
976
981
|
for (const entry of details) {
|
|
977
982
|
if (typeof entry !== "object" || entry === null) continue;
|
|
978
983
|
const record = entry;
|
|
979
|
-
const expireAtMs = toNumber(record.expire_at_ms);
|
|
984
|
+
const expireAtMs = toNumber$1(record.expire_at_ms);
|
|
980
985
|
if (expireAtMs === void 0) continue;
|
|
981
|
-
const granted = toNumber(record.granted_amount) ?? 0;
|
|
982
|
-
const remaining = toNumber(record.remaining_amount) ?? 0;
|
|
986
|
+
const granted = toNumber$1(record.granted_amount) ?? 0;
|
|
987
|
+
const remaining = toNumber$1(record.remaining_amount) ?? 0;
|
|
983
988
|
lots.push({
|
|
984
989
|
granted,
|
|
985
990
|
remaining,
|
|
986
|
-
consumed: toNumber(record.consumed_amount) ?? Math.max(0, granted - remaining),
|
|
987
|
-
grantedAtMs: toNumber(record.granted_at_ms) ?? 0,
|
|
991
|
+
consumed: toNumber$1(record.consumed_amount) ?? Math.max(0, granted - remaining),
|
|
992
|
+
grantedAtMs: toNumber$1(record.granted_at_ms) ?? 0,
|
|
988
993
|
expireAtMs,
|
|
989
|
-
creditType: toNumber(record.credit_type)
|
|
994
|
+
creditType: toNumber$1(record.credit_type)
|
|
990
995
|
});
|
|
991
996
|
}
|
|
992
997
|
return lots.sort((a, b) => a.expireAtMs - b.expireAtMs);
|
|
@@ -1013,7 +1018,7 @@ async function fetchCreditLots(credential, region, fetchImpl = fetch) {
|
|
|
1013
1018
|
const batch = parseCreditLots(document);
|
|
1014
1019
|
const before = lots.length;
|
|
1015
1020
|
lots.push(...batch);
|
|
1016
|
-
const count = toNumber(document.total_count);
|
|
1021
|
+
const count = toNumber$1(document.total_count);
|
|
1017
1022
|
if (count !== void 0) total = count;
|
|
1018
1023
|
if (batch.length < pageSize) break;
|
|
1019
1024
|
if (lots.length === before) break;
|
|
@@ -1132,6 +1137,581 @@ var MinimaxCredits = class {
|
|
|
1132
1137
|
}
|
|
1133
1138
|
};
|
|
1134
1139
|
//#endregion
|
|
1140
|
+
//#region src/credentials.ts
|
|
1141
|
+
/**
|
|
1142
|
+
* The plugin's own credential and state.
|
|
1143
|
+
*
|
|
1144
|
+
* The desktop app signs in with the OAuth 2.0 device authorization grant
|
|
1145
|
+
* (RFC 8628): the app asks the authorization server for a device code, opens
|
|
1146
|
+
* the account page in the browser, and polls the token endpoint until the
|
|
1147
|
+
* person signed in. The plugin can run the same grant itself, which means the
|
|
1148
|
+
* plugin works on a machine where the desktop app was never installed, and
|
|
1149
|
+
* the only human step is the person signing in to their own account in their
|
|
1150
|
+
* own browser.
|
|
1151
|
+
*
|
|
1152
|
+
* What this module stores is deliberately *not* written into the desktop
|
|
1153
|
+
* app's files. The app keeps two files whose `generation` counters must stay
|
|
1154
|
+
* in step, and it treats a mismatch as "signed in somewhere else" and demands
|
|
1155
|
+
* a new sign-in; the plugin has no business writing there. So the plugin keeps
|
|
1156
|
+
* its own copy under the Harness home, next to the files the sibling desktop
|
|
1157
|
+
* connectors keep (`~/.dsh/.minimaxcode-auth.json`).
|
|
1158
|
+
*
|
|
1159
|
+
* @module dsh-connect-minimaxcode/credentials
|
|
1160
|
+
*/
|
|
1161
|
+
/** The file the plugin's own credential lives in. */
|
|
1162
|
+
const PLUGIN_CREDENTIAL_FILENAME = ".minimaxcode-auth.json";
|
|
1163
|
+
/** The file the plugin's own settings and session live in. */
|
|
1164
|
+
const PLUGIN_STATE_FILENAME = ".minimaxcode-state.json";
|
|
1165
|
+
/**
|
|
1166
|
+
* The Harness home.
|
|
1167
|
+
*
|
|
1168
|
+
* The host resolves it the same way: the `DSH_HOME` environment variable when
|
|
1169
|
+
* it is set, otherwise `~/.dsh`. The plugin resolves it on its own rather
|
|
1170
|
+
* than importing the host's helper, so it also works in a plain command line
|
|
1171
|
+
* where the host's packages are not installed.
|
|
1172
|
+
*/
|
|
1173
|
+
function dshHome(env = process.env) {
|
|
1174
|
+
const override = env.DSH_HOME;
|
|
1175
|
+
return typeof override === "string" && override.length > 0 ? override : join(homedir(), ".dsh");
|
|
1176
|
+
}
|
|
1177
|
+
/** Where the plugin's credential lives. */
|
|
1178
|
+
function pluginCredentialPath(env = process.env) {
|
|
1179
|
+
return join(dshHome(env), PLUGIN_CREDENTIAL_FILENAME);
|
|
1180
|
+
}
|
|
1181
|
+
/** Where the plugin's state lives. */
|
|
1182
|
+
function pluginStatePath(env = process.env) {
|
|
1183
|
+
return join(dshHome(env), PLUGIN_STATE_FILENAME);
|
|
1184
|
+
}
|
|
1185
|
+
/** Write a document atomically, readable by its owner only. */
|
|
1186
|
+
async function writePrivate(path, payload) {
|
|
1187
|
+
await mkdir(dirname(path), { recursive: true });
|
|
1188
|
+
const temporary = `${path}.tmp`;
|
|
1189
|
+
await writeFile(temporary, `${JSON.stringify(payload, null, 2)}\n`, { mode: 384 });
|
|
1190
|
+
await rename(temporary, path);
|
|
1191
|
+
}
|
|
1192
|
+
/** Read the plugin's own credential. */
|
|
1193
|
+
async function readPluginCredential(env = process.env) {
|
|
1194
|
+
let text;
|
|
1195
|
+
try {
|
|
1196
|
+
text = await readFile(pluginCredentialPath(env), "utf8");
|
|
1197
|
+
} catch {
|
|
1198
|
+
return;
|
|
1199
|
+
}
|
|
1200
|
+
let document;
|
|
1201
|
+
try {
|
|
1202
|
+
document = JSON.parse(text);
|
|
1203
|
+
} catch {
|
|
1204
|
+
return;
|
|
1205
|
+
}
|
|
1206
|
+
if (typeof document !== "object" || document === null) return void 0;
|
|
1207
|
+
const record = document;
|
|
1208
|
+
const accessToken = record.accessToken;
|
|
1209
|
+
if (typeof accessToken !== "string" || accessToken.length === 0) return void 0;
|
|
1210
|
+
const expiresAtMs = typeof record.expiresAtMs === "number" ? record.expiresAtMs : void 0;
|
|
1211
|
+
return {
|
|
1212
|
+
schemaVersion: typeof record.schemaVersion === "number" ? record.schemaVersion : 1,
|
|
1213
|
+
clientId: typeof record.clientId === "string" ? record.clientId : MINIMAX_OAUTH_CLIENT_ID,
|
|
1214
|
+
scopes: Array.isArray(record.scopes) ? record.scopes.filter((s) => typeof s === "string") : [...MINIMAX_OAUTH_SCOPES],
|
|
1215
|
+
audience: typeof record.audience === "string" ? record.audience : MINIMAX_OAUTH_AUDIENCE,
|
|
1216
|
+
accessToken,
|
|
1217
|
+
refreshToken: typeof record.refreshToken === "string" ? record.refreshToken : void 0,
|
|
1218
|
+
expiresAtMs,
|
|
1219
|
+
obtainedAtMs: typeof record.obtainedAtMs === "number" ? record.obtainedAtMs : void 0
|
|
1220
|
+
};
|
|
1221
|
+
}
|
|
1222
|
+
/** Write the plugin's own credential. */
|
|
1223
|
+
async function writePluginCredential(credential, env = process.env) {
|
|
1224
|
+
await writePrivate(pluginCredentialPath(env), credential);
|
|
1225
|
+
}
|
|
1226
|
+
/** Read the plugin's own state. */
|
|
1227
|
+
async function readPluginState(env = process.env) {
|
|
1228
|
+
let text;
|
|
1229
|
+
try {
|
|
1230
|
+
text = await readFile(pluginStatePath(env), "utf8");
|
|
1231
|
+
} catch {
|
|
1232
|
+
return {
|
|
1233
|
+
schemaVersion: 1,
|
|
1234
|
+
autoCheckin: false
|
|
1235
|
+
};
|
|
1236
|
+
}
|
|
1237
|
+
let document;
|
|
1238
|
+
try {
|
|
1239
|
+
document = JSON.parse(text);
|
|
1240
|
+
} catch {
|
|
1241
|
+
return {
|
|
1242
|
+
schemaVersion: 1,
|
|
1243
|
+
autoCheckin: false
|
|
1244
|
+
};
|
|
1245
|
+
}
|
|
1246
|
+
if (typeof document !== "object" || document === null) return {
|
|
1247
|
+
schemaVersion: 1,
|
|
1248
|
+
autoCheckin: false
|
|
1249
|
+
};
|
|
1250
|
+
const record = document;
|
|
1251
|
+
const session = record.deviceLogin;
|
|
1252
|
+
const deviceLogin = typeof session === "object" && session !== null ? (() => {
|
|
1253
|
+
const value = session;
|
|
1254
|
+
if (typeof value.deviceCode !== "string" || typeof value.codeVerifier !== "string") return void 0;
|
|
1255
|
+
return {
|
|
1256
|
+
deviceCode: value.deviceCode,
|
|
1257
|
+
codeVerifier: value.codeVerifier,
|
|
1258
|
+
userCode: typeof value.userCode === "string" ? value.userCode : "",
|
|
1259
|
+
verificationUri: typeof value.verificationUri === "string" ? value.verificationUri : "",
|
|
1260
|
+
verificationUriComplete: typeof value.verificationUriComplete === "string" ? value.verificationUriComplete : "",
|
|
1261
|
+
expiresAtMs: typeof value.expiresAtMs === "number" ? value.expiresAtMs : 0
|
|
1262
|
+
};
|
|
1263
|
+
})() : void 0;
|
|
1264
|
+
return {
|
|
1265
|
+
schemaVersion: typeof record.schemaVersion === "number" ? record.schemaVersion : 1,
|
|
1266
|
+
autoCheckin: record.autoCheckin === true,
|
|
1267
|
+
lastClaimDay: typeof record.lastClaimDay === "string" ? record.lastClaimDay : void 0,
|
|
1268
|
+
lastClaimAtMs: typeof record.lastClaimAtMs === "number" ? record.lastClaimAtMs : void 0,
|
|
1269
|
+
lastClaimPoints: typeof record.lastClaimPoints === "number" ? record.lastClaimPoints : void 0,
|
|
1270
|
+
deviceLogin
|
|
1271
|
+
};
|
|
1272
|
+
}
|
|
1273
|
+
/** Write the plugin's own state. */
|
|
1274
|
+
async function writePluginState(state, env = process.env) {
|
|
1275
|
+
await writePrivate(pluginStatePath(env), state);
|
|
1276
|
+
}
|
|
1277
|
+
/** Whether the credential is close enough to expiry to be worth refreshing. */
|
|
1278
|
+
function credentialNeedsRefresh(credential, now = Date.now()) {
|
|
1279
|
+
if (credential.expiresAtMs === void 0) return false;
|
|
1280
|
+
return credential.expiresAtMs - now <= 12e4;
|
|
1281
|
+
}
|
|
1282
|
+
/** The credential in the shape the account endpoints expect. */
|
|
1283
|
+
function toOAuthCredential(credential) {
|
|
1284
|
+
return {
|
|
1285
|
+
token: credential.accessToken,
|
|
1286
|
+
refreshToken: credential.refreshToken,
|
|
1287
|
+
expiresAtMs: credential.expiresAtMs,
|
|
1288
|
+
clientId: credential.clientId,
|
|
1289
|
+
scopes: credential.scopes,
|
|
1290
|
+
audience: credential.audience
|
|
1291
|
+
};
|
|
1292
|
+
}
|
|
1293
|
+
//#endregion
|
|
1294
|
+
//#region src/login.ts
|
|
1295
|
+
/**
|
|
1296
|
+
* Signing in without the desktop app.
|
|
1297
|
+
*
|
|
1298
|
+
* The desktop app's only sign-in is the OAuth 2.0 device authorization grant
|
|
1299
|
+
* (RFC 8628). The plugin runs the same grant:
|
|
1300
|
+
*
|
|
1301
|
+
* 1. `POST /oauth2/device/code` asks for a device code. The answer carries a
|
|
1302
|
+
* short `user_code` and the page where the person authorizes it.
|
|
1303
|
+
* 2. The person opens that page, signs in to their own MiniMax account, and
|
|
1304
|
+
* approves the request.
|
|
1305
|
+
* 3. `POST /oauth2/token` is polled with the device code until the grant
|
|
1306
|
+
* arrives.
|
|
1307
|
+
*
|
|
1308
|
+
* The plugin never sees a password and never sees a verification code: the
|
|
1309
|
+
* one human step happens in the person's own browser, on the account site.
|
|
1310
|
+
* PKCE is used, so a device code that leaks cannot be redeemed without the
|
|
1311
|
+
* verifier that never left this process.
|
|
1312
|
+
*
|
|
1313
|
+
* The token this produces is the same `mmoat_` token the desktop app holds.
|
|
1314
|
+
* The account endpoints need it, and the model gateway accepts it too — so
|
|
1315
|
+
* one sign-in is enough for models, credits and the daily grant.
|
|
1316
|
+
*
|
|
1317
|
+
* @module dsh-connect-minimaxcode/login
|
|
1318
|
+
*/
|
|
1319
|
+
/** The endpoint that issues device codes. */
|
|
1320
|
+
const DEVICE_CODE_PATH = "/oauth2/device/code";
|
|
1321
|
+
/** The endpoint that exchanges a device code for a token. */
|
|
1322
|
+
const TOKEN_PATH = "/oauth2/token";
|
|
1323
|
+
/** The device authorization grant type. */
|
|
1324
|
+
const DEVICE_CODE_GRANT = "urn:ietf:params:oauth:grant-type:device_code";
|
|
1325
|
+
/**
|
|
1326
|
+
* Make a PKCE verifier and its S256 challenge.
|
|
1327
|
+
*
|
|
1328
|
+
* The verifier is 43 characters of base64url entropy, which is what RFC 7636
|
|
1329
|
+
* asks for, and it never leaves this process.
|
|
1330
|
+
*/
|
|
1331
|
+
function makePkce() {
|
|
1332
|
+
const verifier = randomBytes(32).toString("base64url");
|
|
1333
|
+
return {
|
|
1334
|
+
verifier,
|
|
1335
|
+
challenge: createHash("sha256").update(verifier).digest("base64url")
|
|
1336
|
+
};
|
|
1337
|
+
}
|
|
1338
|
+
/**
|
|
1339
|
+
* Ask for a device code.
|
|
1340
|
+
*
|
|
1341
|
+
* @returns The session the person authorizes, or `undefined` when the server
|
|
1342
|
+
* would not issue one.
|
|
1343
|
+
*/
|
|
1344
|
+
async function startDeviceLogin(region, fetchImpl = fetch, now = Date.now()) {
|
|
1345
|
+
const { verifier, challenge } = makePkce();
|
|
1346
|
+
const response = await fetchImpl(`${MINIMAX_AUTH_HOSTS[region]}${DEVICE_CODE_PATH}`, {
|
|
1347
|
+
method: "POST",
|
|
1348
|
+
headers: {
|
|
1349
|
+
accept: "application/json",
|
|
1350
|
+
"content-type": "application/x-www-form-urlencoded"
|
|
1351
|
+
},
|
|
1352
|
+
body: new URLSearchParams({
|
|
1353
|
+
client_id: MINIMAX_OAUTH_CLIENT_ID,
|
|
1354
|
+
scope: MINIMAX_OAUTH_SCOPES.join(" "),
|
|
1355
|
+
audience: MINIMAX_OAUTH_AUDIENCE,
|
|
1356
|
+
code_challenge: challenge,
|
|
1357
|
+
code_challenge_method: "S256"
|
|
1358
|
+
})
|
|
1359
|
+
});
|
|
1360
|
+
if (!response.ok) throw new Error(`申请设备码失败: HTTP ${response.status}`);
|
|
1361
|
+
const document = await response.json();
|
|
1362
|
+
const deviceCode = document.device_code;
|
|
1363
|
+
const userCode = document.user_code;
|
|
1364
|
+
const verificationUri = document.verification_uri;
|
|
1365
|
+
if (typeof deviceCode !== "string" || deviceCode.length === 0) throw new Error("设备码响应里没有 device_code");
|
|
1366
|
+
const expiresInSec = typeof document.expires_in === "number" ? document.expires_in : 300;
|
|
1367
|
+
const intervalSec = typeof document.interval === "number" ? document.interval : 3;
|
|
1368
|
+
return {
|
|
1369
|
+
deviceCode,
|
|
1370
|
+
codeVerifier: verifier,
|
|
1371
|
+
userCode: typeof userCode === "string" ? userCode : "",
|
|
1372
|
+
verificationUri: typeof verificationUri === "string" ? verificationUri : "",
|
|
1373
|
+
verificationUriComplete: typeof document.verification_uri_complete === "string" ? document.verification_uri_complete : "",
|
|
1374
|
+
expiresAtMs: now + expiresInSec * 1e3,
|
|
1375
|
+
intervalSec
|
|
1376
|
+
};
|
|
1377
|
+
}
|
|
1378
|
+
/** Read the token document the authorization server answers with. */
|
|
1379
|
+
function readToken(document) {
|
|
1380
|
+
if (typeof document !== "object" || document === null) return void 0;
|
|
1381
|
+
const record = document;
|
|
1382
|
+
const accessToken = record.access_token;
|
|
1383
|
+
if (typeof accessToken !== "string" || accessToken.length === 0) return void 0;
|
|
1384
|
+
return {
|
|
1385
|
+
token: accessToken,
|
|
1386
|
+
refreshToken: typeof record.refresh_token === "string" ? record.refresh_token : void 0,
|
|
1387
|
+
expiresInSec: typeof record.expires_in === "number" ? record.expires_in : void 0,
|
|
1388
|
+
scope: typeof record.scope === "string" ? record.scope : void 0
|
|
1389
|
+
};
|
|
1390
|
+
}
|
|
1391
|
+
/**
|
|
1392
|
+
* Poll for the grant.
|
|
1393
|
+
*
|
|
1394
|
+
* The authorization server answers `400` while the person has not authorized
|
|
1395
|
+
* yet, with `authorization_pending` in the body; that is a normal answer, not
|
|
1396
|
+
* an error.
|
|
1397
|
+
*/
|
|
1398
|
+
async function pollDeviceLogin(session, region, fetchImpl = fetch, now = Date.now()) {
|
|
1399
|
+
if (session.expiresAtMs !== 0 && session.expiresAtMs <= now) return {
|
|
1400
|
+
state: "expired",
|
|
1401
|
+
message: "设备码已过期,请重新发起登录"
|
|
1402
|
+
};
|
|
1403
|
+
const response = await fetchImpl(`${MINIMAX_AUTH_HOSTS[region]}${TOKEN_PATH}`, {
|
|
1404
|
+
method: "POST",
|
|
1405
|
+
headers: {
|
|
1406
|
+
accept: "application/json",
|
|
1407
|
+
"content-type": "application/x-www-form-urlencoded"
|
|
1408
|
+
},
|
|
1409
|
+
body: new URLSearchParams({
|
|
1410
|
+
client_id: MINIMAX_OAUTH_CLIENT_ID,
|
|
1411
|
+
grant_type: DEVICE_CODE_GRANT,
|
|
1412
|
+
device_code: session.deviceCode,
|
|
1413
|
+
code_verifier: session.codeVerifier
|
|
1414
|
+
})
|
|
1415
|
+
});
|
|
1416
|
+
const text = await response.text();
|
|
1417
|
+
let document;
|
|
1418
|
+
try {
|
|
1419
|
+
document = JSON.parse(text);
|
|
1420
|
+
} catch {
|
|
1421
|
+
document = void 0;
|
|
1422
|
+
}
|
|
1423
|
+
if (response.ok) {
|
|
1424
|
+
const token = readToken(document);
|
|
1425
|
+
if (token === void 0) return {
|
|
1426
|
+
state: "expired",
|
|
1427
|
+
message: "登录响应里没有访问令牌"
|
|
1428
|
+
};
|
|
1429
|
+
return {
|
|
1430
|
+
state: "signed-in",
|
|
1431
|
+
credential: {
|
|
1432
|
+
schemaVersion: 1,
|
|
1433
|
+
clientId: MINIMAX_OAUTH_CLIENT_ID,
|
|
1434
|
+
scopes: [...MINIMAX_OAUTH_SCOPES],
|
|
1435
|
+
audience: MINIMAX_OAUTH_AUDIENCE,
|
|
1436
|
+
accessToken: token.token,
|
|
1437
|
+
refreshToken: token.refreshToken,
|
|
1438
|
+
expiresAtMs: token.expiresInSec === void 0 ? void 0 : now + token.expiresInSec * 1e3,
|
|
1439
|
+
obtainedAtMs: now
|
|
1440
|
+
}
|
|
1441
|
+
};
|
|
1442
|
+
}
|
|
1443
|
+
const error = typeof document === "object" && document !== null ? document.error : void 0;
|
|
1444
|
+
const description = typeof document === "object" && document !== null ? document.error_description : void 0;
|
|
1445
|
+
const message = typeof description === "string" ? description : void 0;
|
|
1446
|
+
if (error === "authorization_pending") return { state: "pending" };
|
|
1447
|
+
if (error === "slow_down") return { state: "slow-down" };
|
|
1448
|
+
if (error === "expired_token") return {
|
|
1449
|
+
state: "expired",
|
|
1450
|
+
message
|
|
1451
|
+
};
|
|
1452
|
+
if (error === "access_denied") return {
|
|
1453
|
+
state: "denied",
|
|
1454
|
+
message
|
|
1455
|
+
};
|
|
1456
|
+
return {
|
|
1457
|
+
state: "expired",
|
|
1458
|
+
message: message ?? `登录失败: HTTP ${response.status}`
|
|
1459
|
+
};
|
|
1460
|
+
}
|
|
1461
|
+
/**
|
|
1462
|
+
* Exchange the stored refresh token for a fresh access token.
|
|
1463
|
+
*
|
|
1464
|
+
* The authorization server rotates the refresh token, so the new pair
|
|
1465
|
+
* replaces the old one wherever it is kept.
|
|
1466
|
+
*/
|
|
1467
|
+
async function refreshPluginCredential(credential, region, fetchImpl = fetch, now = Date.now()) {
|
|
1468
|
+
if (credential.refreshToken === void 0) throw new Error("没有可用于续期的 refresh token,请重新登录");
|
|
1469
|
+
const response = await fetchImpl(`${MINIMAX_AUTH_HOSTS[region]}${TOKEN_PATH}`, {
|
|
1470
|
+
method: "POST",
|
|
1471
|
+
headers: {
|
|
1472
|
+
accept: "application/json",
|
|
1473
|
+
"content-type": "application/x-www-form-urlencoded"
|
|
1474
|
+
},
|
|
1475
|
+
body: new URLSearchParams({
|
|
1476
|
+
grant_type: "refresh_token",
|
|
1477
|
+
refresh_token: credential.refreshToken,
|
|
1478
|
+
client_id: credential.clientId,
|
|
1479
|
+
scope: credential.scopes.join(" "),
|
|
1480
|
+
audience: credential.audience
|
|
1481
|
+
})
|
|
1482
|
+
});
|
|
1483
|
+
if (!response.ok) throw new Error(`续期失败: HTTP ${response.status}`);
|
|
1484
|
+
const token = readToken(await response.json());
|
|
1485
|
+
if (token === void 0) throw new Error("续期响应里没有访问令牌");
|
|
1486
|
+
return {
|
|
1487
|
+
...credential,
|
|
1488
|
+
accessToken: token.token,
|
|
1489
|
+
refreshToken: token.refreshToken ?? credential.refreshToken,
|
|
1490
|
+
expiresAtMs: token.expiresInSec === void 0 ? void 0 : now + token.expiresInSec * 1e3,
|
|
1491
|
+
obtainedAtMs: now
|
|
1492
|
+
};
|
|
1493
|
+
}
|
|
1494
|
+
//#endregion
|
|
1495
|
+
//#region src/signin.ts
|
|
1496
|
+
/**
|
|
1497
|
+
* The daily check-in that grants the free credits.
|
|
1498
|
+
*
|
|
1499
|
+
* MiniMax Code hands out a grant of points every day, and the desktop app
|
|
1500
|
+
* shows it as a "每日签到" card: a seven-day cycle worth 4000 points, each
|
|
1501
|
+
* grant valid for thirty days. The app does **not** claim on its own — the
|
|
1502
|
+
* card opens, and a button does the work — so an account that is only used
|
|
1503
|
+
* through the model gateway leaves those points unclaimed.
|
|
1504
|
+
*
|
|
1505
|
+
* The endpoints are the same ones the card calls:
|
|
1506
|
+
*
|
|
1507
|
+
* - `GET /minimax-cloud/api/v1/signin/status` — the cycle, with today's day
|
|
1508
|
+
* marked
|
|
1509
|
+
* - `POST /minimax-cloud/api/v1/signin/claim` — claim today
|
|
1510
|
+
*
|
|
1511
|
+
* Both need the account's `mmoat_` bearer token, and both need a **time zone
|
|
1512
|
+
***: the server answers `invalid timezone_id` when the request carries an
|
|
1513
|
+
* offset or nothing at all, and it is that time zone that decides which day
|
|
1514
|
+
* "today" is. The client sends an IANA name (`Asia/Shanghai`), so this does
|
|
1515
|
+
* too.
|
|
1516
|
+
*
|
|
1517
|
+
* The claim is the one request here that changes something upstream. It is
|
|
1518
|
+
* idempotent per day — claiming again answers "already claimed" and grants
|
|
1519
|
+
* nothing — but the caller still guards it, because "cannot double-grant" is
|
|
1520
|
+
* a property of the upstream that is verified rather than assumed.
|
|
1521
|
+
*
|
|
1522
|
+
* @module dsh-connect-minimaxcode/signin
|
|
1523
|
+
*/
|
|
1524
|
+
/** The claim endpoint. */
|
|
1525
|
+
const SIGNIN_CLAIM_PATH = "/minimax-cloud/api/v1/signin/claim";
|
|
1526
|
+
/** The cycle endpoint, which reports the panel. */
|
|
1527
|
+
const SIGNIN_PANEL_PATH = "/minimax-cloud/api/v1/signin/status";
|
|
1528
|
+
/** Per-day status, as the desktop app enumerates it. */
|
|
1529
|
+
const SIGNIN_DAY_STATUS = {
|
|
1530
|
+
/** The day has not come yet. */
|
|
1531
|
+
upcoming: 1,
|
|
1532
|
+
/** The day is today and can be claimed. */
|
|
1533
|
+
claimable: 2,
|
|
1534
|
+
/** The day is claimed. */
|
|
1535
|
+
claimed: 3,
|
|
1536
|
+
/** The day does not grant anything. */
|
|
1537
|
+
disabled: 4
|
|
1538
|
+
};
|
|
1539
|
+
/** How many days a cycle runs. */
|
|
1540
|
+
const SIGNIN_CYCLE_DAYS = 7;
|
|
1541
|
+
/** The outcome of a claim. */
|
|
1542
|
+
const SIGNIN_CLAIM_RESULT = {
|
|
1543
|
+
/** The grant was credited. */
|
|
1544
|
+
credited: 1,
|
|
1545
|
+
/** Today was already claimed; nothing was granted. */
|
|
1546
|
+
alreadyClaimed: 2
|
|
1547
|
+
};
|
|
1548
|
+
/**
|
|
1549
|
+
* The time zone the server should treat the request as coming from.
|
|
1550
|
+
*
|
|
1551
|
+
* The server wants an IANA name; an offset or a missing value is refused.
|
|
1552
|
+
* The system's own name is the right answer, since "today" should be the
|
|
1553
|
+
* user's today.
|
|
1554
|
+
*/
|
|
1555
|
+
function timezoneId() {
|
|
1556
|
+
try {
|
|
1557
|
+
const resolved = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
1558
|
+
return typeof resolved === "string" && resolved.length > 0 ? resolved : "UTC";
|
|
1559
|
+
} catch {
|
|
1560
|
+
return "UTC";
|
|
1561
|
+
}
|
|
1562
|
+
}
|
|
1563
|
+
/** Read a number the upstream may send as a string. */
|
|
1564
|
+
function toNumber(value) {
|
|
1565
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
1566
|
+
if (typeof value === "string" && value.trim() !== "") {
|
|
1567
|
+
const parsed = Number(value);
|
|
1568
|
+
return Number.isFinite(parsed) ? parsed : void 0;
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1571
|
+
/**
|
|
1572
|
+
* Unwrap the upstream's envelope.
|
|
1573
|
+
*
|
|
1574
|
+
* The account APIs answer `200` with a `base_resp` that carries the real
|
|
1575
|
+
* status, so the HTTP code alone says nothing.
|
|
1576
|
+
*/
|
|
1577
|
+
function unwrap(document) {
|
|
1578
|
+
if (typeof document !== "object" || document === null) return {
|
|
1579
|
+
ok: false,
|
|
1580
|
+
message: "签到接口返回了无法解析的响应"
|
|
1581
|
+
};
|
|
1582
|
+
const record = document;
|
|
1583
|
+
const base = record.base_resp;
|
|
1584
|
+
if (typeof base === "object" && base !== null) {
|
|
1585
|
+
const status = toNumber(base.status_code);
|
|
1586
|
+
if (status !== void 0 && status !== 0) {
|
|
1587
|
+
const message = base.status_msg;
|
|
1588
|
+
return {
|
|
1589
|
+
ok: false,
|
|
1590
|
+
message: typeof message === "string" ? message : `签到接口返回状态码 ${status}`
|
|
1591
|
+
};
|
|
1592
|
+
}
|
|
1593
|
+
}
|
|
1594
|
+
return {
|
|
1595
|
+
ok: true,
|
|
1596
|
+
data: record.data
|
|
1597
|
+
};
|
|
1598
|
+
}
|
|
1599
|
+
/**
|
|
1600
|
+
* Read the cycle out of a panel response.
|
|
1601
|
+
*
|
|
1602
|
+
* The server marks exactly one day as today and at most one day as claimable;
|
|
1603
|
+
* a panel that says otherwise is not something to act on, so it is refused
|
|
1604
|
+
* rather than half-believed.
|
|
1605
|
+
*/
|
|
1606
|
+
function parseSigninPanel(document) {
|
|
1607
|
+
const unwrapped = unwrap(document);
|
|
1608
|
+
if (!unwrapped.ok) return void 0;
|
|
1609
|
+
const data = unwrapped.data;
|
|
1610
|
+
if (typeof data !== "object" || data === null) return void 0;
|
|
1611
|
+
const record = data;
|
|
1612
|
+
const scene = toNumber(record.scene);
|
|
1613
|
+
const rawDays = record.days;
|
|
1614
|
+
if (scene === void 0 || !Array.isArray(rawDays)) return void 0;
|
|
1615
|
+
const days = [];
|
|
1616
|
+
let claimable = 0;
|
|
1617
|
+
let today = 0;
|
|
1618
|
+
for (const entry of rawDays) {
|
|
1619
|
+
if (typeof entry !== "object" || entry === null) continue;
|
|
1620
|
+
const day = entry;
|
|
1621
|
+
const dayNo = toNumber(day.day_no);
|
|
1622
|
+
const points = toNumber(day.points);
|
|
1623
|
+
const status = toNumber(day.status);
|
|
1624
|
+
if (dayNo === void 0 || points === void 0 || status === void 0) continue;
|
|
1625
|
+
if (status < 1 || status > 4) continue;
|
|
1626
|
+
const isToday = day.is_today === true;
|
|
1627
|
+
if (status === SIGNIN_DAY_STATUS.claimable) claimable += 1;
|
|
1628
|
+
if (isToday) today += 1;
|
|
1629
|
+
days.push({
|
|
1630
|
+
dayNo,
|
|
1631
|
+
points,
|
|
1632
|
+
bonusPoints: toNumber(day.bonus_points) ?? 0,
|
|
1633
|
+
status,
|
|
1634
|
+
isToday
|
|
1635
|
+
});
|
|
1636
|
+
}
|
|
1637
|
+
if (days.length === 0) return void 0;
|
|
1638
|
+
if (claimable > 1 || today > 1) return void 0;
|
|
1639
|
+
return {
|
|
1640
|
+
scene,
|
|
1641
|
+
days
|
|
1642
|
+
};
|
|
1643
|
+
}
|
|
1644
|
+
/** Read the result of a claim. */
|
|
1645
|
+
function parseSigninClaim(document) {
|
|
1646
|
+
const unwrapped = unwrap(document);
|
|
1647
|
+
if (!unwrapped.ok) return void 0;
|
|
1648
|
+
const data = unwrapped.data;
|
|
1649
|
+
if (typeof data !== "object" || data === null) return void 0;
|
|
1650
|
+
const record = data;
|
|
1651
|
+
const claimId = record.claim_id;
|
|
1652
|
+
const result = toNumber(record.claim_result);
|
|
1653
|
+
const dayNo = toNumber(record.day_no);
|
|
1654
|
+
const points = toNumber(record.points);
|
|
1655
|
+
const expireAtMs = toNumber(record.expire_at_ms);
|
|
1656
|
+
if (typeof claimId !== "string" || claimId.length === 0) return void 0;
|
|
1657
|
+
if (result !== SIGNIN_CLAIM_RESULT.credited && result !== SIGNIN_CLAIM_RESULT.alreadyClaimed) return void 0;
|
|
1658
|
+
if (dayNo === void 0 || dayNo < 1 || dayNo > 7) return void 0;
|
|
1659
|
+
if (points === void 0 || points < 0) return void 0;
|
|
1660
|
+
if (expireAtMs === void 0) return void 0;
|
|
1661
|
+
return {
|
|
1662
|
+
claimId,
|
|
1663
|
+
result,
|
|
1664
|
+
dayNo,
|
|
1665
|
+
points,
|
|
1666
|
+
bonusPoints: toNumber(record.bonus_points) ?? 0,
|
|
1667
|
+
expireAtMs,
|
|
1668
|
+
panel: parseSigninPanel({ data: record.panel })
|
|
1669
|
+
};
|
|
1670
|
+
}
|
|
1671
|
+
/**
|
|
1672
|
+
* Read the cycle.
|
|
1673
|
+
*
|
|
1674
|
+
* @returns The panel, or `undefined` when the account cannot read it.
|
|
1675
|
+
*/
|
|
1676
|
+
async function fetchSigninPanel(credential, region, fetchImpl = fetch, timezone = timezoneId()) {
|
|
1677
|
+
const response = await fetchImpl(`${MINIMAX_ACCOUNT_HOSTS[region]}${SIGNIN_PANEL_PATH}?timezone_id=${encodeURIComponent(timezone)}`, { headers: { authorization: `Bearer ${credential.token}` } });
|
|
1678
|
+
if (!response.ok) throw new Error(`签到状态请求失败: HTTP ${response.status}`);
|
|
1679
|
+
return parseSigninPanel(await response.json());
|
|
1680
|
+
}
|
|
1681
|
+
/**
|
|
1682
|
+
* Claim today's grant.
|
|
1683
|
+
*
|
|
1684
|
+
* This is the only request in the plugin that changes something upstream. It
|
|
1685
|
+
* grants the day's points, which expire thirty days later.
|
|
1686
|
+
*
|
|
1687
|
+
* @returns What the server answered, or `undefined` when it refused.
|
|
1688
|
+
*/
|
|
1689
|
+
async function claimSignin(credential, region, fetchImpl = fetch, timezone = timezoneId()) {
|
|
1690
|
+
const response = await fetchImpl(`${MINIMAX_ACCOUNT_HOSTS[region]}${SIGNIN_CLAIM_PATH}?timezone_id=${encodeURIComponent(timezone)}`, {
|
|
1691
|
+
method: "POST",
|
|
1692
|
+
headers: {
|
|
1693
|
+
authorization: `Bearer ${credential.token}`,
|
|
1694
|
+
"content-type": "application/json"
|
|
1695
|
+
},
|
|
1696
|
+
body: JSON.stringify({})
|
|
1697
|
+
});
|
|
1698
|
+
if (!response.ok) throw new Error(`签到请求失败: HTTP ${response.status}`);
|
|
1699
|
+
return parseSigninClaim(await response.json());
|
|
1700
|
+
}
|
|
1701
|
+
/** The day's key in the account's own time zone, `YYYY-MM-DD`. */
|
|
1702
|
+
function localDay(now = Date.now(), timezone = timezoneId()) {
|
|
1703
|
+
try {
|
|
1704
|
+
return new Intl.DateTimeFormat("en-CA", {
|
|
1705
|
+
timeZone: timezone,
|
|
1706
|
+
year: "numeric",
|
|
1707
|
+
month: "2-digit",
|
|
1708
|
+
day: "2-digit"
|
|
1709
|
+
}).format(new Date(now));
|
|
1710
|
+
} catch {
|
|
1711
|
+
return new Date(now).toISOString().slice(0, 10);
|
|
1712
|
+
}
|
|
1713
|
+
}
|
|
1714
|
+
//#endregion
|
|
1135
1715
|
//#region src/usage.ts
|
|
1136
1716
|
/** An empty usage record. */
|
|
1137
1717
|
const EMPTY_USAGE = {
|
|
@@ -1268,6 +1848,13 @@ const CATALOG_TTL_MS = 3e5;
|
|
|
1268
1848
|
*/
|
|
1269
1849
|
const CREDIT_TTL_MS = 3e4;
|
|
1270
1850
|
/**
|
|
1851
|
+
* How often the check-in cycle may be re-read.
|
|
1852
|
+
*
|
|
1853
|
+
* The cycle changes once a day, so reading it as often as the balance is
|
|
1854
|
+
* read would buy nothing but load on the account API.
|
|
1855
|
+
*/
|
|
1856
|
+
const SIGNIN_TTL_MS = 3e5;
|
|
1857
|
+
/**
|
|
1271
1858
|
* Compose the plugin.
|
|
1272
1859
|
*
|
|
1273
1860
|
* The credential is re-read on every request rather than cached at start: the
|
|
@@ -1281,20 +1868,106 @@ function apply(ctx) {
|
|
|
1281
1868
|
const credits = new MinimaxCredits();
|
|
1282
1869
|
const meter = new UsageMeter();
|
|
1283
1870
|
let region = "cn";
|
|
1284
|
-
let credential;
|
|
1285
1871
|
let lastCatalogAtMs = 0;
|
|
1286
1872
|
let lastCreditAtMs = 0;
|
|
1873
|
+
let lastSigninAtMs = 0;
|
|
1287
1874
|
let refreshing = false;
|
|
1288
1875
|
let creditState = "unavailable";
|
|
1289
|
-
|
|
1876
|
+
/** The token the model descriptors carry, refreshed with the credential. */
|
|
1877
|
+
let modelToken;
|
|
1878
|
+
/** The plugin's own settings, read from its own file. */
|
|
1879
|
+
let pluginState = {
|
|
1880
|
+
schemaVersion: 1,
|
|
1881
|
+
autoCheckin: false
|
|
1882
|
+
};
|
|
1883
|
+
/** The cycle as the account last reported it. */
|
|
1884
|
+
let signinPanel;
|
|
1885
|
+
/** What the last claim answered. */
|
|
1886
|
+
let signinClaim;
|
|
1887
|
+
/** Why the cycle or the claim could not be read. */
|
|
1888
|
+
let signinError;
|
|
1889
|
+
/** Where the sign-in came from. */
|
|
1890
|
+
let loginSource;
|
|
1891
|
+
/** Whether a device login is waiting to be authorized. */
|
|
1892
|
+
let loginPending = false;
|
|
1893
|
+
/**
|
|
1894
|
+
* The credential the model gateway is called with.
|
|
1895
|
+
*
|
|
1896
|
+
* The plugin's own sign-in comes first: it is the credential the person
|
|
1897
|
+
* chose, and on a machine where the desktop app was never installed it is
|
|
1898
|
+
* the only one there is. The desktop app's token is the fallback, and the
|
|
1899
|
+
* two are interchangeable as far as the gateway is concerned — it accepts
|
|
1900
|
+
* either bearer.
|
|
1901
|
+
*/
|
|
1902
|
+
const resolveGatewayCredential = async () => {
|
|
1903
|
+
const own = await readPluginCredential();
|
|
1904
|
+
if (own !== void 0) {
|
|
1905
|
+
if (credentialNeedsRefresh(own)) try {
|
|
1906
|
+
const next = await refreshPluginCredential(own, region);
|
|
1907
|
+
await writePluginCredential(next);
|
|
1908
|
+
loginSource = "plugin";
|
|
1909
|
+
modelToken = next.accessToken;
|
|
1910
|
+
return {
|
|
1911
|
+
token: next.accessToken,
|
|
1912
|
+
claims: { exp: Math.floor((next.expiresAtMs ?? Date.now()) / 1e3) }
|
|
1913
|
+
};
|
|
1914
|
+
} catch {}
|
|
1915
|
+
else {
|
|
1916
|
+
loginSource = "plugin";
|
|
1917
|
+
modelToken = own.accessToken;
|
|
1918
|
+
return {
|
|
1919
|
+
token: own.accessToken,
|
|
1920
|
+
claims: { exp: Math.floor((own.expiresAtMs ?? Date.now()) / 1e3) }
|
|
1921
|
+
};
|
|
1922
|
+
}
|
|
1923
|
+
}
|
|
1290
1924
|
const state = await readCredential();
|
|
1291
1925
|
if (state.state !== "signed-in") {
|
|
1292
|
-
|
|
1926
|
+
loginSource = void 0;
|
|
1927
|
+
modelToken = void 0;
|
|
1928
|
+
return;
|
|
1929
|
+
}
|
|
1930
|
+
loginSource = "desktop-app";
|
|
1931
|
+
modelToken = state.credential.token;
|
|
1932
|
+
return state.credential;
|
|
1933
|
+
};
|
|
1934
|
+
/**
|
|
1935
|
+
* The credential the account endpoints are called with.
|
|
1936
|
+
*
|
|
1937
|
+
* The account API needs the `mmoat_` token, not the gateway's JWT, so this
|
|
1938
|
+
* is a second credential: the plugin's own sign-in when there is one, and
|
|
1939
|
+
* otherwise the token the desktop app stored.
|
|
1940
|
+
*/
|
|
1941
|
+
const resolveAccountCredential = async (now = Date.now()) => {
|
|
1942
|
+
const own = await readPluginCredential();
|
|
1943
|
+
if (own !== void 0) {
|
|
1944
|
+
if (credentialNeedsRefresh(own, now)) try {
|
|
1945
|
+
const next = await refreshPluginCredential(own, region, fetch, now);
|
|
1946
|
+
await writePluginCredential(next);
|
|
1947
|
+
return {
|
|
1948
|
+
state: "signed-in",
|
|
1949
|
+
credential: toOAuthCredential(next)
|
|
1950
|
+
};
|
|
1951
|
+
} catch {
|
|
1952
|
+
return {
|
|
1953
|
+
state: "expired",
|
|
1954
|
+
credential: toOAuthCredential(own)
|
|
1955
|
+
};
|
|
1956
|
+
}
|
|
1957
|
+
return {
|
|
1958
|
+
state: "signed-in",
|
|
1959
|
+
credential: toOAuthCredential(own)
|
|
1960
|
+
};
|
|
1961
|
+
}
|
|
1962
|
+
return ensureOAuthCredential(region, process.env, now);
|
|
1963
|
+
};
|
|
1964
|
+
const readNow = async () => {
|
|
1965
|
+
const state = await resolveGatewayCredential();
|
|
1966
|
+
if (state === void 0) {
|
|
1293
1967
|
catalog.invalidate();
|
|
1294
1968
|
return;
|
|
1295
1969
|
}
|
|
1296
|
-
|
|
1297
|
-
return credential;
|
|
1970
|
+
return state;
|
|
1298
1971
|
};
|
|
1299
1972
|
/** Refresh the catalog at most once per TTL, never blocking a request. */
|
|
1300
1973
|
const refreshCatalog = async (current) => {
|
|
@@ -1319,7 +1992,7 @@ function apply(ctx) {
|
|
|
1319
1992
|
const now = Date.now();
|
|
1320
1993
|
if (now - lastCreditAtMs < CREDIT_TTL_MS) return;
|
|
1321
1994
|
lastCreditAtMs = now;
|
|
1322
|
-
const state = await
|
|
1995
|
+
const state = await resolveAccountCredential(now);
|
|
1323
1996
|
if (state.state === "signed-out") {
|
|
1324
1997
|
creditState = "signed-out";
|
|
1325
1998
|
return;
|
|
@@ -1331,6 +2004,65 @@ function apply(ctx) {
|
|
|
1331
2004
|
await credits.refresh(state.credential, region, fetch, now);
|
|
1332
2005
|
creditState = credits.snapshot === void 0 ? "unavailable" : "ok";
|
|
1333
2006
|
};
|
|
2007
|
+
/**
|
|
2008
|
+
* Read the check-in cycle, and claim the day's grant when asked to.
|
|
2009
|
+
*
|
|
2010
|
+
* The claim is the one request here that changes something upstream. It is
|
|
2011
|
+
* made at most once a day, only when the person turned it on, and only when
|
|
2012
|
+
* the server itself says the day can be claimed — so a day that is already
|
|
2013
|
+
* claimed never produces a request, and a claim that already happened
|
|
2014
|
+
* upstream is answered with "already claimed" and grants nothing.
|
|
2015
|
+
*/
|
|
2016
|
+
const refreshSignin = async () => {
|
|
2017
|
+
const now = Date.now();
|
|
2018
|
+
if (now - lastSigninAtMs < SIGNIN_TTL_MS && signinPanel !== void 0) return;
|
|
2019
|
+
lastSigninAtMs = now;
|
|
2020
|
+
const state = await resolveAccountCredential();
|
|
2021
|
+
if (state.state !== "signed-in") {
|
|
2022
|
+
signinPanel = void 0;
|
|
2023
|
+
return;
|
|
2024
|
+
}
|
|
2025
|
+
let panel;
|
|
2026
|
+
try {
|
|
2027
|
+
panel = await fetchSigninPanel(state.credential, region, fetch);
|
|
2028
|
+
signinPanel = panel;
|
|
2029
|
+
signinError = void 0;
|
|
2030
|
+
} catch (error) {
|
|
2031
|
+
signinError = error instanceof Error ? error.message : String(error);
|
|
2032
|
+
return;
|
|
2033
|
+
}
|
|
2034
|
+
if (!pluginState.autoCheckin || panel === void 0) return;
|
|
2035
|
+
const today = panel.days.find((day) => day.isToday);
|
|
2036
|
+
if (today === void 0 || today.status !== SIGNIN_DAY_STATUS.claimable) return;
|
|
2037
|
+
if (pluginState.lastClaimDay === localDay()) return;
|
|
2038
|
+
await performClaim(state.credential);
|
|
2039
|
+
};
|
|
2040
|
+
/** Claim today's grant and remember that it happened. */
|
|
2041
|
+
const performClaim = async (credential) => {
|
|
2042
|
+
try {
|
|
2043
|
+
const claim = await claimSignin(credential, region, fetch);
|
|
2044
|
+
if (claim === void 0) {
|
|
2045
|
+
signinError = "签到接口没有返回可识别的结果";
|
|
2046
|
+
return;
|
|
2047
|
+
}
|
|
2048
|
+
signinClaim = claim;
|
|
2049
|
+
signinError = void 0;
|
|
2050
|
+
if (claim.panel !== void 0) signinPanel = claim.panel;
|
|
2051
|
+
pluginState = {
|
|
2052
|
+
...pluginState,
|
|
2053
|
+
lastClaimDay: localDay(),
|
|
2054
|
+
lastClaimAtMs: Date.now(),
|
|
2055
|
+
lastClaimPoints: claim.points
|
|
2056
|
+
};
|
|
2057
|
+
await writePluginState(pluginState);
|
|
2058
|
+
lastCreditAtMs = 0;
|
|
2059
|
+
refreshCredits();
|
|
2060
|
+
return claim;
|
|
2061
|
+
} catch (error) {
|
|
2062
|
+
signinError = error instanceof Error ? error.message : String(error);
|
|
2063
|
+
return;
|
|
2064
|
+
}
|
|
2065
|
+
};
|
|
1334
2066
|
/** The credit balance as the card and the browser half read it. */
|
|
1335
2067
|
const buildCreditStatus = () => {
|
|
1336
2068
|
const snapshot = credits.snapshot;
|
|
@@ -1373,11 +2105,60 @@ function apply(ctx) {
|
|
|
1373
2105
|
requests: meter.records.length
|
|
1374
2106
|
};
|
|
1375
2107
|
};
|
|
2108
|
+
/** The check-in cycle, as the card reads it. */
|
|
2109
|
+
const buildSigninStatus = () => {
|
|
2110
|
+
if (signinPanel === void 0 && signinError === void 0) return void 0;
|
|
2111
|
+
const panel = signinPanel;
|
|
2112
|
+
const today = panel?.days.find((day) => day.isToday);
|
|
2113
|
+
return {
|
|
2114
|
+
state: panel === void 0 ? "unavailable" : "ok",
|
|
2115
|
+
...today === void 0 ? {} : { today: {
|
|
2116
|
+
dayNo: today.dayNo,
|
|
2117
|
+
points: today.points,
|
|
2118
|
+
status: today.status,
|
|
2119
|
+
claimable: today.status === SIGNIN_DAY_STATUS.claimable,
|
|
2120
|
+
claimed: today.status === SIGNIN_DAY_STATUS.claimed
|
|
2121
|
+
} },
|
|
2122
|
+
...panel === void 0 ? {} : {
|
|
2123
|
+
days: panel.days.map((day) => ({
|
|
2124
|
+
dayNo: day.dayNo,
|
|
2125
|
+
points: day.points,
|
|
2126
|
+
status: day.status,
|
|
2127
|
+
isToday: day.isToday
|
|
2128
|
+
})),
|
|
2129
|
+
cyclePoints: panel.days.reduce((sum, day) => sum + day.points, 0)
|
|
2130
|
+
},
|
|
2131
|
+
autoCheckin: pluginState.autoCheckin,
|
|
2132
|
+
...pluginState.lastClaimDay === void 0 ? {} : { lastClaimDay: pluginState.lastClaimDay },
|
|
2133
|
+
...pluginState.lastClaimAtMs === void 0 ? {} : { lastClaimAtMs: pluginState.lastClaimAtMs },
|
|
2134
|
+
...pluginState.lastClaimPoints === void 0 ? {} : { lastClaimPoints: pluginState.lastClaimPoints },
|
|
2135
|
+
...signinClaim === void 0 ? {} : {
|
|
2136
|
+
lastClaimResult: signinClaim.result,
|
|
2137
|
+
lastClaimPoints: signinClaim.points
|
|
2138
|
+
},
|
|
2139
|
+
...signinError === void 0 ? {} : { error: signinError }
|
|
2140
|
+
};
|
|
2141
|
+
};
|
|
2142
|
+
/** How the plugin is signed in, as the card reads it. */
|
|
2143
|
+
const buildLoginStatus = () => {
|
|
2144
|
+
const session = pluginState.deviceLogin;
|
|
2145
|
+
const pending = loginPending && session !== void 0;
|
|
2146
|
+
return {
|
|
2147
|
+
state: pending ? "pending" : loginSource === void 0 ? "signed-out" : "signed-in",
|
|
2148
|
+
...loginSource === void 0 ? {} : { source: loginSource },
|
|
2149
|
+
...pending && session !== void 0 ? {
|
|
2150
|
+
userCode: session.userCode,
|
|
2151
|
+
verificationUri: session.verificationUri,
|
|
2152
|
+
verificationUriComplete: session.verificationUriComplete,
|
|
2153
|
+
expiresAtMs: session.expiresAtMs
|
|
2154
|
+
} : {}
|
|
2155
|
+
};
|
|
2156
|
+
};
|
|
1376
2157
|
const { adapter, invalidate } = createMinimaxCodeAdapter({
|
|
1377
2158
|
catalog,
|
|
1378
2159
|
region: () => region,
|
|
1379
2160
|
resolveApiKey: async () => (await readNow())?.token,
|
|
1380
|
-
bearerToken: () =>
|
|
2161
|
+
bearerToken: () => modelToken,
|
|
1381
2162
|
onUsage: (model, usage) => {
|
|
1382
2163
|
meter.record(model, usage);
|
|
1383
2164
|
refreshCredits();
|
|
@@ -1396,6 +2177,7 @@ function apply(ctx) {
|
|
|
1396
2177
|
reason: state.reason
|
|
1397
2178
|
};
|
|
1398
2179
|
await refreshCredits();
|
|
2180
|
+
await refreshSignin();
|
|
1399
2181
|
return statusDocument({
|
|
1400
2182
|
auth,
|
|
1401
2183
|
region,
|
|
@@ -1408,7 +2190,9 @@ function apply(ctx) {
|
|
|
1408
2190
|
})),
|
|
1409
2191
|
refreshing,
|
|
1410
2192
|
credits: buildCreditStatus(),
|
|
1411
|
-
usage: buildUsageStatus()
|
|
2193
|
+
usage: buildUsageStatus(),
|
|
2194
|
+
signin: buildSigninStatus(),
|
|
2195
|
+
login: buildLoginStatus()
|
|
1412
2196
|
});
|
|
1413
2197
|
};
|
|
1414
2198
|
(async () => {
|
|
@@ -1419,6 +2203,121 @@ function apply(ctx) {
|
|
|
1419
2203
|
invalidate();
|
|
1420
2204
|
}
|
|
1421
2205
|
})();
|
|
2206
|
+
/**
|
|
2207
|
+
* The actions the card can ask for.
|
|
2208
|
+
*
|
|
2209
|
+
* Every one of them is a request the person made by clicking something, so
|
|
2210
|
+
* they are all safe to repeat: a claim that was already made upstream
|
|
2211
|
+
* answers "already claimed", and a sign-in that is already waiting answers
|
|
2212
|
+
* with the same device code.
|
|
2213
|
+
*/
|
|
2214
|
+
const runAction = async (action, body) => {
|
|
2215
|
+
switch (action) {
|
|
2216
|
+
case "claim": {
|
|
2217
|
+
const state = await resolveAccountCredential();
|
|
2218
|
+
if (state.state !== "signed-in") return {
|
|
2219
|
+
ok: false,
|
|
2220
|
+
state: state.state,
|
|
2221
|
+
error: "尚未登录"
|
|
2222
|
+
};
|
|
2223
|
+
const claim = await performClaim(state.credential);
|
|
2224
|
+
return claim === void 0 ? {
|
|
2225
|
+
ok: false,
|
|
2226
|
+
error: signinError ?? "签到失败"
|
|
2227
|
+
} : {
|
|
2228
|
+
ok: true,
|
|
2229
|
+
claim,
|
|
2230
|
+
panel: signinPanel
|
|
2231
|
+
};
|
|
2232
|
+
}
|
|
2233
|
+
case "login-start": {
|
|
2234
|
+
const session = await startDeviceLogin(region);
|
|
2235
|
+
if (session === void 0) return {
|
|
2236
|
+
ok: false,
|
|
2237
|
+
error: "没有拿到设备码"
|
|
2238
|
+
};
|
|
2239
|
+
pluginState = {
|
|
2240
|
+
...pluginState,
|
|
2241
|
+
deviceLogin: session
|
|
2242
|
+
};
|
|
2243
|
+
await writePluginState(pluginState);
|
|
2244
|
+
loginPending = true;
|
|
2245
|
+
return {
|
|
2246
|
+
ok: true,
|
|
2247
|
+
userCode: session.userCode,
|
|
2248
|
+
verificationUri: session.verificationUri,
|
|
2249
|
+
verificationUriComplete: session.verificationUriComplete,
|
|
2250
|
+
expiresAtMs: session.expiresAtMs
|
|
2251
|
+
};
|
|
2252
|
+
}
|
|
2253
|
+
case "login-poll": {
|
|
2254
|
+
const session = pluginState.deviceLogin;
|
|
2255
|
+
if (session === void 0) return {
|
|
2256
|
+
ok: false,
|
|
2257
|
+
state: "signed-out",
|
|
2258
|
+
error: "没有待完成的登录"
|
|
2259
|
+
};
|
|
2260
|
+
const result = await pollDeviceLogin(session, region);
|
|
2261
|
+
if (result.state === "signed-in") {
|
|
2262
|
+
await writePluginCredential(result.credential);
|
|
2263
|
+
pluginState = {
|
|
2264
|
+
...pluginState,
|
|
2265
|
+
deviceLogin: void 0
|
|
2266
|
+
};
|
|
2267
|
+
await writePluginState(pluginState);
|
|
2268
|
+
loginPending = false;
|
|
2269
|
+
loginSource = "plugin";
|
|
2270
|
+
modelToken = result.credential.accessToken;
|
|
2271
|
+
catalog.invalidate();
|
|
2272
|
+
lastCatalogAtMs = 0;
|
|
2273
|
+
lastCreditAtMs = 0;
|
|
2274
|
+
readNow();
|
|
2275
|
+
return {
|
|
2276
|
+
ok: true,
|
|
2277
|
+
state: "signed-in"
|
|
2278
|
+
};
|
|
2279
|
+
}
|
|
2280
|
+
if (result.state === "expired" || result.state === "denied") {
|
|
2281
|
+
pluginState = {
|
|
2282
|
+
...pluginState,
|
|
2283
|
+
deviceLogin: void 0
|
|
2284
|
+
};
|
|
2285
|
+
await writePluginState(pluginState);
|
|
2286
|
+
loginPending = false;
|
|
2287
|
+
return {
|
|
2288
|
+
ok: false,
|
|
2289
|
+
state: result.state,
|
|
2290
|
+
error: result.message
|
|
2291
|
+
};
|
|
2292
|
+
}
|
|
2293
|
+
return {
|
|
2294
|
+
ok: true,
|
|
2295
|
+
state: result.state
|
|
2296
|
+
};
|
|
2297
|
+
}
|
|
2298
|
+
case "settings": {
|
|
2299
|
+
const autoCheckin = body.autoCheckin;
|
|
2300
|
+
if (typeof autoCheckin !== "boolean") return {
|
|
2301
|
+
ok: false,
|
|
2302
|
+
error: "autoCheckin 必须是布尔值"
|
|
2303
|
+
};
|
|
2304
|
+
pluginState = {
|
|
2305
|
+
...pluginState,
|
|
2306
|
+
autoCheckin
|
|
2307
|
+
};
|
|
2308
|
+
await writePluginState(pluginState);
|
|
2309
|
+
if (autoCheckin) refreshSignin();
|
|
2310
|
+
return {
|
|
2311
|
+
ok: true,
|
|
2312
|
+
autoCheckin
|
|
2313
|
+
};
|
|
2314
|
+
}
|
|
2315
|
+
default: return {
|
|
2316
|
+
ok: false,
|
|
2317
|
+
error: `未知操作: ${action}`
|
|
2318
|
+
};
|
|
2319
|
+
}
|
|
2320
|
+
};
|
|
1422
2321
|
ctx.inject(["webServer"], (webCtx) => {
|
|
1423
2322
|
webCtx.effect(() => webCtx.webServer.register({
|
|
1424
2323
|
kind: "exact",
|
|
@@ -1438,7 +2337,50 @@ function apply(ctx) {
|
|
|
1438
2337
|
}
|
|
1439
2338
|
}
|
|
1440
2339
|
}));
|
|
2340
|
+
webCtx.effect(() => webCtx.webServer.register({
|
|
2341
|
+
kind: "exact",
|
|
2342
|
+
path: MINIMAX_ACTION_PATH,
|
|
2343
|
+
handler: async (req, res) => {
|
|
2344
|
+
if (!isLoopback(req.headers.host)) {
|
|
2345
|
+
res.writeHead(403, { "Content-Type": "application/json" });
|
|
2346
|
+
res.end(JSON.stringify({ error: "loopback only" }));
|
|
2347
|
+
return;
|
|
2348
|
+
}
|
|
2349
|
+
if (req.method !== "POST") {
|
|
2350
|
+
res.writeHead(405, { "Content-Type": "application/json" });
|
|
2351
|
+
res.end(JSON.stringify({ error: "POST only" }));
|
|
2352
|
+
return;
|
|
2353
|
+
}
|
|
2354
|
+
let action = "";
|
|
2355
|
+
let body = {};
|
|
2356
|
+
try {
|
|
2357
|
+
action = new URL(req.url ?? "/", "http://127.0.0.1").searchParams.get("action") ?? "";
|
|
2358
|
+
const text = await readRequestBody(req);
|
|
2359
|
+
if (text.length > 0) {
|
|
2360
|
+
const parsed = JSON.parse(text);
|
|
2361
|
+
if (typeof parsed === "object" && parsed !== null) body = parsed;
|
|
2362
|
+
}
|
|
2363
|
+
} catch {}
|
|
2364
|
+
try {
|
|
2365
|
+
const result = await runAction(action, body);
|
|
2366
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
2367
|
+
res.end(JSON.stringify(result));
|
|
2368
|
+
} catch (error) {
|
|
2369
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
2370
|
+
res.end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }));
|
|
2371
|
+
}
|
|
2372
|
+
}
|
|
2373
|
+
}));
|
|
1441
2374
|
});
|
|
1442
2375
|
}
|
|
2376
|
+
/** Read a request body, up to a size that no card needs to exceed. */
|
|
2377
|
+
async function readRequestBody(req) {
|
|
2378
|
+
let text = "";
|
|
2379
|
+
for await (const chunk of req) {
|
|
2380
|
+
text += typeof chunk === "string" ? chunk : chunk.toString("utf8");
|
|
2381
|
+
if (text.length > 65536) break;
|
|
2382
|
+
}
|
|
2383
|
+
return text;
|
|
2384
|
+
}
|
|
1443
2385
|
//#endregion
|
|
1444
|
-
export { EMPTY_USAGE, FALLBACK_MODELS, MEMBERSHIP_PATH, MINIMAXCODE_PROVIDER_ID, MINIMAX_AUTH_FILENAME, MINIMAX_DATA_DIR, MINIMAX_GATEWAYS, MINIMAX_OAUTH_CLIENT_ID, MINIMAX_REGIONS, MINIMAX_STATUS_PATH, MINIMAX_STREAM_IDLE_TIMEOUT_MS, MODEL_PRICES, MinimaxCatalog, MinimaxCredits, UsageMeter, apply, authPath, bearerToken, catalogUrl, chatBaseUrl, createMinimaxCodeAdapter, dataDir, daysRemaining, decodeClaims, ensureOAuthCredential, estimateCny, expiresInMs, expiryLevel, fetchCreditSummary, inject, name, parseAuthDocument, parseCatalog, parseCreditSummary, parseOAuthDocument, priceFor, readCredential, readOAuthCredential, refreshOAuthCredential, statusDocument, writeOAuthCredential };
|
|
2386
|
+
export { DEVICE_CODE_GRANT, DEVICE_CODE_PATH, EMPTY_USAGE, FALLBACK_MODELS, MEMBERSHIP_PATH, MINIMAXCODE_PROVIDER_ID, MINIMAX_AUTH_FILENAME, MINIMAX_DATA_DIR, MINIMAX_GATEWAYS, MINIMAX_OAUTH_CLIENT_ID, MINIMAX_REGIONS, MINIMAX_STATUS_PATH, MINIMAX_STREAM_IDLE_TIMEOUT_MS, MODEL_PRICES, MinimaxCatalog, MinimaxCredits, PLUGIN_CREDENTIAL_FILENAME, PLUGIN_STATE_FILENAME, SIGNIN_CLAIM_PATH, SIGNIN_CLAIM_RESULT, SIGNIN_CYCLE_DAYS, SIGNIN_DAY_STATUS, SIGNIN_PANEL_PATH, UsageMeter, apply, authPath, bearerToken, catalogUrl, chatBaseUrl, claimSignin, createMinimaxCodeAdapter, dataDir, daysRemaining, decodeClaims, dshHome, ensureOAuthCredential, estimateCny, expiresInMs, expiryLevel, fetchCreditSummary, fetchSigninPanel, inject, localDay, makePkce, name, parseAuthDocument, parseCatalog, parseCreditSummary, parseOAuthDocument, parseSigninClaim, parseSigninPanel, pluginCredentialPath, pluginStatePath, pollDeviceLogin, priceFor, readCredential, readOAuthCredential, readPluginCredential, readPluginState, refreshOAuthCredential, refreshPluginCredential, startDeviceLogin, statusDocument, timezoneId, writeOAuthCredential, writePluginCredential, writePluginState };
|