dsh-cmtoken-oauth 1.5.2 → 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 CHANGED
@@ -1,21 +1,24 @@
1
1
  # dsh-cmtoken-oauth
2
2
 
3
- DeepSeek Harness(DSH)插件:在 **DSH 设置页**内一键认证广东移动 MaaS(cmtoken),自动发现并接入模型,无需手动填写 API Key 或模型列表。
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
- - **一键认证**:基于 OAuth 2.0 Device Authorization Grant(RFC 8628)+ PKCE (S256),扫码或输入用户码即可完成,无需配置脚本。
9
+ - **一键认证(一次扫码全搞定)**:扫码登录 order-center 自动桥接密钥管理会话 自动创建/复用专属 **DSH** API key → 拉取模型列表写入 `llm-pi-ai` 的 `providers.cmtoken` 配置(merge,不影响其他 provider)。
10
+ - **算力豆用量**:设置页直接显示生效中套餐的剩余/总额算力豆与到期时间(来自 order-center 用量接口);会话失效时提示重新扫码。
8
11
  - **内联二维码**:认证流程内置 QR 生成(零依赖),无需额外安装二维码工具。
9
- - **自动发现模型**:认证成功后自动拉取可用模型列表,写入 `llm-pi-ai` `providers.cmtoken` 配置(merge,不影响其他 provider)。
10
- - **原生视觉**:视觉模型(Qwen3.8 27B / Qwen3.x / Kimi K2.x 非 code / MiniMax M3)写入配置时自动声明 `input: ["text","image"]` 模态,DSH 直接放行图片输入(经持久附件服务直达模型),粘贴/拖入图片即可用。
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
- - **自动配置上下文**:按模型名称关键词自动匹配各模型的上下文与最大输出长度(DeepSeek V4 / MiniMax M3 / Qwen / GLM / Kimi / MiniMax 2.x / hy 等),无需手动填写。
16
- - **安全存储**:token 存入 DSH 凭据服务(OS keyring / env),不落盘明文。
17
- - **自动续期 / 注销**:令牌临近过期自动 refresh;可随时注销清空凭据。
18
- - **订购算力豆套餐**:设置页内一键跳转广东移动 H5 订购页,直达算力豆套餐购买。
18
+ - **自动配置上下文**:上下文与最大输出按平台 `getApiKeyModels` 返回值动态写入,无需手动维护。
19
+ - **安全存储**:API key 与会话存入 DSH 凭据服务(OS keyring / env),不落盘明文。
20
+ - **注销**:可随时注销清空凭据(API key 在平台上保留,可再次扫码复用)。
21
+ - **算力豆套餐**:设置页内一键跳转算力豆用量/订购页。
19
22
 
20
23
  零第三方运行时依赖(仅使用 Node 内置模块)。
21
24
 
@@ -69,6 +72,21 @@ lib/client.js web:设置页「cmtoken 认证」向导(视觉模型标 👁
69
72
  lib/qr-core.cjs 内联 QR 编码器(输出 SVG)
70
73
  ```
71
74
 
75
+ ## 变更记录
76
+
77
+ ### 1.5.3(2026-09-11)
78
+
79
+ - 修复 DSH 0.1.5+(desktop 0.1.5-rc.1 起)下插件树加载失败:
80
+ `cannot get property "webServer" without inject`。
81
+ - 根因:新版 `connection.rpc.handle(channel, handler)` 在注册时执行
82
+ `owner.webServer.register(route)`,而 `owner` 恒为 connection 插件自身的 ctx
83
+ (其 inject 只有 `credentials`),第三方插件调用必然抛错。
84
+ - 改法:在 `ctx.inject(['webServer'])` 子上下文用
85
+ `ctx.webServer.register({ kind: 'prefix', path: '/cmtoken-oauth', handler })`
86
+ 自注册 RPC 前缀路由,并沿用 connection 的
87
+ `client-request` / `server-response` 信封协议,客户端代码无需改动;
88
+ 另加 loopback 来源校验。
89
+
72
90
  ## License
73
91
 
74
92
  MIT
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://wap.gd.10086.cn/ech/h5/token-prduct/index.html";
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 && !status.expired);
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" }, "支持广东移动模型服务为 DSH 提供一键认证,令牌无需手动刷新。"),
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" }, "支持广东移动模型服务为 DSH 提供一键认证,令牌无需手动刷新。"),
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-code" }, flow.user_code),
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" }, "支持广东移动模型服务为 DSH 提供一键认证,令牌无需手动刷新。"),
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" }, "支持广东移动模型服务为 DSH 提供一键认证,令牌无需手动刷新。"),
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" }, "支持广东移动模型服务为 DSH 提供一键认证,令牌无需手动刷新。"),
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.expiresAt ? react.createElement("span", { className: "co-hint" },
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" }, "支持广东移动模型服务为 DSH 提供一键认证,令牌无需手动刷新。"),
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
- // ---------------- 协议常量(分析报告第 4 节) ----------------
25
- const OAUTH_BASE = 'https://agentlink.idaas.cmpassport.com/oauth2-service';
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
- // 单用户 Web 场景:进行中的设备码流程保存在模块级(keyed by requestId)
37
- const pending = new Map();
38
-
39
- const scope = () => `profile model.completion identity:device:${os.hostname()}`;
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
- function makePkce() {
42
- const verifier = randomBytes(32).toString('base64url');
43
- const challenge = createHash('sha256').update(verifier).digest('base64url');
44
- const state = randomBytes(16).toString('base64url');
45
- return { verifier, challenge, state };
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
- async function requestDeviceCode() {
49
- const { verifier, challenge, state } = makePkce();
50
- const res = await fetch(`${OAUTH_BASE}/oauth/device/code`, {
51
- method: 'POST',
52
- signal: AbortSignal.timeout(TIMEOUT),
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
- 'x-request-id': randomUUID(),
110
+ ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
111
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
57
112
  },
58
- body: new URLSearchParams({
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
- if (!res.ok) throw new Error(`设备码申请失败: ${await res.text()}`);
68
- const json = await res.json();
69
- if (!json.device_code || !json.user_code || !json.verification_uri) {
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 管理会话已失效,请重新扫码认证');
118
+ }
119
+ if (!res.ok || json?.code !== 0) {
120
+ throw new Error(`komect ${path} 失败 ${res.status}: ${String(json?.message ?? '').slice(0, 200) || 'unknown'}`);
71
121
  }
72
- return { ...json, verifier };
122
+ return json.data;
73
123
  }
74
124
 
75
- /** 单次令牌轮询;成功返回 Token,pending 返回 null,失败抛错。 */
76
- async function pollOnce(deviceCode, verifier) {
77
- let res;
78
- try {
79
- res = await fetch(`${OAUTH_BASE}/oauth/device/token`, {
80
- method: 'POST',
81
- signal: AbortSignal.timeout(TIMEOUT),
82
- headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
83
- body: new URLSearchParams({
84
- grant_type: DEVICE_GRANT,
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
- const json = await res.json().catch(() => undefined);
94
- if (res.ok) {
95
- if (!json?.access_token || !json.refresh_token || !json.expires_in) {
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
- return {
99
- status: 'ok',
100
- token: {
101
- access: json.access_token,
102
- refresh: json.refresh_token,
103
- expires: Date.now() + json.expires_in * 1000,
104
- resourceUrl: json.resource_url,
105
- },
106
- };
141
+ item = await komect('/api-keys', { method: 'POST', token: session, body: { name: DSH_KEY_NAME } });
107
142
  }
108
- if (json?.error === 'authorization_pending') return { status: 'pending' };
109
- if (json?.error === 'slow_down') return { status: 'slow_down' };
110
- throw new Error(json?.error_description ?? json?.error ?? '令牌请求失败');
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_ 密钥');
147
+ }
148
+ return apiKey;
111
149
  }
112
150
 
113
- async function refreshToken(refresh) {
114
- const res = await fetch(`${OAUTH_BASE}/oauth/device/token`, {
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(TIMEOUT),
117
- headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
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 (!res.ok || json.error) throw new Error(json?.error_description ?? json?.error ?? '刷新失败');
126
- return {
127
- access: json.access_token,
128
- refresh: json.refresh_token || refresh,
129
- expires: Date.now() + json.expires_in * 1000,
130
- resourceUrl: json.resource_url,
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,119 +203,210 @@ 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: {} } };
219
209
  }
220
210
 
211
+ /**
212
+ * DSH 0.1.5+ 兼容的 RPC 传输层。
213
+ *
214
+ * 旧版通过 `ctx.connection.rpc.handle(channel, handler)` 注册通道;新版
215
+ * connection 服务把路由挂到「调用方 fiber」的 webServer 上
216
+ * (dsh-client-connection/lib/index.js `owner.webServer.register(route)`),
217
+ * 而 owner 恒为 connection 插件自身的 ctx(其 inject 只有 credentials),
218
+ * 因此第三方插件调用必然抛 `cannot get property "webServer" without inject`。
219
+ *
220
+ * 改为在声明了 webServer 的子上下文里自注册一条 prefix 路由,并沿用
221
+ * connection 的 RPC 信封协议(client-request / server-response),
222
+ * 客户端代码无需改动。
223
+ */
224
+ function registerHttpRpc(ctx, channel, dispatch) {
225
+ const webServer = ctx.get?.('webServer') ?? ctx.webServer;
226
+ if (webServer === undefined || typeof webServer.register !== 'function') {
227
+ ctx.logger?.warn?.(`[cmtoken-oauth] webServer 服务不可用,${channel} 通道未注册`);
228
+ return () => {};
229
+ }
230
+ const isLoopback = (req) => {
231
+ const addr = req.socket?.remoteAddress ?? '';
232
+ return addr === '127.0.0.1' || addr === '::1' || addr === '::ffff:127.0.0.1';
233
+ };
234
+ return webServer.register({
235
+ name: 'cmtoken-oauth-rpc',
236
+ kind: 'prefix',
237
+ path: channel,
238
+ handler: async (req, res) => {
239
+ const reply = (status, payload) => {
240
+ res.writeHead(status, { 'content-type': 'application/json' });
241
+ res.end(payload === undefined ? '' : JSON.stringify(payload));
242
+ };
243
+ if (req.method !== 'POST' || !isLoopback(req)) return reply(404);
244
+ let endpoint;
245
+ try {
246
+ const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname;
247
+ if (!pathname.startsWith(channel + '/')) return reply(404);
248
+ endpoint = decodeURIComponent(pathname.slice(channel.length + 1));
249
+ } catch {
250
+ return reply(400);
251
+ }
252
+ if (endpoint === '' || endpoint.includes('/')) return reply(404);
253
+
254
+ const abort = new AbortController();
255
+ res.on('close', () => { if (!res.writableEnded) abort.abort(); });
256
+ const chunks = [];
257
+ try {
258
+ for await (const chunk of req) chunks.push(chunk);
259
+ } catch {
260
+ return reply(400);
261
+ }
262
+ let envelope;
263
+ try {
264
+ envelope = JSON.parse(Buffer.concat(chunks).toString('utf8'));
265
+ } catch {
266
+ return reply(400);
267
+ }
268
+ const rpcId = typeof envelope?.rpcId === 'string' ? envelope.rpcId : 'unknown';
269
+ const method = typeof envelope?.method === 'string' ? envelope.method : endpoint;
270
+ let result;
271
+ try {
272
+ result = await dispatch(method, envelope?.payload, abort.signal);
273
+ } catch (e) {
274
+ result = errorResult(e);
275
+ }
276
+ if (result === null || typeof result !== 'object' || typeof result.ok !== 'boolean') {
277
+ result = { ok: true, value: result ?? {} };
278
+ }
279
+ reply(200, { type: 'server-response', rpcId, result });
280
+ },
281
+ });
282
+ }
283
+
221
284
  function registerRpc(ctx) {
222
- return ctx.connection.rpc.handle('/cmtoken-oauth', async (endpoint, rawPayload, signal) => {
285
+ return registerHttpRpc(ctx, '/cmtoken-oauth', async (endpoint, rawPayload, signal) => {
223
286
  try {
224
287
  if (signal?.aborted) throw new Error('请求已取消。');
225
288
  const payload = (rawPayload && typeof rawPayload === 'object') ? rawPayload : {};
226
289
 
227
290
  switch (endpoint) {
228
- // ---- 开始认证:申请设备码 + 生成二维码 ----
291
+ // ---- 开始认证:order-center 扫码(一次扫码同时获得用量查询 + 密钥管理) ----
229
292
  case 'begin': {
230
- const dc = await requestDeviceCode();
231
- const url = dc.verification_uri_complete ?? dc.verification_uri;
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
+ }
232
302
  const id = randomUUID();
233
- pending.set(id, { device_code: dc.device_code, verifier: dc.verifier, expires_at: Date.now() + (dc.expires_in ?? 600) * 1000 });
303
+ pendingOrder.set(id, { uuid: dc.uuid, expires_at: Date.now() + (dc.expire ? dc.expire * 1000 : ORDER_QR_TTL_MS) });
234
304
  return {
235
305
  ok: true,
236
306
  value: {
237
307
  requestId: id,
238
- qr_svg: qrSvg(url),
239
- verification_uri: url,
240
- verification_uri_complete: dc.verification_uri_complete,
241
- user_code: dc.user_code,
242
- interval: dc.interval ?? 2,
243
- 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),
244
311
  },
245
312
  };
246
313
  }
247
314
 
248
- // ---- 轮询令牌(client interval 调用);成功即自动发现模型 + 写配置 ----
315
+ // ---- 轮询扫码状态;成功即:桥接密钥管理会话 取/建 DSH 密钥 → 发现模型 → 写配置 ----
249
316
  case 'poll': {
250
317
  const rid = payload.requestId;
251
- const rec = pending.get(rid);
318
+ const rec = pendingOrder.get(rid);
252
319
  if (!rec) return { ok: false, error: { code: 'bad-request', message: '认证流程已失效,请重新开始', details: {} } };
253
320
  if (Date.now() > rec.expires_at) {
254
- pending.delete(rid);
255
- return { ok: false, error: { code: 'timeout', message: '授权超时(未在有效期内完成扫码),请重新开始', details: {} } };
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: {} } };
256
331
  }
257
- const r = await pollOnce(rec.device_code, rec.verifier);
258
- if (r.status === 'pending') return { ok: true, value: { status: 'pending' } };
259
- if (r.status === 'slow_down') return { ok: true, value: { status: 'slow_down' } };
260
- // 成功:发现模型 + 写配置 + 存凭据
261
- pending.delete(rid);
262
- const token = r.token;
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);
263
352
  let models = [];
264
353
  try {
265
- models = await fetchModels(token.access, payload.discoveryUrl);
354
+ models = await fetchKomectModels(session);
266
355
  } catch (e) {
267
- // 模型发现失败:凭据照存,配置给最小模型集,报错提示
268
- ctx.logger?.warn?.('[cmtoken-oauth] 模型发现失败: ' + String(e.message || e));
269
- models = [{ id: 'deepseekv4-flash', name: 'deepseekv4-flash', contextWindow: 192000, maxTokens: 8192 }];
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'] }];
270
359
  }
271
- await writeConfigAndCreds(ctx, token, models);
360
+ await writeConfigAndCreds(ctx, { apiKey, session, expires }, models);
272
361
  return {
273
362
  ok: true,
274
363
  value: {
275
364
  status: 'ok',
276
- // input 模态一并返回,client 用它给视觉模型打标
277
365
  models: models.map((m) => ({ ...m, input: m.input ?? modelInputModalities(m.id) })),
278
- baseURL: token.resourceUrl || DEFAULT_API_BASE,
366
+ baseURL: GW_BASE,
367
+ keyName: DSH_KEY_NAME,
368
+ usage,
369
+ maskedPhone: st.phone ?? null,
279
370
  },
280
371
  };
281
372
  }
282
373
 
283
- // ---- 认证状态 ----
374
+ // ---- 认证状态(含算力豆用量) ----
284
375
  case 'status': {
285
- // 先触发一次按需自动刷新:用户一旦查看状态,若 access 已过期/临期
286
- // 且有有效 refresh token,立即刷新,避免只靠粗粒度定时器漏刷。
287
- await autoRefresh(ctx).catch((e) => ctx.logger?.warn?.('[cmtoken-oauth] 状态查询时自动刷新失败: ' + String(e?.message || e)));
288
376
  const creds = ctx.credentials;
289
377
  const apiInfo = await creds?.describe(credentialRef(API_KEY_ENV)).catch(() => undefined);
290
- const refreshInfo = await creds?.describe(credentialRef(REFRESH_ENV)).catch(() => undefined);
378
+ const sessInfo = await creds?.describe(credentialRef(SESSION_ENV)).catch(() => undefined);
379
+ const orderInfo = await creds?.describe(credentialRef(ORDER_SESSION_ENV)).catch(() => undefined);
291
380
  const expRaw = await creds?.resolve(credentialRef(EXPIRES_ENV)).catch(() => undefined);
292
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
+ }
293
395
  const candidates = visionModelCandidates(ctx);
294
396
  const cfg = await readPluginConfig();
295
397
  return {
296
398
  ok: true,
297
399
  value: {
298
400
  configured: !!(apiInfo?.configured),
299
- hasRefresh: !!(refreshInfo?.configured),
300
- expiresAt: Number.isFinite(expires) && expires > 0 ? expires : null,
301
- expired: Number.isFinite(expires) && expires > 0 ? Date.now() > expires : null,
302
- // 视觉回退模型:候选(按优先级升序)+ 当前选择(null = 自动)
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,
303
410
  visionModels: candidates,
304
411
  visionModel: candidates.some((c) => c.id === cfg.visionModel) ? cfg.visionModel : null,
305
412
  },
@@ -322,20 +429,24 @@ function registerRpc(ctx) {
322
429
  return { ok: true, value: { visionModel: hit.id } };
323
430
  }
324
431
 
325
- // ---- 刷新令牌 ----
432
+ // ---- 刷新:order_session 有效则重新桥接 + 重新发现模型 ----
326
433
  case 'refresh': {
327
- const ref = await ctx.credentials?.resolve(credentialRef(REFRESH_ENV));
328
- if (!ref) return { ok: false, error: { code: 'bad-request', message: '没有可用的刷新令牌', details: {} } };
329
- const token = await refreshToken(ref.value);
330
- await ctx.credentials.set(credentialRef(API_KEY_ENV), token.access);
331
- await ctx.credentials.set(credentialRef(REFRESH_ENV), token.refresh);
332
- await ctx.credentials.set(credentialRef(EXPIRES_ENV), String(token.expires));
333
- return { ok: true, value: { expiresAt: token.expires } };
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 } };
334
443
  }
335
444
 
336
445
  // ---- 注销(清除凭据,保留 provider 配置) ----
337
446
  case 'logout': {
338
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(() => {});
339
450
  await ctx.credentials?.unset(credentialRef(REFRESH_ENV)).catch(() => {});
340
451
  await ctx.credentials?.unset(credentialRef(EXPIRES_ENV)).catch(() => {});
341
452
  return { ok: true, value: {} };
@@ -347,23 +458,24 @@ function registerRpc(ctx) {
347
458
  } catch (e) {
348
459
  return errorResult(e);
349
460
  }
350
- }, { authority: 'loopback' });
461
+ });
351
462
  }
352
463
 
353
- async function writeConfigAndCreds(ctx, token, models) {
464
+ async function writeConfigAndCreds(ctx, { apiKey, session, expires }, models) {
354
465
  // 1) 凭据(OS keyring / 凭据服务,apiKeyEnv 引用)
355
- await ctx.credentials.set(credentialRef(API_KEY_ENV), token.access);
356
- await ctx.credentials.set(credentialRef(REFRESH_ENV), token.refresh);
357
- await ctx.credentials.set(credentialRef(EXPIRES_ENV), String(token.expires));
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));
358
470
 
359
471
  // 2) llm-pi-ai provider 配置(merge 进现有 providers,保留其他路由)
360
472
  const section = {
361
473
  providers: {
362
474
  cmtoken: {
363
- displayName: 'cmtoken',
475
+ displayName: 'komect 算力豆',
364
476
  apiKeyEnv: API_KEY_ENV,
365
477
  api: 'openai-completions',
366
- baseURL: token.resourceUrl || DEFAULT_API_BASE,
478
+ baseURL: GW_BASE,
367
479
  models: models.map((m) => ({
368
480
  id: m.id,
369
481
  name: m.name,
@@ -383,55 +495,6 @@ async function writeConfigAndCreds(ctx, token, models) {
383
495
  }
384
496
  }
385
497
 
386
- // ---------------- 自动刷新令牌 ----------------
387
- // 定时器只负责「保底」:每个短周期检查一次;同时 status 查询也会触发按需自动刷新,
388
- // 因此只要 token 过期且 refresh token 仍有效,就会在过期后迅速被刷新,而不是等 2 小时。
389
- const AUTO_REFRESH_INTERVAL_MS = 60_000; // 每 1 分钟检查一次(轻量,剩余充足时直接返回)
390
- const AUTO_REFRESH_THRESHOLD_MS = 60 * 60_000; // 剩余 <1 小时即提前刷新(token 生命周期 ~5 小时,从容轮换)
391
- const REFRESH_COOLDOWN_MS = 60_000; // 刷新失败后冷却 1 分钟再试
392
- let lastRefreshAttempt = 0;
393
-
394
- /** 检查凭据:若 access 快过期且有 refresh,则自动刷新并更新凭据。 */
395
- async function autoRefresh(ctx) {
396
- const creds = ctx.credentials;
397
- if (!creds) return;
398
- const refRes = await creds.resolve(credentialRef(REFRESH_ENV)).catch(() => undefined);
399
- if (!refRes) return; // 没有 refresh token(未认证或已注销)
400
-
401
- // 失败冷却:上次刷新失败后 REFRESH_COOLDOWN_MS 内不重试,避免无限循环
402
- const now = Date.now();
403
- if (now - lastRefreshAttempt < REFRESH_COOLDOWN_MS) return;
404
-
405
- const expRes = await creds.resolve(credentialRef(EXPIRES_ENV)).catch(() => undefined);
406
- const expires = expRes ? Number(expRes.value) : NaN;
407
- if (!Number.isFinite(expires) || expires <= 0) return;
408
- const remaining = expires - now;
409
- // 剩余还多 → 不用刷;过期超过 7 天 → refresh 大概率已失效,放弃自动刷(需重新认证)
410
- if (remaining > AUTO_REFRESH_THRESHOLD_MS) return;
411
- if (remaining < -7 * 24 * 3600_000) return;
412
-
413
- try {
414
- const token = await refreshToken(refRes.value);
415
- await creds.set(credentialRef(API_KEY_ENV), token.access);
416
- await creds.set(credentialRef(REFRESH_ENV), token.refresh);
417
- await creds.set(credentialRef(EXPIRES_ENV), String(token.expires));
418
- lastRefreshAttempt = 0;
419
- ctx.logger?.info?.('[cmtoken-oauth] 令牌已自动刷新,新过期时间: ' + new Date(token.expires).toISOString());
420
- } catch (e) {
421
- lastRefreshAttempt = Date.now();
422
- ctx.logger?.warn?.('[cmtoken-oauth] 自动刷新失败(冷却 ' + REFRESH_COOLDOWN_MS / 60000 + ' 分钟): ' + String(e?.message || e));
423
- }
424
- }
425
-
426
- /** 启动自动刷新定时器;返回清理函数。 */
427
- function startAutoRefresh(ctx) {
428
- const timer = setInterval(() => {
429
- void autoRefresh(ctx).catch((e) => ctx.logger?.warn?.('[cmtoken-oauth] 自动刷新异常: ' + String(e?.message || e)));
430
- }, AUTO_REFRESH_INTERVAL_MS);
431
- timer.unref?.();
432
- return () => clearInterval(timer);
433
- }
434
-
435
498
  // ---------------- 视觉回退:cmtoken_read_image 工具 ----------------
436
499
  // DSH 的图片准入以模型声明的 input 模态为准:文本模型收图直接报 UNSUPPORTED_CONTENT,
437
500
  // rc8 没有原生的"改投视觉模型"路由。本工具补上这条通路:任何在用模型把图片路径交给
@@ -593,7 +656,7 @@ async function describeImage({ baseURL, apiKey, model, dataUri, focus, signal })
593
656
  }
594
657
  if (!res.ok) {
595
658
  const text = (await res.text().catch(() => '')).slice(0, 300);
596
- if (res.status === 401) throw new Error('cmtoken 访问令牌无效,请在设置 → cmtoken 认证里重新认证');
659
+ if (res.status === 401) throw new Error('komect API key 无效,请在设置 → cmtoken 认证里重新认证');
597
660
  if (res.status === 403 && /订阅套餐|订阅已过期|余额不足/.test(text)) throw new Error('订阅错误:' + text);
598
661
  throw new Error(`视觉模型识图失败 ${res.status}: ${text}`);
599
662
  }
@@ -609,7 +672,7 @@ function registerVisionTool(ctx) {
609
672
  const tool = {
610
673
  name: VISION_TOOL_NAME,
611
674
  description:
612
- '通过 cmtoken 视觉模型读取图片,仅作为纯文本模型的视觉补足:当前模型原生支持识图(input 声明含 image 或可用 read_image)时不要调用本工具,直接看图即可。当对话中出现当前模型无法查看的图片时使用,典型形态是 [Image: <本地路径>] 行(粘贴/拖入的图片由插件自动落盘)或裸的本地绝对路径、http(s) URL:截图、照片、图表、扫描件等。返回结构化取证结果(summary、ocr_full_text、layout、semantics、uncertainty),请引用证据而不是猜测。需要已完成 cmtoken 一键认证。',
675
+ '通过 cmtoken 视觉模型读取图片。当对话中出现当前模型无法查看的图片时使用,典型形态是 [Image: <本地路径>] 行(粘贴/拖入的图片由插件自动落盘)或裸的本地绝对路径、http(s) URL:截图、照片、图表、扫描件等。返回结构化取证结果(summary、ocr_full_text、layout、semantics、uncertainty),请引用证据而不是猜测。需要已完成 cmtoken 一键认证。',
613
676
  parameters: {
614
677
  type: 'object',
615
678
  properties: {
@@ -631,10 +694,8 @@ function registerVisionTool(ctx) {
631
694
  const model = await resolveVisionModel(ctx);
632
695
  if (!model) throw new Error('cmtoken 未配置视觉模型:请先在 DSH 设置 → cmtoken 认证完成一键认证');
633
696
 
634
- // 识图前先按需刷新令牌(复用自动续期逻辑),避免用过期 access 调用
635
- await autoRefresh(ctx).catch(() => {});
636
697
  const key = await ctx.credentials?.resolve(credentialRef(API_KEY_ENV));
637
- if (!key) throw new Error('cmtoken 访问令牌不可用:请先在 DSH 设置 → cmtoken 认证完成一键认证');
698
+ if (!key) throw new Error('komect API key 不可用:请先在 DSH 设置 → cmtoken 认证完成一键认证');
638
699
 
639
700
  const bytes = await loadImageBytes(source, exec?.signal);
640
701
  if (bytes.length === 0) throw new Error('图片内容为空');
@@ -646,7 +707,7 @@ function registerVisionTool(ctx) {
646
707
 
647
708
  const signals = [AbortSignal.timeout(VISION_TOOL_TIMEOUT_MS)];
648
709
  if (exec?.signal) signals.push(exec.signal);
649
- const baseURL = ctx.settings?.get?.(settingsNamespace('llm-pi-ai'))?.providers?.cmtoken?.baseURL || DEFAULT_API_BASE;
710
+ const baseURL = ctx.settings?.get?.(settingsNamespace('llm-pi-ai'))?.providers?.cmtoken?.baseURL || GW_BASE;
650
711
  const evidence = await describeImage({
651
712
  baseURL,
652
713
  apiKey: key.value,
@@ -734,10 +795,9 @@ async function evidenceForBlock(ctx, block, signal, cache) {
734
795
  }
735
796
  const model = await resolveVisionModel(ctx);
736
797
  if (!model) throw new Error('cmtoken 未配置视觉模型,请先完成一键认证');
737
- await autoRefresh(ctx).catch(() => {});
738
798
  const keyRes = await ctx.credentials?.resolve(credentialRef(API_KEY_ENV));
739
- if (!keyRes) throw new Error('cmtoken 访问令牌不可用,请重新认证');
740
- const baseURL = ctx.settings?.get?.(settingsNamespace('llm-pi-ai'))?.providers?.cmtoken?.baseURL || DEFAULT_API_BASE;
799
+ if (!keyRes) throw new Error('komect API key 不可用,请重新认证');
800
+ const baseURL = ctx.settings?.get?.(settingsNamespace('llm-pi-ai'))?.providers?.cmtoken?.baseURL || GW_BASE;
741
801
  const signals = [AbortSignal.timeout(VISION_TOOL_TIMEOUT_MS)];
742
802
  if (signal) signals.push(signal);
743
803
  const evidence = await describeImage({
@@ -825,16 +885,13 @@ function modelLabelToName(label) {
825
885
  return s.replace(/\s*\(视觉回退\)\s*$/i, '').trim();
826
886
  }
827
887
 
828
- /** 在已认证模型列表里查找(id 或显示名)。只做「相等」匹配(忽略大小写与分隔符),
829
- * 绝不做子串包含——否则其他 provider 的同名系模型(如 zai-coding-cn 的
830
- * glm-5.3-flash 包含 cmtoken 组 "glm-5" 的字样)会被误判为 cmtoken 模型而遭到
831
- * 拖图接管,违背「本插件只服务 cmtoken 提供方」的设计边界;找不到返回 undefined。 */
888
+ /** 在已认证模型列表里查找(id 或显示名,忽略大小写;兜底子串匹配);找不到返回 undefined。 */
832
889
  function findConfiguredModel(ctx, name) {
833
890
  if (!name) return undefined;
834
891
  const models = ctx.settings?.get?.(settingsNamespace('llm-pi-ai'))?.providers?.cmtoken?.models ?? [];
835
- const norm = (s) => String(s ?? '').toLowerCase().replace(/[-_\s.]+/g, '');
836
- const target = norm(name);
837
- return models.find((m) => norm(m.id) === target || norm(m.name) === target);
892
+ const lower = String(name).toLowerCase();
893
+ return models.find((m) => String(m.id).toLowerCase() === lower || String(m.name ?? '').toLowerCase() === lower)
894
+ ?? models.find((m) => lower.includes(String(m.id).toLowerCase()) || lower.includes(String(m.name ?? '').toLowerCase()));
838
895
  }
839
896
 
840
897
  // ---------------- 视觉回退模型选择(~/.dsh/cmtoken-oauth.json) ----------------
@@ -862,12 +919,13 @@ async function writePluginConfig(patch) {
862
919
  return cfg;
863
920
  }
864
921
 
865
- /** 已认证模型里的视觉模型,按回退优先级升序。 */
922
+ /** 已认证模型里的视觉模型:以发现接口的 input 模态声明为准(supports_images),
923
+ * 同能力时按回退优先级(visionRank)排序;无声明时回退名称启发(兼容旧配置)。 */
866
924
  function visionModelCandidates(ctx) {
867
925
  const models = ctx.settings?.get?.(settingsNamespace('llm-pi-ai'))?.providers?.cmtoken?.models ?? [];
868
926
  return models
869
- .map((m) => ({ id: m.id, name: m.name ?? m.id, rank: visionRank(m.id) }))
870
- .filter((m) => m.rank > 0)
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 }))
871
929
  .sort((a, b) => a.rank - b.rank);
872
930
  }
873
931
 
@@ -1080,25 +1138,26 @@ function registerVisionAdapter(ctx) {
1080
1138
 
1081
1139
  export function apply(ctx, cfg = {}) {
1082
1140
  let removeRpc = () => {};
1083
- let stopTimer = () => {};
1084
1141
  let disposeVisionAdapter = () => {};
1142
+ // 在声明了 webServer 的子上下文里注册 RPC 前缀路由(DSH 0.1.5+)。
1143
+ const rpcScope = ctx.inject(['webServer'], (scope) => {
1144
+ removeRpc = registerRpc(scope);
1145
+ });
1146
+ void rpcScope;
1085
1147
  ctx.effect(() => {
1086
- removeRpc = registerRpc(ctx);
1087
- stopTimer = startAutoRefresh(ctx);
1088
1148
  registerVisionTool(ctx);
1089
1149
  registerPasteRoute(ctx);
1090
1150
  registerPasteInjection(ctx);
1091
1151
  // 视觉回退包装分组(cmtoken-vision)默认关闭:粘贴识图已覆盖纯文本模型,
1092
1152
  // 需要真实图片气泡的会话可在 profile 的插件 config 里设 visionRoute: true。
1093
1153
  if (cfg.visionRoute === true) disposeVisionAdapter = registerVisionAdapter(ctx);
1094
- // 启动时立即补刷一次:若停机期间 access 过期、但 refresh 仍有效,马上恢复,
1095
- // 不必等 60 秒定时器;失败只记日志,不影响启动。
1096
- void autoRefresh(ctx).catch((e) => ctx.logger?.warn?.('[cmtoken-oauth] 启动补刷失败: ' + String(e?.message || e)));
1097
1154
  return () => {
1098
1155
  removeRpc();
1099
- stopTimer();
1100
1156
  disposeVisionAdapter();
1101
1157
  };
1102
1158
  }, 'cmtoken-oauth: host service');
1103
- ctx.logger?.info?.('[cmtoken-oauth] host 服务已启动(自动刷新已开启:启动即补刷 + 每 1 分钟检查 + status 查询按需触发,剩余 <1 小时或已过期即刷新)');
1159
+ ctx.logger?.info?.('[cmtoken-oauth] host 服务已启动(komect API-key 通道:密钥长期有效,无需定时刷新)');
1104
1160
  }
1161
+
1162
+
1163
+
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-cmtoken-oauth",
3
- "version": "1.5.2",
4
- "description": "DeepSeek Harness 插件:在 DSH 设置页一键完成广东移动 MaaS(cmtoken)的 OAuth 2.0 Device Grant + PKCE 认证,自动发现模型并写入 llm-pi-ai provider 配置(视觉模型自动声明图片输入模态);粘贴识图让纯文本模型直接收图(Qwen3.8 / Qwen3.x / Kimi K2.x / MiniMax M3 自动识读),另有 cmtoken_read_image 工具与可选的 cmtoken-vision 包装路由;凭据安全保存并自动续期。零第三方运行时依赖。",
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",