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.
- package/lib/client.js +38 -12
- package/lib/index.js +432 -81
- 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" }, "
|
|
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" }, "
|
|
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" }, "
|
|
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" }, "
|
|
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" }, "
|
|
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" }, "
|
|
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)已由
|
|
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('
|
|
117
|
+
throw new Error('中国移动算力豆管理会话已失效,请重新扫码认证');
|
|
118
118
|
}
|
|
119
119
|
if (!res.ok || json?.code !== 0) {
|
|
120
|
-
throw new Error(
|
|
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('
|
|
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('
|
|
159
|
+
if (res.status === 401) throw new Error('中国移动算力豆管理会话已失效,请重新扫码认证');
|
|
160
160
|
if (!res.ok || json?.code !== 0 || !Array.isArray(json?.data)) {
|
|
161
|
-
throw new Error(
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
// ----
|
|
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
|
-
// ----
|
|
586
|
+
// ---- 轮询扫码状态;成功即完成该通道的认证 + 配置写入 ----
|
|
316
587
|
case 'poll': {
|
|
317
|
-
const
|
|
318
|
-
|
|
319
|
-
|
|
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
|
-
|
|
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
|
-
// ----
|
|
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: '
|
|
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('
|
|
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('
|
|
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('
|
|
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
|
|
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.
|
|
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",
|