dsh-cmtoken-oauth 1.0.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/LICENSE +21 -0
- package/README.md +51 -0
- package/cordis.patch.yml +5 -0
- package/lib/client.js +201 -0
- package/lib/index.js +348 -0
- package/lib/qr-core.cjs +843 -0
- package/lib/types/client/index.d.ts +5 -0
- package/lib/types/index.d.ts +7 -0
- package/package.json +59 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 cmtoken-oauth contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# cmtoken 一键认证(DeepSeek Harness 插件)
|
|
2
|
+
|
|
3
|
+
在 **DeepSeek Harness 设置**里一键认证广东移动 MaaS(cmtoken),自动发现模型并接入 DSH:
|
|
4
|
+
|
|
5
|
+
- 🔑 **一键认证**:标准 OAuth 2.0 Device Grant (RFC 8628) + PKCE (S256),扫码即完成
|
|
6
|
+
- 📷 **二维码**:内联 QR 编码器(零依赖)生成 SVG 二维码
|
|
7
|
+
- 🔄 **自动发现模型**:认证后拉取可用模型列表写入 provider 配置
|
|
8
|
+
- 🗝 **安全存储**:token 存 DSH 凭据服务(OS keyring / env),不落盘明文
|
|
9
|
+
- 🔁 **刷新 / 注销**:令牌过期可一键刷新,可随时注销
|
|
10
|
+
|
|
11
|
+
零第三方运行时依赖(仅 Node 内置模块)。
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## 安装(分享给别人)
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
# 在 DSH profile 目录(默认 ~/.dsh/profiles/web)执行
|
|
19
|
+
dsh plugin --profile web add github:你的用户名/dsh-cmtoken-oauth
|
|
20
|
+
# 或 npm:
|
|
21
|
+
dsh plugin --profile web add dsh-cmtoken-oauth
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
`dsh plugin add` 会自动加入 profile 的 `dsh.profile.bundles`,重启 `dsh web` 生效。
|
|
25
|
+
|
|
26
|
+
## 使用
|
|
27
|
+
|
|
28
|
+
1. 打开 DSH Web → 设置(左下角齿轮)→ 左侧导航点「**cmtoken 认证**」。
|
|
29
|
+
2. 点「开始一键认证」→ 用手机扫描二维码(或打开链接输入用户码)→ 在中国移动 IDaaS 页确认授权。
|
|
30
|
+
3. 自动完成:发现模型 → 写入 `llm-pi-ai.providers.cmtoken` → 存凭据。
|
|
31
|
+
4. 在 DSH 模型选择器里选用 cmtoken 模型即可。
|
|
32
|
+
|
|
33
|
+
## 技术说明
|
|
34
|
+
|
|
35
|
+
- 协议端点(分析自 cmtoken@1.0.7):
|
|
36
|
+
- 授权服务器 `https://agentlink.idaas.cmpassport.com/oauth2-service`
|
|
37
|
+
- 设备码 `POST /oauth/device/code`、令牌 `POST /oauth/device/token`
|
|
38
|
+
- 模型发现 `GET https://maas.gd.chinamobile.com:36007/ai/uifm/open/v1/models`
|
|
39
|
+
- 凭据引用:`CMTOKEN_API_KEY` / `CMTOKEN_REFRESH_TOKEN` / `CMTOKEN_EXPIRES`
|
|
40
|
+
- 写配置:`llm-pi-ai` 设置 namespace 的 `providers.cmtoken`(merge,不覆盖其他 provider)
|
|
41
|
+
|
|
42
|
+
## 开发
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
lib/index.js # host:OAuth 核心(PKCE/设备码/轮询/刷新/模型发现)+ RPC
|
|
46
|
+
lib/client.js # web:设置页「cmtoken 认证」向导(二维码+轮询+完成)
|
|
47
|
+
lib/qr-core.cjs # 内联 QR 编码器(提取自 qrcode-terminal,输出 SVG)
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## License
|
|
51
|
+
MIT
|
package/cordis.patch.yml
ADDED
package/lib/client.js
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({
|
|
2
|
+
id: "cmtoken-oauth",
|
|
3
|
+
factory: (require) => {
|
|
4
|
+
var module = { exports: {} };
|
|
5
|
+
var exports = module.exports;
|
|
6
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
7
|
+
|
|
8
|
+
let react = require("react");
|
|
9
|
+
let { useState, useEffect, useCallback, useRef } = react;
|
|
10
|
+
let primitives = require("@deepseek-ai/dsh-client-ui-primitives");
|
|
11
|
+
let { Button, Pill } = primitives;
|
|
12
|
+
|
|
13
|
+
// ==================== 工具 ====================
|
|
14
|
+
const CHANNEL = "/cmtoken-oauth";
|
|
15
|
+
function unwrap(r) { if (r && r.ok) return r.value; throw new Error(r && r.error ? r.error.message : "未知错误"); }
|
|
16
|
+
|
|
17
|
+
// ==================== 样式 ====================
|
|
18
|
+
const tagId = "cmtoken-oauth/style.css";
|
|
19
|
+
if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]") === null) {
|
|
20
|
+
const tag = document.createElement("style");
|
|
21
|
+
tag.dataset.plugin = "cmtoken-oauth";
|
|
22
|
+
tag.dataset.pluginCss = tagId;
|
|
23
|
+
tag.textContent = `
|
|
24
|
+
.dsh-co{display:flex;flex-direction:column;gap:14px;font-size:14px;color:var(--dsw-alias-label-primary);max-width:520px}
|
|
25
|
+
.dsh-co h3{margin:0;font-size:15px}
|
|
26
|
+
.dsh-co p{font-size:13px;color:var(--dsw-alias-label-secondary);line-height:1.7;margin:0}
|
|
27
|
+
.dsh-co .co-qr{display:flex;flex-direction:column;align-items:center;gap:10px;background:var(--dsw-alias-bg-raised);border:1px solid var(--dsw-alias-border-l2);border-radius:14px;padding:18px}
|
|
28
|
+
.dsh-co .co-qr svg{width:200px;height:200px;border-radius:8px}
|
|
29
|
+
.dsh-co .co-qr .co-url{font-size:12.5px;color:var(--dsw-alias-label-tertiary);word-break:break-all;text-align:center}
|
|
30
|
+
.dsh-co .co-qr .co-code{font-size:16px;font-weight:700;letter-spacing:2px;color:var(--dsw-alias-accent,#4d6bfe)}
|
|
31
|
+
.dsh-co .co-status{display:flex;align-items:center;gap:8px;font-size:13px;color:var(--dsw-alias-label-secondary)}
|
|
32
|
+
.dsh-co .co-dot{width:8px;height:8px;border-radius:50%;background:var(--dsw-alias-accent,#4d6bfe);animation:co-blink 1.2s infinite}
|
|
33
|
+
@keyframes co-blink{50%{opacity:.25}}
|
|
34
|
+
.dsh-co .co-ok{color:var(--dsw-alias-state-success,#3ecf8e)}
|
|
35
|
+
.dsh-co .co-err{color:var(--dsw-alias-state-error-primary,#e5484d);font-size:13px}
|
|
36
|
+
.dsh-co .co-models{display:flex;flex-wrap:wrap;gap:6px}
|
|
37
|
+
.dsh-co .co-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
|
|
38
|
+
.dsh-co .co-hint{font-size:12px;color:var(--dsw-alias-label-tertiary);line-height:1.6}
|
|
39
|
+
`;
|
|
40
|
+
document.head.appendChild(tag);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ==================== 认证向导组件 ====================
|
|
44
|
+
function CmTokenSettings(props) {
|
|
45
|
+
const { rpc } = props;
|
|
46
|
+
const [phase, setPhase] = useState("loading"); // loading | idle | qr | done | error
|
|
47
|
+
const [status, setStatus] = useState(null);
|
|
48
|
+
const [flow, setFlow] = useState(null); // {requestId, interval}
|
|
49
|
+
const [models, setModels] = useState([]);
|
|
50
|
+
const [error, setError] = useState(null);
|
|
51
|
+
const pollTimer = useRef(null);
|
|
52
|
+
|
|
53
|
+
const refreshStatus = useCallback(async () => {
|
|
54
|
+
try { setStatus(unwrap(await rpc.call(CHANNEL, "status", {}))); }
|
|
55
|
+
catch { setStatus(null); }
|
|
56
|
+
}, [rpc]);
|
|
57
|
+
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
refreshStatus().then(() => setPhase((p) => (p === "loading" ? "idle" : p)));
|
|
60
|
+
return () => { if (pollTimer.current) clearInterval(pollTimer.current); };
|
|
61
|
+
}, [refreshStatus]);
|
|
62
|
+
|
|
63
|
+
const begin = async () => {
|
|
64
|
+
setError(null);
|
|
65
|
+
try {
|
|
66
|
+
const v = unwrap(await rpc.call(CHANNEL, "begin", {}));
|
|
67
|
+
setFlow(v);
|
|
68
|
+
setPhase("qr");
|
|
69
|
+
// 启动轮询
|
|
70
|
+
if (pollTimer.current) clearInterval(pollTimer.current);
|
|
71
|
+
pollTimer.current = setInterval(async () => {
|
|
72
|
+
try {
|
|
73
|
+
const r = unwrap(await rpc.call(CHANNEL, "poll", { requestId: v.requestId }));
|
|
74
|
+
if (r.status === "ok") {
|
|
75
|
+
if (pollTimer.current) clearInterval(pollTimer.current);
|
|
76
|
+
setModels(r.models || []);
|
|
77
|
+
setPhase("done");
|
|
78
|
+
refreshStatus();
|
|
79
|
+
}
|
|
80
|
+
} catch (e) {
|
|
81
|
+
if (pollTimer.current) clearInterval(pollTimer.current);
|
|
82
|
+
setError(e.message || String(e));
|
|
83
|
+
setPhase("error");
|
|
84
|
+
}
|
|
85
|
+
}, Math.max((v.interval || 2) * 1000, 2000));
|
|
86
|
+
} catch (e) {
|
|
87
|
+
setError(e.message || String(e));
|
|
88
|
+
setPhase("error");
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const doRefresh = async () => {
|
|
93
|
+
setError(null);
|
|
94
|
+
try { unwrap(await rpc.call(CHANNEL, "refresh", {})); await refreshStatus(); }
|
|
95
|
+
catch (e) { setError(e.message || String(e)); }
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const doLogout = async () => {
|
|
99
|
+
setError(null);
|
|
100
|
+
try { unwrap(await rpc.call(CHANNEL, "logout", {})); await refreshStatus(); setModels([]); }
|
|
101
|
+
catch (e) { setError(e.message || String(e)); }
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
// loading
|
|
105
|
+
if (phase === "loading") {
|
|
106
|
+
return react.createElement("div", { className: "dsh-co" }, react.createElement("div", { className: "co-hint" }, "加载中…"));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// 已认证状态卡片(顶部常显)
|
|
110
|
+
const statusCard = react.createElement("div", null,
|
|
111
|
+
react.createElement("h3", null, "CMToken 模型服务"),
|
|
112
|
+
react.createElement("p", null, "广东移动 MaaS(cmtoken):通过中国移动 IDaaS 扫码授权后,自动发现模型并接入 DSH 的模型列表。令牌临期或过期后会自动刷新(只要有有效的刷新令牌),无需手动干预。"),
|
|
113
|
+
status ? react.createElement("div", { className: "co-row", style: { marginTop: 8 } },
|
|
114
|
+
status.configured ? react.createElement(Pill, { active: true }, "已认证" + (status.expired ? "(已过期)" : "")) : react.createElement(Pill, null, "未认证"),
|
|
115
|
+
status.expiresAt ? react.createElement("span", { className: "co-hint" }, "剩余约 " + Math.max(0, Math.round((status.expiresAt - Date.now()) / 60000)) + " 分钟(自动刷新)") : null,
|
|
116
|
+
status.configured && !status.expired ? react.createElement(Button, { variant: "ghost", size: "sm", onClick: doRefresh }, "立即刷新") : null,
|
|
117
|
+
status.configured ? react.createElement(Button, { variant: "ghost", size: "sm", onClick: doLogout }, "注销") : null
|
|
118
|
+
) : null
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
// 二维码阶段
|
|
122
|
+
if (phase === "qr" && flow) {
|
|
123
|
+
return react.createElement("div", { className: "dsh-co" },
|
|
124
|
+
statusCard,
|
|
125
|
+
react.createElement("div", { className: "co-qr" },
|
|
126
|
+
react.createElement("div", { dangerouslySetInnerHTML: { __html: flow.qr_svg } }),
|
|
127
|
+
react.createElement("div", { className: "co-code" }, flow.user_code),
|
|
128
|
+
react.createElement("div", { className: "co-url" }, flow.verification_uri),
|
|
129
|
+
react.createElement("div", { className: "co-hint" }, "请用手机扫描二维码完成授权(或打开上方链接输入用户码)。有效期 " + Math.round((flow.expires_in || 600) / 60) + " 分钟。")
|
|
130
|
+
),
|
|
131
|
+
react.createElement("div", { className: "co-status" },
|
|
132
|
+
react.createElement("span", { className: "co-dot" }),
|
|
133
|
+
react.createElement("span", null, "正在等待扫码授权…")
|
|
134
|
+
),
|
|
135
|
+
error ? react.createElement("div", { className: "co-err" }, error) : null
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// 完成阶段
|
|
140
|
+
if (phase === "done") {
|
|
141
|
+
return react.createElement("div", { className: "dsh-co" },
|
|
142
|
+
statusCard,
|
|
143
|
+
react.createElement("div", { className: "co-ok" }, "✅ 认证成功!已发现 " + models.length + " 个模型并写入配置。"),
|
|
144
|
+
models.length ? react.createElement("div", null,
|
|
145
|
+
react.createElement("div", { className: "co-hint", style: { marginBottom: 6 } }, "可用模型:"),
|
|
146
|
+
react.createElement("div", { className: "co-models" }, models.map((m, i) => react.createElement(Pill, { key: i }, m.name || m.id)))
|
|
147
|
+
) : null,
|
|
148
|
+
react.createElement("p", null, "现在可以在 DSH 的模型选择器里选用 cmtoken 模型了。")
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// 错误阶段
|
|
153
|
+
if (phase === "error") {
|
|
154
|
+
return react.createElement("div", { className: "dsh-co" },
|
|
155
|
+
statusCard,
|
|
156
|
+
react.createElement("div", { className: "co-err" }, "❌ " + (error || "认证失败")),
|
|
157
|
+
react.createElement(Button, { variant: "primary", size: "sm", onClick: () => { setPhase("idle"); setError(null); } }, "返回重试")
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// idle:未开始 / 未认证
|
|
162
|
+
return react.createElement("div", { className: "dsh-co" },
|
|
163
|
+
statusCard,
|
|
164
|
+
status && status.configured && !status.expired
|
|
165
|
+
? react.createElement("div", { className: "co-ok" }, "✅ 已通过 cmtoken 认证,模型可用。需要重新认证或换账号时点下方按钮。")
|
|
166
|
+
: react.createElement("p", null, "一键认证:点击下方按钮,用手机扫描二维码即可完成广东移动 MaaS 授权(无需手动填写 API Key)。"),
|
|
167
|
+
react.createElement("div", { className: "co-row" },
|
|
168
|
+
react.createElement(Button, { variant: "primary", size: "md", onClick: begin }, "开始一键认证"),
|
|
169
|
+
status && status.hasRefresh && status.expired ? react.createElement(Button, { variant: "outline", size: "md", onClick: doRefresh }, "刷新令牌") : null
|
|
170
|
+
),
|
|
171
|
+
error ? react.createElement("div", { className: "co-err" }, error) : null
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ==================== 插件主体 ====================
|
|
176
|
+
const NS = "cmtoken-oauth";
|
|
177
|
+
const zh = { "nav": "cmtoken 认证" };
|
|
178
|
+
const en = { "nav": "cmtoken Auth" };
|
|
179
|
+
const inject = ["slots", "locale", "connection"];
|
|
180
|
+
|
|
181
|
+
function apply(ctx) {
|
|
182
|
+
ctx.effect(() => ctx.locale.register(NS, { zh, en }), "cmtoken-oauth: dictionaries");
|
|
183
|
+
const connection = ctx.get("connection");
|
|
184
|
+
const rpc = connection.rpc;
|
|
185
|
+
const t = ctx.locale.bind(NS);
|
|
186
|
+
|
|
187
|
+
// 注册「设置 → cmtoken 认证」页面
|
|
188
|
+
ctx.slots.inject("settings.section", () => ctx.slots.register({
|
|
189
|
+
name: "settings.section",
|
|
190
|
+
id: "cmtoken",
|
|
191
|
+
order: 60,
|
|
192
|
+
label: () => t("nav"),
|
|
193
|
+
inject: () => ({ rpc }),
|
|
194
|
+
}, CmTokenSettings));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
exports.apply = apply;
|
|
198
|
+
exports.inject = inject;
|
|
199
|
+
return module.exports;
|
|
200
|
+
}
|
|
201
|
+
});
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// cmtoken 一键认证 — DeepSeek Harness host 插件
|
|
3
|
+
// OAuth 2.0 Device Authorization Grant (RFC 8628) + PKCE (S256)
|
|
4
|
+
// 对接广东移动 MaaS(cmtoken):一键认证 → 自动发现模型 →
|
|
5
|
+
// 写入 llm-pi-ai provider 配置 + 凭据(OS keyring / env)。
|
|
6
|
+
// ============================================================
|
|
7
|
+
import { randomBytes, randomUUID, createHash } from 'node:crypto';
|
|
8
|
+
import os from 'node:os';
|
|
9
|
+
import { createRequire } from 'node:module';
|
|
10
|
+
import z from '@deepseek-ai/schemastery';
|
|
11
|
+
import { settingsNamespace } from '@deepseek-ai/dsh-settings';
|
|
12
|
+
import { credentialRef } from '@deepseek-ai/dsh-credentials';
|
|
13
|
+
|
|
14
|
+
const require2 = createRequire(import.meta.url);
|
|
15
|
+
const { qrSvg } = require2('./qr-core.cjs');
|
|
16
|
+
|
|
17
|
+
export const name = 'cmtoken-oauth';
|
|
18
|
+
export const inject = ['connection', 'settings', 'credentials'];
|
|
19
|
+
|
|
20
|
+
export const Config = z.object({});
|
|
21
|
+
|
|
22
|
+
// ---------------- 协议常量(分析报告第 4 节) ----------------
|
|
23
|
+
const OAUTH_BASE = 'https://agentlink.idaas.cmpassport.com/oauth2-service';
|
|
24
|
+
const CLIENT_ID = '00a60e3140284b408d51a436ecce121d';
|
|
25
|
+
const DEVICE_GRANT = 'urn:ietf:params:oauth:grant-type:device_code';
|
|
26
|
+
const DEFAULT_API_BASE = 'https://maas.gd.chinamobile.com:36007/ai/uifm/open/v1';
|
|
27
|
+
const DISCOVERY_URL = DEFAULT_API_BASE + '/models';
|
|
28
|
+
const TIMEOUT = 900_000;
|
|
29
|
+
|
|
30
|
+
const API_KEY_ENV = 'CMTOKEN_API_KEY';
|
|
31
|
+
const REFRESH_ENV = 'CMTOKEN_REFRESH_TOKEN';
|
|
32
|
+
const EXPIRES_ENV = 'CMTOKEN_EXPIRES';
|
|
33
|
+
|
|
34
|
+
// 单用户 Web 场景:进行中的设备码流程保存在模块级(keyed by requestId)
|
|
35
|
+
const pending = new Map();
|
|
36
|
+
|
|
37
|
+
const scope = () => `profile model.completion identity:device:${os.hostname()}`;
|
|
38
|
+
|
|
39
|
+
function makePkce() {
|
|
40
|
+
const verifier = randomBytes(32).toString('base64url');
|
|
41
|
+
const challenge = createHash('sha256').update(verifier).digest('base64url');
|
|
42
|
+
const state = randomBytes(16).toString('base64url');
|
|
43
|
+
return { verifier, challenge, state };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function requestDeviceCode() {
|
|
47
|
+
const { verifier, challenge, state } = makePkce();
|
|
48
|
+
const res = await fetch(`${OAUTH_BASE}/oauth/device/code`, {
|
|
49
|
+
method: 'POST',
|
|
50
|
+
signal: AbortSignal.timeout(TIMEOUT),
|
|
51
|
+
headers: {
|
|
52
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
53
|
+
Accept: 'application/json',
|
|
54
|
+
'x-request-id': randomUUID(),
|
|
55
|
+
},
|
|
56
|
+
body: new URLSearchParams({
|
|
57
|
+
response_type: 'device_code',
|
|
58
|
+
client_id: CLIENT_ID,
|
|
59
|
+
scope: scope(),
|
|
60
|
+
code_challenge: challenge,
|
|
61
|
+
code_challenge_method: 'S256',
|
|
62
|
+
state,
|
|
63
|
+
}),
|
|
64
|
+
});
|
|
65
|
+
if (!res.ok) throw new Error(`设备码申请失败: ${await res.text()}`);
|
|
66
|
+
const json = await res.json();
|
|
67
|
+
if (!json.device_code || !json.user_code || !json.verification_uri) {
|
|
68
|
+
throw new Error(json.error_description ?? json.error ?? '设备码响应不完整');
|
|
69
|
+
}
|
|
70
|
+
return { ...json, verifier };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** 单次令牌轮询;成功返回 Token,pending 返回 null,失败抛错。 */
|
|
74
|
+
async function pollOnce(deviceCode, verifier) {
|
|
75
|
+
let res;
|
|
76
|
+
try {
|
|
77
|
+
res = await fetch(`${OAUTH_BASE}/oauth/device/token`, {
|
|
78
|
+
method: 'POST',
|
|
79
|
+
signal: AbortSignal.timeout(TIMEOUT),
|
|
80
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
|
|
81
|
+
body: new URLSearchParams({
|
|
82
|
+
grant_type: DEVICE_GRANT,
|
|
83
|
+
client_id: CLIENT_ID,
|
|
84
|
+
device_code: deviceCode,
|
|
85
|
+
code_verifier: verifier,
|
|
86
|
+
}),
|
|
87
|
+
});
|
|
88
|
+
} catch {
|
|
89
|
+
return { status: 'pending' }; // 网络抖动 → 当 pending
|
|
90
|
+
}
|
|
91
|
+
const json = await res.json().catch(() => undefined);
|
|
92
|
+
if (res.ok) {
|
|
93
|
+
if (!json?.access_token || !json.refresh_token || !json.expires_in) {
|
|
94
|
+
throw new Error('令牌响应不完整');
|
|
95
|
+
}
|
|
96
|
+
return {
|
|
97
|
+
status: 'ok',
|
|
98
|
+
token: {
|
|
99
|
+
access: json.access_token,
|
|
100
|
+
refresh: json.refresh_token,
|
|
101
|
+
expires: Date.now() + json.expires_in * 1000,
|
|
102
|
+
resourceUrl: json.resource_url,
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
if (json?.error === 'authorization_pending') return { status: 'pending' };
|
|
107
|
+
if (json?.error === 'slow_down') return { status: 'slow_down' };
|
|
108
|
+
throw new Error(json?.error_description ?? json?.error ?? '令牌请求失败');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function refreshToken(refresh) {
|
|
112
|
+
const res = await fetch(`${OAUTH_BASE}/oauth/device/token`, {
|
|
113
|
+
method: 'POST',
|
|
114
|
+
signal: AbortSignal.timeout(TIMEOUT),
|
|
115
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
|
|
116
|
+
body: new URLSearchParams({
|
|
117
|
+
grant_type: 'refresh_token',
|
|
118
|
+
client_id: CLIENT_ID,
|
|
119
|
+
refresh_token: refresh,
|
|
120
|
+
}),
|
|
121
|
+
});
|
|
122
|
+
const json = await res.json();
|
|
123
|
+
if (!res.ok || json.error) throw new Error(json?.error_description ?? json?.error ?? '刷新失败');
|
|
124
|
+
return {
|
|
125
|
+
access: json.access_token,
|
|
126
|
+
refresh: json.refresh_token || refresh,
|
|
127
|
+
expires: Date.now() + json.expires_in * 1000,
|
|
128
|
+
resourceUrl: json.resource_url,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function fetchModels(access, discoveryUrl) {
|
|
133
|
+
const res = await fetch(discoveryUrl || DISCOVERY_URL, {
|
|
134
|
+
signal: AbortSignal.timeout(15_000),
|
|
135
|
+
headers: { Accept: 'application/json', Authorization: `Bearer ${access}` },
|
|
136
|
+
});
|
|
137
|
+
if (!res.ok) {
|
|
138
|
+
const text = (await res.text()).slice(0, 200);
|
|
139
|
+
if (res.status === 403 && /订阅套餐|订阅已过期|余额不足/.test(text)) {
|
|
140
|
+
throw new Error('订阅错误:' + text);
|
|
141
|
+
}
|
|
142
|
+
throw new Error(`模型发现失败 ${res.status}: ${text}`);
|
|
143
|
+
}
|
|
144
|
+
const json = await res.json();
|
|
145
|
+
const list = Array.isArray(json.models) ? json.models : Array.isArray(json.data) ? json.data : [];
|
|
146
|
+
return list.map((m) => ({
|
|
147
|
+
id: m.id,
|
|
148
|
+
name: m.name ?? m.id,
|
|
149
|
+
contextWindow: m.contextWindow ?? 192_000,
|
|
150
|
+
maxTokens: m.maxTokens ?? 8192,
|
|
151
|
+
}));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function errorResult(e) {
|
|
155
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
156
|
+
return { ok: false, error: { code: 'bad-request', message, details: {} } };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function registerRpc(ctx) {
|
|
160
|
+
return ctx.connection.rpc.handle('/cmtoken-oauth', async (endpoint, rawPayload, signal) => {
|
|
161
|
+
try {
|
|
162
|
+
if (signal?.aborted) throw new Error('请求已取消。');
|
|
163
|
+
const payload = (rawPayload && typeof rawPayload === 'object') ? rawPayload : {};
|
|
164
|
+
|
|
165
|
+
switch (endpoint) {
|
|
166
|
+
// ---- 开始认证:申请设备码 + 生成二维码 ----
|
|
167
|
+
case 'begin': {
|
|
168
|
+
const dc = await requestDeviceCode();
|
|
169
|
+
const url = dc.verification_uri_complete ?? dc.verification_uri;
|
|
170
|
+
const id = randomUUID();
|
|
171
|
+
pending.set(id, { device_code: dc.device_code, verifier: dc.verifier, expires_at: Date.now() + (dc.expires_in ?? 600) * 1000 });
|
|
172
|
+
return {
|
|
173
|
+
ok: true,
|
|
174
|
+
value: {
|
|
175
|
+
requestId: id,
|
|
176
|
+
qr_svg: qrSvg(url),
|
|
177
|
+
verification_uri: url,
|
|
178
|
+
verification_uri_complete: dc.verification_uri_complete,
|
|
179
|
+
user_code: dc.user_code,
|
|
180
|
+
interval: dc.interval ?? 2,
|
|
181
|
+
expires_in: dc.expires_in ?? 600,
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ---- 轮询令牌(client 按 interval 调用);成功即自动发现模型 + 写配置 ----
|
|
187
|
+
case 'poll': {
|
|
188
|
+
const rid = payload.requestId;
|
|
189
|
+
const rec = pending.get(rid);
|
|
190
|
+
if (!rec) return { ok: false, error: { code: 'bad-request', message: '认证流程已失效,请重新开始', details: {} } };
|
|
191
|
+
if (Date.now() > rec.expires_at) {
|
|
192
|
+
pending.delete(rid);
|
|
193
|
+
return { ok: false, error: { code: 'timeout', message: '授权超时(未在有效期内完成扫码),请重新开始', details: {} } };
|
|
194
|
+
}
|
|
195
|
+
const r = await pollOnce(rec.device_code, rec.verifier);
|
|
196
|
+
if (r.status === 'pending') return { ok: true, value: { status: 'pending' } };
|
|
197
|
+
if (r.status === 'slow_down') return { ok: true, value: { status: 'slow_down' } };
|
|
198
|
+
// 成功:发现模型 + 写配置 + 存凭据
|
|
199
|
+
pending.delete(rid);
|
|
200
|
+
const token = r.token;
|
|
201
|
+
let models = [];
|
|
202
|
+
try {
|
|
203
|
+
models = await fetchModels(token.access, payload.discoveryUrl);
|
|
204
|
+
} catch (e) {
|
|
205
|
+
// 模型发现失败:凭据照存,配置给最小模型集,报错提示
|
|
206
|
+
ctx.logger?.warn?.('[cmtoken-oauth] 模型发现失败: ' + String(e.message || e));
|
|
207
|
+
models = [{ id: 'deepseekv4-flash', name: 'deepseekv4-flash', contextWindow: 192000, maxTokens: 8192 }];
|
|
208
|
+
}
|
|
209
|
+
await writeConfigAndCreds(ctx, token, models);
|
|
210
|
+
return { ok: true, value: { status: 'ok', models, baseURL: token.resourceUrl || DEFAULT_API_BASE } };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ---- 认证状态 ----
|
|
214
|
+
case 'status': {
|
|
215
|
+
// 先触发一次按需自动刷新:用户一旦查看状态,若 access 已过期/临期
|
|
216
|
+
// 且有有效 refresh token,立即刷新,避免只靠粗粒度定时器漏刷。
|
|
217
|
+
await autoRefresh(ctx).catch((e) => ctx.logger?.warn?.('[cmtoken-oauth] 状态查询时自动刷新失败: ' + String(e?.message || e)));
|
|
218
|
+
const creds = ctx.credentials;
|
|
219
|
+
const apiInfo = await creds?.describe(credentialRef(API_KEY_ENV)).catch(() => undefined);
|
|
220
|
+
const refreshInfo = await creds?.describe(credentialRef(REFRESH_ENV)).catch(() => undefined);
|
|
221
|
+
const expRaw = await creds?.resolve(credentialRef(EXPIRES_ENV)).catch(() => undefined);
|
|
222
|
+
const expires = expRaw ? Number(expRaw.value) : NaN;
|
|
223
|
+
return {
|
|
224
|
+
ok: true,
|
|
225
|
+
value: {
|
|
226
|
+
configured: !!(apiInfo?.configured),
|
|
227
|
+
hasRefresh: !!(refreshInfo?.configured),
|
|
228
|
+
expiresAt: Number.isFinite(expires) && expires > 0 ? expires : null,
|
|
229
|
+
expired: Number.isFinite(expires) && expires > 0 ? Date.now() > expires : null,
|
|
230
|
+
},
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ---- 刷新令牌 ----
|
|
235
|
+
case 'refresh': {
|
|
236
|
+
const ref = await ctx.credentials?.resolve(credentialRef(REFRESH_ENV));
|
|
237
|
+
if (!ref) return { ok: false, error: { code: 'bad-request', message: '没有可用的刷新令牌', details: {} } };
|
|
238
|
+
const token = await refreshToken(ref.value);
|
|
239
|
+
await ctx.credentials.set(credentialRef(API_KEY_ENV), token.access);
|
|
240
|
+
await ctx.credentials.set(credentialRef(REFRESH_ENV), token.refresh);
|
|
241
|
+
await ctx.credentials.set(credentialRef(EXPIRES_ENV), String(token.expires));
|
|
242
|
+
return { ok: true, value: { expiresAt: token.expires } };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ---- 注销(清除凭据,保留 provider 配置) ----
|
|
246
|
+
case 'logout': {
|
|
247
|
+
await ctx.credentials?.unset(credentialRef(API_KEY_ENV)).catch(() => {});
|
|
248
|
+
await ctx.credentials?.unset(credentialRef(REFRESH_ENV)).catch(() => {});
|
|
249
|
+
await ctx.credentials?.unset(credentialRef(EXPIRES_ENV)).catch(() => {});
|
|
250
|
+
return { ok: true, value: {} };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
default:
|
|
254
|
+
throw new Error(`未知端点: ${endpoint}`);
|
|
255
|
+
}
|
|
256
|
+
} catch (e) {
|
|
257
|
+
return errorResult(e);
|
|
258
|
+
}
|
|
259
|
+
}, { authority: 'loopback' });
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async function writeConfigAndCreds(ctx, token, models) {
|
|
263
|
+
// 1) 凭据(OS keyring / 凭据服务,apiKeyEnv 引用)
|
|
264
|
+
await ctx.credentials.set(credentialRef(API_KEY_ENV), token.access);
|
|
265
|
+
await ctx.credentials.set(credentialRef(REFRESH_ENV), token.refresh);
|
|
266
|
+
await ctx.credentials.set(credentialRef(EXPIRES_ENV), String(token.expires));
|
|
267
|
+
|
|
268
|
+
// 2) llm-pi-ai provider 配置(merge 进现有 providers,保留其他路由)
|
|
269
|
+
const section = {
|
|
270
|
+
providers: {
|
|
271
|
+
cmtoken: {
|
|
272
|
+
displayName: 'cmtoken',
|
|
273
|
+
apiKeyEnv: API_KEY_ENV,
|
|
274
|
+
api: 'openai-completions',
|
|
275
|
+
baseURL: token.resourceUrl || DEFAULT_API_BASE,
|
|
276
|
+
models: models.map((m) => ({ id: m.id, name: m.name, contextWindow: m.contextWindow, maxTokens: m.maxTokens })),
|
|
277
|
+
},
|
|
278
|
+
},
|
|
279
|
+
};
|
|
280
|
+
if (ctx.settings) {
|
|
281
|
+
await ctx.settings.update(settingsNamespace('llm-pi-ai'), section);
|
|
282
|
+
} else {
|
|
283
|
+
throw new Error('settings 服务不可用,无法写入 provider 配置');
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// ---------------- 自动刷新令牌 ----------------
|
|
288
|
+
// 定时器只负责「保底」:每个短周期检查一次;同时 status 查询也会触发按需自动刷新,
|
|
289
|
+
// 因此只要 token 过期且 refresh token 仍有效,就会在过期后迅速被刷新,而不是等 2 小时。
|
|
290
|
+
const AUTO_REFRESH_INTERVAL_MS = 60_000; // 每 1 分钟检查一次(轻量,剩余充足时直接返回)
|
|
291
|
+
const AUTO_REFRESH_THRESHOLD_MS = 60 * 60_000; // 剩余 <1 小时即提前刷新(token 生命周期 ~5 小时,从容轮换)
|
|
292
|
+
const REFRESH_COOLDOWN_MS = 60_000; // 刷新失败后冷却 1 分钟再试
|
|
293
|
+
let lastRefreshAttempt = 0;
|
|
294
|
+
|
|
295
|
+
/** 检查凭据:若 access 快过期且有 refresh,则自动刷新并更新凭据。 */
|
|
296
|
+
async function autoRefresh(ctx) {
|
|
297
|
+
const creds = ctx.credentials;
|
|
298
|
+
if (!creds) return;
|
|
299
|
+
const refRes = await creds.resolve(credentialRef(REFRESH_ENV)).catch(() => undefined);
|
|
300
|
+
if (!refRes) return; // 没有 refresh token(未认证或已注销)
|
|
301
|
+
|
|
302
|
+
// 失败冷却:上次刷新失败后 REFRESH_COOLDOWN_MS 内不重试,避免无限循环
|
|
303
|
+
const now = Date.now();
|
|
304
|
+
if (now - lastRefreshAttempt < REFRESH_COOLDOWN_MS) return;
|
|
305
|
+
|
|
306
|
+
const expRes = await creds.resolve(credentialRef(EXPIRES_ENV)).catch(() => undefined);
|
|
307
|
+
const expires = expRes ? Number(expRes.value) : NaN;
|
|
308
|
+
if (!Number.isFinite(expires) || expires <= 0) return;
|
|
309
|
+
const remaining = expires - now;
|
|
310
|
+
// 剩余还多 → 不用刷;过期超过 7 天 → refresh 大概率已失效,放弃自动刷(需重新认证)
|
|
311
|
+
if (remaining > AUTO_REFRESH_THRESHOLD_MS) return;
|
|
312
|
+
if (remaining < -7 * 24 * 3600_000) return;
|
|
313
|
+
|
|
314
|
+
try {
|
|
315
|
+
const token = await refreshToken(refRes.value);
|
|
316
|
+
await creds.set(credentialRef(API_KEY_ENV), token.access);
|
|
317
|
+
await creds.set(credentialRef(REFRESH_ENV), token.refresh);
|
|
318
|
+
await creds.set(credentialRef(EXPIRES_ENV), String(token.expires));
|
|
319
|
+
lastRefreshAttempt = 0;
|
|
320
|
+
ctx.logger?.info?.('[cmtoken-oauth] 令牌已自动刷新,新过期时间: ' + new Date(token.expires).toISOString());
|
|
321
|
+
} catch (e) {
|
|
322
|
+
lastRefreshAttempt = Date.now();
|
|
323
|
+
ctx.logger?.warn?.('[cmtoken-oauth] 自动刷新失败(冷却 ' + REFRESH_COOLDOWN_MS / 60000 + ' 分钟): ' + String(e?.message || e));
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** 启动自动刷新定时器;返回清理函数。 */
|
|
328
|
+
function startAutoRefresh(ctx) {
|
|
329
|
+
const timer = setInterval(() => {
|
|
330
|
+
void autoRefresh(ctx).catch((e) => ctx.logger?.warn?.('[cmtoken-oauth] 自动刷新异常: ' + String(e?.message || e)));
|
|
331
|
+
}, AUTO_REFRESH_INTERVAL_MS);
|
|
332
|
+
timer.unref?.();
|
|
333
|
+
return () => clearInterval(timer);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
export function apply(ctx, cfg = {}) {
|
|
337
|
+
let removeRpc = () => {};
|
|
338
|
+
let stopTimer = () => {};
|
|
339
|
+
ctx.effect(() => {
|
|
340
|
+
removeRpc = registerRpc(ctx);
|
|
341
|
+
stopTimer = startAutoRefresh(ctx);
|
|
342
|
+
return () => {
|
|
343
|
+
removeRpc();
|
|
344
|
+
stopTimer();
|
|
345
|
+
};
|
|
346
|
+
}, 'cmtoken-oauth: host service');
|
|
347
|
+
ctx.logger?.info?.('[cmtoken-oauth] host 服务已启动(自动刷新已开启:每 1 分钟检查 + status 查询按需触发,剩余 <1 小时或已过期即刷新)');
|
|
348
|
+
}
|