dsh-cmtoken-oauth 1.5.3 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -8
- package/lib/client.js +26 -15
- package/lib/index.js +238 -256
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,21 +1,24 @@
|
|
|
1
1
|
# dsh-cmtoken-oauth
|
|
2
2
|
|
|
3
|
-
DeepSeek Harness(DSH)插件:在 **DSH
|
|
3
|
+
DeepSeek Harness(DSH)插件:在 **DSH 设置页**内一键认证中国移动算力豆平台(komect,`ai.soho.komect.com`),扫码一次即完成认证、密钥管理与用量查询,自动发现并接入模型,无需手动填写 API Key 或模型列表。
|
|
4
|
+
|
|
5
|
+
> v1.6.0 起认证通道迁移:旧 cmtoken OAuth Device Grant(广东移动 MaaS)已由 **komect API key 通道**取代——扫码登录 order-center 后经单向桥接换取密钥管理会话,自动创建/复用名为 **DSH** 的 API key(`lx_sk_`,长期有效,无需刷新令牌),模型规格(上下文窗口 / 最大输出 / 图片支持)由平台接口动态提供。
|
|
4
6
|
|
|
5
7
|
## 功能
|
|
6
8
|
|
|
7
|
-
-
|
|
9
|
+
- **一键认证(一次扫码全搞定)**:扫码登录 order-center → 自动桥接密钥管理会话 → 自动创建/复用专属 **DSH** API key → 拉取模型列表写入 `llm-pi-ai` 的 `providers.cmtoken` 配置(merge,不影响其他 provider)。
|
|
10
|
+
- **算力豆用量**:设置页直接显示生效中套餐的剩余/总额算力豆与到期时间(来自 order-center 用量接口);会话失效时提示重新扫码。
|
|
8
11
|
- **内联二维码**:认证流程内置 QR 生成(零依赖),无需额外安装二维码工具。
|
|
9
|
-
-
|
|
10
|
-
-
|
|
12
|
+
- **自动发现模型 + 动态规格**:认证成功后拉取可用模型,上下文窗口 / 最大输出 / 图片支持均按平台返回值写入,不再依赖硬编码表。
|
|
13
|
+
- **原生视觉**:平台声明支持图片的模型(qwen38-27b / Qwen3.6-35B / Kimi K2.5/K2.6 等)写入配置时自动声明 `input: ["text","image"]` 模态,DSH 直接放行图片输入(经持久附件服务直达模型),粘贴/拖入图片即可用。
|
|
11
14
|
- **粘贴/拖图(普通模型直接收图)**:纯文本模型(如 deepseekv4-flash)贴图/拖图时,client 端拦截并把图片(自动压缩到 ~1080p:长边 ≤1920)存到 `~/.dsh/cmtoken-pastes/`——**输入框和会话气泡不出现路径文本**:输入框上方显示「cmtoken识图」胶囊缩略图条(与输入卡片左对齐,✕ 可撤回),发送时刻由 host 的 `agent/pre-step` 钩子把 `[Image: 路径]` 追加到最后一条用户消息(仅模型上下文可见)。识图延迟到模型按需调用 `cmtoken_read_image` 工具时完成;粘贴后 30 分钟内未随消息发出的图片自动放弃注入。视觉模型不接管,保留原生缩略图体验。
|
|
12
15
|
- **视觉回退模型可选**:设置 → cmtoken 认证页可指定识图引擎(默认「自动」按优先级 qwen38 > qwen3.x > kimi k2.x 非 code > minimax-m3),选择持久化在 `~/.dsh/cmtoken-oauth.json`,工具与判定路由均遵循。
|
|
13
16
|
- **视觉回退工具**:注册 `cmtoken_read_image` 工具——任何模型把图片路径(本地路径或 http(s) URL)交给工具,host 侧自动选用优先级最高的视觉模型识图,返回结构化取证(概述 / 全文转写 / 版面 / 语义 / 不确定项)。无需外部识图服务,凭据与配额都在 cmtoken 体系内。
|
|
14
17
|
- **可选包装路由(默认关闭)**:profile 插件 config 设 `visionRoute: true` 可注册 `cmtoken-vision` 分组——把纯文本模型镜像成声明图片输入的条目,贴图以真实图片气泡进入会话、请求时自动转证据文本。需要图片气泡原样展示的会话可开启。
|
|
15
|
-
-
|
|
16
|
-
- **安全存储**:
|
|
17
|
-
-
|
|
18
|
-
-
|
|
18
|
+
- **自动配置上下文**:上下文与最大输出按平台 `getApiKeyModels` 返回值动态写入,无需手动维护。
|
|
19
|
+
- **安全存储**:API key 与会话存入 DSH 凭据服务(OS keyring / env),不落盘明文。
|
|
20
|
+
- **注销**:可随时注销清空凭据(API key 在平台上保留,可再次扫码复用)。
|
|
21
|
+
- **算力豆套餐**:设置页内一键跳转算力豆用量/订购页。
|
|
19
22
|
|
|
20
23
|
零第三方运行时依赖(仅使用 Node 内置模块)。
|
|
21
24
|
|
package/lib/client.js
CHANGED
|
@@ -12,7 +12,7 @@ window.__ModuleLoader__.load({
|
|
|
12
12
|
|
|
13
13
|
// ==================== 工具 ====================
|
|
14
14
|
const CHANNEL = "/cmtoken-oauth";
|
|
15
|
-
const ORDER_URL = "https://
|
|
15
|
+
const ORDER_URL = "https://ai.soho.komect.com/ai/llm/order-center/public/pages/token-usage?business=workbuddy";
|
|
16
16
|
function unwrap(r) { if (r && r.ok) return r.value; throw new Error(r && r.error ? r.error.message : "未知错误"); }
|
|
17
17
|
|
|
18
18
|
// ==================== 样式 ====================
|
|
@@ -122,7 +122,7 @@ window.__ModuleLoader__.load({
|
|
|
122
122
|
catch (e) { setError(e.message || String(e)); }
|
|
123
123
|
};
|
|
124
124
|
|
|
125
|
-
const authenticated = !!(status && status.configured
|
|
125
|
+
const authenticated = !!(status && status.configured);
|
|
126
126
|
|
|
127
127
|
// 未认证时自动开始认证(设置页一打开就出二维码)
|
|
128
128
|
useEffect(() => {
|
|
@@ -137,17 +137,15 @@ window.__ModuleLoader__.load({
|
|
|
137
137
|
if (phase === "loading") {
|
|
138
138
|
authCard = react.createElement("div", { className: "co-card" },
|
|
139
139
|
react.createElement("div", { className: "co-card-title" }, "认证"),
|
|
140
|
-
react.createElement("p", { className: "co-hint" }, "
|
|
140
|
+
react.createElement("p", { className: "co-hint" }, "中国移动算力豆(komect)一键认证:扫码即自动创建专属 DSH API key,长期有效,无需刷新令牌。"),
|
|
141
141
|
react.createElement("div", { className: "co-hint" }, "加载中…"));
|
|
142
142
|
} else if (phase === "qr" && flow) {
|
|
143
143
|
authCard = react.createElement("div", { className: "co-card" },
|
|
144
144
|
react.createElement("div", { className: "co-card-title" }, "认证"),
|
|
145
|
-
react.createElement("p", { className: "co-hint" }, "
|
|
145
|
+
react.createElement("p", { className: "co-hint" }, "中国移动算力豆(komect)一键认证:扫码即自动创建专属 DSH API key,长期有效,无需刷新令牌。"),
|
|
146
146
|
react.createElement("div", { className: "co-qr" },
|
|
147
147
|
react.createElement("div", { dangerouslySetInnerHTML: { __html: flow.qr_svg } }),
|
|
148
|
-
react.createElement("div", { className: "co-
|
|
149
|
-
react.createElement("div", { className: "co-url" }, flow.verification_uri),
|
|
150
|
-
react.createElement("div", { className: "co-hint" }, "手机扫码完成授权,有效期 " + Math.round((flow.expires_in || 600) / 60) + " 分钟")
|
|
148
|
+
react.createElement("div", { className: "co-hint" }, "扫码登录中国移动账号,一次扫码完成认证与用量关联,约 " + Math.round(flow.expires_in || 180) + " 秒内有效")
|
|
151
149
|
),
|
|
152
150
|
react.createElement("div", { className: "co-status" },
|
|
153
151
|
react.createElement("span", { className: "co-dot" }),
|
|
@@ -155,36 +153,48 @@ window.__ModuleLoader__.load({
|
|
|
155
153
|
} else if (phase === "done") {
|
|
156
154
|
authCard = react.createElement("div", { className: "co-card" },
|
|
157
155
|
react.createElement("div", { className: "co-card-title" }, "认证"),
|
|
158
|
-
react.createElement("p", { className: "co-hint" }, "
|
|
156
|
+
react.createElement("p", { className: "co-hint" }, "中国移动算力豆(komect)一键认证:扫码即自动创建专属 DSH API key,长期有效,无需刷新令牌。"),
|
|
159
157
|
react.createElement("div", { className: "co-auth-ok" }, "✅ 已认证" + (models.length ? ",已接入 " + models.length + " 个模型" : "")),
|
|
160
158
|
react.createElement("div", { className: "co-row" },
|
|
161
|
-
react.createElement(Button, { variant: "outline", size: "md", onClick: doRefresh }, "
|
|
159
|
+
react.createElement(Button, { variant: "outline", size: "md", onClick: doRefresh }, "刷新模型与用量"),
|
|
162
160
|
react.createElement(Button, { variant: "outline", size: "md", onClick: begin }, "重新认证")));
|
|
163
161
|
} else if (phase === "error") {
|
|
164
162
|
authCard = react.createElement("div", { className: "co-card" },
|
|
165
163
|
react.createElement("div", { className: "co-card-title" }, "认证"),
|
|
166
|
-
react.createElement("p", { className: "co-hint" }, "
|
|
164
|
+
react.createElement("p", { className: "co-hint" }, "中国移动算力豆(komect)一键认证:扫码即自动创建专属 DSH API key,长期有效,无需刷新令牌。"),
|
|
167
165
|
react.createElement("div", { className: "co-err" }, "❌ " + (error || "认证失败")),
|
|
168
166
|
react.createElement("div", { className: "co-row" },
|
|
169
167
|
react.createElement(Button, { variant: "primary", size: "md", onClick: () => { setPhase("ok"); setError(null); } }, "返回重试")));
|
|
170
168
|
} else if (authenticated) {
|
|
171
169
|
authCard = react.createElement("div", { className: "co-card" },
|
|
172
170
|
react.createElement("div", { className: "co-card-title" }, "认证"),
|
|
173
|
-
react.createElement("p", { className: "co-hint" }, "
|
|
171
|
+
react.createElement("p", { className: "co-hint" }, "中国移动算力豆(komect)一键认证:扫码即自动创建专属 DSH API key,长期有效,无需刷新令牌。"),
|
|
174
172
|
react.createElement("div", { className: "co-auth-ok" },
|
|
175
173
|
"✅ 已认证",
|
|
176
|
-
status && status.
|
|
177
|
-
status.expired ? "令牌已过期" : "剩余约 " + Math.max(0, Math.round((status.expiresAt - Date.now()) / 60000)) + " 分钟") : null),
|
|
174
|
+
status && status.sessionExpired ? react.createElement("span", { className: "co-hint" }, "(管理会话已过期,重新扫码可刷新模型列表)") : null),
|
|
178
175
|
react.createElement("div", { className: "co-row" },
|
|
179
|
-
react.createElement(Button, { variant: "outline", size: "md", onClick: doRefresh }, "
|
|
176
|
+
react.createElement(Button, { variant: "outline", size: "md", onClick: doRefresh }, "刷新模型与用量"),
|
|
180
177
|
react.createElement(Button, { variant: "outline", size: "md", onClick: begin }, "重新认证")));
|
|
181
178
|
} else {
|
|
182
179
|
authCard = react.createElement("div", { className: "co-card" },
|
|
183
180
|
react.createElement("div", { className: "co-card-title" }, "认证"),
|
|
184
|
-
react.createElement("p", { className: "co-hint" }, "
|
|
181
|
+
react.createElement("p", { className: "co-hint" }, "中国移动算力豆(komect)一键认证:扫码即自动创建专属 DSH API key,长期有效,无需刷新令牌。"),
|
|
185
182
|
react.createElement("div", { className: "co-hint" }, "准备二维码…"));
|
|
186
183
|
}
|
|
187
184
|
|
|
185
|
+
// ---- 卡片:算力豆用量 ----
|
|
186
|
+
const usageCard = status && status.usageLinked
|
|
187
|
+
? react.createElement("div", { className: "co-card" },
|
|
188
|
+
react.createElement("div", { className: "co-card-title" }, "算力豆用量"),
|
|
189
|
+
status.usageExpired
|
|
190
|
+
? react.createElement("p", { className: "co-hint" }, "用量会话已失效,点击「重新认证」再扫一次码即可恢复查询。")
|
|
191
|
+
: (status.usage && status.usage.length
|
|
192
|
+
? status.usage.map((u, i) => react.createElement("div", { key: i, className: "co-row", style: { justifyContent: "space-between" } },
|
|
193
|
+
react.createElement("span", null, u.name),
|
|
194
|
+
react.createElement("span", { className: "co-hint" },
|
|
195
|
+
u.remain + " / " + u.total + " 豆" + (u.expireTime ? " · " + u.expireTime + " 到期" : ""))))
|
|
196
|
+
: react.createElement("p", { className: "co-hint" }, "暂无生效中的算力豆套餐。")))
|
|
197
|
+
: null;
|
|
188
198
|
// ---- 卡片:视觉识图 ----
|
|
189
199
|
const visionCard = status && status.visionModels && status.visionModels.length > 0
|
|
190
200
|
? react.createElement("div", { className: "co-card" },
|
|
@@ -209,6 +219,7 @@ window.__ModuleLoader__.load({
|
|
|
209
219
|
return react.createElement("div", { className: "dsh-co" },
|
|
210
220
|
react.createElement("h3", null, "cmtoken 模型服务"),
|
|
211
221
|
authCard,
|
|
222
|
+
usageCard,
|
|
212
223
|
visionCard,
|
|
213
224
|
planCard,
|
|
214
225
|
phase !== "error" && error ? react.createElement("div", { className: "co-err" }, error) : null);
|
package/lib/index.js
CHANGED
|
@@ -21,146 +21,162 @@ export const inject = ['connection', 'settings', 'credentials', 'tools', 'llm',
|
|
|
21
21
|
|
|
22
22
|
export const Config = z.object({});
|
|
23
23
|
|
|
24
|
-
// ----------------
|
|
25
|
-
|
|
26
|
-
const CLIENT_ID = '00a60e3140284b408d51a436ecce121d';
|
|
27
|
-
const DEVICE_GRANT = 'urn:ietf:params:oauth:grant-type:device_code';
|
|
28
|
-
const DEFAULT_API_BASE = 'https://maas.gd.chinamobile.com:36007/ai/uifm/open/v1';
|
|
29
|
-
const DISCOVERY_URL = DEFAULT_API_BASE + '/models';
|
|
30
|
-
const TIMEOUT = 900_000;
|
|
24
|
+
// ---------------- 协议常量 ----------------
|
|
25
|
+
// 旧通道(广东移动 MaaS OAuth Device Grant)已由 komect API-key 通道取代(见下)。
|
|
31
26
|
|
|
32
27
|
const API_KEY_ENV = 'CMTOKEN_API_KEY';
|
|
33
28
|
const REFRESH_ENV = 'CMTOKEN_REFRESH_TOKEN';
|
|
34
29
|
const EXPIRES_ENV = 'CMTOKEN_EXPIRES';
|
|
30
|
+
const SESSION_ENV = 'KOMECT_SESSION'; // 新通道管理会话(模型发现用)
|
|
31
|
+
const ORDER_SESSION_ENV = 'KOMECT_ORDER_SESSION'; // order-center 用量查询会话
|
|
32
|
+
|
|
33
|
+
// ---------------- 算力豆用量(order-center,可选关联) ----------------
|
|
34
|
+
// 用量页(/ai/llm/order-center/public/pages/token-usage)逆向所得:
|
|
35
|
+
// GET {ORDER}/sso/qrcode?business=workbuddy → {uuid, qr_code_url, expire, poll_interval_ms}
|
|
36
|
+
// POST {ORDER}/sso/qrcode/status {uuid} → {state:'0'|'1'|'2'|'-1'|'-3', order_session, phone}
|
|
37
|
+
// POST {ORDER}/token/usage {order_session, page, page_size}
|
|
38
|
+
// → {orderBenefitList:[{commodityName, remainBalance, totalBalance, expireTime, benefitStatus}]}
|
|
39
|
+
// 余额单位:1 算力豆 = 10000(900 豆套餐 totalBalance=9000000)。
|
|
40
|
+
// 注意:order-center 与 API key 页是两套会话,需各自扫码;本功能为可选关联。
|
|
41
|
+
const ORDER_BASE = 'https://ai.soho.komect.com/ai/llm/order-center/public/client/v1';
|
|
42
|
+
const ORDER_QR_TTL_MS = 180_000;
|
|
43
|
+
const pendingOrder = new Map();
|
|
44
|
+
|
|
45
|
+
async function orderPost(path, body, timeout = 20_000) {
|
|
46
|
+
const res = await fetch(`${ORDER_BASE}${path}`, {
|
|
47
|
+
method: 'POST',
|
|
48
|
+
signal: AbortSignal.timeout(timeout),
|
|
49
|
+
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
50
|
+
body: JSON.stringify(body ?? {}),
|
|
51
|
+
});
|
|
52
|
+
const json = await res.json().catch(() => undefined);
|
|
53
|
+
if (json?.code === 3004) throw new Error('用量会话已失效,请重新扫码关联');
|
|
54
|
+
if (!res.ok || json?.code !== 0) {
|
|
55
|
+
throw new Error(`order-center ${path} 失败 ${res.status}: ${String(json?.message ?? '').slice(0, 200) || 'unknown'}`);
|
|
56
|
+
}
|
|
57
|
+
return json.data;
|
|
58
|
+
}
|
|
35
59
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
const
|
|
60
|
+
/** 桥接:order_session → api-key/link → sso_token → sso-login → {session, expires}。 */
|
|
61
|
+
async function bridgeManagementSession(orderSession) {
|
|
62
|
+
const linkData = await orderPost('/api-key/link', { order_session: orderSession });
|
|
63
|
+
const link = linkData?.link;
|
|
64
|
+
const ssoToken = typeof link === 'string' ? new URL(link).searchParams.get('token') : null;
|
|
65
|
+
if (!ssoToken) throw new Error('桥接失败:api-key/link 未返回有效 token');
|
|
66
|
+
const data = await komect('/api-keys/sso-login', { method: 'POST', body: { sso_token: ssoToken } });
|
|
67
|
+
if (!data?.management_session) throw new Error('桥接失败:sso-login 未返回管理会话');
|
|
68
|
+
return { session: data.management_session, expires: (data.expires_at ?? 0) * 1000 || null };
|
|
69
|
+
}
|
|
40
70
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
const
|
|
44
|
-
const
|
|
45
|
-
return
|
|
71
|
+
/** order_session → 算力豆余额摘要(豆单位,1 豆 = 10000)。 */
|
|
72
|
+
async function fetchUsageSummary(orderSession) {
|
|
73
|
+
const data = await orderPost('/token/usage', { order_session: orderSession, page: 1, page_size: 20 });
|
|
74
|
+
const list = Array.isArray(data?.orderBenefitList) ? data.orderBenefitList : [];
|
|
75
|
+
return list
|
|
76
|
+
.filter((b) => b && b.benefitStatus === '生效中')
|
|
77
|
+
.map((b) => ({
|
|
78
|
+
name: b.commodityName ?? b.prodName ?? '算力豆套餐',
|
|
79
|
+
remain: Number(b.remainBalance) / 10_000,
|
|
80
|
+
total: Number(b.totalBalance) / 10_000,
|
|
81
|
+
expireTime: b.expireTime ?? null,
|
|
82
|
+
}));
|
|
46
83
|
}
|
|
47
84
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
85
|
+
// ---------------- 新通道:komect(ai.soho.komect.com,默认) ----------------
|
|
86
|
+
// API key 管理页(https://ai.soho.komect.com/ai/llm/api-key-page/)逆向所得:
|
|
87
|
+
// 扫码登录:POST {USER}/api-keys/login/qr → {uuid, qr_code_url, poll_interval_ms}
|
|
88
|
+
// 轮询: POST {USER}/api-keys/login/qr/status {uuid} → {state: '0'|'1'|'2', session}
|
|
89
|
+
// state 2 时 session = {management_session(lx_km_), expires_at(epoch 秒), ...}
|
|
90
|
+
// 密钥管理(Bearer management_session):
|
|
91
|
+
// GET /api-keys → {items:[{id,name,masked_key,revealable}], limit, masked_phone}
|
|
92
|
+
// POST /api-keys {name} → 创建
|
|
93
|
+
// POST /api-keys/{id}/reveal → {api_key}(lx_sk_ 明文;仅新建可 reveal)
|
|
94
|
+
// DELETE /api-keys/{id}
|
|
95
|
+
// 模型发现:POST {GW}/models/getApiKeyModels(Bearer management_session)
|
|
96
|
+
// → [{modelName, name, extInfo:{limits:{max_input_tokens,max_output_tokens},
|
|
97
|
+
// supports_images, thinking}}]
|
|
98
|
+
// 推理网关:{GW} OpenAI 兼容(Bearer lx_sk_)
|
|
99
|
+
const USER_BASE = 'https://ai.soho.komect.com/ai/llm/agent';
|
|
100
|
+
const GW_BASE = 'https://ai.soho.komect.com/ai/llm/gw/v1';
|
|
101
|
+
const DSH_KEY_NAME = 'DSH'; // 一键认证专用密钥名:只认/只建这个名字的密钥
|
|
102
|
+
|
|
103
|
+
/** komect 用户服务请求封装;ok 时返回 data,失败抛错。 */
|
|
104
|
+
async function komect(path, { method = 'GET', token, body, timeout = 20_000 } = {}) {
|
|
105
|
+
const res = await fetch(`${USER_BASE}${path}`, {
|
|
106
|
+
method,
|
|
107
|
+
signal: AbortSignal.timeout(timeout),
|
|
53
108
|
headers: {
|
|
54
|
-
'Content-Type': 'application/x-www-form-urlencoded',
|
|
55
109
|
Accept: 'application/json',
|
|
56
|
-
'
|
|
110
|
+
...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
|
|
111
|
+
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
57
112
|
},
|
|
58
|
-
body:
|
|
59
|
-
response_type: 'device_code',
|
|
60
|
-
client_id: CLIENT_ID,
|
|
61
|
-
scope: scope(),
|
|
62
|
-
code_challenge: challenge,
|
|
63
|
-
code_challenge_method: 'S256',
|
|
64
|
-
state,
|
|
65
|
-
}),
|
|
113
|
+
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
|
66
114
|
});
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
throw new Error(json.error_description ?? json.error ?? '设备码响应不完整');
|
|
115
|
+
const json = await res.json().catch(() => undefined);
|
|
116
|
+
if (res.status === 401 || json?.code === 3004) {
|
|
117
|
+
throw new Error('komect 管理会话已失效,请重新扫码认证');
|
|
71
118
|
}
|
|
72
|
-
|
|
119
|
+
if (!res.ok || json?.code !== 0) {
|
|
120
|
+
throw new Error(`komect ${path} 失败 ${res.status}: ${String(json?.message ?? '').slice(0, 200) || 'unknown'}`);
|
|
121
|
+
}
|
|
122
|
+
return json.data;
|
|
73
123
|
}
|
|
74
124
|
|
|
75
|
-
/**
|
|
76
|
-
async function
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
client_id: CLIENT_ID,
|
|
86
|
-
device_code: deviceCode,
|
|
87
|
-
code_verifier: verifier,
|
|
88
|
-
}),
|
|
89
|
-
});
|
|
90
|
-
} catch {
|
|
91
|
-
return { status: 'pending' }; // 网络抖动 → 当 pending
|
|
125
|
+
/** 确保「DSH」密钥存在并返回明文:只认 DSH 名;不存在则创建;存在但不可 reveal(旧密钥取不回原文)则删除重建。 */
|
|
126
|
+
async function ensureDshApiKey(session) {
|
|
127
|
+
const list = await komect('/api-keys', { token: session });
|
|
128
|
+
const items = Array.isArray(list?.items) ? list.items : [];
|
|
129
|
+
const existing = items.find((it) => it?.name === DSH_KEY_NAME);
|
|
130
|
+
let item = existing;
|
|
131
|
+
if (existing && existing.revealable !== true) {
|
|
132
|
+
// 旧 DSH 密钥拿不回明文:它是本插件命名约定专属的,删除重建
|
|
133
|
+
await komect(`/api-keys/${encodeURIComponent(existing.id)}`, { method: 'DELETE', token: session });
|
|
134
|
+
item = undefined;
|
|
92
135
|
}
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
throw new Error(
|
|
136
|
+
if (!item) {
|
|
137
|
+
if ((list?.limit ?? 10) > 0 && items.length >= (list?.limit ?? 10)) {
|
|
138
|
+
// 配额已满且没有可复用的 DSH:报错提示(不删别人的密钥)
|
|
139
|
+
throw new Error(`密钥已达上限(${items.length}/${list?.limit}),请到 API key 管理页删除不需要的密钥后重试`);
|
|
97
140
|
}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
resourceUrl: json.resource_url,
|
|
105
|
-
},
|
|
106
|
-
};
|
|
141
|
+
item = await komect('/api-keys', { method: 'POST', token: session, body: { name: DSH_KEY_NAME } });
|
|
142
|
+
}
|
|
143
|
+
const revealed = await komect(`/api-keys/${encodeURIComponent(item.id)}/reveal`, { method: 'POST', token: session });
|
|
144
|
+
const apiKey = revealed?.api_key;
|
|
145
|
+
if (typeof apiKey !== 'string' || !apiKey.startsWith('lx_sk_')) {
|
|
146
|
+
throw new Error('komect 密钥 reveal 失败:未返回有效的 lx_sk_ 密钥');
|
|
107
147
|
}
|
|
108
|
-
|
|
109
|
-
if (json?.error === 'slow_down') return { status: 'slow_down' };
|
|
110
|
-
throw new Error(json?.error_description ?? json?.error ?? '令牌请求失败');
|
|
148
|
+
return apiKey;
|
|
111
149
|
}
|
|
112
150
|
|
|
113
|
-
|
|
114
|
-
|
|
151
|
+
/** komect 模型发现:动态读取 limits 与图片支持,不再依赖硬编码 MODEL_SPECS。 */
|
|
152
|
+
async function fetchKomectModels(session) {
|
|
153
|
+
const res = await fetch(`${GW_BASE}/models/getApiKeyModels`, {
|
|
115
154
|
method: 'POST',
|
|
116
|
-
signal: AbortSignal.timeout(
|
|
117
|
-
headers: {
|
|
118
|
-
body: new URLSearchParams({
|
|
119
|
-
grant_type: 'refresh_token',
|
|
120
|
-
client_id: CLIENT_ID,
|
|
121
|
-
refresh_token: refresh,
|
|
122
|
-
}),
|
|
155
|
+
signal: AbortSignal.timeout(20_000),
|
|
156
|
+
headers: { Accept: 'application/json', Authorization: `Bearer ${session}` },
|
|
123
157
|
});
|
|
124
|
-
const json = await res.json();
|
|
125
|
-
if (
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
158
|
+
const json = await res.json().catch(() => undefined);
|
|
159
|
+
if (res.status === 401) throw new Error('komect 管理会话已失效,请重新扫码认证');
|
|
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'}`);
|
|
162
|
+
}
|
|
163
|
+
return json.data
|
|
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
|
+
}));
|
|
132
173
|
}
|
|
133
174
|
|
|
134
|
-
// 根据模型名称关键词匹配「输入(上下文)/ 输出(最大生成)」规格。
|
|
135
|
-
// 规格优先级:已知关键词匹配 > 发现接口自带值 > 通用默认值。
|
|
136
|
-
const MODEL_SPECS = [
|
|
137
|
-
// DeepSeek V4 系列:deepseek-v4-pro(1M)/ deepseek-v4-flash(中台仅 512k)
|
|
138
|
-
{ match: (n) => n.includes('deepseek') && n.includes('v4') && n.includes('flash'), contextWindow: 524_288, maxTokens: 393_216 },
|
|
139
|
-
{ match: (n) => n.includes('deepseek') && n.includes('v4'), contextWindow: 1_048_576, maxTokens: 393_216 },
|
|
140
|
-
// MiniMax-M3(支持图片)
|
|
141
|
-
{ match: (n) => n.includes('minimax') && /m3/.test(n), contextWindow: 1_048_576, maxTokens: 131_072 },
|
|
142
|
-
// Qwen 开源系列:qwen36-35b / qwen38-27b(支持图片)
|
|
143
|
-
{ match: (n) => n.includes('qwen36') || n.includes('qwen38'), contextWindow: 262_144, maxTokens: 65_536 },
|
|
144
|
-
// Qwen 闭源系列:Qwen3.x-flash / plus / max(支持图片)
|
|
145
|
-
{ match: (n) => n.includes('qwen'), contextWindow: 1_048_576, maxTokens: 65_536 },
|
|
146
|
-
// 智谱 GLM:glm-5.2 / glm-5.3
|
|
147
|
-
{ match: (n) => n.includes('glm'), contextWindow: 1_048_576, maxTokens: 131_072 },
|
|
148
|
-
// MiniMax 2.x 系列:M2.7-highspeed / MiniMax M2.5
|
|
149
|
-
{ match: (n) => n.startsWith('m2') || (n.includes('minimax') && /m2/.test(n)), contextWindow: 204_800, maxTokens: 65_536 },
|
|
150
|
-
// hy3 / hy3-preview 等
|
|
151
|
-
{ match: (n) => /hy/.test(n), contextWindow: 262_144, maxTokens: 65_536 },
|
|
152
|
-
];
|
|
153
175
|
|
|
176
|
+
// 模型规格默认值:komect 通道正常情况下由 getApiKeyModels 动态提供,
|
|
177
|
+
// 仅在发现接口失败时兜底使用。
|
|
154
178
|
const DEFAULT_MODEL_SPEC = { contextWindow: 262_144, maxTokens: 65_536 };
|
|
155
179
|
|
|
156
|
-
function modelSpecFor(id) {
|
|
157
|
-
const n = String(id).toLowerCase().replace(/[-_\s.]+/g, '');
|
|
158
|
-
for (const spec of MODEL_SPECS) {
|
|
159
|
-
if (spec.match(n)) return spec;
|
|
160
|
-
}
|
|
161
|
-
return DEFAULT_MODEL_SPEC;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
180
|
// ---------------- 视觉(图片)能力判定与回退优先级 ----------------
|
|
165
181
|
// 视觉模型优先级(值越小越优先,0 表示不支持图片):
|
|
166
182
|
// 1) Qwen3.8 27B(qwen38-27b 等)—— 最优先
|
|
@@ -187,32 +203,6 @@ function modelInputModalities(id) {
|
|
|
187
203
|
return supportsVision(id) ? ['text', 'image'] : ['text'];
|
|
188
204
|
}
|
|
189
205
|
|
|
190
|
-
async function fetchModels(access, discoveryUrl) {
|
|
191
|
-
const res = await fetch(discoveryUrl || DISCOVERY_URL, {
|
|
192
|
-
signal: AbortSignal.timeout(15_000),
|
|
193
|
-
headers: { Accept: 'application/json', Authorization: `Bearer ${access}` },
|
|
194
|
-
});
|
|
195
|
-
if (!res.ok) {
|
|
196
|
-
const text = (await res.text()).slice(0, 200);
|
|
197
|
-
if (res.status === 403 && /订阅套餐|订阅已过期|余额不足/.test(text)) {
|
|
198
|
-
throw new Error('订阅错误:' + text);
|
|
199
|
-
}
|
|
200
|
-
throw new Error(`模型发现失败 ${res.status}: ${text}`);
|
|
201
|
-
}
|
|
202
|
-
const json = await res.json();
|
|
203
|
-
const list = Array.isArray(json.models) ? json.models : Array.isArray(json.data) ? json.data : [];
|
|
204
|
-
return list.map((m) => {
|
|
205
|
-
// 已知模型按名称关键词配置最大上下文;其余回退到发现接口值或通用默认
|
|
206
|
-
const spec = modelSpecFor(m.id);
|
|
207
|
-
return {
|
|
208
|
-
id: m.id,
|
|
209
|
-
name: m.name ?? m.id,
|
|
210
|
-
contextWindow: spec.contextWindow,
|
|
211
|
-
maxTokens: spec.maxTokens,
|
|
212
|
-
};
|
|
213
|
-
});
|
|
214
|
-
}
|
|
215
|
-
|
|
216
206
|
function errorResult(e) {
|
|
217
207
|
const message = e instanceof Error ? e.message : String(e);
|
|
218
208
|
return { ok: false, error: { code: 'bad-request', message, details: {} } };
|
|
@@ -298,81 +288,125 @@ function registerRpc(ctx) {
|
|
|
298
288
|
const payload = (rawPayload && typeof rawPayload === 'object') ? rawPayload : {};
|
|
299
289
|
|
|
300
290
|
switch (endpoint) {
|
|
301
|
-
// ----
|
|
291
|
+
// ---- 开始认证:order-center 扫码(一次扫码同时获得用量查询 + 密钥管理) ----
|
|
302
292
|
case 'begin': {
|
|
303
|
-
const
|
|
304
|
-
|
|
293
|
+
const res = await fetch(`${ORDER_BASE}/sso/qrcode?business=workbuddy`, {
|
|
294
|
+
signal: AbortSignal.timeout(20_000),
|
|
295
|
+
headers: { Accept: 'application/json' },
|
|
296
|
+
});
|
|
297
|
+
const json = await res.json().catch(() => undefined);
|
|
298
|
+
const dc = json?.data;
|
|
299
|
+
if (!res.ok || json?.code !== 0 || !dc?.uuid || !dc.qr_code_url) {
|
|
300
|
+
throw new Error('二维码获取失败,请重试');
|
|
301
|
+
}
|
|
305
302
|
const id = randomUUID();
|
|
306
|
-
|
|
303
|
+
pendingOrder.set(id, { uuid: dc.uuid, expires_at: Date.now() + (dc.expire ? dc.expire * 1000 : ORDER_QR_TTL_MS) });
|
|
307
304
|
return {
|
|
308
305
|
ok: true,
|
|
309
306
|
value: {
|
|
310
307
|
requestId: id,
|
|
311
|
-
qr_svg: qrSvg(
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
user_code: dc.user_code,
|
|
315
|
-
interval: dc.interval ?? 2,
|
|
316
|
-
expires_in: dc.expires_in ?? 600,
|
|
308
|
+
qr_svg: qrSvg(dc.qr_code_url),
|
|
309
|
+
interval: Math.max(1, Math.round((dc.poll_interval_ms ?? 1500) / 1000)),
|
|
310
|
+
expires_in: dc.expire ?? Math.round(ORDER_QR_TTL_MS / 1000),
|
|
317
311
|
},
|
|
318
312
|
};
|
|
319
313
|
}
|
|
320
314
|
|
|
321
|
-
// ----
|
|
315
|
+
// ---- 轮询扫码状态;成功即:桥接密钥管理会话 → 取/建 DSH 密钥 → 发现模型 → 写配置 ----
|
|
322
316
|
case 'poll': {
|
|
323
317
|
const rid = payload.requestId;
|
|
324
|
-
const rec =
|
|
318
|
+
const rec = pendingOrder.get(rid);
|
|
325
319
|
if (!rec) return { ok: false, error: { code: 'bad-request', message: '认证流程已失效,请重新开始', details: {} } };
|
|
326
320
|
if (Date.now() > rec.expires_at) {
|
|
327
|
-
|
|
328
|
-
return { ok: false, error: { code: 'timeout', message: '
|
|
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: {} } };
|
|
329
335
|
}
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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);
|
|
336
352
|
let models = [];
|
|
337
353
|
try {
|
|
338
|
-
models = await
|
|
354
|
+
models = await fetchKomectModels(session);
|
|
339
355
|
} catch (e) {
|
|
340
|
-
//
|
|
341
|
-
ctx.logger?.warn?.('[cmtoken-oauth] 模型发现失败: ' + String(e.message || e));
|
|
342
|
-
models = [{ id: 'deepseekv4-flash', name: '
|
|
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'] }];
|
|
343
359
|
}
|
|
344
|
-
await writeConfigAndCreds(ctx,
|
|
360
|
+
await writeConfigAndCreds(ctx, { apiKey, session, expires }, models);
|
|
345
361
|
return {
|
|
346
362
|
ok: true,
|
|
347
363
|
value: {
|
|
348
364
|
status: 'ok',
|
|
349
|
-
// input 模态一并返回,client 用它给视觉模型打标
|
|
350
365
|
models: models.map((m) => ({ ...m, input: m.input ?? modelInputModalities(m.id) })),
|
|
351
|
-
baseURL:
|
|
366
|
+
baseURL: GW_BASE,
|
|
367
|
+
keyName: DSH_KEY_NAME,
|
|
368
|
+
usage,
|
|
369
|
+
maskedPhone: st.phone ?? null,
|
|
352
370
|
},
|
|
353
371
|
};
|
|
354
372
|
}
|
|
355
373
|
|
|
356
|
-
// ----
|
|
374
|
+
// ---- 认证状态(含算力豆用量) ----
|
|
357
375
|
case 'status': {
|
|
358
|
-
// 先触发一次按需自动刷新:用户一旦查看状态,若 access 已过期/临期
|
|
359
|
-
// 且有有效 refresh token,立即刷新,避免只靠粗粒度定时器漏刷。
|
|
360
|
-
await autoRefresh(ctx).catch((e) => ctx.logger?.warn?.('[cmtoken-oauth] 状态查询时自动刷新失败: ' + String(e?.message || e)));
|
|
361
376
|
const creds = ctx.credentials;
|
|
362
377
|
const apiInfo = await creds?.describe(credentialRef(API_KEY_ENV)).catch(() => undefined);
|
|
363
|
-
const
|
|
378
|
+
const sessInfo = await creds?.describe(credentialRef(SESSION_ENV)).catch(() => undefined);
|
|
379
|
+
const orderInfo = await creds?.describe(credentialRef(ORDER_SESSION_ENV)).catch(() => undefined);
|
|
364
380
|
const expRaw = await creds?.resolve(credentialRef(EXPIRES_ENV)).catch(() => undefined);
|
|
365
381
|
const expires = expRaw ? Number(expRaw.value) : NaN;
|
|
382
|
+
// 用量:order_session 有效时实时取,失败标记过期(不阻断状态查询)
|
|
383
|
+
let usage = null;
|
|
384
|
+
let usageExpired = false;
|
|
385
|
+
if (orderInfo?.configured) {
|
|
386
|
+
const osRes = await creds?.resolve(credentialRef(ORDER_SESSION_ENV)).catch(() => undefined);
|
|
387
|
+
if (osRes) {
|
|
388
|
+
try {
|
|
389
|
+
usage = await fetchUsageSummary(osRes.value);
|
|
390
|
+
} catch {
|
|
391
|
+
usageExpired = true; // 会话失效,重新扫码即可
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
}
|
|
366
395
|
const candidates = visionModelCandidates(ctx);
|
|
367
396
|
const cfg = await readPluginConfig();
|
|
368
397
|
return {
|
|
369
398
|
ok: true,
|
|
370
399
|
value: {
|
|
371
400
|
configured: !!(apiInfo?.configured),
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
401
|
+
hasSession: !!(sessInfo?.configured),
|
|
402
|
+
usageLinked: !!(orderInfo?.configured),
|
|
403
|
+
usage,
|
|
404
|
+
usageExpired,
|
|
405
|
+
// 管理会话过期只影响「重新发现模型」,API key 本身长期有效
|
|
406
|
+
sessionExpiresAt: Number.isFinite(expires) && expires > 0 ? expires : null,
|
|
407
|
+
sessionExpired: Number.isFinite(expires) && expires > 0 ? Date.now() > expires : null,
|
|
408
|
+
keyName: DSH_KEY_NAME,
|
|
409
|
+
baseURL: GW_BASE,
|
|
376
410
|
visionModels: candidates,
|
|
377
411
|
visionModel: candidates.some((c) => c.id === cfg.visionModel) ? cfg.visionModel : null,
|
|
378
412
|
},
|
|
@@ -395,20 +429,24 @@ function registerRpc(ctx) {
|
|
|
395
429
|
return { ok: true, value: { visionModel: hit.id } };
|
|
396
430
|
}
|
|
397
431
|
|
|
398
|
-
// ----
|
|
432
|
+
// ---- 刷新:order_session 有效则重新桥接 + 重新发现模型 ----
|
|
399
433
|
case 'refresh': {
|
|
400
|
-
const
|
|
401
|
-
if (!
|
|
402
|
-
|
|
403
|
-
await
|
|
404
|
-
await
|
|
405
|
-
await
|
|
406
|
-
|
|
434
|
+
const osRes = await ctx.credentials?.resolve(credentialRef(ORDER_SESSION_ENV));
|
|
435
|
+
if (!osRes) return { ok: false, error: { code: 'bad-request', message: '没有可用的扫码会话,请重新扫码认证(API key 本身不受影响)', details: {} } };
|
|
436
|
+
let usage = null;
|
|
437
|
+
try { usage = await fetchUsageSummary(osRes.value); } catch { /* 会话失效不阻断 */ }
|
|
438
|
+
const { session, expires } = await bridgeManagementSession(osRes.value);
|
|
439
|
+
const models = await fetchKomectModels(session);
|
|
440
|
+
const apiKey = await ensureDshApiKey(session);
|
|
441
|
+
await writeConfigAndCreds(ctx, { apiKey, session, expires }, models);
|
|
442
|
+
return { ok: true, value: { models: models.length, usage } };
|
|
407
443
|
}
|
|
408
444
|
|
|
409
445
|
// ---- 注销(清除凭据,保留 provider 配置) ----
|
|
410
446
|
case 'logout': {
|
|
411
447
|
await ctx.credentials?.unset(credentialRef(API_KEY_ENV)).catch(() => {});
|
|
448
|
+
await ctx.credentials?.unset(credentialRef(SESSION_ENV)).catch(() => {});
|
|
449
|
+
await ctx.credentials?.unset(credentialRef(ORDER_SESSION_ENV)).catch(() => {});
|
|
412
450
|
await ctx.credentials?.unset(credentialRef(REFRESH_ENV)).catch(() => {});
|
|
413
451
|
await ctx.credentials?.unset(credentialRef(EXPIRES_ENV)).catch(() => {});
|
|
414
452
|
return { ok: true, value: {} };
|
|
@@ -423,20 +461,21 @@ function registerRpc(ctx) {
|
|
|
423
461
|
});
|
|
424
462
|
}
|
|
425
463
|
|
|
426
|
-
async function writeConfigAndCreds(ctx,
|
|
464
|
+
async function writeConfigAndCreds(ctx, { apiKey, session, expires }, models) {
|
|
427
465
|
// 1) 凭据(OS keyring / 凭据服务,apiKeyEnv 引用)
|
|
428
|
-
|
|
429
|
-
await ctx.credentials.set(credentialRef(
|
|
430
|
-
await ctx.credentials.set(credentialRef(
|
|
466
|
+
// API key(lx_sk_)长期有效;session 为管理会话(模型发现用),过期仅需重新扫码
|
|
467
|
+
if (apiKey) await ctx.credentials.set(credentialRef(API_KEY_ENV), apiKey);
|
|
468
|
+
if (session) await ctx.credentials.set(credentialRef(SESSION_ENV), session);
|
|
469
|
+
if (expires) await ctx.credentials.set(credentialRef(EXPIRES_ENV), String(expires));
|
|
431
470
|
|
|
432
471
|
// 2) llm-pi-ai provider 配置(merge 进现有 providers,保留其他路由)
|
|
433
472
|
const section = {
|
|
434
473
|
providers: {
|
|
435
474
|
cmtoken: {
|
|
436
|
-
displayName: '
|
|
475
|
+
displayName: 'komect 算力豆',
|
|
437
476
|
apiKeyEnv: API_KEY_ENV,
|
|
438
477
|
api: 'openai-completions',
|
|
439
|
-
baseURL:
|
|
478
|
+
baseURL: GW_BASE,
|
|
440
479
|
models: models.map((m) => ({
|
|
441
480
|
id: m.id,
|
|
442
481
|
name: m.name,
|
|
@@ -456,55 +495,6 @@ async function writeConfigAndCreds(ctx, token, models) {
|
|
|
456
495
|
}
|
|
457
496
|
}
|
|
458
497
|
|
|
459
|
-
// ---------------- 自动刷新令牌 ----------------
|
|
460
|
-
// 定时器只负责「保底」:每个短周期检查一次;同时 status 查询也会触发按需自动刷新,
|
|
461
|
-
// 因此只要 token 过期且 refresh token 仍有效,就会在过期后迅速被刷新,而不是等 2 小时。
|
|
462
|
-
const AUTO_REFRESH_INTERVAL_MS = 60_000; // 每 1 分钟检查一次(轻量,剩余充足时直接返回)
|
|
463
|
-
const AUTO_REFRESH_THRESHOLD_MS = 60 * 60_000; // 剩余 <1 小时即提前刷新(token 生命周期 ~5 小时,从容轮换)
|
|
464
|
-
const REFRESH_COOLDOWN_MS = 60_000; // 刷新失败后冷却 1 分钟再试
|
|
465
|
-
let lastRefreshAttempt = 0;
|
|
466
|
-
|
|
467
|
-
/** 检查凭据:若 access 快过期且有 refresh,则自动刷新并更新凭据。 */
|
|
468
|
-
async function autoRefresh(ctx) {
|
|
469
|
-
const creds = ctx.credentials;
|
|
470
|
-
if (!creds) return;
|
|
471
|
-
const refRes = await creds.resolve(credentialRef(REFRESH_ENV)).catch(() => undefined);
|
|
472
|
-
if (!refRes) return; // 没有 refresh token(未认证或已注销)
|
|
473
|
-
|
|
474
|
-
// 失败冷却:上次刷新失败后 REFRESH_COOLDOWN_MS 内不重试,避免无限循环
|
|
475
|
-
const now = Date.now();
|
|
476
|
-
if (now - lastRefreshAttempt < REFRESH_COOLDOWN_MS) return;
|
|
477
|
-
|
|
478
|
-
const expRes = await creds.resolve(credentialRef(EXPIRES_ENV)).catch(() => undefined);
|
|
479
|
-
const expires = expRes ? Number(expRes.value) : NaN;
|
|
480
|
-
if (!Number.isFinite(expires) || expires <= 0) return;
|
|
481
|
-
const remaining = expires - now;
|
|
482
|
-
// 剩余还多 → 不用刷;过期超过 7 天 → refresh 大概率已失效,放弃自动刷(需重新认证)
|
|
483
|
-
if (remaining > AUTO_REFRESH_THRESHOLD_MS) return;
|
|
484
|
-
if (remaining < -7 * 24 * 3600_000) return;
|
|
485
|
-
|
|
486
|
-
try {
|
|
487
|
-
const token = await refreshToken(refRes.value);
|
|
488
|
-
await creds.set(credentialRef(API_KEY_ENV), token.access);
|
|
489
|
-
await creds.set(credentialRef(REFRESH_ENV), token.refresh);
|
|
490
|
-
await creds.set(credentialRef(EXPIRES_ENV), String(token.expires));
|
|
491
|
-
lastRefreshAttempt = 0;
|
|
492
|
-
ctx.logger?.info?.('[cmtoken-oauth] 令牌已自动刷新,新过期时间: ' + new Date(token.expires).toISOString());
|
|
493
|
-
} catch (e) {
|
|
494
|
-
lastRefreshAttempt = Date.now();
|
|
495
|
-
ctx.logger?.warn?.('[cmtoken-oauth] 自动刷新失败(冷却 ' + REFRESH_COOLDOWN_MS / 60000 + ' 分钟): ' + String(e?.message || e));
|
|
496
|
-
}
|
|
497
|
-
}
|
|
498
|
-
|
|
499
|
-
/** 启动自动刷新定时器;返回清理函数。 */
|
|
500
|
-
function startAutoRefresh(ctx) {
|
|
501
|
-
const timer = setInterval(() => {
|
|
502
|
-
void autoRefresh(ctx).catch((e) => ctx.logger?.warn?.('[cmtoken-oauth] 自动刷新异常: ' + String(e?.message || e)));
|
|
503
|
-
}, AUTO_REFRESH_INTERVAL_MS);
|
|
504
|
-
timer.unref?.();
|
|
505
|
-
return () => clearInterval(timer);
|
|
506
|
-
}
|
|
507
|
-
|
|
508
498
|
// ---------------- 视觉回退:cmtoken_read_image 工具 ----------------
|
|
509
499
|
// DSH 的图片准入以模型声明的 input 模态为准:文本模型收图直接报 UNSUPPORTED_CONTENT,
|
|
510
500
|
// rc8 没有原生的"改投视觉模型"路由。本工具补上这条通路:任何在用模型把图片路径交给
|
|
@@ -666,7 +656,7 @@ async function describeImage({ baseURL, apiKey, model, dataUri, focus, signal })
|
|
|
666
656
|
}
|
|
667
657
|
if (!res.ok) {
|
|
668
658
|
const text = (await res.text().catch(() => '')).slice(0, 300);
|
|
669
|
-
if (res.status === 401) throw new Error('
|
|
659
|
+
if (res.status === 401) throw new Error('komect API key 无效,请在设置 → cmtoken 认证里重新认证');
|
|
670
660
|
if (res.status === 403 && /订阅套餐|订阅已过期|余额不足/.test(text)) throw new Error('订阅错误:' + text);
|
|
671
661
|
throw new Error(`视觉模型识图失败 ${res.status}: ${text}`);
|
|
672
662
|
}
|
|
@@ -704,10 +694,8 @@ function registerVisionTool(ctx) {
|
|
|
704
694
|
const model = await resolveVisionModel(ctx);
|
|
705
695
|
if (!model) throw new Error('cmtoken 未配置视觉模型:请先在 DSH 设置 → cmtoken 认证完成一键认证');
|
|
706
696
|
|
|
707
|
-
// 识图前先按需刷新令牌(复用自动续期逻辑),避免用过期 access 调用
|
|
708
|
-
await autoRefresh(ctx).catch(() => {});
|
|
709
697
|
const key = await ctx.credentials?.resolve(credentialRef(API_KEY_ENV));
|
|
710
|
-
if (!key) throw new Error('
|
|
698
|
+
if (!key) throw new Error('komect API key 不可用:请先在 DSH 设置 → cmtoken 认证完成一键认证');
|
|
711
699
|
|
|
712
700
|
const bytes = await loadImageBytes(source, exec?.signal);
|
|
713
701
|
if (bytes.length === 0) throw new Error('图片内容为空');
|
|
@@ -719,7 +707,7 @@ function registerVisionTool(ctx) {
|
|
|
719
707
|
|
|
720
708
|
const signals = [AbortSignal.timeout(VISION_TOOL_TIMEOUT_MS)];
|
|
721
709
|
if (exec?.signal) signals.push(exec.signal);
|
|
722
|
-
const baseURL = ctx.settings?.get?.(settingsNamespace('llm-pi-ai'))?.providers?.cmtoken?.baseURL ||
|
|
710
|
+
const baseURL = ctx.settings?.get?.(settingsNamespace('llm-pi-ai'))?.providers?.cmtoken?.baseURL || GW_BASE;
|
|
723
711
|
const evidence = await describeImage({
|
|
724
712
|
baseURL,
|
|
725
713
|
apiKey: key.value,
|
|
@@ -807,10 +795,9 @@ async function evidenceForBlock(ctx, block, signal, cache) {
|
|
|
807
795
|
}
|
|
808
796
|
const model = await resolveVisionModel(ctx);
|
|
809
797
|
if (!model) throw new Error('cmtoken 未配置视觉模型,请先完成一键认证');
|
|
810
|
-
await autoRefresh(ctx).catch(() => {});
|
|
811
798
|
const keyRes = await ctx.credentials?.resolve(credentialRef(API_KEY_ENV));
|
|
812
|
-
if (!keyRes) throw new Error('
|
|
813
|
-
const baseURL = ctx.settings?.get?.(settingsNamespace('llm-pi-ai'))?.providers?.cmtoken?.baseURL ||
|
|
799
|
+
if (!keyRes) throw new Error('komect API key 不可用,请重新认证');
|
|
800
|
+
const baseURL = ctx.settings?.get?.(settingsNamespace('llm-pi-ai'))?.providers?.cmtoken?.baseURL || GW_BASE;
|
|
814
801
|
const signals = [AbortSignal.timeout(VISION_TOOL_TIMEOUT_MS)];
|
|
815
802
|
if (signal) signals.push(signal);
|
|
816
803
|
const evidence = await describeImage({
|
|
@@ -932,12 +919,13 @@ async function writePluginConfig(patch) {
|
|
|
932
919
|
return cfg;
|
|
933
920
|
}
|
|
934
921
|
|
|
935
|
-
/**
|
|
922
|
+
/** 已认证模型里的视觉模型:以发现接口的 input 模态声明为准(supports_images),
|
|
923
|
+
* 同能力时按回退优先级(visionRank)排序;无声明时回退名称启发(兼容旧配置)。 */
|
|
936
924
|
function visionModelCandidates(ctx) {
|
|
937
925
|
const models = ctx.settings?.get?.(settingsNamespace('llm-pi-ai'))?.providers?.cmtoken?.models ?? [];
|
|
938
926
|
return models
|
|
939
|
-
.
|
|
940
|
-
.
|
|
927
|
+
.filter((m) => (Array.isArray(m.input) ? m.input.includes('image') : supportsVision(m.id)))
|
|
928
|
+
.map((m) => ({ id: m.id, name: m.name ?? m.id, rank: visionRank(m.id) || 99 }))
|
|
941
929
|
.sort((a, b) => a.rank - b.rank);
|
|
942
930
|
}
|
|
943
931
|
|
|
@@ -1150,7 +1138,6 @@ function registerVisionAdapter(ctx) {
|
|
|
1150
1138
|
|
|
1151
1139
|
export function apply(ctx, cfg = {}) {
|
|
1152
1140
|
let removeRpc = () => {};
|
|
1153
|
-
let stopTimer = () => {};
|
|
1154
1141
|
let disposeVisionAdapter = () => {};
|
|
1155
1142
|
// 在声明了 webServer 的子上下文里注册 RPC 前缀路由(DSH 0.1.5+)。
|
|
1156
1143
|
const rpcScope = ctx.inject(['webServer'], (scope) => {
|
|
@@ -1158,23 +1145,18 @@ export function apply(ctx, cfg = {}) {
|
|
|
1158
1145
|
});
|
|
1159
1146
|
void rpcScope;
|
|
1160
1147
|
ctx.effect(() => {
|
|
1161
|
-
stopTimer = startAutoRefresh(ctx);
|
|
1162
1148
|
registerVisionTool(ctx);
|
|
1163
1149
|
registerPasteRoute(ctx);
|
|
1164
1150
|
registerPasteInjection(ctx);
|
|
1165
1151
|
// 视觉回退包装分组(cmtoken-vision)默认关闭:粘贴识图已覆盖纯文本模型,
|
|
1166
1152
|
// 需要真实图片气泡的会话可在 profile 的插件 config 里设 visionRoute: true。
|
|
1167
1153
|
if (cfg.visionRoute === true) disposeVisionAdapter = registerVisionAdapter(ctx);
|
|
1168
|
-
// 启动时立即补刷一次:若停机期间 access 过期、但 refresh 仍有效,马上恢复,
|
|
1169
|
-
// 不必等 60 秒定时器;失败只记日志,不影响启动。
|
|
1170
|
-
void autoRefresh(ctx).catch((e) => ctx.logger?.warn?.('[cmtoken-oauth] 启动补刷失败: ' + String(e?.message || e)));
|
|
1171
1154
|
return () => {
|
|
1172
1155
|
removeRpc();
|
|
1173
|
-
stopTimer();
|
|
1174
1156
|
disposeVisionAdapter();
|
|
1175
1157
|
};
|
|
1176
1158
|
}, 'cmtoken-oauth: host service');
|
|
1177
|
-
ctx.logger?.info?.('[cmtoken-oauth] host
|
|
1159
|
+
ctx.logger?.info?.('[cmtoken-oauth] host 服务已启动(komect API-key 通道:密钥长期有效,无需定时刷新)');
|
|
1178
1160
|
}
|
|
1179
1161
|
|
|
1180
1162
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-cmtoken-oauth",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "DeepSeek Harness 插件:在 DSH
|
|
3
|
+
"version": "1.6.0",
|
|
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",
|
|
7
7
|
"types": "./lib/types/index.d.ts",
|