dsh-cmtoken-oauth 1.6.0 → 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.
Files changed (3) hide show
  1. package/lib/client.js +38 -12
  2. package/lib/index.js +432 -81
  3. 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 { setStatus(unwrap(await rpc.call(CHANNEL, "status", {}))); }
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" }, "中国移动算力豆(komect)一键认证:扫码即自动创建专属 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" }, "中国移动算力豆(komect)一键认证:扫码即自动创建专属 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-hint" }, "扫码登录中国移动账号,一次扫码完成认证与用量关联,约 " + Math.round(flow.expires_in || 180) + " 秒内有效")
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" }, "中国移动算力豆(komect)一键认证:扫码即自动创建专属 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" }, "中国移动算力豆(komect)一键认证:扫码即自动创建专属 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" }, "中国移动算力豆(komect)一键认证:扫码即自动创建专属 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.sessionExpired ? react.createElement("span", { className: "co-hint" }, "(管理会话已过期,重新扫码可刷新模型列表)") : null),
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" }, "中国移动算力豆(komect)一键认证:扫码即自动创建专属 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
@@ -22,7 +22,7 @@ export const inject = ['connection', 'settings', 'credentials', 'tools', 'llm',
22
22
  export const Config = z.object({});
23
23
 
24
24
  // ---------------- 协议常量 ----------------
25
- // 旧通道(广东移动 MaaS OAuth Device Grant)已由 komect API-key 通道取代(见下)。
25
+ // 旧通道(广东移动 MaaS OAuth Device Grant)已由 中国移动算力豆 API-key 通道取代(见下)。
26
26
 
27
27
  const API_KEY_ENV = 'CMTOKEN_API_KEY';
28
28
  const REFRESH_ENV = 'CMTOKEN_REFRESH_TOKEN';
@@ -114,10 +114,10 @@ async function komect(path, { method = 'GET', token, body, timeout = 20_000 } =
114
114
  });
115
115
  const json = await res.json().catch(() => undefined);
116
116
  if (res.status === 401 || json?.code === 3004) {
117
- throw new Error('komect 管理会话已失效,请重新扫码认证');
117
+ throw new Error('中国移动算力豆管理会话已失效,请重新扫码认证');
118
118
  }
119
119
  if (!res.ok || json?.code !== 0) {
120
- throw new Error(`komect ${path} 失败 ${res.status}: ${String(json?.message ?? '').slice(0, 200) || 'unknown'}`);
120
+ throw new Error(`算力豆 ${path} 失败 ${res.status}: ${String(json?.message ?? '').slice(0, 200) || 'unknown'}`);
121
121
  }
122
122
  return json.data;
123
123
  }
@@ -143,7 +143,7 @@ async function ensureDshApiKey(session) {
143
143
  const revealed = await komect(`/api-keys/${encodeURIComponent(item.id)}/reveal`, { method: 'POST', token: session });
144
144
  const apiKey = revealed?.api_key;
145
145
  if (typeof apiKey !== 'string' || !apiKey.startsWith('lx_sk_')) {
146
- throw new Error('komect 密钥 reveal 失败:未返回有效的 lx_sk_ 密钥');
146
+ throw new Error('算力豆密钥 reveal 失败:未返回有效的 lx_sk_ 密钥');
147
147
  }
148
148
  return apiKey;
149
149
  }
@@ -156,20 +156,34 @@ async function fetchKomectModels(session) {
156
156
  headers: { Accept: 'application/json', Authorization: `Bearer ${session}` },
157
157
  });
158
158
  const json = await res.json().catch(() => undefined);
159
- if (res.status === 401) throw new Error('komect 管理会话已失效,请重新扫码认证');
159
+ if (res.status === 401) throw new Error('中国移动算力豆管理会话已失效,请重新扫码认证');
160
160
  if (!res.ok || json?.code !== 0 || !Array.isArray(json?.data)) {
161
- throw new Error(`komect 模型发现失败 ${res.status}: ${String(json?.message ?? '').slice(0, 200) || 'unknown'}`);
161
+ throw new Error(`算力豆模型发现失败 ${res.status}: ${String(json?.message ?? '').slice(0, 200) || 'unknown'}`);
162
162
  }
163
163
  return json.data
164
164
  .filter((m) => typeof m?.modelName === 'string' && m?.modelType === 'llm')
165
- .map((m) => ({
166
- id: m.modelName,
167
- name: m.name ?? m.modelName,
168
- contextWindow: m.extInfo?.limits?.max_input_tokens ?? DEFAULT_MODEL_SPEC.contextWindow,
169
- maxTokens: m.extInfo?.limits?.max_output_tokens ?? DEFAULT_MODEL_SPEC.maxTokens,
170
- // 图片准入以服务端 supports_images 声明为准
171
- input: m.extInfo?.supports_images === true ? ['text', 'image'] : ['text'],
172
- }));
165
+ .map((m) => {
166
+ // 限时免费模型(名称/描述带「限时免费」)服务端对 max_tokens 的实际上限低于声明值,
167
+ // 超限直接网关 500。按 2026-09-18 实测的各模型最大可用值精确收口:
168
+ // flash:262144 ✓ / 512000 ✗;Qwen3.6:196608 / 262144 ✗;qwen38:256000 ✓
169
+ // 未列入映射的免费标记模型保守收口 131072;付费模型按平台声明值。
170
+ const free = /限时免费|限量免费/.test(`${m.name ?? ''}${m.desc ?? ''}`);
171
+ const maxOut = m.extInfo?.limits?.max_output_tokens ?? DEFAULT_MODEL_SPEC.maxTokens;
172
+ const FREE_MODEL_MAX_TOKENS = {
173
+ 'deepseekv4-flash-guangdong': 262_144,
174
+ 'qwen3.6-35b-guangdong': 196_608,
175
+ 'qwen38-27b-guangdong': 256_000,
176
+ };
177
+ const freeCap = FREE_MODEL_MAX_TOKENS[String(m.modelName).toLowerCase()] ?? 131_072;
178
+ return {
179
+ id: m.modelName,
180
+ name: m.name ?? m.modelName,
181
+ contextWindow: m.extInfo?.limits?.max_input_tokens ?? DEFAULT_MODEL_SPEC.contextWindow,
182
+ maxTokens: free ? Math.min(maxOut, freeCap) : maxOut,
183
+ // 图片准入以服务端 supports_images 声明为准
184
+ input: m.extInfo?.supports_images === true ? ['text', 'image'] : ['text'],
185
+ };
186
+ });
173
187
  }
174
188
 
175
189
 
@@ -177,6 +191,233 @@ async function fetchKomectModels(session) {
177
191
  // 仅在发现接口失败时兜底使用。
178
192
  const DEFAULT_MODEL_SPEC = { contextWindow: 262_144, maxTokens: 65_536 };
179
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
+
180
421
  // ---------------- 视觉(图片)能力判定与回退优先级 ----------------
181
422
  // 视觉模型优先级(值越小越优先,0 表示不支持图片):
182
423
  // 1) Qwen3.8 27B(qwen38-27b 等)—— 最优先
@@ -288,8 +529,37 @@ function registerRpc(ctx) {
288
529
  const payload = (rawPayload && typeof rawPayload === 'object') ? rawPayload : {};
289
530
 
290
531
  switch (endpoint) {
291
- // ---- 开始认证:order-center 扫码(一次扫码同时获得用量查询 + 密钥管理) ----
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
+ // ---- 开始认证:按通道获取二维码 ----
292
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 扫码(一次扫码同时获得用量查询 + 密钥管理)
293
563
  const res = await fetch(`${ORDER_BASE}/sso/qrcode?business=workbuddy`, {
294
564
  signal: AbortSignal.timeout(20_000),
295
565
  headers: { Accept: 'application/json' },
@@ -305,6 +575,7 @@ function registerRpc(ctx) {
305
575
  ok: true,
306
576
  value: {
307
577
  requestId: id,
578
+ channel: 'komect',
308
579
  qr_svg: qrSvg(dc.qr_code_url),
309
580
  interval: Math.max(1, Math.round((dc.poll_interval_ms ?? 1500) / 1000)),
310
581
  expires_in: dc.expire ?? Math.round(ORDER_QR_TTL_MS / 1000),
@@ -312,66 +583,14 @@ function registerRpc(ctx) {
312
583
  };
313
584
  }
314
585
 
315
- // ---- 轮询扫码状态;成功即:桥接密钥管理会话 取/建 DSH 密钥 → 发现模型 → 写配置 ----
586
+ // ---- 轮询扫码状态;成功即完成该通道的认证 + 配置写入 ----
316
587
  case 'poll': {
317
- const rid = payload.requestId;
318
- const rec = pendingOrder.get(rid);
319
- if (!rec) return { ok: false, error: { code: 'bad-request', message: '认证流程已失效,请重新开始', details: {} } };
320
- if (Date.now() > rec.expires_at) {
321
- pendingOrder.delete(rid);
322
- return { ok: false, error: { code: 'timeout', message: '二维码已过期(未在有效期内完成扫码),请重新开始', details: {} } };
323
- }
324
- const st = await orderPost('/sso/qrcode/status', { uuid: rec.uuid });
325
- const state = String(st?.state ?? '');
326
- if (state === '0') return { ok: true, value: { status: 'pending' } };
327
- if (state === '1') return { ok: true, value: { status: 'pending', scanned: true } };
328
- if (state === '-1' || state === '-3') {
329
- pendingOrder.delete(rid);
330
- return { ok: false, error: { code: 'bad-request', message: '扫码认证失败(' + (st?.state_desc ?? state) + '),请重新扫码', details: {} } };
331
- }
332
- if (state !== '2' || !st?.order_session) {
333
- pendingOrder.delete(rid);
334
- return { ok: false, error: { code: 'bad-request', message: '扫码认证未完成,请刷新二维码重试', details: {} } };
335
- }
336
- // 扫码成功:order_session → 桥接管理会话 → DSH 密钥 → 模型 → 写配置
337
- pendingOrder.delete(rid);
338
- const orderSession = st.order_session;
339
- await ctx.credentials.set(credentialRef(ORDER_SESSION_ENV), orderSession);
340
-
341
- // 用量(失败不阻断主流程)
342
- let usage = null;
343
- try {
344
- usage = await fetchUsageSummary(orderSession);
345
- } catch (e) {
346
- ctx.logger?.warn?.('[cmtoken-oauth] 算力豆用量获取失败: ' + String(e?.message || e));
347
- }
348
-
349
- // 桥接:order_session → api-key/link → sso_token → sso-login → management_session
350
- const { session, expires } = await bridgeManagementSession(orderSession);
351
- const apiKey = await ensureDshApiKey(session);
352
- let models = [];
353
- try {
354
- models = await fetchKomectModels(session);
355
- } catch (e) {
356
- // 模型发现失败:密钥照存,配置给最小模型集,报错提示
357
- ctx.logger?.warn?.('[cmtoken-oauth] komect 模型发现失败: ' + String(e.message || e));
358
- models = [{ id: 'deepseekv4-flash-guangdong', name: 'DeepSeek V4 Flash', contextWindow: 512_000, maxTokens: 512_000, input: ['text'] }];
359
- }
360
- await writeConfigAndCreds(ctx, { apiKey, session, expires }, models);
361
- return {
362
- ok: true,
363
- value: {
364
- status: 'ok',
365
- models: models.map((m) => ({ ...m, input: m.input ?? modelInputModalities(m.id) })),
366
- baseURL: GW_BASE,
367
- keyName: DSH_KEY_NAME,
368
- usage,
369
- maskedPhone: st.phone ?? null,
370
- },
371
- };
588
+ const ch = await currentChannel();
589
+ if (ch === 'maas') return await pollMaaS(payload);
590
+ return await pollKomect(ctx, payload);
372
591
  }
373
592
 
374
- // ---- 认证状态(含算力豆用量) ----
593
+ // ---- 认证状态(分通道;含算力豆用量) ----
375
594
  case 'status': {
376
595
  const creds = ctx.credentials;
377
596
  const apiInfo = await creds?.describe(credentialRef(API_KEY_ENV)).catch(() => undefined);
@@ -379,7 +598,28 @@ function registerRpc(ctx) {
379
598
  const orderInfo = await creds?.describe(credentialRef(ORDER_SESSION_ENV)).catch(() => undefined);
380
599
  const expRaw = await creds?.resolve(credentialRef(EXPIRES_ENV)).catch(() => undefined);
381
600
  const expires = expRaw ? Number(expRaw.value) : NaN;
382
- // 用量:order_session 有效时实时取,失败标记过期(不阻断状态查询)
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
+ // 新通道:用量 + 管理会话信息
383
623
  let usage = null;
384
624
  let usageExpired = false;
385
625
  if (orderInfo?.configured) {
@@ -392,12 +632,12 @@ function registerRpc(ctx) {
392
632
  }
393
633
  }
394
634
  }
395
- const candidates = visionModelCandidates(ctx);
396
- const cfg = await readPluginConfig();
397
635
  return {
398
636
  ok: true,
399
637
  value: {
400
638
  configured: !!(apiInfo?.configured),
639
+ channel,
640
+ activeChannel,
401
641
  hasSession: !!(sessInfo?.configured),
402
642
  usageLinked: !!(orderInfo?.configured),
403
643
  usage,
@@ -429,8 +669,20 @@ function registerRpc(ctx) {
429
669
  return { ok: true, value: { visionModel: hit.id } };
430
670
  }
431
671
 
432
- // ---- 刷新:order_session 有效则重新桥接 + 重新发现模型 ----
672
+ // ---- 刷新:按当前激活通道处理 ----
433
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 有效则重新桥接 + 重新发现模型
434
686
  const osRes = await ctx.credentials?.resolve(credentialRef(ORDER_SESSION_ENV));
435
687
  if (!osRes) return { ok: false, error: { code: 'bad-request', message: '没有可用的扫码会话,请重新扫码认证(API key 本身不受影响)', details: {} } };
436
688
  let usage = null;
@@ -461,6 +713,100 @@ function registerRpc(ctx) {
461
713
  });
462
714
  }
463
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
+
464
810
  async function writeConfigAndCreds(ctx, { apiKey, session, expires }, models) {
465
811
  // 1) 凭据(OS keyring / 凭据服务,apiKeyEnv 引用)
466
812
  // API key(lx_sk_)长期有效;session 为管理会话(模型发现用),过期仅需重新扫码
@@ -472,7 +818,7 @@ async function writeConfigAndCreds(ctx, { apiKey, session, expires }, models) {
472
818
  const section = {
473
819
  providers: {
474
820
  cmtoken: {
475
- displayName: 'komect 算力豆',
821
+ displayName: '中国移动算力豆',
476
822
  apiKeyEnv: API_KEY_ENV,
477
823
  api: 'openai-completions',
478
824
  baseURL: GW_BASE,
@@ -656,7 +1002,7 @@ async function describeImage({ baseURL, apiKey, model, dataUri, focus, signal })
656
1002
  }
657
1003
  if (!res.ok) {
658
1004
  const text = (await res.text().catch(() => '')).slice(0, 300);
659
- if (res.status === 401) throw new Error('komect API key 无效,请在设置 → cmtoken 认证里重新认证');
1005
+ if (res.status === 401) throw new Error('算力豆 API key 无效,请在设置 → cmtoken 认证里重新认证');
660
1006
  if (res.status === 403 && /订阅套餐|订阅已过期|余额不足/.test(text)) throw new Error('订阅错误:' + text);
661
1007
  throw new Error(`视觉模型识图失败 ${res.status}: ${text}`);
662
1008
  }
@@ -695,7 +1041,7 @@ function registerVisionTool(ctx) {
695
1041
  if (!model) throw new Error('cmtoken 未配置视觉模型:请先在 DSH 设置 → cmtoken 认证完成一键认证');
696
1042
 
697
1043
  const key = await ctx.credentials?.resolve(credentialRef(API_KEY_ENV));
698
- if (!key) throw new Error('komect API key 不可用:请先在 DSH 设置 → cmtoken 认证完成一键认证');
1044
+ if (!key) throw new Error('算力豆 API key 不可用:请先在 DSH 设置 → cmtoken 认证完成一键认证');
699
1045
 
700
1046
  const bytes = await loadImageBytes(source, exec?.signal);
701
1047
  if (bytes.length === 0) throw new Error('图片内容为空');
@@ -796,7 +1142,7 @@ async function evidenceForBlock(ctx, block, signal, cache) {
796
1142
  const model = await resolveVisionModel(ctx);
797
1143
  if (!model) throw new Error('cmtoken 未配置视觉模型,请先完成一键认证');
798
1144
  const keyRes = await ctx.credentials?.resolve(credentialRef(API_KEY_ENV));
799
- if (!keyRes) throw new Error('komect API key 不可用,请重新认证');
1145
+ if (!keyRes) throw new Error('算力豆 API key 不可用,请重新认证');
800
1146
  const baseURL = ctx.settings?.get?.(settingsNamespace('llm-pi-ai'))?.providers?.cmtoken?.baseURL || GW_BASE;
801
1147
  const signals = [AbortSignal.timeout(VISION_TOOL_TIMEOUT_MS)];
802
1148
  if (signal) signals.push(signal);
@@ -1138,6 +1484,7 @@ function registerVisionAdapter(ctx) {
1138
1484
 
1139
1485
  export function apply(ctx, cfg = {}) {
1140
1486
  let removeRpc = () => {};
1487
+ let stopTimer = () => {};
1141
1488
  let disposeVisionAdapter = () => {};
1142
1489
  // 在声明了 webServer 的子上下文里注册 RPC 前缀路由(DSH 0.1.5+)。
1143
1490
  const rpcScope = ctx.inject(['webServer'], (scope) => {
@@ -1145,6 +1492,9 @@ export function apply(ctx, cfg = {}) {
1145
1492
  });
1146
1493
  void rpcScope;
1147
1494
  ctx.effect(() => {
1495
+ // 旧通道令牌自动刷新定时器:仅当存在 legacy refresh token 时才有实际动作,
1496
+ // 新通道(算力豆)lx_sk_ 长期有效、从不写该凭据,定时器空转。
1497
+ stopTimer = startLegacyAutoRefresh(ctx);
1148
1498
  registerVisionTool(ctx);
1149
1499
  registerPasteRoute(ctx);
1150
1500
  registerPasteInjection(ctx);
@@ -1153,10 +1503,11 @@ export function apply(ctx, cfg = {}) {
1153
1503
  if (cfg.visionRoute === true) disposeVisionAdapter = registerVisionAdapter(ctx);
1154
1504
  return () => {
1155
1505
  removeRpc();
1506
+ stopTimer();
1156
1507
  disposeVisionAdapter();
1157
1508
  };
1158
1509
  }, 'cmtoken-oauth: host service');
1159
- ctx.logger?.info?.('[cmtoken-oauth] host 服务已启动(komect API-key 通道:密钥长期有效,无需定时刷新)');
1510
+ ctx.logger?.info?.('[cmtoken-oauth] host 服务已启动(双通道:中国移动算力豆[默认] / 广东移动 MaaS 旧通道;旧通道自动续期仅在旧凭据存在时生效)');
1160
1511
  }
1161
1512
 
1162
1513
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-cmtoken-oauth",
3
- "version": "1.6.0",
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",