dsh-cmtoken-oauth 1.6.1 → 1.7.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/lib/client.js +38 -12
- package/lib/index.js +400 -63
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -61,9 +61,18 @@ window.__ModuleLoader__.load({
|
|
|
61
61
|
const [error, setError] = useState(null);
|
|
62
62
|
const pollTimer = useRef(null);
|
|
63
63
|
const autoStarted = useRef(false);
|
|
64
|
+
const [channel, setChannelState] = useState("komect");
|
|
65
|
+
const switchChannel = async (ch) => {
|
|
66
|
+
setError(null); setPhase("loading");
|
|
67
|
+
try {
|
|
68
|
+
unwrap(await rpc.call(CHANNEL, "set-channel", { channel: ch }));
|
|
69
|
+
setChannelState(ch);
|
|
70
|
+
begin();
|
|
71
|
+
} catch (e) { setPhase("error"); setError(e.message || String(e)); }
|
|
72
|
+
};
|
|
64
73
|
|
|
65
74
|
const refreshStatus = useCallback(async () => {
|
|
66
|
-
try {
|
|
75
|
+
try { const s = unwrap(await rpc.call(CHANNEL, "status", {})); setStatus(s); if (s && s.channel) setChannelState(s.channel); }
|
|
67
76
|
catch { setStatus(null); }
|
|
68
77
|
}, [rpc]);
|
|
69
78
|
|
|
@@ -137,15 +146,18 @@ window.__ModuleLoader__.load({
|
|
|
137
146
|
if (phase === "loading") {
|
|
138
147
|
authCard = react.createElement("div", { className: "co-card" },
|
|
139
148
|
react.createElement("div", { className: "co-card-title" }, "认证"),
|
|
140
|
-
react.createElement("p", { className: "co-hint" }, "中国移动算力豆一键认证:扫码即自动创建专属 DSH API key,长期有效,无需刷新令牌。"),
|
|
149
|
+
react.createElement("p", { className: "co-hint" }, channel === "maas" ? "广东移动 MaaS 旧通道:扫码授权后自动发现模型,令牌自动续期。" : "中国移动算力豆一键认证:扫码即自动创建专属 DSH API key,长期有效,无需刷新令牌。"),
|
|
141
150
|
react.createElement("div", { className: "co-hint" }, "加载中…"));
|
|
142
151
|
} else if (phase === "qr" && flow) {
|
|
143
152
|
authCard = react.createElement("div", { className: "co-card" },
|
|
144
153
|
react.createElement("div", { className: "co-card-title" }, "认证"),
|
|
145
|
-
react.createElement("p", { className: "co-hint" }, "中国移动算力豆一键认证:扫码即自动创建专属 DSH API key,长期有效,无需刷新令牌。"),
|
|
154
|
+
react.createElement("p", { className: "co-hint" }, channel === "maas" ? "广东移动 MaaS 旧通道:扫码授权后自动发现模型,令牌自动续期。" : "中国移动算力豆一键认证:扫码即自动创建专属 DSH API key,长期有效,无需刷新令牌。"),
|
|
146
155
|
react.createElement("div", { className: "co-qr" },
|
|
147
156
|
react.createElement("div", { dangerouslySetInnerHTML: { __html: flow.qr_svg } }),
|
|
148
|
-
react.createElement("div", { className: "co-
|
|
157
|
+
(flow.channel === "maas" && flow.user_code ? react.createElement("div", { className: "co-code" }, flow.user_code) : null),
|
|
158
|
+
flow.channel === "maas"
|
|
159
|
+
? react.createElement("div", { className: "co-hint" }, "使用手机扫码并输入用户码完成授权,有效期 " + Math.round((flow.expires_in || 600) / 60) + " 分钟")
|
|
160
|
+
: react.createElement("div", { className: "co-hint" }, "扫码登录中国移动账号,一次扫码完成认证与用量关联,约 " + Math.round(flow.expires_in || 180) + " 秒内有效")
|
|
149
161
|
),
|
|
150
162
|
react.createElement("div", { className: "co-status" },
|
|
151
163
|
react.createElement("span", { className: "co-dot" }),
|
|
@@ -153,36 +165,50 @@ window.__ModuleLoader__.load({
|
|
|
153
165
|
} else if (phase === "done") {
|
|
154
166
|
authCard = react.createElement("div", { className: "co-card" },
|
|
155
167
|
react.createElement("div", { className: "co-card-title" }, "认证"),
|
|
156
|
-
react.createElement("p", { className: "co-hint" }, "中国移动算力豆一键认证:扫码即自动创建专属 DSH API key,长期有效,无需刷新令牌。"),
|
|
168
|
+
react.createElement("p", { className: "co-hint" }, channel === "maas" ? "广东移动 MaaS 旧通道:扫码授权后自动发现模型,令牌自动续期。" : "中国移动算力豆一键认证:扫码即自动创建专属 DSH API key,长期有效,无需刷新令牌。"),
|
|
157
169
|
react.createElement("div", { className: "co-auth-ok" }, "✅ 已认证" + (models.length ? ",已接入 " + models.length + " 个模型" : "")),
|
|
158
170
|
react.createElement("div", { className: "co-row" },
|
|
159
|
-
react.createElement(Button, { variant: "outline", size: "md", onClick: doRefresh }, "刷新模型与用量"),
|
|
171
|
+
react.createElement(Button, { variant: "outline", size: "md", onClick: doRefresh }, (status && status.activeChannel === "maas" ? "刷新令牌" : "刷新模型与用量")),
|
|
160
172
|
react.createElement(Button, { variant: "outline", size: "md", onClick: begin }, "重新认证")));
|
|
161
173
|
} else if (phase === "error") {
|
|
162
174
|
authCard = react.createElement("div", { className: "co-card" },
|
|
163
175
|
react.createElement("div", { className: "co-card-title" }, "认证"),
|
|
164
|
-
react.createElement("p", { className: "co-hint" }, "中国移动算力豆一键认证:扫码即自动创建专属 DSH API key,长期有效,无需刷新令牌。"),
|
|
176
|
+
react.createElement("p", { className: "co-hint" }, channel === "maas" ? "广东移动 MaaS 旧通道:扫码授权后自动发现模型,令牌自动续期。" : "中国移动算力豆一键认证:扫码即自动创建专属 DSH API key,长期有效,无需刷新令牌。"),
|
|
165
177
|
react.createElement("div", { className: "co-err" }, "❌ " + (error || "认证失败")),
|
|
166
178
|
react.createElement("div", { className: "co-row" },
|
|
167
179
|
react.createElement(Button, { variant: "primary", size: "md", onClick: () => { setPhase("ok"); setError(null); } }, "返回重试")));
|
|
168
180
|
} else if (authenticated) {
|
|
169
181
|
authCard = react.createElement("div", { className: "co-card" },
|
|
170
182
|
react.createElement("div", { className: "co-card-title" }, "认证"),
|
|
171
|
-
react.createElement("p", { className: "co-hint" }, "中国移动算力豆一键认证:扫码即自动创建专属 DSH API key,长期有效,无需刷新令牌。"),
|
|
183
|
+
react.createElement("p", { className: "co-hint" }, channel === "maas" ? "广东移动 MaaS 旧通道:扫码授权后自动发现模型,令牌自动续期。" : "中国移动算力豆一键认证:扫码即自动创建专属 DSH API key,长期有效,无需刷新令牌。"),
|
|
172
184
|
react.createElement("div", { className: "co-auth-ok" },
|
|
173
185
|
"✅ 已认证",
|
|
174
|
-
status && status.
|
|
186
|
+
status && status.activeChannel === "maas" && status.expiresAt ? react.createElement("span", { className: "co-hint" },
|
|
187
|
+
status.expired ? "(令牌已过期,请刷新或重新认证)" : "(令牌剩余约 " + Math.max(0, Math.round((status.expiresAt - Date.now()) / 60000)) + " 分钟)") : null,
|
|
188
|
+
status && status.activeChannel !== "maas" && status.sessionExpired ? react.createElement("span", { className: "co-hint" }, "(管理会话已过期,重新扫码可刷新模型列表)") : null),
|
|
175
189
|
react.createElement("div", { className: "co-row" },
|
|
176
|
-
react.createElement(Button, { variant: "outline", size: "md", onClick: doRefresh }, "刷新模型与用量"),
|
|
190
|
+
react.createElement(Button, { variant: "outline", size: "md", onClick: doRefresh }, (status && status.activeChannel === "maas" ? "刷新令牌" : "刷新模型与用量")),
|
|
177
191
|
react.createElement(Button, { variant: "outline", size: "md", onClick: begin }, "重新认证")));
|
|
178
192
|
} else {
|
|
179
193
|
authCard = react.createElement("div", { className: "co-card" },
|
|
180
194
|
react.createElement("div", { className: "co-card-title" }, "认证"),
|
|
181
|
-
react.createElement("p", { className: "co-hint" }, "中国移动算力豆一键认证:扫码即自动创建专属 DSH API key,长期有效,无需刷新令牌。"),
|
|
195
|
+
react.createElement("p", { className: "co-hint" }, channel === "maas" ? "广东移动 MaaS 旧通道:扫码授权后自动发现模型,令牌自动续期。" : "中国移动算力豆一键认证:扫码即自动创建专属 DSH API key,长期有效,无需刷新令牌。"),
|
|
182
196
|
react.createElement("div", { className: "co-hint" }, "准备二维码…"));
|
|
183
197
|
}
|
|
184
198
|
|
|
185
|
-
// ----
|
|
199
|
+
// ---- 通道选择(认证卡上方) ----
|
|
200
|
+
const channelCard = react.createElement("div", { className: "co-card" },
|
|
201
|
+
react.createElement("div", { className: "co-card-title" }, "认证通道"),
|
|
202
|
+
react.createElement("div", { className: "co-row" },
|
|
203
|
+
react.createElement("select", {
|
|
204
|
+
className: "co-select",
|
|
205
|
+
value: channel,
|
|
206
|
+
onChange: (e) => switchChannel(e.target.value),
|
|
207
|
+
},
|
|
208
|
+
react.createElement("option", { value: "komect" }, "中国移动算力豆(推荐)"),
|
|
209
|
+
react.createElement("option", { value: "maas" }, "广东移动 MaaS(旧通道)")),
|
|
210
|
+
react.createElement("span", { className: "co-hint" }, "切换后需重新扫码认证")));
|
|
211
|
+
// ---- 卡片:算力豆用量 ----
|
|
186
212
|
const usageCard = status && status.usageLinked
|
|
187
213
|
? react.createElement("div", { className: "co-card" },
|
|
188
214
|
react.createElement("div", { className: "co-card-title" }, "算力豆用量"),
|
package/lib/index.js
CHANGED
|
@@ -191,6 +191,233 @@ async function fetchKomectModels(session) {
|
|
|
191
191
|
// 仅在发现接口失败时兜底使用。
|
|
192
192
|
const DEFAULT_MODEL_SPEC = { contextWindow: 262_144, maxTokens: 65_536 };
|
|
193
193
|
|
|
194
|
+
// ---------------- 旧通道:广东移动 MaaS(OAuth Device Grant,legacy 可选) ----------------
|
|
195
|
+
const OAUTH_BASE = 'https://agentlink.idaas.cmpassport.com/oauth2-service';
|
|
196
|
+
const CLIENT_ID = '00a60e3140284b408d51a436ecce121d';
|
|
197
|
+
const DEVICE_GRANT = 'urn:ietf:params:oauth:grant-type:device_code';
|
|
198
|
+
const MAASTIMEOUT = 900_000;
|
|
199
|
+
const legacyPending = new Map();
|
|
200
|
+
|
|
201
|
+
const scope = () => `profile model.completion identity:device:${os.hostname()}`;
|
|
202
|
+
|
|
203
|
+
function makePkce() {
|
|
204
|
+
const verifier = randomBytes(32).toString('base64url');
|
|
205
|
+
const challenge = createHash('sha256').update(verifier).digest('base64url');
|
|
206
|
+
const state = randomBytes(16).toString('base64url');
|
|
207
|
+
return { verifier, challenge, state };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function requestDeviceCode() {
|
|
211
|
+
const { verifier, challenge, state } = makePkce();
|
|
212
|
+
const res = await fetch(`${OAUTH_BASE}/oauth/device/code`, {
|
|
213
|
+
method: 'POST',
|
|
214
|
+
signal: AbortSignal.timeout(MAASTIMEOUT),
|
|
215
|
+
headers: {
|
|
216
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
217
|
+
Accept: 'application/json',
|
|
218
|
+
'x-request-id': randomUUID(),
|
|
219
|
+
},
|
|
220
|
+
body: new URLSearchParams({
|
|
221
|
+
response_type: 'device_code',
|
|
222
|
+
client_id: CLIENT_ID,
|
|
223
|
+
scope: scope(),
|
|
224
|
+
code_challenge: challenge,
|
|
225
|
+
code_challenge_method: 'S256',
|
|
226
|
+
state,
|
|
227
|
+
}),
|
|
228
|
+
});
|
|
229
|
+
if (!res.ok) throw new Error(`设备码申请失败: ${await res.text()}`);
|
|
230
|
+
const json = await res.json();
|
|
231
|
+
if (!json.device_code || !json.user_code || !json.verification_uri) {
|
|
232
|
+
throw new Error(json.error_description ?? json.error ?? '设备码响应不完整');
|
|
233
|
+
}
|
|
234
|
+
return { ...json, verifier };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** 单次令牌轮询;成功返回 Token,pending 返回 null,失败抛错。 */
|
|
238
|
+
async function pollOnce(deviceCode, verifier) {
|
|
239
|
+
let res;
|
|
240
|
+
try {
|
|
241
|
+
res = await fetch(`${OAUTH_BASE}/oauth/device/token`, {
|
|
242
|
+
method: 'POST',
|
|
243
|
+
signal: AbortSignal.timeout(MAASTIMEOUT),
|
|
244
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
|
|
245
|
+
body: new URLSearchParams({
|
|
246
|
+
grant_type: DEVICE_GRANT,
|
|
247
|
+
client_id: CLIENT_ID,
|
|
248
|
+
device_code: deviceCode,
|
|
249
|
+
code_verifier: verifier,
|
|
250
|
+
}),
|
|
251
|
+
});
|
|
252
|
+
} catch {
|
|
253
|
+
return { status: 'pending' }; // 网络抖动 → 当 pending
|
|
254
|
+
}
|
|
255
|
+
const json = await res.json().catch(() => undefined);
|
|
256
|
+
if (res.ok) {
|
|
257
|
+
if (!json?.access_token || !json.refresh_token || !json.expires_in) {
|
|
258
|
+
throw new Error('令牌响应不完整');
|
|
259
|
+
}
|
|
260
|
+
return {
|
|
261
|
+
status: 'ok',
|
|
262
|
+
token: {
|
|
263
|
+
access: json.access_token,
|
|
264
|
+
refresh: json.refresh_token,
|
|
265
|
+
expires: Date.now() + json.expires_in * 1000,
|
|
266
|
+
resourceUrl: json.resource_url,
|
|
267
|
+
},
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
if (json?.error === 'authorization_pending') return { status: 'pending' };
|
|
271
|
+
if (json?.error === 'slow_down') return { status: 'slow_down' };
|
|
272
|
+
throw new Error(json?.error_description ?? json?.error ?? '令牌请求失败');
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async function refreshToken(refresh) {
|
|
276
|
+
const res = await fetch(`${OAUTH_BASE}/oauth/device/token`, {
|
|
277
|
+
method: 'POST',
|
|
278
|
+
signal: AbortSignal.timeout(MAASTIMEOUT),
|
|
279
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
|
|
280
|
+
body: new URLSearchParams({
|
|
281
|
+
grant_type: 'refresh_token',
|
|
282
|
+
client_id: CLIENT_ID,
|
|
283
|
+
refresh_token: refresh,
|
|
284
|
+
}),
|
|
285
|
+
});
|
|
286
|
+
const json = await res.json();
|
|
287
|
+
if (!res.ok || json.error) throw new Error(json?.error_description ?? json?.error ?? '刷新失败');
|
|
288
|
+
return {
|
|
289
|
+
access: json.access_token,
|
|
290
|
+
refresh: json.refresh_token || refresh,
|
|
291
|
+
expires: Date.now() + json.expires_in * 1000,
|
|
292
|
+
resourceUrl: json.resource_url,
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// 根据模型名称关键词匹配「输入(上下文)/ 输出(最大生成)」规格(MaaS 旧发现接口无规格字段)。
|
|
297
|
+
const MAAS_MODEL_SPECS = [
|
|
298
|
+
{ match: (n) => n.includes('deepseek') && n.includes('v4') && n.includes('flash'), contextWindow: 524_288, maxTokens: 393_216 },
|
|
299
|
+
{ match: (n) => n.includes('deepseek') && n.includes('v4'), contextWindow: 1_048_576, maxTokens: 393_216 },
|
|
300
|
+
{ match: (n) => n.includes('minimax') && /m3/.test(n), contextWindow: 1_048_576, maxTokens: 131_072 },
|
|
301
|
+
{ match: (n) => n.includes('qwen36') || n.includes('qwen38'), contextWindow: 262_144, maxTokens: 65_536 },
|
|
302
|
+
{ match: (n) => n.includes('qwen'), contextWindow: 1_048_576, maxTokens: 65_536 },
|
|
303
|
+
{ match: (n) => n.includes('glm'), contextWindow: 1_048_576, maxTokens: 131_072 },
|
|
304
|
+
{ match: (n) => n.startsWith('m2') || (n.includes('minimax') && /m2/.test(n)), contextWindow: 204_800, maxTokens: 65_536 },
|
|
305
|
+
{ match: (n) => /hy/.test(n), contextWindow: 262_144, maxTokens: 65_536 },
|
|
306
|
+
];
|
|
307
|
+
|
|
308
|
+
function maasModelSpecFor(id) {
|
|
309
|
+
const n = String(id).toLowerCase().replace(/[-_\s.]+/g, '');
|
|
310
|
+
for (const spec of MAAS_MODEL_SPECS) {
|
|
311
|
+
if (spec.match(n)) return spec;
|
|
312
|
+
}
|
|
313
|
+
return DEFAULT_MODEL_SPEC;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const MAAS_API_BASE = 'https://maas.gd.chinamobile.com:36007/ai/uifm/open/v1';
|
|
317
|
+
|
|
318
|
+
async function fetchMaasModels(access, discoveryUrl) {
|
|
319
|
+
const res = await fetch(discoveryUrl || (MAAS_API_BASE + '/models'), {
|
|
320
|
+
signal: AbortSignal.timeout(15_000),
|
|
321
|
+
headers: { Accept: 'application/json', Authorization: `Bearer ${access}` },
|
|
322
|
+
});
|
|
323
|
+
if (!res.ok) {
|
|
324
|
+
const text = (await res.text()).slice(0, 200);
|
|
325
|
+
if (res.status === 403 && /订阅套餐|订阅已过期|余额不足/.test(text)) {
|
|
326
|
+
throw new Error('订阅错误:' + text);
|
|
327
|
+
}
|
|
328
|
+
throw new Error(`模型发现失败 ${res.status}: ${text}`);
|
|
329
|
+
}
|
|
330
|
+
const json = await res.json();
|
|
331
|
+
const list = Array.isArray(json.models) ? json.models : Array.isArray(json.data) ? json.data : [];
|
|
332
|
+
return list.map((m) => {
|
|
333
|
+
const spec = maasModelSpecFor(m.id);
|
|
334
|
+
return {
|
|
335
|
+
id: m.id,
|
|
336
|
+
name: m.name ?? m.id,
|
|
337
|
+
contextWindow: spec.contextWindow,
|
|
338
|
+
maxTokens: spec.maxTokens,
|
|
339
|
+
};
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// 旧通道令牌自动刷新:仅在存在 legacy refresh token(CMTOKEN_REFRESH_TOKEN)时生效,
|
|
344
|
+
// 新通道(算力豆)的 lx_sk_ 长期有效、从不写该凭据,因此定时器对新通道天然空转。
|
|
345
|
+
const AUTO_REFRESH_INTERVAL_MS = 60_000;
|
|
346
|
+
const AUTO_REFRESH_THRESHOLD_MS = 60 * 60_000;
|
|
347
|
+
const REFRESH_COOLDOWN_MS = 60_000;
|
|
348
|
+
let lastRefreshAttempt = 0;
|
|
349
|
+
|
|
350
|
+
async function legacyAutoRefresh(ctx) {
|
|
351
|
+
const creds = ctx.credentials;
|
|
352
|
+
if (!creds) return;
|
|
353
|
+
const refRes = await creds.resolve(credentialRef(REFRESH_ENV)).catch(() => undefined);
|
|
354
|
+
if (!refRes) return; // 没有 legacy refresh token → 不处理
|
|
355
|
+
|
|
356
|
+
const now = Date.now();
|
|
357
|
+
if (now - lastRefreshAttempt < REFRESH_COOLDOWN_MS) return;
|
|
358
|
+
|
|
359
|
+
const expRes = await creds.resolve(credentialRef(EXPIRES_ENV)).catch(() => undefined);
|
|
360
|
+
const expires = expRes ? Number(expRes.value) : NaN;
|
|
361
|
+
if (!Number.isFinite(expires) || expires <= 0) return;
|
|
362
|
+
const remaining = expires - now;
|
|
363
|
+
if (remaining > AUTO_REFRESH_THRESHOLD_MS) return;
|
|
364
|
+
if (remaining < -7 * 24 * 3600_000) return;
|
|
365
|
+
|
|
366
|
+
try {
|
|
367
|
+
const token = await refreshToken(refRes.value);
|
|
368
|
+
await creds.set(credentialRef(API_KEY_ENV), token.access);
|
|
369
|
+
await creds.set(credentialRef(REFRESH_ENV), token.refresh);
|
|
370
|
+
await creds.set(credentialRef(EXPIRES_ENV), String(token.expires));
|
|
371
|
+
lastRefreshAttempt = 0;
|
|
372
|
+
ctx.logger?.info?.('[cmtoken-oauth] 旧通道令牌已自动刷新,新过期时间: ' + new Date(token.expires).toISOString());
|
|
373
|
+
} catch (e) {
|
|
374
|
+
lastRefreshAttempt = Date.now();
|
|
375
|
+
ctx.logger?.warn?.('[cmtoken-oauth] 旧通道自动刷新失败(冷却 1 分钟): ' + String(e?.message || e));
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function startLegacyAutoRefresh(ctx) {
|
|
380
|
+
const timer = setInterval(() => {
|
|
381
|
+
void legacyAutoRefresh(ctx).catch((e) => ctx.logger?.warn?.('[cmtoken-oauth] 旧通道自动刷新异常: ' + String(e?.message || e)));
|
|
382
|
+
}, AUTO_REFRESH_INTERVAL_MS);
|
|
383
|
+
timer.unref?.();
|
|
384
|
+
return () => clearInterval(timer);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** 当前选择的认证通道:komect(中国移动算力豆,默认)| maas(广东移动 MaaS 旧通道)。 */
|
|
388
|
+
async function currentChannel() {
|
|
389
|
+
const cfg = await readPluginConfig();
|
|
390
|
+
return cfg.channel === 'maas' ? 'maas' : 'komect';
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
async function writeLegacyConfigAndCreds(ctx, token, models) {
|
|
394
|
+
await ctx.credentials.set(credentialRef(API_KEY_ENV), token.access);
|
|
395
|
+
await ctx.credentials.set(credentialRef(REFRESH_ENV), token.refresh);
|
|
396
|
+
await ctx.credentials.set(credentialRef(EXPIRES_ENV), String(token.expires));
|
|
397
|
+
const section = {
|
|
398
|
+
providers: {
|
|
399
|
+
cmtoken: {
|
|
400
|
+
displayName: 'cmtoken (广东移动 MaaS)',
|
|
401
|
+
apiKeyEnv: API_KEY_ENV,
|
|
402
|
+
api: 'openai-completions',
|
|
403
|
+
baseURL: token.resourceUrl || MAAS_API_BASE,
|
|
404
|
+
models: models.map((m) => ({
|
|
405
|
+
id: m.id,
|
|
406
|
+
name: m.name,
|
|
407
|
+
contextWindow: m.contextWindow,
|
|
408
|
+
maxTokens: m.maxTokens,
|
|
409
|
+
input: m.input ?? modelInputModalities(m.id),
|
|
410
|
+
})),
|
|
411
|
+
},
|
|
412
|
+
},
|
|
413
|
+
};
|
|
414
|
+
if (ctx.settings) {
|
|
415
|
+
await ctx.settings.update(settingsNamespace('llm-pi-ai'), section);
|
|
416
|
+
} else {
|
|
417
|
+
throw new Error('settings 服务不可用,无法写入 provider 配置');
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
194
421
|
// ---------------- 视觉(图片)能力判定与回退优先级 ----------------
|
|
195
422
|
// 视觉模型优先级(值越小越优先,0 表示不支持图片):
|
|
196
423
|
// 1) Qwen3.8 27B(qwen38-27b 等)—— 最优先
|
|
@@ -302,8 +529,37 @@ function registerRpc(ctx) {
|
|
|
302
529
|
const payload = (rawPayload && typeof rawPayload === 'object') ? rawPayload : {};
|
|
303
530
|
|
|
304
531
|
switch (endpoint) {
|
|
305
|
-
// ----
|
|
532
|
+
// ---- 选择认证通道(komect=中国移动算力豆[默认] | maas=广东移动 MaaS 旧通道) ----
|
|
533
|
+
case 'set-channel': {
|
|
534
|
+
const ch = payload.channel === 'maas' ? 'maas' : 'komect';
|
|
535
|
+
await writePluginConfig({ channel: ch });
|
|
536
|
+
return { ok: true, value: { channel: ch } };
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// ---- 开始认证:按通道获取二维码 ----
|
|
306
540
|
case 'begin': {
|
|
541
|
+
const ch = await currentChannel();
|
|
542
|
+
if (ch === 'maas') {
|
|
543
|
+
// 旧通道:OAuth Device Grant 设备码
|
|
544
|
+
const dc = await requestDeviceCode();
|
|
545
|
+
const url = dc.verification_uri_complete ?? dc.verification_uri;
|
|
546
|
+
const id = randomUUID();
|
|
547
|
+
legacyPending.set(id, { device_code: dc.device_code, verifier: dc.verifier, expires_at: Date.now() + (dc.expires_in ?? 600) * 1000 });
|
|
548
|
+
return {
|
|
549
|
+
ok: true,
|
|
550
|
+
value: {
|
|
551
|
+
requestId: id,
|
|
552
|
+
channel: 'maas',
|
|
553
|
+
qr_svg: qrSvg(url),
|
|
554
|
+
verification_uri: url,
|
|
555
|
+
verification_uri_complete: dc.verification_uri_complete,
|
|
556
|
+
user_code: dc.user_code,
|
|
557
|
+
interval: dc.interval ?? 5,
|
|
558
|
+
expires_in: dc.expires_in ?? 600,
|
|
559
|
+
},
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
// 新通道:order-center 扫码(一次扫码同时获得用量查询 + 密钥管理)
|
|
307
563
|
const res = await fetch(`${ORDER_BASE}/sso/qrcode?business=workbuddy`, {
|
|
308
564
|
signal: AbortSignal.timeout(20_000),
|
|
309
565
|
headers: { Accept: 'application/json' },
|
|
@@ -319,6 +575,7 @@ function registerRpc(ctx) {
|
|
|
319
575
|
ok: true,
|
|
320
576
|
value: {
|
|
321
577
|
requestId: id,
|
|
578
|
+
channel: 'komect',
|
|
322
579
|
qr_svg: qrSvg(dc.qr_code_url),
|
|
323
580
|
interval: Math.max(1, Math.round((dc.poll_interval_ms ?? 1500) / 1000)),
|
|
324
581
|
expires_in: dc.expire ?? Math.round(ORDER_QR_TTL_MS / 1000),
|
|
@@ -326,66 +583,14 @@ function registerRpc(ctx) {
|
|
|
326
583
|
};
|
|
327
584
|
}
|
|
328
585
|
|
|
329
|
-
// ----
|
|
586
|
+
// ---- 轮询扫码状态;成功即完成该通道的认证 + 配置写入 ----
|
|
330
587
|
case 'poll': {
|
|
331
|
-
const
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
if (Date.now() > rec.expires_at) {
|
|
335
|
-
pendingOrder.delete(rid);
|
|
336
|
-
return { ok: false, error: { code: 'timeout', message: '二维码已过期(未在有效期内完成扫码),请重新开始', details: {} } };
|
|
337
|
-
}
|
|
338
|
-
const st = await orderPost('/sso/qrcode/status', { uuid: rec.uuid });
|
|
339
|
-
const state = String(st?.state ?? '');
|
|
340
|
-
if (state === '0') return { ok: true, value: { status: 'pending' } };
|
|
341
|
-
if (state === '1') return { ok: true, value: { status: 'pending', scanned: true } };
|
|
342
|
-
if (state === '-1' || state === '-3') {
|
|
343
|
-
pendingOrder.delete(rid);
|
|
344
|
-
return { ok: false, error: { code: 'bad-request', message: '扫码认证失败(' + (st?.state_desc ?? state) + '),请重新扫码', details: {} } };
|
|
345
|
-
}
|
|
346
|
-
if (state !== '2' || !st?.order_session) {
|
|
347
|
-
pendingOrder.delete(rid);
|
|
348
|
-
return { ok: false, error: { code: 'bad-request', message: '扫码认证未完成,请刷新二维码重试', details: {} } };
|
|
349
|
-
}
|
|
350
|
-
// 扫码成功:order_session → 桥接管理会话 → DSH 密钥 → 模型 → 写配置
|
|
351
|
-
pendingOrder.delete(rid);
|
|
352
|
-
const orderSession = st.order_session;
|
|
353
|
-
await ctx.credentials.set(credentialRef(ORDER_SESSION_ENV), orderSession);
|
|
354
|
-
|
|
355
|
-
// 用量(失败不阻断主流程)
|
|
356
|
-
let usage = null;
|
|
357
|
-
try {
|
|
358
|
-
usage = await fetchUsageSummary(orderSession);
|
|
359
|
-
} catch (e) {
|
|
360
|
-
ctx.logger?.warn?.('[cmtoken-oauth] 算力豆用量获取失败: ' + String(e?.message || e));
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
// 桥接:order_session → api-key/link → sso_token → sso-login → management_session
|
|
364
|
-
const { session, expires } = await bridgeManagementSession(orderSession);
|
|
365
|
-
const apiKey = await ensureDshApiKey(session);
|
|
366
|
-
let models = [];
|
|
367
|
-
try {
|
|
368
|
-
models = await fetchKomectModels(session);
|
|
369
|
-
} catch (e) {
|
|
370
|
-
// 模型发现失败:密钥照存,配置给最小模型集,报错提示
|
|
371
|
-
ctx.logger?.warn?.('[cmtoken-oauth] 算力豆模型发现失败: ' + String(e.message || e));
|
|
372
|
-
models = [{ id: 'deepseekv4-flash-guangdong', name: 'DeepSeek V4 Flash', contextWindow: 512_000, maxTokens: 262_144, input: ['text'] }];
|
|
373
|
-
}
|
|
374
|
-
await writeConfigAndCreds(ctx, { apiKey, session, expires }, models);
|
|
375
|
-
return {
|
|
376
|
-
ok: true,
|
|
377
|
-
value: {
|
|
378
|
-
status: 'ok',
|
|
379
|
-
models: models.map((m) => ({ ...m, input: m.input ?? modelInputModalities(m.id) })),
|
|
380
|
-
baseURL: GW_BASE,
|
|
381
|
-
keyName: DSH_KEY_NAME,
|
|
382
|
-
usage,
|
|
383
|
-
maskedPhone: st.phone ?? null,
|
|
384
|
-
},
|
|
385
|
-
};
|
|
588
|
+
const ch = await currentChannel();
|
|
589
|
+
if (ch === 'maas') return await pollMaaS(payload);
|
|
590
|
+
return await pollKomect(ctx, payload);
|
|
386
591
|
}
|
|
387
592
|
|
|
388
|
-
// ----
|
|
593
|
+
// ---- 认证状态(分通道;含算力豆用量) ----
|
|
389
594
|
case 'status': {
|
|
390
595
|
const creds = ctx.credentials;
|
|
391
596
|
const apiInfo = await creds?.describe(credentialRef(API_KEY_ENV)).catch(() => undefined);
|
|
@@ -393,7 +598,28 @@ function registerRpc(ctx) {
|
|
|
393
598
|
const orderInfo = await creds?.describe(credentialRef(ORDER_SESSION_ENV)).catch(() => undefined);
|
|
394
599
|
const expRaw = await creds?.resolve(credentialRef(EXPIRES_ENV)).catch(() => undefined);
|
|
395
600
|
const expires = expRaw ? Number(expRaw.value) : NaN;
|
|
396
|
-
|
|
601
|
+
const cfg = await readPluginConfig();
|
|
602
|
+
const channel = cfg.channel === 'maas' ? 'maas' : 'komect';
|
|
603
|
+
const activeChannel = cfg.activeChannel === 'maas' ? 'maas' : (cfg.activeChannel === 'komect' ? 'komect' : channel);
|
|
604
|
+
const candidates = visionModelCandidates(ctx);
|
|
605
|
+
|
|
606
|
+
// 旧通道(当前激活):返回令牌过期信息
|
|
607
|
+
if (activeChannel === 'maas') {
|
|
608
|
+
return {
|
|
609
|
+
ok: true,
|
|
610
|
+
value: {
|
|
611
|
+
configured: !!(apiInfo?.configured),
|
|
612
|
+
channel,
|
|
613
|
+
activeChannel,
|
|
614
|
+
expiresAt: Number.isFinite(expires) && expires > 0 ? expires : null,
|
|
615
|
+
expired: Number.isFinite(expires) && expires > 0 ? Date.now() > expires : null,
|
|
616
|
+
visionModels: candidates,
|
|
617
|
+
visionModel: candidates.some((c) => c.id === cfg.visionModel) ? cfg.visionModel : null,
|
|
618
|
+
},
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// 新通道:用量 + 管理会话信息
|
|
397
623
|
let usage = null;
|
|
398
624
|
let usageExpired = false;
|
|
399
625
|
if (orderInfo?.configured) {
|
|
@@ -406,12 +632,12 @@ function registerRpc(ctx) {
|
|
|
406
632
|
}
|
|
407
633
|
}
|
|
408
634
|
}
|
|
409
|
-
const candidates = visionModelCandidates(ctx);
|
|
410
|
-
const cfg = await readPluginConfig();
|
|
411
635
|
return {
|
|
412
636
|
ok: true,
|
|
413
637
|
value: {
|
|
414
638
|
configured: !!(apiInfo?.configured),
|
|
639
|
+
channel,
|
|
640
|
+
activeChannel,
|
|
415
641
|
hasSession: !!(sessInfo?.configured),
|
|
416
642
|
usageLinked: !!(orderInfo?.configured),
|
|
417
643
|
usage,
|
|
@@ -443,8 +669,20 @@ function registerRpc(ctx) {
|
|
|
443
669
|
return { ok: true, value: { visionModel: hit.id } };
|
|
444
670
|
}
|
|
445
671
|
|
|
446
|
-
// ----
|
|
672
|
+
// ---- 刷新:按当前激活通道处理 ----
|
|
447
673
|
case 'refresh': {
|
|
674
|
+
const ch = await (async () => { const c = await readPluginConfig(); return c.activeChannel === 'maas' ? 'maas' : (c.activeChannel === 'komect' ? 'komect' : await currentChannel()); })();
|
|
675
|
+
if (ch === 'maas') {
|
|
676
|
+
// 旧通道:刷新令牌
|
|
677
|
+
const ref = await ctx.credentials?.resolve(credentialRef(REFRESH_ENV));
|
|
678
|
+
if (!ref) return { ok: false, error: { code: 'bad-request', message: '没有可用的刷新令牌', details: {} } };
|
|
679
|
+
const token = await refreshToken(ref.value);
|
|
680
|
+
await ctx.credentials.set(credentialRef(API_KEY_ENV), token.access);
|
|
681
|
+
await ctx.credentials.set(credentialRef(REFRESH_ENV), token.refresh);
|
|
682
|
+
await ctx.credentials.set(credentialRef(EXPIRES_ENV), String(token.expires));
|
|
683
|
+
return { ok: true, value: { expiresAt: token.expires } };
|
|
684
|
+
}
|
|
685
|
+
// 新通道:order_session 有效则重新桥接 + 重新发现模型
|
|
448
686
|
const osRes = await ctx.credentials?.resolve(credentialRef(ORDER_SESSION_ENV));
|
|
449
687
|
if (!osRes) return { ok: false, error: { code: 'bad-request', message: '没有可用的扫码会话,请重新扫码认证(API key 本身不受影响)', details: {} } };
|
|
450
688
|
let usage = null;
|
|
@@ -475,6 +713,100 @@ function registerRpc(ctx) {
|
|
|
475
713
|
});
|
|
476
714
|
}
|
|
477
715
|
|
|
716
|
+
/** 新通道轮询:order-center 扫码 → 桥接 → DSH 密钥 → 模型 → 写配置。 */
|
|
717
|
+
async function pollKomect(ctx, payload) {
|
|
718
|
+
const rid = payload.requestId;
|
|
719
|
+
const rec = pendingOrder.get(rid);
|
|
720
|
+
if (!rec) return { ok: false, error: { code: 'bad-request', message: '认证流程已失效,请重新开始', details: {} } };
|
|
721
|
+
if (Date.now() > rec.expires_at) {
|
|
722
|
+
pendingOrder.delete(rid);
|
|
723
|
+
return { ok: false, error: { code: 'timeout', message: '二维码已过期(未在有效期内完成扫码),请重新开始', details: {} } };
|
|
724
|
+
}
|
|
725
|
+
const st = await orderPost('/sso/qrcode/status', { uuid: rec.uuid });
|
|
726
|
+
const state = String(st?.state ?? '');
|
|
727
|
+
if (state === '0') return { ok: true, value: { status: 'pending' } };
|
|
728
|
+
if (state === '1') return { ok: true, value: { status: 'pending', scanned: true } };
|
|
729
|
+
if (state === '-1' || state === '-3') {
|
|
730
|
+
pendingOrder.delete(rid);
|
|
731
|
+
return { ok: false, error: { code: 'bad-request', message: '扫码认证失败(' + (st?.state_desc ?? state) + '),请重新扫码', details: {} } };
|
|
732
|
+
}
|
|
733
|
+
if (state !== '2' || !st?.order_session) {
|
|
734
|
+
pendingOrder.delete(rid);
|
|
735
|
+
return { ok: false, error: { code: 'bad-request', message: '扫码认证未完成,请刷新二维码重试', details: {} } };
|
|
736
|
+
}
|
|
737
|
+
// 扫码成功:order_session → 桥接管理会话 → DSH 密钥 → 模型 → 写配置
|
|
738
|
+
pendingOrder.delete(rid);
|
|
739
|
+
const orderSession = st.order_session;
|
|
740
|
+
await ctx.credentials.set(credentialRef(ORDER_SESSION_ENV), orderSession);
|
|
741
|
+
await writePluginConfig({ activeChannel: 'komect' });
|
|
742
|
+
|
|
743
|
+
// 用量(失败不阻断主流程)
|
|
744
|
+
let usage = null;
|
|
745
|
+
try {
|
|
746
|
+
usage = await fetchUsageSummary(orderSession);
|
|
747
|
+
} catch (e) {
|
|
748
|
+
ctx.logger?.warn?.('[cmtoken-oauth] 算力豆用量获取失败: ' + String(e?.message || e));
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
// 桥接:order_session → api-key/link → sso_token → sso-login → management_session
|
|
752
|
+
const { session, expires } = await bridgeManagementSession(orderSession);
|
|
753
|
+
const apiKey = await ensureDshApiKey(session);
|
|
754
|
+
let models = [];
|
|
755
|
+
try {
|
|
756
|
+
models = await fetchKomectModels(session);
|
|
757
|
+
} catch (e) {
|
|
758
|
+
// 模型发现失败:密钥照存,配置给最小模型集,报错提示
|
|
759
|
+
ctx.logger?.warn?.('[cmtoken-oauth] 算力豆模型发现失败: ' + String(e.message || e));
|
|
760
|
+
models = [{ id: 'deepseekv4-flash-guangdong', name: 'DeepSeek V4 Flash', contextWindow: 512_000, maxTokens: 262_144, input: ['text'] }];
|
|
761
|
+
}
|
|
762
|
+
await writeConfigAndCreds(ctx, { apiKey, session, expires }, models);
|
|
763
|
+
return {
|
|
764
|
+
ok: true,
|
|
765
|
+
value: {
|
|
766
|
+
status: 'ok',
|
|
767
|
+
models: models.map((m) => ({ ...m, input: m.input ?? modelInputModalities(m.id) })),
|
|
768
|
+
baseURL: GW_BASE,
|
|
769
|
+
keyName: DSH_KEY_NAME,
|
|
770
|
+
usage,
|
|
771
|
+
maskedPhone: st.phone ?? null,
|
|
772
|
+
},
|
|
773
|
+
};
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
/** 旧通道轮询:OAuth Device Grant → 令牌 → 模型发现 → 写配置。 */
|
|
777
|
+
async function pollMaaS(ctx, payload) {
|
|
778
|
+
const rid = payload.requestId;
|
|
779
|
+
const rec = legacyPending.get(rid);
|
|
780
|
+
if (!rec) return { ok: false, error: { code: 'bad-request', message: '认证流程已失效,请重新开始', details: {} } };
|
|
781
|
+
if (Date.now() > rec.expires_at) {
|
|
782
|
+
legacyPending.delete(rid);
|
|
783
|
+
return { ok: false, error: { code: 'timeout', message: '授权超时(未在有效期内完成扫码),请重新开始', details: {} } };
|
|
784
|
+
}
|
|
785
|
+
const r = await pollOnce(rec.device_code, rec.verifier);
|
|
786
|
+
if (r.status === 'pending') return { ok: true, value: { status: 'pending' } };
|
|
787
|
+
if (r.status === 'slow_down') return { ok: true, value: { status: 'pending', slowDown: true } };
|
|
788
|
+
legacyPending.delete(rid);
|
|
789
|
+
const token = r.token;
|
|
790
|
+
await writePluginConfig({ activeChannel: 'maas' });
|
|
791
|
+
let models = [];
|
|
792
|
+
try {
|
|
793
|
+
models = await fetchMaasModels(token.access, payload.discoveryUrl);
|
|
794
|
+
} catch (e) {
|
|
795
|
+
ctx.logger?.warn?.('[cmtoken-oauth] 旧通道模型发现失败: ' + String(e.message || e));
|
|
796
|
+
models = [{ id: 'deepseekv4-flash', name: 'deepseekv4-flash', contextWindow: 524_288, maxTokens: 65_536, input: ['text'] }];
|
|
797
|
+
}
|
|
798
|
+
await writeLegacyConfigAndCreds(ctx, token, models);
|
|
799
|
+
return {
|
|
800
|
+
ok: true,
|
|
801
|
+
value: {
|
|
802
|
+
status: 'ok',
|
|
803
|
+
models: models.map((m) => ({ ...m, input: m.input ?? modelInputModalities(m.id) })),
|
|
804
|
+
baseURL: token.resourceUrl || MAAS_API_BASE,
|
|
805
|
+
expiresAt: token.expires,
|
|
806
|
+
},
|
|
807
|
+
};
|
|
808
|
+
}
|
|
809
|
+
|
|
478
810
|
async function writeConfigAndCreds(ctx, { apiKey, session, expires }, models) {
|
|
479
811
|
// 1) 凭据(OS keyring / 凭据服务,apiKeyEnv 引用)
|
|
480
812
|
// API key(lx_sk_)长期有效;session 为管理会话(模型发现用),过期仅需重新扫码
|
|
@@ -1152,6 +1484,7 @@ function registerVisionAdapter(ctx) {
|
|
|
1152
1484
|
|
|
1153
1485
|
export function apply(ctx, cfg = {}) {
|
|
1154
1486
|
let removeRpc = () => {};
|
|
1487
|
+
let stopTimer = () => {};
|
|
1155
1488
|
let disposeVisionAdapter = () => {};
|
|
1156
1489
|
// 在声明了 webServer 的子上下文里注册 RPC 前缀路由(DSH 0.1.5+)。
|
|
1157
1490
|
const rpcScope = ctx.inject(['webServer'], (scope) => {
|
|
@@ -1159,6 +1492,9 @@ export function apply(ctx, cfg = {}) {
|
|
|
1159
1492
|
});
|
|
1160
1493
|
void rpcScope;
|
|
1161
1494
|
ctx.effect(() => {
|
|
1495
|
+
// 旧通道令牌自动刷新定时器:仅当存在 legacy refresh token 时才有实际动作,
|
|
1496
|
+
// 新通道(算力豆)lx_sk_ 长期有效、从不写该凭据,定时器空转。
|
|
1497
|
+
stopTimer = startLegacyAutoRefresh(ctx);
|
|
1162
1498
|
registerVisionTool(ctx);
|
|
1163
1499
|
registerPasteRoute(ctx);
|
|
1164
1500
|
registerPasteInjection(ctx);
|
|
@@ -1167,10 +1503,11 @@ export function apply(ctx, cfg = {}) {
|
|
|
1167
1503
|
if (cfg.visionRoute === true) disposeVisionAdapter = registerVisionAdapter(ctx);
|
|
1168
1504
|
return () => {
|
|
1169
1505
|
removeRpc();
|
|
1506
|
+
stopTimer();
|
|
1170
1507
|
disposeVisionAdapter();
|
|
1171
1508
|
};
|
|
1172
1509
|
}, 'cmtoken-oauth: host service');
|
|
1173
|
-
ctx.logger?.info?.('[cmtoken-oauth] host
|
|
1510
|
+
ctx.logger?.info?.('[cmtoken-oauth] host 服务已启动(双通道:中国移动算力豆[默认] / 广东移动 MaaS 旧通道;旧通道自动续期仅在旧凭据存在时生效)');
|
|
1174
1511
|
}
|
|
1175
1512
|
|
|
1176
1513
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-cmtoken-oauth",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.0",
|
|
4
4
|
"description": "DeepSeek Harness 插件:在 DSH 设置页一键完成中国移动算力豆平台(komect)扫码认证——一次扫码自动桥接密钥管理会话、创建专属 DSH API key(lx_sk_,长期有效)、发现模型(上下文/输出/图片支持由平台动态提供)并写入 llm-pi-ai provider 配置,同时展示算力豆用量余额;粘贴识图让纯文本模型直接收图,另有 cmtoken_read_image 工具与可选的 cmtoken-vision 包装路由。零第三方运行时依赖。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./lib/index.js",
|